React 하이드레이션 에러가 Safari에서만, 그것도 전 페이지에서 한 달 넘게 울리고 있었다.
범인은 코드가 아니라 브라우저였고, Sentry가 가리킨 곳은 전부 틀렸다. 추적 과정을 그대로 적는다.
Sentry가 보여준 서버 렌더링(왼쪽) vs 클라이언트 렌더링(오른쪽). 왼쪽은 텅 비어 있다.
이 대비가 첫 번째 함정이었다:

실서비스 전 페이지에서 Safari 사용자에게만 발생하던 Hydration Error의 원인은
Safari의 전화번호 자동 감지(data detector) 였다. 푸터에 일반 텍스트로 렌더된
대표전화 02-1234-5678(그리고 전화번호처럼 생긴 계좌번호)를 Safari가 하이드레이션 전에
<a href="tel:"> 링크로 감싸 DOM을 바꿔버렸고, React는 "서버 HTML과 다르다"며
트리 전체를 클라이언트에서 다시 그렸다.
수정은 사실상 두 줄이었다:
// 1) 자동 감지 자체를 끈다 (Next.js Metadata API)
export const metadata = { formatDetection: { telephone: false } };
// 2) UX는 명시적 tel: 링크로 보존
<a href={`tel:${tel.replace(/-/g, "")}`}>{tel}</a>
전체 사이트를 게스트 플로우로 E2E 점검하던 중, 로컬 dev에서 이상한 패턴을 봤다.
어느 페이지를 열어도(404 페이지까지) 콘솔에 EXCEPTION이 정확히 2건씩 찍혔다.
[EXCEPTION] (http://localhost:3000/:0:0)
Object
[EXCEPTION] (http://localhost:3000/:0:0)
Object
:0:0 — 소스 파일/라인 정보가 없음Object — Error 인스턴스가 아닌 객체가 throw됨원인을 몰라 "Sentry 대시보드에서 확인하자"로 적어두고 넘어갔다. (스포일러: 이 2건은 범인이 아니었다.)
💥 옆길 — 대시보드를 열었더니 Sentry가 사실 꺼져 있었다 (펼치기)정작 대시보드를 보려니 더 큰 문제가 있었다.
enabled: NODE_ENV === "production" 가드를 넣어 차단.SENTRY_DSN env가 없어 Sentry가 조용히 꺼져 있었다.dsn: undefined라 클라이언트가 안 만들어지는 상태.window.__SENTRY__는 있는데 active client가 없고, envelope 요청이 전혀 안 나감.throw new Error('[sentry-e2e-test] ...')) envelope POST 200 →
계측이 살아난 뒤에야 진짜 알람이 보이기 시작했다.
계측을 살리고 대시보드를 열자 이미 이벤트가 쌓여 있었다.
Hydration Error — "Hydration failed - the server rendered HTML didn't match the client."
- 총 36건, 첫 발생 한 달 전, 마지막 발생 10시간 전 (진행 중)
- environment: production 100%
- browser: Mobile Safari가 57%, 나머지도 전부 Safari 계열 (데스크톱 Mac Safari 포함)
- url: 홈이 43%, 나머지는 다른 페이지들에 분산

이슈 상세를 위에서 아래로 훑으면 개요(36건·Safari 태그) → Highlights → 서버/클라이언트 Diff까지
증거가 한눈에 이어진다:

하이드레이션 실패는 React가 클라이언트에서 트리 전체를 다시 그리게 만든다.
즉 모바일 첫 로드에서 성능 저하 + 깜빡임으로 이어지는 실질적 문제다. Chrome에서는 0건.
Sentry는 하이드레이션 에러에 서버/클라이언트 HTML diff를 보여준다. 열어봤더니:
background-color:#250a00 vs rgb(37, 10, 0),top:0 vs top: 0px 같은 차이가 새빨갛게 하이라이트
둘 다 원인이 아니었다.
useEffect 안에서 랜덤 배치를 생성하는 클라이언트 전용 렌더다.
#250a00과 rgb(37, 10, 0)은 같은 색이다.window 접근도 없는 완전 정적 컴포넌트였다.💡 교훈 1: Sentry의 하이드레이션 diff는 "이 근처"라는 힌트일 뿐, 하이라이트를 그대로 믿으면 안 된다.
로컬 Chrome에서 모바일 뷰포트까지 바꿔가며 재현을 시도했지만, React가 dev 모드에서 뿜는
상세 경고("Hydration failed because...")는 어디에도 없었다. 콘솔의 Object 예외 2건만 여전했다.
결정적 판별: 그 예외들은 Sentry의 dev 이슈 어디에도 잡히지 않았다.
페이지 컨텍스트에서 throw된 에러라면 Sentry가 반드시 수집한다(비-Error 객체도
"Object captured as exception"으로). 안 잡혔다는 건 페이지 밖 — 브라우저 확장의 isolated world에서
발생했다는 뜻이다. 같은 시점에 확장 특유의 에러도 관찰됐다:
Error: A listener indicated an asynchronous response by returning true,
but the message channel closed before a response was received
→ 로컬 미스터리 2건 = 확장프로그램 노이즈로 종결. 실서비스 이슈와는 별개였다.
남은 단서를 다시 정렬했다.
| 단서 | 함의 |
|---|---|
| Safari 계열에서만 발생 (Chrome 0건) | 브라우저 고유 동작 |
| 홈 43% + 나머지 전 페이지 분산 | 모든 페이지 공통 요소 (홈이 1위인 건 트래픽 비중) |
| 모바일 Safari 다수, 데스크톱 Safari도 존재 | iOS에서 더 공격적인 기능 |
"Safari가 하이드레이션 전에 DOM을 바꾼다" + "모든 페이지에 있다"의 교집합에서
고전적인 용의자가 떠올랐다: Safari의 전화번호 자동 감지(data detector).
Safari(특히 iOS)는 02-1234-5678 같은 패턴의 텍스트를 발견하면 자동으로
<a href="tel:...">로 감싼다. React 공식 문서도
하이드레이션 불일치 원인으로 "HTML을 수정하는 브라우저 기능"을 명시한다.
확인해보니 모든 페이지에 렌더되는 푸터에 정확히 그 패턴이 있었다:
// 모든 페이지에 렌더되는 Footer
대표전화 02-1234-5678 · ○○은행 1234-XX-XXXXXX
// └ 전화번호 패턴 └ 이것도 전화번호처럼 생김!
그리고 <meta name="format-detection">은 어디에도 없었다.
모든 조각이 맞아떨어졌다: Safari만 / 전 페이지 / 푸터의 숫자 텍스트.
// app/layout.tsx — Metadata API로 자동 감지 비활성화
export const metadata: Metadata = {
formatDetection: { telephone: false, address: false, email: false },
};
// → <meta name="format-detection" content="telephone=no, address=no, email=no"/>
자동 감지를 끄면 모바일에서 번호를 탭해 거는 UX가 사라지므로, 그건 명시적 링크로 보존한다:
// Footer
<a href={`tel:${tel.replace(/-/g, "")}`} className="hover:underline">
{tel}
</a>
이제 대표전화는 Safari가 임의로 감싼 링크가 아니라, 우리가 의도해서 만든 tel: 링크다.
검증(로컬 SSR HTML):
$ curl -s http://localhost:3000/ | grep format-detection
<meta name="format-detection" content="telephone=no, address=no, email=no"/>
"하이드레이션 전에 DOM을 바꾸는" 대표 원인은 이것 말고도 많다. 하이드레이션 에러를 만나면
이 목록부터 의심해보길:
format-detection 메타로 차단)chrome-extension:// 스택으로 판별)Date.now() / Math.random() 사용typeof window 분기로 서버/클라이언트가 다른 트리를 렌더<p> 안의 <div> 등 — 브라우저가 파싱하며 교정)세 줄로 요약하면:
format-detection 메타 한 줄이, 전 페이지 하이드레이션 에러를 막는다.여러분도 format-detection 빼먹고 Safari에서 조용히 당하고 있을지도 모릅니다...ㅎㅎ
비슷한 삽질담이나 "이런 브라우저 자동 변환도 있더라" 하는 케이스가 있으면 댓글로 공유해주세요. 🙌