Published on

Next.js 14 서버컴포넌트와 클라이언트 상태 동기화

Authors

서버 컴포넌트(React Server Components, RSC)는 “서버에서 렌더링된 최신 데이터”를 강하게 보장하는 대신, 클라이언트에서의 상호작용 상태(폼 입력, 토글, 선택, 임시 정렬/필터 등)와 충돌하기 쉽습니다. Next.js 14(App Router)에서는 이 경계가 더 명확해졌고, 따라서 “어떤 상태를 서버 소스로 두고, 어떤 상태를 클라이언트 소스로 둘지”를 의식적으로 설계해야 합니다.

이 글에서는 Next.js 14 환경에서 자주 부딪히는 서버컴포넌트 data 와 클라이언트 상태 ui state 를 안정적으로 동기화하는 패턴을, 코드 중심으로 정리합니다.

문제의 본질: 서버는 진실, 클라에는 임시 상태가 있다

RSC에서 페이지를 구성하면 데이터는 보통 다음 흐름을 탑니다.

  • 서버에서 fetch 또는 DB 호출로 최신 데이터를 가져온다
  • 서버 컴포넌트가 HTML을 만든다
  • 클라이언트에서 하이드레이션 후, 인터랙션은 클라이언트 컴포넌트가 담당한다

여기서 동기화 문제가 생기는 대표 케이스는 다음과 같습니다.

  • 서버가 내려준 리스트를 클라이언트에서 정렬/필터/선택했는데, 서버 재검증(revalidate)로 리스트가 바뀌며 선택이 초기화됨
  • 토글/좋아요 같은 즉시 반응이 필요한 UI를 서버 액션으로 처리할 때, 응답 대기 동안 UI가 멈춘 것처럼 보임
  • 서버 액션 성공 후에도 RSC 캐시가 갱신되지 않아 “저장했는데 화면이 안 바뀜” 현상이 발생
  • 반대로 너무 공격적으로 router.refresh() 를 호출해 입력 중인 폼이 리셋되는 UX가 발생

핵심은 “서버 데이터의 갱신”과 “클라이언트 UI 상태의 지속”을 분리하고, 합쳐 보여줄 때는 규칙을 두는 것입니다.

패턴 1: 서버 데이터는 스냅샷, 클라 상태는 오버레이

가장 기본이면서도 강력한 패턴은 서버에서 내려오는 데이터를 “스냅샷”으로 보고, 클라이언트는 그 위에 UI 상태를 “오버레이”로 얹는 방식입니다.

예를 들어 서버에서 내려온 items 는 최신 데이터이고, 클라이언트는 체크박스 선택 상태 selectedIds 만 별도로 관리합니다.

서버 컴포넌트: 데이터 스냅샷 제공

// app/items/page.tsx
import ItemsClient from './items-client'

async function getItems() {
  const res = await fetch('https://example.com/api/items', {
    cache: 'no-store',
  })
  if (!res.ok) throw new Error('failed to load')
  return res.json() as Promise<Array<{ id: string; name: string }>>
}

export default async function Page() {
  const items = await getItems()
  return <ItemsClient initialItems={items} />
}

클라이언트 컴포넌트: 오버레이 상태 유지

// app/items/items-client.tsx
'use client'

import { useMemo, useState } from 'react'

type Item = { id: string; name: string }

type Props = {
  initialItems: Item[]
}

export default function ItemsClient({ initialItems }: Props) {
  const [selectedIds, setSelectedIds] = useState(() => new Set<string>())
  const [query, setQuery] = useState('')

  const visible = useMemo(() => {
    const q = query.trim().toLowerCase()
    if (!q) return initialItems
    return initialItems.filter((it) => it.name.toLowerCase().includes(q))
  }, [initialItems, query])

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="search"
      />

      <ul>
        {visible.map((it) => {
          const checked = selectedIds.has(it.id)
          return (
            <li key={it.id}>
              <label>
                <input
                  type="checkbox"
                  checked={checked}
                  onChange={() => {
                    setSelectedIds((prev) => {
                      const next = new Set(prev)
                      if (next.has(it.id)) next.delete(it.id)
                      else next.add(it.id)
                      return next
                    })
                  }}
                />
                {it.name}
              </label>
            </li>
          )
        })}
      </ul>
    </div>
  )
}

이 방식의 장점은 명확합니다.

  • 서버 데이터가 바뀌어도, 선택/입력 같은 UI 상태는 유지된다
  • “서버 데이터는 서버가 책임”지고, “UI 상태는 클라가 책임”진다

주의점은 initialItems 가 갱신될 때 selectedIds 가 “존재하지 않는 id”를 포함할 수 있다는 점입니다. 따라서 서버 데이터가 바뀔 수 있는 화면이라면, useEffect 로 교집합만 남기는 정리 로직을 두는 게 안전합니다.

클라이언트 상태/이펙트가 복잡해질수록 메모리 누수나 정리 누락이 생기기 쉬운데, 이 주제는 React 메모리 누수? useEffect 클린업 9가지도 함께 참고하면 좋습니다.

패턴 2: 서버 액션은 “쓰기”, RSC는 “읽기”로 역할을 고정

