Deallo

안정성 · 호환성

Chrome 번역이 React 앱을 죽이는 문제 — DOM API 경계 가드

영어 단일 UI 서비스라 사용자가 Chrome 페이지 번역을 켜고 쓸 가능성이 높았다. 그런데 번역을 켜면 화면 전환·조건부 렌더링 순간 앱 전체가 크래시했다. 컴포넌트를 고쳐서 될 일이 아니었다. 원인은 React fiber 안에 있었고, 출구는 웹 표준 API 두 개의 경계에 있었다.

증상

Chrome(또는 Whale 파파고) 번역을 켠 상태에서:

  • 메뉴를 누르거나 목록 → 상세로 전환하는 순간 앱 전체 크래시
  • 일부 화면은 아무것도 안 눌러도 로딩 문구가 로테이션되다 자폭
  • 프로덕션에선 "client-side exception" 화면으로 앱 사망

에러는 두 종류였다.

NotFoundError: Failed to execute 'insertBefore' on 'Node': ... not a child of this node
NotFoundError: Failed to execute 'removeChild' on 'Node': ... not a child of this node

원인 (핵심 한 줄)

브라우저 번역기는 페이지의 텍스트 노드를 <font>번역문</font>으로 치환하고 원본 노드를 DOM에서 분리한다. React는 fiber에 자신이 만든 DOM 노드의 직접 참조를 들고 있어서, 다음 커밋에서 그 (이미 고아가 된) 참조로 removeChild/insertBefore를 호출하면 브라우저가 NotFoundError를 던지고, 커밋 단계의 uncaught 예외라 앱 전체가 죽는다.

React는 "자기가 만든 DOM은 자기만 만진다"를 전제로 설계됐다. 번역기·확장프로그램이 그 전제를 깬다. (React 공식 미해결 이슈 facebook/react#11538, React 19에서도 동일.)

어디서 막을 것인가 — 레이어 선택

후보커버리지판정
translate="no"로 영역 제외해당 영역만 (번역 사용자 경험 훼손)부분책
텍스트를 <span>으로 래핑수정한 곳만 — 모든 텍스트가 잠재 지뢰비현실적
DOM API 경계 가드전 화면 + 미래 화면 + 타 확장✅ 채택

핵심 판단: 크래시는 전부 단 두 개의 웹 표준 API(Node.prototype.removeChild, insertBefore)를 지나간다. 원인(어떤 번역기인가)을 보지 말고 결과 상태(부모 불일치)를 그 경계에서 처리하면 부류 전체가 한 번에 막힌다.

해결 — DOM prototype 가드

removeChild/insertBefore를 패치해, 부모-자식 관계가 깨진 경우 throw 대신 안전하게 no-op (또는 appendChild로 폴백)하도록 했다.

"use client";
import { useEffect } from "react";
 
export function DomTranslationGuard() {
  useEffect(() => {
    installDomTranslationGuard(); // 앱 생애주기 동안 유지
  }, []);
  return null;
}

설계 결정 두 가지:

  • 왜 모듈 스코프가 아니라 useEffect? 'use client' 컴포넌트도 SSR 때 서버에서 모듈이 평가된다. 모듈 스코프에서 Node.prototype을 만지면 DOM이 없는 Node.js 런타임에서 깨진다. useEffect는 하이드레이션 후 클라이언트에서만 실행되는 가장 단순한 경계다. 번역은 사용자가 로드 후 켜는 동작이라 타이밍도 충분하다.
  • 왜 cleanup(restore)을 안 하나? RootLayout에 마운트돼 수명 = 앱 수명이다. cleanup을 반환하면 StrictMode(dev)의 mount → cleanup → mount 사이클에서 가드가 잠깐 풀린다. 설치 멱등성(중복 설치 no-op)으로 처리하는 게 더 안전했다.

이 버그가 남긴 것

컴포넌트마다 래핑하는 전수 수정은 새 코드마다 재발한다. 모든 경로가 지나가는 단일 지점(웹 표준 API)을 찾아 거기서 결과 상태를 처리하면, 원인이 무엇이든(번역기·확장) 부류 전체를 한 번에 막을 수 있다.