SSR hydration 불일치를 잡은 기록
hydration mismatch의 범인은 ⌘K 배지 한 글자였어요. 서버에는 navigator가 없거든요. 6줄짜리 수정과, 그 뒤에 세운 순수성 규칙 이야기입니다.

배포된 공개 페이지 콘솔에 hydration mismatch 오류가 떴어요. 범인은요? 검색 버튼의 ⌘K 배지, 한 글자였습니다.
이 블로그의 SSR 구조는 단순해요. Worker가 renderToString으로 페이지 HTML을 완성하고, 렌더링에 쓴 페이지 컨텍스트를 <script type="application/json">으로 같이 심어요. 클라이언트는 그 JSON으로 같은 트리를 만들어 hydrateRoot로 붙고요. 전제는 하나 — 서버 HTML과 클라이언트의 첫 렌더가 같아야 한다. 그 전제가 깨진 거예요.
hydration이 실제로 하는 일
용어를 먼저 갈고 가야 뒤 이야기가 됩니다. hydration은 서버가 만든 HTML에 브라우저가 "손을 얹는" 과정이에요.
| 단계 | 서버 | 브라우저 |
|---|---|---|
| 1 | 컴포넌트 트리를 HTML 문자열로 만듦 | 받아서 그대로 표시 — 여기서 이미 글자가 보임 |
| 2 | 렌더에 쓴 데이터를 JSON으로 같이 심음 | 그 JSON으로 같은 트리를 다시 만듦 |
| 3 | — | 만든 트리와 받은 DOM이 같은지 대조 |
| 4 | — | 같으면 이벤트만 붙이고 끝 |
3번이 이 글의 전부예요. 브라우저는 서버가 보낸 DOM을 믿고 시작하는데, 자기가 만든 트리와 다르면 믿을 수가 없어집니다.
그래서 데이터를 JSON으로 같이 심는 거예요. 같은 데이터로 그리면 같은 결과가 나온다는 전제 위에 이 구조가 서 있거든요. 그런데 데이터가 아니라 환경이 다르면요? 그게 이번 사건입니다.
원인 — 서버에는 navigator가 없다
⌘K(Mac) 또는 Ctrl K를 보여주는 <kbd> 라벨의 판정을 렌더 도중 navigator.platform으로 하고 있었어요. typeof navigator 가드가 있어서 크래시는 안 나요. 대신 서버는 platform 미상을 비Mac으로 간주해 "Ctrl K"를 그리는데, Mac 브라우저의 hydration 첫 렌더에서는 navigator.platform이 이미 "MacIntel"이라 "⌘K"가 나옵니다. 두 출력이 어긋나요.
라벨 한 글자 차이지만 대가는 작지 않아요. React는 불일치를 만나면 서버 DOM을 신뢰하지 못하고 클라이언트 렌더로 복구를 시도합니다. 서버가 완성한 첫 페인트를 클라이언트가 다시 그리는 셈이라, SSR에 들인 비용 자체가 무력화돼요.
(범인은 언제나 제일 작은 녀석입니다.)
불일치의 종류
겪고 나서 정리해 보니 원인이 몇 갈래로 나뉘더라고요.
| 원인 | 예 | 서버에서는 |
|---|---|---|
| 브라우저 전역을 렌더 중에 읽음 | navigator · window · localStorage | 값이 없어서 기본값으로 감 |
| 시각을 렌더 중에 만듦 | new Date() · 상대 시간 표시 | 밀리초 단위로 다름 |
| 난수 | id 생성 · 셔플 | 매번 다름 |
| 로케일 의존 포맷 | 날짜 · 숫자 표기 | 서버 로케일로 그림 |
| 조건부 렌더에 환경 분기 | isMobile · isTouch | 판정 근거가 없음 |
공통점은 하나예요. 전부 "같은 입력이면 같은 출력"을 깨는 것들입니다. 이 SSR 구조는 그 순수성 가정 위에 서 있는데, 위 다섯 가지가 정확히 그 가정을 찌르는 거죠.
그리고 대가가 작지 않아요. React는 불일치를 만나면 서버 DOM을 신뢰하지 못하고 클라이언트 렌더로 복구를 시도합니다. 서버가 완성한 첫 페인트를 브라우저가 다시 그리는 셈이라, SSR에 들인 비용 자체가 무력화돼요.
수정 — 판정을 hydration 이후로 미룹니다
처방은 정석대로예요. 렌더 중에 브라우저 환경을 읽지 않고, hydration이 끝난 뒤 effect에서 읽습니다.
const [platform, setPlatform] = useState<string>();
const kbdLabel = blogSearchShortcut.kbd.label.get(platform);
useEffect(() => {
setPlatform(navigator.platform);
// ...
}, []);클라이언트 첫 렌더도 undefined로 시작해 서버 출력과 항상 일치하고, hydration 이후에야 실제 플랫폼으로 갱신돼요. 수정은 6줄입니다.
트레이드오프는 있어요. Mac 사용자는 첫 페인트에서 잠깐 Ctrl K를 봤다가 ⌘K로 바뀌는 걸 봅니다. User-Agent를 서버에서 스니핑해 처음부터 맞출 수도 있지만, 캐시되는 공개 HTML이 요청 헤더에 따라 갈라지는 비용을 배지 하나 때문에 치를 이유가 없었어요.
재발 방지로 회귀 테스트도 넣었습니다. navigator를 MacIntel로 스텁하고, 서버 렌더 출력에 ⌘K가 아닌 Ctrl K가 남는지 확인해요. 지키려는 명제는 "서버 렌더는 실행 환경의 platform에 영향받지 않는다"예요. 렌더 경로에서 다시 navigator를 읽는 회귀는 배포 전에 테스트에서 걸립니다.
다른 처방들도 검토했어요
effect로 미루는 게 정석이긴 한데, 다른 길도 있어서 비교해 봤습니다.
| 처방 | 되는가 | 안 고른 이유 |
|---|---|---|
| 경고를 끄는 속성 | 불일치는 그대로 남음 | 증상만 가리고 원인은 그대로 |
| 서버에서 User-Agent 스니핑 | 처음부터 맞출 수 있음 | 캐시되는 공개 HTML이 요청 헤더마다 갈라짐 |
| CSS 미디어 쿼리로 둘 다 그리기 | JS 없이 됨 | 키 조합 표기는 CSS로 판정할 수 없음 |
| effect 이후로 미루기 | 확실함 | 첫 페인트에서 잠깐 다른 값이 보임 |
두 번째가 제일 아쉬웠어요. 사용자 경험만 보면 이게 제일 낫거든요. 그런데 공개 페이지 HTML은 캐시되는 자원이라, 헤더에 따라 응답이 갈라지면 캐시 적중률이 통째로 떨어집니다. 배지 하나 때문에 치를 값이 아니었어요.
세 번째는 진지하게 봤는데, 맥이냐 아니냐를 CSS로 알 방법이 없더라고요. 화면 크기나 포인터 종류는 알 수 있어도 키보드 수식키는 못 봅니다.
그래서 네 번째로 갔고, 트레이드오프를 명시적으로 받아들였어요. 맥 사용자는 첫 페인트에서 잠깐 Ctrl K를 봤다가 ⌘K로 바뀌는 걸 봅니다.
남은 생각
흔한 버그고 수정도 특별할 게 없어요. 그래도 기록하는 이유는, 이 버그가 구조의 약점을 정확히 찌르기 때문이에요. 이 SSR 구조는 "같은 입력이면 같은 출력"이라는 순수성 가정 위에 서 있고, 컨텍스트 JSON 주입은 데이터 쪽 입력을 강제로 일치시키는 장치거든요. 그런데 데이터가 아닌 환경(navigator, 시각, 난수)이 렌더에 스며드는 건 계약 바깥이라, 아무도 막아주지 않아요.
그래서 규칙을 세웠어요. 렌더 안에서 브라우저 전역을 읽는 코드는 전부 의심 대상. 환경 의존 값은 "서버와 같은 기본값으로 렌더한 뒤 effect에서 동기화"가 기본 패턴. 첫 페인트를 SSR로 완성하겠다는 목표는 결국 이런 작은 순수성 규율의 합으로 지켜집니다.
그래서 규칙을 검사로 바꿨어요
"렌더 안에서 브라우저 전역을 읽지 말자"를 문서에만 두면 반년 안에 또 들어옵니다. 그래서 두 겹으로 뒀어요.
// 1. 렌더 경로에서 브라우저 전역 접근 금지 (정적 검사)
{
selector: "MemberExpression[object.name=/^(navigator|window|localStorage)$/]",
message: "렌더 중에 읽지 마세요. effect 안으로 옮기세요.",
}
// 2. 회귀 테스트 — 서버 렌더가 환경을 안 탄다는 명제를 고정
test("서버 렌더는 실행 환경의 platform 에 영향받지 않는다", () => {
stubNavigator({ platform: "MacIntel" });
const html = renderToString(<SearchButton />);
expect(html).toContain("Ctrl K"); // ⌘K 가 아니어야 합니다
});두 번째 테스트가 조금 이상하게 보일 수 있어요. 맥으로 스텁해 놓고 맥이 아닌 결과를 기대하니까요.
그런데 이게 정확히 지키려는 명제예요. 서버 렌더는 실행 환경이 무엇이든 같은 결과를 내야 합니다. 렌더 경로에서 다시 navigator를 읽는 코드가 들어오면 이 테스트가 먼저 실패해요.
그리고 후일담에 적은 날짜 표기 건도 같은 검사에 걸렸어요. 시각 포맷을 24시간제로 못 박은 것도, 결국 "환경마다 다르게 계산되는 값"을 없앤 거고요.
후일담 — 하나 더 잡았어요
이 글 이후에도 같은 계열을 하나 더 잡았어요. 날짜 표기요. 서버와 브라우저가 시각을 12시간제와 24시간제로 다르게 그리면 그 자체가 hydration 불일치가 돼요. 표기를 24시간제로 못 박아서 끝냈습니다. 범인은 늘 "환경마다 다르게 계산되는 값"이에요.
이걸로 "빠르게 느껴지게"는 닫아요. 느끼게 만드는 이야기로 시작해서, 진짜로 만들고, 그 대가로 생긴 버그까지 치웠습니다.
그리고 블로그 만들기도 여기서 닫아요
돌아보면 — 보던 페이지가 그 자리에서 편집 화면이 되는 것에서 시작해, 에디터를 플랫폼에 가두고, 새벽의 발행을 크론에게 맡기고, 영상을 받고, 하트와 댓글을 달고, 댓글창에 주민을 들이고, 글끼리 잇고, 찾아오는 문을 내고, 마지막으로 이 모든 걸 빠르게 만들었어요.
"완성"이 기능이 다 있다는 뜻은 아니에요. 더 이상 "아직 이건 안 돼요"라는 변명 없이 독자를 맞을 수 있는 상태 — 거기까지가 이 여정의 약속이었고, 여기서 닫습니다.
블로그는 이제 만들 것이 아니라 굴릴 것이에요. 만들기의 시즌은 끝났고, 다음은 운영의 시즌입니다. 집계와 인사이트, 구독 발송, 검색 노출 같은 이야기들은 "arcaC 블로그 운영기"에서 만나요.


