레시피 목록

the node before which the new node is to be inserted is not a child of this node 에러를 해결해 보자

2025년 6월 2일 5분 읽기
디버깅

어느 날 외국 유저가 CS 채널에 문의를 남겼습니다. 특정 페이지에서만 항상 이런 오류가 발생한다는 거였습니다.

code
the node before which the new node is to be inserted is not a child of this node

재현이 안 되는 버그였다

처음엔 재현이 어려워서 정확한 원인을 파악하기가 쉽지 않았습니다. 같은 페이지를 몇 번을 들어가 봐도 제 화면에서는 아무 일도 일어나지 않았으니까요.

다양한 테스트 케이스를 시도해본 끝에 조건 하나를 찾았습니다. 브라우저 자동 번역 기능이 활성화된 상태에서 해당 페이지로 접근했을 때 발생한다는 사실이었습니다.

현재 회사 플랫폼은 웹과 앱(웹뷰)로 구성돼 있고, 외국 유저는 모두 웹으로만 접근 가능합니다. 아직 다국어를 지원하지 않아서 외국 유저는 한글을 그대로 보거나 브라우저 자동 번역을 써야 합니다. 저에게는 없던 조건이 그들에게는 기본값이었던 겁니다.

주석을 하나씩 지우다 만난 한 줄

문제가 되는 페이지의 컴포넌트들을 하나하나 주석 처리하면서 좁혀나갔습니다. 그리고 문제의 코드를 찾았습니다.

html
<span>{productValue} 개</span>

JSX에서 {productValue} 개처럼 표현식과 정적 문자열을 나란히 쓰면, React는 이 둘을 각각 별도의 자식으로 처리합니다. 표현식 {productValue}는 동적으로 변할 수 있는 값이고, " 개"는 변하지 않는 리터럴이라서 React가 별도로 관리해야 할 두 개의 노드로 분리되는 겁니다. 실제 DOM에서는 이렇게 렌더링됩니다.

code
<span>
  #text: "3"
  #text: " 개"
</span>

브라우저 자동 번역 기능은 이 구조를 그대로 유지하지 않습니다. "3 개"를 "3 pcs"로 번역할 때 두 텍스트 노드를 하나로 합치거나 기존 노드를 제거하고 새로운 노드를 삽입하는 방식으로 DOM을 직접 수정합니다.

문제는 이 시점에서 React의 fiber tree와 실제 DOM이 어긋나기 시작한다는 겁니다. React는 내부적으로 가상 DOM 트리(fiber tree)에 각 DOM 노드에 대한 참조를 유지하니까요. 번역 기능이 DOM을 변경하면 실제 DOM 구조가 달라지지만, React의 fiber tree는 여전히 번역 이전의 노드 참조를 들고 있습니다. 이후 productValue가 바뀌거나 컴포넌트가 리렌더링되면, React는 fiber tree에 저장된 참조를 기준으로 DOM 조작을 시도하죠. 그런데 번역 과정에서 해당 노드가 이미 분리됐거나 다른 구조로 대체된 상태라, insertBefore 같은 DOM API가 "그 노드는 이 부모의 자식이 아니다"라는 DOMException을 던지게 되는 겁니다.

템플릿 리터럴 하나로 끝났다

해결 방법은 간단합니다. 두 개로 나뉜 텍스트 노드를 React가 하나의 노드로 처리하게 만드는 겁니다.

html
<!-- AS-IS: React가 두 개의 텍스트 노드로 분리 -->
<span>{productValue} 개</span>

<!-- TO-BE: React가 하나의 텍스트 노드로 처리 -->
<span>{`${productValue} 개`}</span>

템플릿 리터럴로 감싸면 표현식과 정적 문자열이 평가 시점에 하나의 문자열로 합쳐집니다. React 입장에서는 단일 동적 표현식을 받는 것과 같으니까 DOM에 텍스트 노드를 하나만 생성하죠. 번역 기능도 이 텍스트를 하나의 덩어리로 인식해서 처리하고, React가 나중에 참조하는 노드도 변하지 않으니 충돌이 발생하지 않습니다.

같은 모양은 미리 다 바꿔두는 게 낫다

비슷한 구조를 다른 곳에서도 쓰고 있다면 같은 방식으로 예방할 수 있습니다.

tsx
// 피해야 할 패턴
<div>{count}개</div>
<div>{price}원</div>
<div>{name}님</div>

// 권장 패턴
<div>{`${count}개`}</div>
<div>{`${price}원`}</div>
<div>{`${name}님`}</div>

