Published on

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

Authors
Binance registration banner

서버 컴포넌트(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가 초기화됨” 같은 동기화 악몽을 상당 부분 제거할 수 있습니다.