Published on

Next.js App Router 렌더 폭증? useOptimistic·SWR로 상태 분리

Authors

서버 컴포넌트(RSC) 기반의 Next.js App Router는 기본값이 "서버에서 데이터를 만들고" "클라이언트는 상호작용만 담당"하도록 설계되어 있습니다. 그런데 실제 앱을 만들다 보면 작은 입력 하나, 버튼 클릭 하나가 페이지 전반의 리렌더와 재요청으로 번지면서 렌더가 폭증하는 순간을 맞습니다. 특히 목록 페이지에서 좋아요, 체크, 태그 추가 같은 미세 상호작용이 많을수록 체감 성능이 급격히 떨어집니다.

이 글에서는 App Router에서 렌더 폭증이 발생하는 전형적인 원인을 짚고, useOptimistic와 SWR을 조합해 서버 상태와 클라이언트 상호작용 상태를 분리하는 방법을 예제로 정리합니다.

관련해서 RSC/이미지/폰트까지 포함한 LCP 개선 흐름은 아래 글도 참고하면 좋습니다.

App Router에서 "렌더 폭증"이 생기는 대표 패턴

1) URL 변경, router.refresh() 남발로 RSC 재평가

App Router에서 router.push()로 쿼리를 바꾸거나 router.refresh()를 호출하면, 해당 라우트 트리의 서버 컴포넌트가 다시 평가됩니다. 이는 정상 동작이지만, 아래처럼 사소한 UI 상태까지 URL 또는 서버 트리 재평가에 묶어버리면 문제가 됩니다.

  • 검색 입력의 매 타이핑을 쿼리스트링에 반영
  • 토글 버튼 클릭마다 router.refresh()로 목록을 다시 받기
  • 모달 열림/닫힘을 서버 상태처럼 취급

2) 서버 데이터와 로컬 UI 상태가 한 컴포넌트에 섞임

서버에서 가져온 목록과, 클라이언트에서만 필요한 상태(선택된 행, 펼침 상태, 임시 입력값)가 같은 컴포넌트에 섞이면 변경 범위가 커집니다.

  • 목록 아이템 하나의 좋아요 상태 변경이 목록 전체 리렌더로 확산
  • 입력 폼 타이핑이 상위 트리까지 영향을 줌

3) 낙관적 업데이트를 "서버 재요청"으로 해결

낙관적 UI를 구현하지 못하면, 클릭할 때마다 서버 응답을 기다리거나 아예 서버 트리를 새로고침해서 동기화하려고 합니다. 이 방식은 UX도 느리고, 불필요한 RSC 재평가/네트워크 비용을 만들어냅니다.

해결 전략: 상태를 3종류로 나누기

App Router에서 렌더 폭증을 줄이는 핵심은 상태를 성격에 따라 분리하는 것입니다.

  1. 서버 상태(Server State): DB/외부 API에서 온 사실 데이터. 예) 게시글 목록, 좋아요 수
  2. 낙관적 상태(Optimistic State): 서버 반영 전까지 UI를 즉시 바꾸기 위한 임시 상태. 예) 좋아요 즉시 +1
  3. 로컬 UI 상태(Local UI State): 서버와 무관한 화면 상태. 예) 모달 열림, 필터 드롭다운, 체크박스 선택

이 중 1)은 RSC 또는 SWR 같은 캐시 계층으로 관리하고, 2)는 useOptimistic로 다루며, 3)은 useState로 로컬에 가둡니다.

안티패턴 예제: 클릭마다 router.refresh()

아래는 "좋아요"를 누르면 서버 액션을 호출하고, 화면 동기화를 위해 router.refresh()를 호출하는 흔한 패턴입니다.

'use client'

import { useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { likePost } from './actions'

export function LikeButton({ postId, liked }: { postId: string; liked: boolean }) {
  const router = useRouter()
  const [isPending, startTransition] = useTransition()

  return (
    <button
      disabled={isPending}
      onClick={() =>
        startTransition(async () => {
          await likePost({ postId })
          router.refresh()
        })
      }
    >
      {liked ? 'Liked' : 'Like'}
    </button>
  )
}

문제는 router.refresh()가 해당 라우트의 서버 컴포넌트 트리를 다시 평가하게 만들어, 좋아요 버튼 하나가 페이지 전반의 재렌더/재요청 트리거가 된다는 점입니다.

useOptimistic로 "UI 즉시 반응"을 서버 트리와 분리

useOptimistic는 서버 반영이 완료되기 전까지 UI를 먼저 바꾸고, 실패 시 롤백하거나 재검증으로 정합성을 맞추는 패턴에 유리합니다.

1) 서버 액션 준비

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

import { revalidateTag } from 'next/cache'

export async function toggleLike(input: { postId: string }) {
  // DB 업데이트 수행
  // await db.like.toggle(...)

  // 목록이 `fetch(..., { next: { tags: ['posts'] } })`를 사용한다면 태그 기반 재검증
  revalidateTag('posts')

  return { ok: true }
}

여기서 중요한 점은 클릭마다 router.refresh()로 전체를 새로고침하지 말고, 서버 데이터 쪽은 revalidateTag() 또는 revalidatePath() 같은 "의도된" 재검증으로 정리하는 것입니다.

2) 클라이언트에서 낙관적 상태 적용

'use client'

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

type LikeState = { liked: boolean; count: number }

type Props = {
  postId: string
  initialLiked: boolean
  initialCount: number
}

export function LikeButtonOptimistic({ postId, initialLiked, initialCount }: Props) {
  const [isPending, startTransition] = useTransition()

  const [optimistic, setOptimistic] = useOptimistic<LikeState, { type: 'toggle' }>(
    { liked: initialLiked, count: initialCount },
    (state, action) => {
      if (action.type !== 'toggle') return state
      const nextLiked = !state.liked
      return {
        liked: nextLiked,
        count: state.count + (nextLiked ? 1 : -1),
      }
    }
  )

  return (
    <button
      aria-busy={isPending}
      onClick={() => {
        // UI를 먼저 바꿈
        setOptimistic({ type: 'toggle' })

        // 서버 반영은 transition으로
        startTransition(async () => {
          const res = await toggleLike({ postId })
          if (!res.ok) {
            // 실패 시에는 보수적으로: 이후 SWR revalidate 또는 서버 재검증에 맡기는 편이 안전
            // 이 예제에서는 별도 처리 생략
          }
        })
      }}
    >
      {optimistic.liked ? 'Liked' : 'Like'} ({optimistic.count})
    </button>
  )
}

이렇게 하면 좋아요 버튼 클릭이 서버 트리 전체를 흔들지 않고도 즉시 반응합니다.

다만 useOptimistic만으로는 "다른 컴포넌트에서 같은 데이터"를 공유할 때 동기화가 어렵습니다. 예를 들어 목록의 카드와 상단 요약 위젯이 같은 좋아요 카운트를 보여주면, 한 군데만 낙관적으로 바꾸면 불일치가 생길 수 있습니다. 이 지점에서 SWR 같은 클라이언트 캐시가 유용합니다.

SWR로 서버 상태 캐싱: RSC와 "클라이언트 데이터" 경계 만들기

App Router에서는 서버에서 렌더한 결과(RSC)가 기본이지만, 상호작용이 빈번하고 부분 업데이트가 많은 영역은 "클라이언트 캐시"로 빼는 게 오히려 안정적입니다.

1) API 라우트(또는 Route Handler)로 읽기 엔드포인트 제공

// app/api/posts/route.ts
import { NextResponse } from 'next/server'

