전화번호 텍스트 하나 때문에, Safari에서만 터지던 Hydration Error 36건 추적기

Do Young·2026년 7월 3일

refactor

목록 보기
2/4

React 하이드레이션 에러가 Safari에서만, 그것도 전 페이지에서 한 달 넘게 울리고 있었다.
범인은 코드가 아니라 브라우저였고, Sentry가 가리킨 곳은 전부 틀렸다. 추적 과정을 그대로 적는다.

Sentry가 보여준 서버 렌더링(왼쪽) vs 클라이언트 렌더링(오른쪽). 왼쪽은 텅 비어 있다.
이 대비가 첫 번째 함정이었다:
서버 vs 클라이언트 Slider Diff — 왼쪽은 빈 화면, 오른쪽은 아이템 가득


TL;DR

실서비스 전 페이지에서 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>

1. 발단 — E2E 점검 중 정체불명 예외 2건

전체 사이트를 게스트 플로우로 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가 사실 꺼져 있었다 (펼치기)

정작 대시보드를 보려니 더 큰 문제가 있었다.

  1. 로컬 dev 트래픽이 Sentry로 전송되고 있었다. → init 3곳(client/server/edge)에
    enabled: NODE_ENV === "production" 가드를 넣어 차단.
  2. QA 환경에는 SENTRY_DSN env가 없어 Sentry가 조용히 꺼져 있었다.
    SDK는 로드되는데 dsn: undefined라 클라이언트가 안 만들어지는 상태.
    진단법: window.__SENTRY__는 있는데 active client가 없고, envelope 요청이 전혀 안 나감.
  3. 수집 테스트: 콘솔에서 의도적으로 에러를 던져
    (throw new Error('[sentry-e2e-test] ...')) envelope POST 200 →
    release/environment/url 메타까지 정확히 잡히는 것 확인 후 Resolve.

E2E 테스트 에러가 대시보드에 잡힌 모습

계측이 살아난 뒤에야 진짜 알람이 보이기 시작했다.

2. 발견 — 실서비스에서 한 달째 울리고 있던 진짜 알람

계측을 살리고 대시보드를 열자 이미 이벤트가 쌓여 있었다.

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%, 나머지는 다른 페이지들에 분산

Sentry 이슈 개요 — 36건, Safari 태그 분포

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

Sentry 이슈 상세 워크스루

하이드레이션 실패는 React가 클라이언트에서 트리 전체를 다시 그리게 만든다.
즉 모바일 첫 로드에서 성능 저하 + 깜빡임으로 이어지는 실질적 문제다. Chrome에서는 0건.

3. 함정 — Sentry의 diff 하이라이트를 믿지 마라

Sentry는 하이드레이션 에러에 서버/클라이언트 HTML diff를 보여준다. 열어봤더니:

  • Slider Diff(맨 위 이미지): 서버는 히어로가 빈 화면, 클라이언트는 떠다니는 아이템이 가득
  • HTML Diff: 프로모 배너 영역에 background-color:#250a00 vs rgb(37, 10, 0),
    top:0 vs top: 0px 같은 차이가 새빨갛게 하이라이트

Sentry HTML Diff — 정규화 노이즈가 하이라이트된 모습

둘 다 원인이 아니었다.

  1. 떠다니는 재료는 useEffect 안에서 랜덤 배치를 생성하는 클라이언트 전용 렌더다.
    서버엔 존재할 수 없으니 하이드레이션 불일치를 만들 수 없다. 클라이언트 스냅샷은
    에러 시점의 DOM이라 이미 effect가 돌아 아이템이 있을 뿐이다.
    ㅌ
    홈 상단 — 떠다니는 재료 영역
  1. 색상 표기·px 단위 차이는 서버 스냅샷(HTML 문자열)과 클라이언트 스냅샷(라이브 DOM의
    computed style)의 직렬화 방식 차이다. #250a00과 rgb(37, 10, 0)은 같은 색이다.
    Sentry 정규화 노이즈일 뿐.
  2. 하이라이트가 가리키던 프로모 배너 컴포넌트는, 코드를 열어보니 랜덤도 시간도
    window 접근도 없는 완전 정적 컴포넌트였다.

💡 교훈 1: Sentry의 하이드레이션 diff는 "이 근처"라는 힌트일 뿐, 하이라이트를 그대로 믿으면 안 된다.

4. 배제 — 로컬의 예외 2건은 범인이 아니었다

로컬 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건 = 확장프로그램 노이즈로 종결. 실서비스 이슈와는 별개였다.

5. 추리 — 태그 분포가 가리킨 곳

남은 단서를 다시 정렬했다.

단서함의
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만 / 전 페이지 / 푸터의 숫자 텍스트.

6. 수정 — 두 파일, 사실상 두 줄

// 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"/>

7. 정리 — 같은 부류의 다른 범인들

"하이드레이션 전에 DOM을 바꾸는" 대표 원인은 이것 말고도 많다. 하이드레이션 에러를 만나면
이 목록부터 의심해보길:

  • Safari 전화번호/주소/이메일 자동 감지 (이번 케이스 → format-detection 메타로 차단)
  • 브라우저 확장 (Grammarly, 다크모드, 번역기 — chrome-extension:// 스택으로 판별)
  • Google Translate 자동 번역
  • 렌더 중 Date.now() / Math.random() 사용
  • typeof window 분기로 서버/클라이언트가 다른 트리를 렌더
  • 잘못된 HTML 중첩 (<p> 안의 <div> 등 — 브라우저가 파싱하며 교정)

마무리

세 줄로 요약하면:

  1. Hydration Error가 특정 브라우저에서만 뜨면, 코드가 아니라 그 브라우저가 DOM을 건드리는지 의심하라.
  2. Sentry의 diff 하이라이트는 방향만 알려준다. 하이라이트 자체를 범인으로 지목하지 마라.
  3. format-detection 메타 한 줄이, 전 페이지 하이드레이션 에러를 막는다.

여러분도 format-detection 빼먹고 Safari에서 조용히 당하고 있을지도 모릅니다...ㅎㅎ
비슷한 삽질담이나 "이런 브라우저 자동 변환도 있더라" 하는 케이스가 있으면 댓글로 공유해주세요. 🙌

profile
풀스택 개발자

0개의 댓글