Google 번역을 켰더니 React 앱이 오류 화면으로 바뀐 이유

한국어, 영어, 일본어, 중국어를 직접 제공하는 React 랜딩 페이지에서 예상하지 못한 문제가 생겼습니다. Chrome이 띄운 "이 페이지를 번역하시겠습니까?" 제안을 수락하면 번역이 시작되는 듯하다가, 잠시 뒤 사이트의 "일시적인 오류가 발생했습니다" 화면으로 바뀌었습니다.

처음에는 Google 번역 서비스가 실패하면서 보여 주는 화면처럼 보였습니다. 하지만 그 문구는 Google 것이 아니었습니다. 프로젝트의 오류 경계가 출력하는 문구였습니다. 즉 번역 자체의 실패라기보다, 번역 이후 React 렌더링이 예외를 던지고 있었습니다.

오류 화면의 주인부터 확인했다

문제 화면에 나온 문장을 코드 전체에서 검색했습니다. 같은 문구가 Next.js의 error.tsx와 global-error.tsx에 있었습니다.

이 한 번의 검색으로 범위가 크게 줄었습니다.

Google 번역기의 자체 오류 화면이 아니다.

서버가 잘못된 HTML을 보낸 것도 아니다.

번역이 페이지를 바꾼 뒤, 클라이언트의 React 트리에서 예외가 발생했다.

그 예외를 애플리케이션의 오류 경계가 잡아 대체 화면을 그렸다.

화면에 보이는 에러 문구가 누구의 것인지 먼저 확인하면 외부 서비스 문제와 애플리케이션 문제를 빠르게 나눌 수 있습니다.

Google 번역은 글자만 바꾸지 않는다

Chrome의 페이지 번역은 별도의 번역 스크립트를 페이지에 주입합니다. 이 스크립트는 화면에 있는 텍스트를 찾고, 번역 결과를 보여 주기 위해 실제 DOM의 텍스트 노드를 교체하거나 새 요소로 감쌉니다.

문제는 React 역시 같은 DOM을 자신이 관리한다고 가정한다는 점입니다. React는 이전 렌더 결과를 기준으로 다음 변경분을 계산합니다. 그런데 그 사이 Google 번역이 자식 노드를 교체해 버리면 React가 기억하는 구조와 브라우저의 실제 구조가 달라집니다.

그 상태에서 상태 변경, 애니메이션, 반응형 분기 같은 이유로 컴포넌트가 다시 렌더링되면 React는 이미 사라진 노드를 제거하거나, 더는 존재하지 않는 노드 앞에 새 노드를 넣으려 할 수 있습니다. 이때 브라우저에서는 다음 계열의 DOM 예외가 날 수 있습니다.

Failed to execute 'removeChild' on 'Node'
The node to be removed is not a child of this node

React 저장소에도 Google Translate가 DOM을 수정한 뒤 removeChild 예외가 발생하는 같은 종류의 사례가 오래전부터 보고돼 있습니다.

처음에는 잘 보이다가 나중에 깨지는 이유

이 문제는 일반적인 하이드레이션 오류와 시점이 다릅니다.

서버 HTML이 내려오고 React 하이드레이션이 정상적으로 끝납니다.

사용자가 번역을 실행합니다.

Google 번역이 이미 만들어진 DOM을 바꿉니다.

이후 React 컴포넌트가 다시 렌더링됩니다.

React가 기대한 노드와 실제 노드가 달라 DOM 조작이 실패합니다.

그래서 첫 화면은 멀쩡하고 번역된 문장도 잠깐 보일 수 있습니다. 동적인 다시 렌더링이 일어나는 순간에야 오류 경계로 들어갑니다.

suppressHydrationWarning으로 해결되지 않는다

React나 Next.js 프로젝트에서 DOM 불일치를 보면 suppressHydrationWarning을 먼저 떠올리기 쉽습니다. 하지만 이 옵션은 서버 HTML과 최초 클라이언트 렌더 결과가 다른 하이드레이션 경고를 제한하는 용도입니다.

이번 문제는 하이드레이션이 끝난 뒤 제3자 스크립트가 DOM을 바꿔서 발생합니다. 경고를 숨겨도 실제 노드 구조는 돌아오지 않으므로 해결책이 아닙니다.