export async function GET() {
  // const posts = await db.post.findMany(...)
  const posts = [
    { id: '1', title: 'Hello', liked: false, likeCount: 3 },
    { id: '2', title: 'World', liked: true, likeCount: 10 },
  ]

  return NextResponse.json({ posts })
}

2) SWR 훅으로 목록을 클라이언트 캐시에 올리기

'use client'

import useSWR from 'swr'

type Post = { id: string; title: string; liked: boolean; likeCount: number }

type PostsResponse = { posts: Post[] }

const fetcher = (url: string) => fetch(url).then((r) => r.json())

export function PostsListClient() {
  const { data, isLoading, mutate } = useSWR<PostsResponse>('/api/posts', fetcher, {
    revalidateOnFocus: false,
  })

  if (isLoading) return <div>Loading...</div>

  return (
    <ul>
      {data?.posts.map((p) => (
        <li key={p.id}>
          {p.title} - {p.likeCount}
          <button
            onClick={async () => {
              // 1) SWR 캐시를 낙관적으로 갱신
              await mutate(
                (cur) => {
                  if (!cur) return cur
                  return {
                    posts: cur.posts.map((x) =>
                      x.id === p.id
                        ? {
                            ...x,
                            liked: !x.liked,
                            likeCount: x.likeCount + (!x.liked ? 1 : -1),
                          }
                        : x
                    ),
                  }
                },
                { revalidate: false }
              )

              // 2) 서버 반영
              await fetch('/api/posts/like', {
                method: 'POST',
                headers: { 'content-type': 'application/json' },
                body: JSON.stringify({ postId: p.id }),
              })

              // 3) 정합성 재검증(필요 시)
              mutate()
            }}
          >
            {p.liked ? 'Liked' : 'Like'}
          </button>
        </li>
      ))}
    </ul>
  )
}

이 접근의 장점은 명확합니다.

  • 좋아요 클릭이 라우트 서버 트리 재평가로 이어지지 않음
  • 같은 데이터를 쓰는 여러 컴포넌트가 SWR 캐시를 통해 동기화
  • 낙관적 업데이트와 롤백을 mutate 패턴으로 일관되게 구현 가능

반대로 단점도 있습니다.

  • RSC가 제공하는 "서버 중심 단일 진실 소스"에서 일부가 이탈
  • API 라우트를 별도로 유지해야 할 수 있음

그래서 실전에서는 "읽기"는 RSC로 두되, 상호작용이 많은 부분만 SWR로 분리하는 하이브리드가 자주 쓰입니다.

useOptimistic와 SWR을 함께 쓰는 실전 패턴

가장 깔끔한 조합은 다음입니다.

  • 목록 데이터는 SWR 캐시에 둔다
  • 각 아이템의 즉시 반응은 useOptimistic로 처리한다
  • 최종 정합성은 SWR mutate() 재검증으로 맞춘다

핵심은 낙관적 상태의 범위를 컴포넌트 로컬로 제한하고, 공유 상태는 SWR 캐시에 위임하는 것입니다.

예시: 카드 컴포넌트 내부는 useOptimistic, 외부는 SWR

'use client'

import { useOptimistic, useTransition } from 'react'
import { mutate as globalMutate } from 'swr'

type Post = { id: string; title: string; liked: boolean; likeCount: number }

export function PostCard({ post }: { post: Post }) {
  const [isPending, startTransition] = useTransition()

  const [optimistic, setOptimistic] = useOptimistic(post, (state, _action: { type: 'toggle' }) => {
    const nextLiked = !state.liked
    return {
      ...state,
      liked: nextLiked,
      likeCount: state.likeCount + (nextLiked ? 1 : -1),
    }
  })

  return (
    <article>
      <h3>{optimistic.title}</h3>
      <button
        aria-busy={isPending}
        onClick={() => {
          setOptimistic({ type: 'toggle' })

          startTransition(async () => {
            await fetch('/api/posts/like', {
              method: 'POST',
              headers: { 'content-type': 'application/json' },
              body: JSON.stringify({ postId: optimistic.id }),
            })

            // 전역 SWR 캐시 재검증: 목록/상세 등 같은 키를 쓰는 곳 동기화
            globalMutate('/api/posts')
          })
        }}
      >
        {optimistic.liked ? 'Liked' : 'Like'} ({optimistic.likeCount})
      </button>
    </article>
  )
}