사실 그동안 브라우저 자동 번역을 많이 썼지만, 이로 인해 React 내부 구조와 충돌이 생길 수 있다는 건 생각하지 못했습니다. 다국어를 지원하지 않아서 외국 유저가 자동 번역에 의존하는 서비스라면, 번역 기능이 DOM을 직접 건드리는 방식으로 동작한다는 점을 염두에 두고 JSX를 작성하는 게 좋습니다.


React는 자기가 기억하는 노드를 찾아간다

이 오류가 왜 발생하는지 좀 더 깊이 이해하려면 React가 DOM을 관리하는 방식을 알아야 합니다.

React는 컴포넌트 트리를 fiber 트리라는 내부 자료구조로 관리합니다. 각 fiber 노드는 실제 DOM 노드에 대한 참조(stateNode)를 들고 있고, 리렌더링이 일어날 때 이 참조를 기준으로 DOM을 조작하죠.

code
fiber 트리                   실제 DOM
─────────────                ──────────────
<span>                       <span>
  fiber(#text: "3")    →       #text: "3"
  fiber(#text: " 개")  →       #text: " 개"

브라우저 번역 기능은 React 바깥에서 DOM을 직접 수정합니다. "3 개"를 "3 pcs"로 번역할 때, 기존 두 텍스트 노드를 제거하고 새로운 단일 노드를 삽입하는 식입니다.

code
번역 후 실제 DOM
──────────────
<span>
  #text: "3 pcs"   ← 번역이 삽입한 새 노드

그런데 React의 fiber 트리는 여전히 이전 참조를 들고 있습니다.

code
번역 후 fiber 트리           실제 DOM
─────────────                ──────────────
<span>                       <span>
  fiber(#text: "3")  → (이미 DOM에서 제거됨)
  fiber(#text: " 개") → (이미 DOM에서 제거됨)

이 상태에서 productValue가 바뀌어 리렌더링이 일어나면, React는 fiber 트리에 저장된 참조를 기준으로 DOM 조작을 시도합니다. insertBefore를 호출할 때 "이 노드를 이 부모의 이 위치에 삽입해라"라고 하는데, 참조하는 노드가 이미 DOM에서 제거돼서 부모의 자식이 아닌 상태니까 오류가 나는 거죠.

바깥에서 DOM을 건드리는 건 전부 같은 위험을 안는다

같은 원리로 충돌이 발생할 수 있는 다른 상황들도 있습니다.

jQuery가 React의 영역에 손을 댈 때

레거시 코드베이스에서 일부 기능은 jQuery로, 다른 부분은 React로 관리하는 경우입니다. jQuery가 React가 관리하는 DOM 영역을 직접 수정하면 같은 문제가 생깁니다.

javascript
// React가 관리하는 영역을 jQuery가 건드리면 위험
$('#react-root').find('.product-count').text('품절');
// React의 다음 리렌더링에서 fiber 참조와 DOM이 어긋남

채팅 위젯과 분석 툴도 결국 같은 일을 한다

Intercom, Zendesk 같은 채팅 위젯이나 Google Optimize 같은 A/B 테스트 도구는 <body> 아래에 DOM 요소를 삽입하거나 기존 요소를 수정합니다. React가 관리하는 영역과 겹치지 않으면 괜찮지만, 겹치면 예상치 못한 충돌이 생길 수 있습니다.

관리 영역을 명확히 갈라주면 된다

외부 DOM 조작이 불가피한 경우엔 React 컴포넌트 바깥에서 처리하거나, 해당 영역을 React가 관리하지 않는 독립된 DOM 노드로 분리하는 게 안전합니다.

tsx
// React 컴포넌트 안에서 ref를 통해 명시적으로 관리 영역을 넘겨주는 방식
function ThirdPartyWidget() {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (containerRef.current) {
      // 서드파티 라이브러리가 이 컨테이너만 관리하도록
      thirdPartyLib.init(containerRef.current);
      return () => thirdPartyLib.destroy(containerRef.current);
    }
  }, []);

  return <div ref={containerRef} />;
}

마치며

이번 버그가 흥미로웠던 건 원인이 "우리 코드"가 아니었다는 점입니다. JSX 문법, 브라우저 번역 기능, React 내부 구조 — 세 가지가 맞물려서 발생한 문제였습니다.

React가 DOM을 관리하는 방식, 그러니까 fiber 트리의 참조 기반 조작을 이해하면 비슷한 증상이 나타났을 때 범위를 빠르게 좁힐 수 있습니다. "외부에서 DOM을 건드리는 무언가가 있지 않을까" 하는 방향으로 말이죠.

재현이 안 되는 버그를 만난다면, 내 환경에는 없고 사용자 환경에만 있는 것이 무엇인지부터 세어보면 좋겠습니다. 저는 그 목록에 번역 기능이 있으리라고는 생각도 못 했습니다.