인터넷에는 Node.prototype.removeChild나 insertBefore를 덮어써 예외를 무시하는 우회 코드도 있습니다. 이런 방식은 페이지 전체의 DOM 동작을 바꾸고, 이미 어긋난 React 상태를 감춘 채 실행을 계속하게 합니다. 다른 라이브러리까지 영향을 받을 수 있어 운영 코드에는 넣지 않았습니다.

텍스트를 항상 별도 요소로 감싸고 동적인 텍스트 노드를 줄이면 충돌 가능성을 낮출 수는 있습니다. 하지만 사이트 전체에서 외부 번역기가 어떤 DOM을 만들지 통제하기는 어렵습니다.

자체 다국어 사이트라면 번역 대상에서 제외한다

이 사이트는 이미 언어별 경로와 번역 문구를 직접 제공합니다. 한국어 /, 영어 /en, 일본어 /ja, 중국어 /zh처럼 사용자가 원하는 언어를 고를 수 있습니다.

이 경우 브라우저 자동 번역은 필요한 기능이 아니라 React DOM을 건드리는 두 번째 번역 계층입니다. 그래서 루트 레이아웃에서 페이지 번역을 명시적으로 제외했습니다.





{children}


두 설정의 역할은 조금 다릅니다.

는 Google에 페이지 번역 제안을 하지 말라는 신호입니다.

translate="no"는 해당 요소와 자손의 텍스트를 번역하지 말라는 HTML 표준 속성입니다.

lang={locale}는 그대로 유지합니다. 검색엔진과 보조 기술이 현재 문서 언어를 정확히 알아야 하기 때문입니다.

적용 뒤에는 각 언어의 빌드 결과 HTML에 lang, translate="no", notranslate가 모두 들어갔는지 확인했습니다.

모든 사이트에서 번역을 막아야 하는 것은 아니다

이 선택에는 조건이 있습니다. 자체 번역이 없는 단일 언어 사이트라면 브라우저 번역은 해외 사용자에게 중요한 접근성 기능입니다. React 충돌 가능성만으로 전체 페이지의 번역을 막는 것은 과한 대응일 수 있습니다.

그런 사이트라면 먼저 실제 콘솔 예외를 수집하고, 자주 바뀌는 컴포넌트와 번역 대상 텍스트를 분리하거나 문제가 나는 영역만 translate="no"로 제외하는 편이 낫습니다. 오류 경계도 페이지 전체가 아니라 작은 기능 단위로 나누면 한 컴포넌트의 충돌이 전체 화면을 덮는 일을 줄일 수 있습니다.

또한 이 속성은 브라우저와 번역 도구에 전달하는 힌트이지 보안 장치가 아닙니다. 일부 확장 프로그램이나 사용자가 강제로 실행한 도구는 무시할 수 있습니다.

정리

번역 후 사이트의 오류 화면이 보인다면 먼저 그 문구가 브라우저 것인지 애플리케이션 오류 경계 것인지 확인합니다. React 오류라면 번역 스크립트가 DOM을 바꾼 뒤 동적 렌더링과 충돌했을 가능성을 봅니다.

suppressHydrationWarning은 이 문제를 해결하지 않습니다. DOM 메서드를 전역 패치하는 방식도 피하는 편이 안전합니다. 이미 언어별 페이지를 제공하는 사이트라면 notranslate와 translate="no"로 브라우저 번역 계층 자체를 제외하는 것이 가장 단순하고 예측 가능한 해결책입니다.

정확한 예외 이름과 호출 위치까지 확정하려면 배포 환경의 브라우저 콘솔이나 오류 수집 도구에서 첫 번째 스택을 확인해야 합니다. 이번 사례에서는 자체 오류 경계 진입 시점, Google 번역의 DOM 변경 방식, React에 보고된 동일 계열의 충돌을 함께 근거로 원인을 좁혔습니다.

참고 자료

Chromium Translate Design Document

React issue #11538: Google Translate와 DOM 조작 충돌

HTML Living Standard: translate 속성

같이 읽기:

크롬에서는 멀쩡한데 아이폰에서만 흰 화면이 뜨는 이유