이렇게 하면 카드 내부는 즉시 반응하고, 다른 영역과의 정합성은 SWR 재검증으로 맞춥니다. 클릭마다 router.refresh()로 라우트 전체를 흔들 필요가 없어집니다.

렌더 폭증을 줄이는 추가 체크리스트

1) URL은 "공유되어야 하는 상태"만 담기

검색/필터를 URL에 넣는 건 좋지만, 타이핑마다 반영하면 폭증합니다. 디바운스하거나, "검색" 버튼으로 확정하는 UX로 바꾸는 게 효과적입니다.

// 예: 디바운스는 lodash 또는 직접 구현
// URL 업데이트는 확정 시점에만 수행

2) 서버 액션 이후 revalidateTag를 우선 고려

router.refresh()는 강력하지만 범위가 큽니다. 데이터 단위로 태그를 잡아 재검증하면, 서버 쪽 캐시/정합성은 유지하면서도 불필요한 렌더를 줄일 수 있습니다.

3) 상호작용 많은 위젯은 "클라이언트 섬"으로 격리

목록 전체를 클라이언트로 옮기라는 뜻이 아니라, 좋아요/북마크/체크처럼 이벤트 폭이 큰 부분만 별도 클라이언트 컴포넌트로 분리합니다.

4) 성능 문제는 N+1처럼 "폭발 패턴"으로 관찰하기

렌더 폭증은 프론트의 N+1에 가깝습니다. 작은 이벤트가 여러 컴포넌트/요청으로 증폭되는 구조를 먼저 끊어야 합니다. 백엔드에서 N+1을 잡는 사고방식이 프론트에도 그대로 적용됩니다.

어떤 선택이 맞나: useOptimistic vs SWR vs router.refresh()

  • router.refresh()

    • 장점: 구현이 단순, RSC 정합성 강함
    • 단점: 범위가 커서 렌더/네트워크 비용이 쉽게 폭증
  • useOptimistic

    • 장점: 즉시 반응, 구현이 비교적 간단, 서버 액션과 궁합 좋음
    • 단점: 공유 데이터 동기화는 별도 전략 필요
  • SWR

    • 장점: 공유 상태 동기화, 낙관적 업데이트/롤백 패턴 성숙, 재검증 전략 유연
    • 단점: 클라이언트 캐시 레이어 운영 비용, RSC 중심 설계에서 일부 이탈

실무에서는 보통 다음 순서로 적용하면 실패 확률이 낮습니다.

  1. router.refresh()를 무조건 쓰기 전에, 서버 액션에서 revalidateTag()로 범위를 줄일 수 있는지 검토
  2. 클릭 즉시 반응이 필요한 UI는 useOptimistic로 로컬 낙관 상태를 도입
  3. 동일 데이터가 여러 컴포넌트에서 공유되고 상호작용이 많다면 SWR로 클라이언트 캐시를 도입

마무리

App Router에서 렌더 폭증은 프레임워크 결함이라기보다, "서버 상태"와 "클라이언트 상호작용"의 경계를 흐리게 만들 때 자주 발생합니다. useOptimistic는 클릭 단위의 즉시 반응을, SWR은 공유 데이터의 캐시와 동기화를 담당하게 분리하면, 페이지 전체 리렌더를 유발하는 router.refresh() 의존도를 크게 낮출 수 있습니다.

정리하면 다음 한 줄이 실전에서 가장 중요합니다.

  • 서버 데이터는 서버가, 상호작용은 클라이언트가, 정합성은 재검증이 책임진다.