프로덕션에서만 터지는 hydration 에러, 이진 탐색 알고리즘으로 잡는 법
로컬에선 멀쩡한데 프로덕션에서만 나는 React 하이드레이션 에러를 변인 통제와 이등분 탐색으로 30분 만에 격리한 실제 디버깅 절차를 정리합니다. 이 에러를 잡아낸 400줄짜리 자체 에러 모니터링 구성도 함께 공개합니다.
오후 8시, 운영 중인 사이트의 에러 모니터링 채널에 알림이 하나 도착했습니다.

React error #418은 하이드레이션 실패 에러입니다. 하이드레이션 실패는 서버가 렌더링한 HTML과 클라이언트가 그린 React 트리가 어긋날 때 발생합니다. 문제는 이 에러가 디버깅하기 유난히 까다로운 조건을 모두 갖추고 있었다는 점입니다.
프로덕션 번들이라 에러 메시지가 minified되어 원인 힌트가 없음
로컬에서 프로덕션 빌드를 돌려도 재현되지 않음
React가 클라이언트에서 트리를 다시 그려 자동 복구하므로 화면은 멀쩡함
화면이 멀쩡하니 사용자 제보도 없었습니다. 모니터링 체계가 없었다면 존재조차 몰랐을 버그입니다. 이런 에러를 추측 없이 잡는 절차를 실제 사례 그대로 정리했습니다. 절차를 소개하기 전에 이 알림이 도착한 경로부터 설명해 보겠습니다.
0단계, 감시망은 버그보다 먼저 세워둔다
이 사이트에는 Sentry 같은 외부 에러 모니터링 서비스가 없습니다. 계측을 위한 파일 하나와 API 라우트 하나, 스모크 테스트 스크립트 하나, 디스코드 웹훅으로 만든 400줄 남짓의 자체 구성이 전부입니다. 감시망은 크게 세 층으로 구성했습니다.
파일 | 역할 | 전체 | 주석·빈 줄 제외 |
|---|---|---|---|
| 첫째 층, 브라우저 계측 | 90줄 | 77줄 |
| 둘째 층, 서버 중계 + 디스코드 웹훅 | 160줄 | 132줄 |
| 셋째 층, 스모크 테스트 | 112줄 | 87줄 |
| 셋째 층 실행 워크플로 | 48줄 | - |
첫째 층, 실사용자 브라우저 계측: Next.js는 프로젝트 루트에 instrumentation-client.ts 파일을 두면 애플리케이션을 위한 코드보다 이 파일에 있는 코드가 먼저 실행되며, 어떤 페이지에 접근하든 먼저 실행되는 로직을 구현할 수 있게 도와줍니다. 모던웹연구소는 여기에 window 객체의 error·unhandledrejection에 리스너를 걸고 프로덕션에서만 동작하게 하여 간단한 계측 시스템을 만들었습니다.
// instrumentation-client.ts. 프로덕션에서만 계측을 켠다
if (process.env.NODE_ENV === 'production' && typeof window !== 'undefined') {
window.addEventListener('error', (event) => {
const error = event.error as Error | undefined;
const message = error?.message ?? event.message ?? 'Unknown error';
if (isNoise(message, error?.stack, event.filename)) return;
send({ url: window.location.href, message, stack: error?.stack,
userAgent: navigator.userAgent });
});
window.addEventListener('unhandledrejection', (event) => { /* 동일 패턴 */ });
}
리스너에 잡히는 에러 정보 전부를 디스코드 채널로 보내면 디스코드는 순식간에 소음으로 가득 찹니다. 그렇기 때문에 에러 정보를 보내기 전, 세 가지 에러는 거르기로 했습니다.
브라우저 확장 프로그램에서 난 에러 (스택에
chrome-extension://포함)GTM(Google Tag Manager)이 로드하는 분석 스크립트 자체의 에러 (스택에 우리 번들 경로
/_next/가 전혀 없는 경우)내용 없는 크로스 오리진 에러 (
Script error.한 줄뿐이라 조치 불가)
여기에 세션당 최대 3건 상한을 두었고, 에러 정보 전송은 페이지 이탈 중에도 유실되지 않도록 navigator.sendBeacon 메서드 호출을 먼저 시도한 뒤 실패하면 fetch keepalive로 폴백 처리하기로 했습니다.
둘째 층, 서버 중계 라우트: 브라우저에서 디스코드 웹훅을 직접 호출하면 웹훅 URL이 번들에 노출되어 누구나 채널에 스팸을 보낼 수 있습니다. 그래서 /api/monitor/client-error 라우트를 사이에 두었습니다. 이 라우트는 Origin과 페이지 URL이 우리 도메인인지 검사하고, headless 브라우저(스모크 테스트 수행 시 사용)의 보고를 제외하고, 같은 메시지는 10분간 중복을 제거해서 시간당 20건 상한을 넘지 않게 처리했습니다. 중복 제거는 인스턴스 메모리 기반(서버리스)이라 콜드 스타트 때 초기화되지만 알림 폭주를 막는 용도로는 충분하다고 판단했습니다. 이 필터를 통과한 에러만 페이지·메시지·스택·브라우저 정보와 함께 디스코드에 에러 정보가 전달되게 했습니다. 글 서두의 알림이 바로 이 형식입니다. hydration 계열 에러(#418·#423·#425)는 메시지 패턴으로 자동 분류해 '자동 복구됨. 방문자 브라우저의 번역·확장 프로그램이 원인일 수 있음. 같은 페이지에서 반복되면 사이트 버그 의심' 같은 해석 힌트를 알림에 함께 붙이기도 하였습니다. 새벽에 알림을 받았을 때 당장 일어나야 하는지 아침에 봐도 되는지를 알림을 통해 판단하기 위해서입니다.
셋째 층, 스모크 테스트: 실사용자 계측은 이미 터진 에러를 알려주는 층입니다. 그런데 에러는 방문자가 겪기 전에 따로 잡아야 하므로 별도 층이 필요합니다. 이를 위해 GitHub Actions에서 Playwright로 프로덕션 배포 성공 직후와 6시간마다 주요 페이지를 순회하며 클라이언트 익셉션과 에러가 나는 화면을 검사하게 했고, 실패하면 같은 디스코드 채널로 알림을 보내는 식으로 층을 구성했습니다. 순회는 URL을 입력해 직접 접속하는 게 아니라 Playwright의 도움으로 실제 링크를 클릭한 것처럼 이동하게 했습니다. Next.js의 소프트 내비게이션에서만 터지는 회귀가 있기 때문입니다.
집으로 치면 첫째 층은 화재경보기, 셋째 층은 정기 안전 점검입니다. 불이 난 뒤에야 알림이 울리는 장치와 불이 나기 전에 돌리는 점검은 서로를 대체하지 못합니다. 이번 에러는 화재경보기 쪽이 잡았습니다. 이제 에러 해결에 사용한 방법을 절차대로 소개해보겠습니다.
1단계, 프로덕션과 같은 조건을 만든다
로컬에서 재현되지 않는 프로덕션 에러를 마주했을 때 던져야 할 첫 질문은 "빌드 조건이 무엇이 다른가?"입니다.
Next.js에서 NEXT_PUBLIC_* 환경 변수는 빌드 시점에 번들에 들어갑니다. 로컬 빌드에는 없고 프로덕션 빌드에만 있는 값이 있다면 두 번들은 다른 코드일 수밖에 없습니다. 그런데 이 값은 어차피 클라이언트에 공개되므로 배포된 페이지에서 역추출이 가능합니다.
curl -s https://example.com/page | grep -oE 'GTM-[A-Z0-9]+'
# GTM-XXXXXXX위와 같은 명령어를 실행하면 손쉽게 Google Tag Manager ID가 추출됩니다. 같은 값을 넣고 로컬에서 다시 빌드를 돌리니 에러가 즉시 재현되었습니다.
NEXT_PUBLIC_GTM_ID=GTM-XXXXXXX npm run build && npm start여기서 흔한 함정이 있습니다. 'GTM이 켜져야 터지니 GTM이 원인'이라고 결론 내리는 것입니다. 아직은 그럴 수 없습니다. GTM은 재현 조건일 뿐 원인인지는 모르기 때문입니다. 실제로 나중에 밝혀진 원인은 GTM과 무관했습니다. GTM 스크립트는 head의 노드 수를 바꾸면서 잠복해 있던 다른 불일치를 크래시로 격상시킨 촉매였을 뿐이었습니다.
2단계, 용의자를 줄 세운다
에러는 특정 페이지에서만 발생하고 있었습니다. 같은 빌드에서 다른 페이지를 모두 검사해 어떤 페이지에서 에러가 나는지부터 확정했습니다.
/ → 정상
/introduction/getting-started → 정상
/features/plan-mode → 정상
/introduction/business-inquiry → 에러
에러가 난 페이지에만 있는 요소가 당연히 용의자 목록이 됩니다. 이 페이지에만 있던 것은 세 가지였습니다.
문의 폼 (클라이언트 컴포넌트,
useSearchParams사용)이메일 복사 버튼 (클라이언트 컴포넌트)
홍보 배너 (정적 마크업)

직감으로는 useSearchParams와 Suspense가 얽힌 복잡한 폼이니까 1번이 가장 수상하다 여겼습니다. 하지만 직감으로 접근하기 시작하면 안 됩니다.
3단계, 하나씩 제거하며 이등분한다
가장 수상한 것부터 하나씩 제거하고, 매번 다시 빌드를 하며 원인을 찾기 시작했습니다. 변인은 반드시 한 번에 하나만 바꿨습니다.
폼 제거 후 빌드 → 여전히 에러 (폼은 무죄)
이메일 버튼 문단 제거 후 빌드 → 에러 사라짐 (범인 격리)
가장 수상했던 폼이 실제로는 무죄였습니다. 직감으로 폼부터 뜯어고쳤다면 존재하지 않는 버그를 고치느라 시간을 태웠을 겁니다. 변인을 하나씩 제거하는 실험은 리빌드 시간이 들지만 결과가 이진적이라 해석에 논쟁의 여지가 없었습니다. 요소가 n개면 최악의 경우에 n번, 이등분하면 log n번이면 실험이 끝납니다.
4단계, minified를 벗기고 원문 진단을 받는다
범인이 있는 위치는 알았지만 '왜'는 아직 몰랐습니다. React의 dev 빌드는 하이드레이션 불일치 지점을 트리 다이어그램으로 정확히 짚어주기 때문에, 이걸 사용해보기로 했습니다. 그런데 GTM이 프로덕션 게이트(NODE_ENV === "production") 뒤에 있어 dev 서버에서는 로드되지 않는다는 점이 문제였습니다.
게이트를 임시로 열어서 문제를 해결했습니다. 커밋에만 포함시키지 않으면 됩니다.
// 조사용 임시 변경. 커밋 금지
const isProd = process.env.NODE_ENV === "production"
|| process.env.FORCE_ANALYTICS === "1";dev 서버가 '왜'에 대한 답을 주었습니다.
In HTML, <p> cannot be a descendant of <p>.
This will cause a hydration error.원인은 MDX 파일의 이 마크업이었습니다.
<p>
이 폼은 <CopyEmail email="..." />로 접수됩니다. 핵심 정보만 정리해 주시면
1차 답변과 다음 액션을 빠르게 안내드립니다.
</p>MDX는 여러 줄로 쓴 JSX 태그의 내부 텍스트를 블록으로 파싱해 문단 <p>를 하나 더 만듭니다. 결과는 <p><p>…</p></p>. HTML 명세상 p는 p를 품을 수 없으므로 브라우저 파서가 바깥 p를 강제로 닫아버리고, 서버가 보낸 DOM과 React가 기대하는 트리가 어긋나 hydration 에러가 발생했습니다. 한 줄로 합치면 문제가 해결됩니다.
<p>이 폼은 <CopyEmail email="..." />로 접수됩니다. 핵심 정보만 정리해 주시면 1차 답변과 다음 액션을 빠르게 안내드립니다.</p>
5단계, 고치기 전에 감시망부터 검증한다
수정 후, 배포하기 전에 한 가지를 먼저 확인했습니다. 감시망이 이 버그를 정말 잡아내는가. 고치기 전 코드를 대상으로 0단계 셋째 층의 스모크 테스트를 돌려 실패하는 것을 확인하고, 수정 후 통과하는 것을 확인했습니다. 탐지 못 하는 감시망은 다음 회귀 때 침묵합니다. 버그가 살아 있는 지금이 감시망의 탐지 능력을 검증할 유일한 기회입니다.

정리
이 절차의 핵심은 세 가지였습니다.
변인은 한 번에 하나만 바꾼다. 추측으로 여러 군데를 동시에 고치면 무엇이 효과였는지 영영 알 수 없습니다. 제거 실험은 느려 보여도 각 단계의 결론이 확정적이라 전체를 봤을 땐 가장 빠릅니다.
재현 환경을 프로덕션과 동일하게 만든다. '로컬에서는 안 나는데요'는 디버깅의 끝이 아니라 시작입니다. 빌드 시점 환경 변수, 분석 스크립트, CDN처럼 로컬과 프로덕션이 다른 지점을 하나씩 맞춰가면 재현 조건이 단서가 되어줍니다.
관찰 도구를 먼저 갖춘다. 이 버그는 자동 복구되는 유형이라 화면만 봐서는 영원히 모릅니다. 0단계에서 설명한 감시망이 없었다면 존재조차 몰랐을 것이고 이 글도 없었을 겁니다. Sentry 같은 외부 서비스 구독 없이도 계측 파일 하나와 API 라우트 하나면 자체적으로 에러 모니터링을 시작할 수 있습니다. 감시망의 규모보다 감시망이 있다는 사실이 먼저입니다.
이 글은 모던웹연구소 (www.modernweblabs.com)에서 처음 발행되었습니다. © 모던웹연구소. 무단 전재 및 재배포를 금합니다.
뉴스레터
엔터프라이즈 현장 전문가들이 검증한 노트, 격주 발행.
Claude Code, GitHub Copilot, AI 네이티브 엔지니어링 전략과 도입 사례를 격주로 정리해 보내드립니다.
모던웹연구소 · 컨설팅 안내
글을 읽었다면, 다음은 팀에 이식할 차례입니다.
이 글에서 다룬 방식을 우리 팀에 어떻게 적용할지, 짧은 대화부터 시작하면 됩니다.
함께할 수 있는 일
Claude Code · GitHub Copilot
2일 핸즈온 + AI 채점 기반 사내 인증
AI 네이티브 전략
운영 표준·측정·거버넌스 재설계
웹 플랫폼
Next.js 기반 풀스택 서비스 구축