본문 바로가기
tech epilogue

파편적으로 알던 SSR을 제대로 연결해보기

by rami_ 2026. 6. 2.

"SSR 알아요?" 라는 질문에 "네"라고 대답할 수 있지만, "hydration이 정확히 어떻게 동작해요?", "Sentry 소스맵은 왜 필요해요?" "ISR이랑 SSR은 언제 나눠 써요?" 라고 물으면 말이 느려졌습니다. 쓸 줄은 알지만 설명은 못하는 상태, 파편적으로 알고 있다는 게 이런 거구나 싶었습니다.

 

상태 동기화, 에러 추적, 캐시 전략, 이 세 가지를 중심으로 내가 알고 있던 것들을 한 번 정리했습니다

 


 

 

1. 서버 -> 클라이언트 상태 동기화(Hydration)

SSR에서 가장 먼저 마주치는 문제는 서버가 이미 만들어놓은 데이터를 클라이언트가 왜 또 fetch하냐는 것입니다. 이걸 해결하는 메커니즘이 hydration이고, 핵심은 두 단계로 요약됩니다.

  • dehydrate — 서버의 살아있는 JS 객체(queryClient)를 JSON 문자열로 압축해서 HTML에 끼워 보내기
  • hydrate — 브라우저가 그 JSON을 받아서 다시 살아있는 JS 객체(캐시)로 복원하기

흐름으로 보면 이렇습니다.

서버 : queryClient에 데이터 prefetch
   -> dehydrate(queryClient) -> JSON 직렬화
   -> HTML의 __NEXT_DATA__에 포함시켜 전송
   
클라이언트 : <HydrationBoundary state={dehydratedState}>
    -> 캐시 자동 복원
    -> 초기 fetch 없이 즉시 렌더링

 

코드로는 아래처럼 씁니다.

//서버 (page.tsx or getServerSideProps)

const queryClient = new QueryClient();
await queryClient.prefetchQuery({
  queryKey : ['user', id],
  queryFn, () => fetchUser(id),
});

const dehydratedState = dehydrated(queryClient);
//props로 넘기거나 RSC에서 직접 사용

//클라이언트 (layout or page)
<HydrationBoundary state={dehydratedState}>
  <UserPropfile /> //추가 네트워크 요청 없이 바로 데이터 있음.
</HydrationBoundary>

 

 

 

hydration mismatch

hydration 자체는 잘 동작해도, 서버와 클라이언트가 만들어낸 HTML이 다르면 React가 에러를 냅니다. 이게 hydration mismatch입니다.

1단계 : 에러 메시지 자체를 읽기

Error: Hydration failed because the initial UI does not match what was rendered on the server.

Expected server HTML to contain a matching <div> in <div>.

 

여기서 핵심은 어느 태그/텍스트 값이 다른가 입니다. Expected가 서버, 실제로 렌더된게 클라이언트입니다.

값의 차이가 숫자면 시간/랜덤 문제, 태그 자체가 아예 없으면 조건부렌더링 문제일 가능성이 높습니다.

 

2단계 : 원인 유형 4가지와 빠른 수정 패턴

 

원인은 대부분 아래 네 가지 중 하나입니다.

 

유형 1. 시간/랜덤 값

// 문제: 서버에서 계산한 값 ≠ 클라이언트에서 계산한 값
const id = Math.random()  // 서버: 0.123... 클라이언트: 0.456...

// 해결: useEffect로 클라이언트 mount 이후에만 값 세팅
const [id, setId] = useState<string | null>(null)
useEffect(() => { setId(crypto.randomUUID()) }, [])

 

유형2. 브라우저 전용 API

// 문제
const theme = localStorage.getItem('theme')  // 서버에서 ReferenceError

// 해결 A: dynamic import로 해당 컴포넌트 자체를 CSR 전용으로 격리
const ThemeToggle = dynamic(() => import('./ThemeToggle'), { ssr: false })

// 해결 B: typeof window 가드
const theme = typeof window !== 'undefined' ? localStorage.getItem('theme') : null

 

유형3. 날짜/locale 포맷 차이

서버(Node.js)의 timezone과 브라우저의 timezone이 다를 때 발생합니다. 가장 흔한 케이스는 new Date().toLocaleDateString() 처럼 locale-sensitive한 포맷을 그냥 쓰는 경우입니다.

// 문제: 서버는 UTC, 클라이언트는 KST → 날짜가 달라짐
{new Date(post.createdAt).toLocaleDateString()}