Next.js 14에서 서버 액션은 쓰기 작업에 매우 편리하지만, 읽기와 쓰기를 섞으면 동기화 규칙이 흐려집니다. 추천하는 기본 규칙은 다음입니다.

  • 읽기: 서버 컴포넌트에서 fetch 로 수행
  • 쓰기: 서버 액션에서 수행
  • 쓰기 성공 후: revalidatePath 또는 revalidateTag 로 읽기 캐시를 무효화

서버 액션 예시: 토글 저장 + 캐시 무효화

// app/items/actions.ts
'use server'

import { revalidatePath } from 'next/cache'

export async function toggleStar(itemId: string) {
  // 실제로는 DB 업데이트
  await fetch('https://example.com/api/items/toggle-star', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ itemId }),
  })

  // 읽기 경로 무효화
  revalidatePath('/items')
}

클라이언트 컴포넌트: 액션 호출

'use client'

import { useTransition } from 'react'
import { toggleStar } from './actions'

export function StarButton({ itemId, initialStarred }: { itemId: string; initialStarred: boolean }) {
  const [isPending, startTransition] = useTransition()

  return (
    <button
      disabled={isPending}
      onClick={() => {
        startTransition(async () => {
          await toggleStar(itemId)
        })
      }}
    >
      {initialStarred ? 'starred' : 'star'}
    </button>
  )
}

여기서 중요한 포인트는 “쓰기 성공 후 화면 갱신을 어떻게 체감시키는가”입니다.

  • revalidatePath 는 다음 RSC 렌더에서 최신 값을 보장하지만, 즉시 UI가 바뀌는 느낌은 약할 수 있습니다.
  • 그래서 다음 패턴(낙관적 업데이트)을 결합하는 경우가 많습니다.

패턴 3: 낙관적 업데이트는 useOptimistic 로, 최종 확정은 RSC로

좋아요/북마크/체크 같은 UI는 클릭 즉시 바뀌어야 합니다. 이때 서버 액션 왕복을 기다리면 UX가 느려집니다. Next.js 14에서는 React의 useOptimisticuseTransition 조합이 실용적입니다.

'use client'

import { useOptimistic, useTransition } from 'react'
import { toggleStar } from './actions'

export function StarOptimistic({ itemId, starred }: { itemId: string; starred: boolean }) {
  const [isPending, startTransition] = useTransition()
  const [optimisticStarred, setOptimisticStarred] = useOptimistic(
    starred,
    (prev) => !prev
  )

  return (
    <button
      aria-busy={isPending}
      onClick={() => {
        setOptimisticStarred(null)
        startTransition(async () => {
          await toggleStar(itemId)
        })
      }}
    >
      {optimisticStarred ? 'starred' : 'star'}
    </button>
  )
}

이 구조에서 일관성 규칙은 다음과 같습니다.

  • 버튼 클릭 직후는 클라이언트가 “임시로” 진실이다
  • 서버 액션이 성공하면, revalidatePath 로 서버 진실을 갱신한다
  • 다음 렌더에서 서버 진실이 클라이언트 임시 상태를 덮어쓴다

실패 케이스(네트워크 오류 등)에서는 롤백 UI가 필요합니다. toggleStar 에서 에러를 던지면 startTransition 내부에서 잡아 토스트를 띄우고, 낙관 상태를 다시 되돌리는 방식으로 처리합니다.

패턴 4: router.refresh() 는 최소화하고 “경계”를 좁혀라

상태 동기화가 꼬이는 가장 흔한 원인은 “너무 큰 단위로 RSC를 다시 렌더”하는 것입니다. router.refresh() 는 강력하지만, 입력 중인 폼/탭/아코디언 상태까지 리셋시키기 쉽습니다.

권장 전략은 다음입니다.

  • 가능한 한 revalidatePath 또는 revalidateTag 로 서버 캐시만 무효화
  • UI 상태가 많은 화면은 RSC 영역을 작게 쪼개고, 갱신이 필요한 부분만 서버 컴포넌트로 분리

예를 들어 “상단 필터/탭은 클라이언트”, “리스트 본문은 서버 컴포넌트”로 나누면, 필터 UI는 유지하면서 리스트만 서버에서 최신으로 바꿀 수 있습니다.

패턴 5: 검색/필터는 URL을 단일 소스로, 클라 상태는 파생값으로

검색어, 페이지네이션, 정렬 같은 상태는 “공유 가능”하고 “뒤로가기/새로고침에 강해야” 합니다. 이런 상태를 클라이언트 useState 로만 들고 있으면 서버 데이터와 쉽게 분리됩니다.

Next.js App Router에서는 URL searchParams 를 단일 소스로 두고, 서버 컴포넌트가 이를 읽어 데이터 패칭을 수행하는 패턴이 안정적입니다.

// app/items/page.tsx
export default async function Page({
  searchParams,
}: {
  searchParams: Promise<{ q?: string; page?: string }>
}) {
  const sp = await searchParams
  const q = sp.q ?? ''
  const page = Number(sp.page ?? '1')

  const res = await fetch(`https://example.com/api/items?q=${encodeURIComponent(q)}&page=${page}`, {
    cache: 'no-store',
  })
  const items = (await res.json()) as Array<{ id: string; name: string }>

  return (
    <div>
      <SearchBox initialQ={q} />
      <ItemsList items={items} />
    </div>
  )
}
// app/items/search-box.tsx
'use client'

