← 전체 글로 돌아가기

Next.js

React Server Component 경계에서 브라우저 전용 코드를 분리한 기록

Next.js App Router에서 window와 이벤트 핸들러가 섞인 컴포넌트를 어디까지 client로 둘지 정한 실제 기준을 적었습니다.

에러 메시지가 가리킨 곳과 원인은 달랐다

상품 상세 화면에 브라우저의 localStorage를 읽어 최근 본 상품을 표시하려고 했다. 그런데 페이지 전체를 "use client"로 바꾸기에는 서버에서 가져오는 상품 정보와 SEO용 본문까지 클라이언트 렌더링으로 밀고 싶지 않았다. 처음에는 import 에러가 난 파일에 지시어를 붙였고, 그 결과 데이터 패칭 컴포넌트까지 브라우저 번들에 들어갔다.

헷갈렸던 점은 client component를 import했다고 부모가 자동으로 client가 되는 것은 아니지만, client 경계 안으로 넘기는 props는 직렬화 가능해야 한다는 사실이었다. 함수와 서버 전용 객체를 그대로 넘기는 설계가 먼저 문제였다.

내가 나눈 경계

서버 컴포넌트는 상품 조회와 구조를 담당하고, 브라우저 동작만 작은 컴포넌트로 분리했다.

// app/products/[id]/page.tsx
import RecentButton from './RecentButton'

export default async function Page({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id)
  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <RecentButton productId={product.id} />
    </main>
  )
}
// app/products/[id]/RecentButton.tsx
'use client'

export default function RecentButton({ productId }: { productId: string }) {
  function remember() {
    const old = JSON.parse(localStorage.getItem('recent') ?? '[]')
    const next = [productId, ...old.filter((id: string) => id !== productId)].slice(0, 10)
    localStorage.setItem('recent', JSON.stringify(next))
  }
  return <button onClick={remember}>최근 본 상품에 저장</button>
}

핵심은 서버에서 만들어진 getProduct 함수나 DB row 전체를 넘기지 않고, 버튼에 필요한 문자열 ID만 넘긴 것이다. Date, BigInt, ORM 객체를 props로 전달할 때 생기는 경고도 이 기준으로 함께 정리했다.

배포 전에 확인한 항목

  • window, document, localStorage를 읽는 파일이 작은 client 경계 안에 있는가
  • 이벤트 핸들러를 서버 컴포넌트 JSX에 직접 넣지 않았는가
  • props에 함수, DB 연결 객체, 직렬화되지 않는 값이 섞이지 않았는가
  • 상품 제목과 본문이 자바스크립트 로드 전에도 HTML에 있는가
  • 브라우저에서만 가능한 값은 초기 렌더와 달라도 hydration 경고를 내지 않는가

경계를 정하는 짧은 결론

이번 수정 뒤 번들 크기보다 더 확실하게 좋아진 부분은 책임이 보인다는 점이었다. 서버에서 할 일은 서버에 남기고, 클릭·포커스·브라우저 저장소처럼 상호작용이 필요한 조각만 client로 보낸다. 에러가 난 파일 전체를 client로 만드는 것은 빠른 임시 해결일 뿐, 경계를 다시 그리는 것이 오래 가는 해결이었다.