"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한 채로 캐시되어 상태 동기화 버그로 이어집니다. 각각을 따로 알고 있어도 연결이 안 되면 디버깅할 때 막히는 이유가 거기 있었습니다.
'tech epilogue' 카테고리의 다른 글
| Rolling Session (0) | 2026.06.23 |
|---|---|
| React vs Vue 렌더링 차이 (0) | 2026.06.11 |
| Jira 티켓 하나로 PR까지 자동 생성하는 AI 에이전트 만들기 (0) | 2026.05.28 |
| 권한 체계 설계하기(feat. vue, pinia) (0) | 2026.05.20 |
| ChunkLoadError 해결기 (0) | 2026.05.15 |