import { useRouter, useSearchParams } from 'next/navigation'
import { useTransition } from 'react'

export function SearchBox({ initialQ }: { initialQ: string }) {
  const router = useRouter()
  const sp = useSearchParams()
  const [isPending, startTransition] = useTransition()

  return (
    <div>
      <input
        defaultValue={initialQ}
        onChange={(e) => {
          const next = new URLSearchParams(sp.toString())
          next.set('q', e.target.value)
          next.delete('page')

          startTransition(() => {
            router.replace(`/items?${next.toString()}`)
          })
        }}
      />
      <span>{isPending ? 'loading' : ''}</span>
    </div>
  )
}

이 패턴의 장점은 다음과 같습니다.

  • 서버 데이터가 URL과 1대1로 대응되어 동기화가 단순해진다
  • 공유/북마크/뒤로가기 동작이 자연스럽다
  • 클라이언트 상태는 URL에서 파생된 값만 유지하면 된다

패턴 6: 서버 캐시 전략은 tag 로 분리하고, 쓰기 후 정확히 무효화

RSC 데이터 패칭에서 fetch 캐시를 활용하면 성능이 좋아지지만, 무효화 전략이 엉키면 “업데이트했는데 안 바뀜”이 발생합니다. 이를 피하려면 next 옵션의 tags 를 적극 사용합니다.

// app/items/data.ts
export async function getItems() {
  const res = await fetch('https://example.com/api/items', {
    next: { tags: ['items'] },
  })
  if (!res.ok) throw new Error('failed')
  return res.json() as Promise<Array<{ id: string; name: string }>>
}
// app/items/actions.ts
'use server'

import { revalidateTag } from 'next/cache'

export async function createItem(name: string) {
  await fetch('https://example.com/api/items', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ name }),
  })

  revalidateTag('items')
}

path 기반 무효화는 범위가 넓어질 수 있고, 라우트 구조가 바뀌면 깨지기 쉽습니다. 반면 tag 기반은 “데이터 도메인” 중심으로 무효화할 수 있어 동기화 설계가 깔끔해집니다.

동기화 설계 체크리스트

서버컴포넌트와 클라이언트 상태를 같이 쓰는 화면을 만들 때, 아래 질문에 답하면 대부분의 동기화 버그를 예방할 수 있습니다.

  1. 이 상태는 “공유/재현 가능”해야 하는가
    • 예: 검색어/정렬/페이지는 URL 단일 소스로
    • 아니오: 모달 열림/탭 선택/임시 체크는 클라 로컬 상태
  2. 이 데이터는 “서버가 최종 진실”인가
    • 예: 주문 상태/권한/재고는 서버 진실, 클라는 낙관적 표시만
  3. 쓰기 후 어떤 단위로 읽기 캐시를 무효화할 것인가
    • revalidateTag 로 도메인 단위 무효화 권장
  4. 화면 갱신이 UI 상태를 리셋시키는가
    • 그렇다면 router.refresh() 남용을 의심하고, RSC 경계를 더 쪼갠다

흔한 함정: 동기화 문제를 “클라 전역 상태”로만 해결하려 하지 말 것

Zustand/Redux/React Query 같은 도구는 여전히 유용하지만, RSC 시대에는 “서버가 이미 상태를 들고 내려온다”는 사실을 먼저 활용하는 편이 단순합니다.

  • 서버 데이터는 서버에서 패칭하고 캐시/무효화 전략을 명확히 한다
  • 클라이언트 전역 상태는 정말 필요한 UI 상태에만 사용한다

특히 서버 액션과 이벤트 처리 로직이 복잡해지면, 결국 분산 시스템의 “중복 실행”이나 “재시도” 같은 문제와 유사한 형태로 나타납니다. 데이터 갱신이 여러 번 일어나도 안전한 설계(멱등성)가 중요하다는 점에서, MSA SAGA 보상 트랜잭션 중복 실행 방지법에서 다루는 관점도 의외로 도움이 됩니다.

결론: 동기화는 “경계”와 “단일 소스”를 정하는 작업

Next.js 14에서 서버컴포넌트와 클라이언트 상태를 잘 섞는 핵심은 기술 트릭이 아니라 규칙입니다.

  • 서버 데이터는 스냅샷으로 내려주고, 클라 UI 상태는 오버레이로 유지
  • 읽기(RSC)와 쓰기(서버 액션)를 분리하고, 쓰기 후 캐시 무효화를 명시
  • 낙관적 업데이트로 UX를 확보하되, 최종 확정은 서버 데이터로 귀결
  • 공유 가능한 상태는 URL을 단일 소스로

이 규칙을 지키면 “새로고침하면 맞는데 화면에서만 틀림”, “저장했는데 반영이 안 됨”, “갱신할수록 UI가 초기화됨” 같은 동기화 악몽을 상당 부분 제거할 수 있습니다.