// 해결: ISO 문자열을 서버에서 넘기고 포맷은 클라이언트에서만
// 또는 locale을 명시적으로 고정
{new Date(post.createdAt).toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' })}

 

유형4. 외부 라이브러리

styled-components, emotion같은 css-in-js 라이브러리는 별도 SSR 설정 없이 쓰면 className이 서버/클라이언트 간에 달라집니다.

// Next.js 13+ App Router + styled-components
// next.config.js
compiler: {
  styledComponents: true,
}

 

3단계 도저히 못찾을 때 - 이진탐색

컴포넌트가 복잡해서 어디서 나는지 모를 때 범위를 반씩 줄이는 방법이 가장 빠릅니다.

// 의심 컴포넌트에 suppressHydrationWarning 임시 추가하면서 좁히기
<div suppressHydrationWarning>
  <SuspectChild />   // 여기 넣고 에러 사라지면 → 이 안에 원인 있음
</div>

 

supressHydrationWarning을 영구적으로 쓰는건 근본 해결이 아닙니다. 원인을 찾는 도구로만 임시 사용하고, 찾은 뒤 제거하는게 맞습니다.

 

 

2. 프로덕션 클라이언트 에러 추적(Sentry + 소스맵)

 

A. 전체 크래시 - ErrorBoundary

앱 전체가 터졌을 때 흰 화면 대신 보여주는 fallback UI입니다. React의 ErrorBoundary를 직접 만들어야 합니다.

// error.tsx (App Router) 또는 커스텀 ErrorBoundary
export default function ErrorPage({ error, reset }) {
  useEffect(() => {
    Sentry.captureException(error)  // ← 여기서 Sentry에 전송
  }, [error])

  return (
    <div>
      <h2>문제가 발생했어요</h2>
      <button onClick={reset}>다시 시도</button>
    </div>
  )
}

 

B. 부분 실패 - 컴포넌트 단위 fallback

페이지 전체가 아닌 특정 섹션만 에러 UI로 교체합니다. Suspense와 조합해서 씁니다.

<ErrorBoundary fallback={<SectionError />}>
  <RecommendationWidget />   // 이것만 터져도 나머지 페이지는 정상
</ErrorBoundary>

 

C. 조용한 실패 - 사용자는 모름

분석 이벤트 전송, 로깅, 비필수 API 호출 같은건 실패해도 사용자에게 보여줄 필요가 없습니다. try/catch로 잡아서 sentry에만 보냅니다.

try {
  await sendAnalyticsEvent(payload)
} catch (err) {
  Sentry.captureException(err)  // 사용자는 아무것도 모름
}

 

소스맵이 없으면 왜 문제인가

소스맵 없이 Sentry에 쌓이는 스택 트레이스는 main.a3f9c21.js:1:48291 같은 식입니다. 빌드 후 모든 코드가 한 줄로 압축되어 있어서 파일명도, 줄 번호도 의미가 없습니다. 소스맵이 연결되면 같은 에러가 src/components/ProductCard.tsx:47처럼 원본 코드 위치로 바로 떨어집니다.

 

빌드된 프로덕션 코드는 minify 되어있어 스택 트레이스만으로는 원인 파악이 불가능합니다. 소스맵이 핵심입니다.

// next.config.js
const { withSentryConfig } = require('@sentry/nextjs')

module.exports = withSentryConfig(nextConfig, {
  silent: true,
  org: 'your-org',
  project: 'your-project',
  widenClientFileUpload: true,  // 소스맵 범위 확장
  hideSourceMaps: true,          // 클라이언트에 소스맵 노출 방지
  disableLogger: true,
})

 

서버/클라이언트 에러를 분리해서 보는 이유

에러 유형 발생 위치 sentry 태그
RSC / API route 에러 node.js 런타임 runtime : server
hydration mismatch 브라우저 runtime : browser
edge runtime vercel edge runtime : edge

 

sentry.server.config.ts와 sentry.client.config.ts를 분리 작성하면 각 환경별 필터링과 알림 설정을 독립적으로 관리할 수 있습니다.

 

소스맵 타이밍 업로드 - CI/CD 파이프라인에서 빌드 직후 next build && sentry-cli sourcemaps upload 순서로 실행해야 하며, 소스맵 파일 자체는 .gitignore에 포함시켜 레포에 올리지 않는게 보안상 맞습니다.

 

 

3. SSR 인프라 비용 / 캐시 전략

SSR의 가장 큰 비용 문제는 모든 요청마다 서버 컴퓨팅이 발생한다는 점입니다. 캐시 레이어를 어디에 어떻게 두느냐가 비용을 수십배 차이나게 합니다.

 

전략 적합한 경우 TTL 예시
SSG(정적 생성) 컨텐츠 변경이 드문 페이지 영구(재배포 전까지)
ISR 주기적 업데이트 필요 revalidate: 60
on-demand ISR 특정 이벤트에만 갱신 CMS webhook 연동
SSR + CDN 캐시 사용자별 데이터 없는 SSR s-maxage=60, stale-while-revalidate
full SSR 완전히 사용자별 개인화 캐시불가

 

CloudFront 기준 캐시 헤더는 res.setHeader로 직접 설정합니다.

export async function getServerSideProps({ res }) {
  res.setHeader('Cache-Control', 's-maxage=60, stale-while-revalidate=30')
  const data = await fetchProduct()
  return { props: { data } }
}

 

비용 절감 우선순위 원칙 

1. 사용자 개인화 데이터가 없는 페이지 -> ISR 또는 SSG로 전환 먼저 검토

2. 개인화 필요하지만 공통 영역이 있다면 -> Partial rendering으로 shell은 캐시, 개인화부분만 CSR

3. 완전한 SSR이 필요하다면 -> CloudFront s-maxage + Vary 헤더로 CDN 히트율 극대화

 

 


 

 

정리하고 나서 느낀 건, 이 세 가지는 서로 독립적이지 않다는 점입니다. Sentry 소스맵이 있어야 hydration mismatch의 정확한 발생 위치를 파악할 수 있고, 캐시 전략을 잘못 설정하면 dehydratedState가 stale한 채로 캐시되어 상태 동기화 버그로 이어집니다. 각각을 따로 알고 있어도 연결이 안 되면 디버깅할 때 막히는 이유가 거기 있었습니다.