개발 기술/사소하지만 놓치기 쉬운 개발 지식

[React] Google Translate가 바꾼 DOM, 왜 insertBefore에서 실패했을까?

by GicoMomg 2026. 5. 31.

0. 들어가며…

브라우저의 Google Translate 기능을 켠 상태에서 Button을 클릭했더니, 예상하지 못한 React DOM 오류가 발생했다.



문제가 된 Button은 클릭 시 로딩 상태를 보여주는 컴포넌트였다.

기본 상태에서는 Upload File 텍스트를 보여주고,
클릭하면 isPending 상태가 true로 바뀌면서 로딩 스피너와 Loading... 텍스트를 보여준다.

{isPending && <Spinner />}
{isPending ? "Loading..." : "Upload File"}



일반적인 상황에서는 문제가 없었다.

하지만 Chrome의 Google Translate 기능을 켠 상태에서 이 Button을 클릭하면, 화면 crash가 발생했다.

NotFoundError: Failed to execute 'insertBefore' on 'Node'
The node before which the new node is to be inserted is not a child of this node.

처음에는 Button의 로딩 처리 문제처럼 보였다.

하지만 원인을 따라가 보니 문제는 로딩 상태 자체가 아니라, Google Translate가 React가 렌더링한 DOM을 직접 변경한다는 점에 있었다.

 

React는 자신이 렌더링한 DOM 구조를 기준으로 다음 업데이트를 계산한다.

그런데 중간에 Google Translate가 TextNode를 다른 DOM 구조로 교체하면, React가 기억하는 DOM과 실제 브라우저 DOM이 달라질 수 있다.

이번 글에서는 Button의 loading 상태 전환 중 발생한 insertBefore NotFoundError를 통해, React와 Google Translate가 왜 충돌할 수 있는지 살펴본다.





1. 문제 원인과 해결 방안 알아보기

1) 재현 상황: 로딩 버튼과 Google Translate

문제가 발생한 조건을 정리하면 다음과 같다.

1. Button 내부에 텍스트 노드가 렌더링되어 있다.

2. Chrome의 Google Translate 기능을 켠다.

3. 사용자가 Button을 클릭한다.

4. isPending 상태가 true로 바뀌면서 Spinner와 Loading 텍스트를 렌더링하려고 한다.

5. React가 DOM을 업데이트하는 과정에서 NotFoundError가 발생한다.🚨🚨

 

즉, 문제는 단순히 Button에 로딩 상태가 있다는 점이 아니다.

핵심은 다음 두 조건이 함께 만족될 때 발생한다.

1. Google Translate가 Button 내부 TextNode를 편집한다.

2. 이후 React가 상태 변경에 따라 기존 TextNode를 기준으로 DOM을 삽입하거나 제거하려고 한다.

이제 이 두 조건이 왜 insertBefore NotFoundError로 이어지는지 살펴보자.




2) 문제 원인 살펴보기

(1) 브라우저 번역 기능은 TextNode를 교체할 수 있다

Google Translate 같은 브라우저 번역 기능은 텍스트를 직접 수정하거나,
기존 TextNode를 제거한 뒤 새로운 DOM 노드로 교체한다.

번역 기능을 켜기 전 Button의 DOM 구조는 단순하다.

<button>
  <svg />
  Upload File
</button>

DOM 트리로 보면 다음과 같이, Button 내부에 아이콘을 표현하는 svg와 텍스트 노드가 존재한다.

button
├── svg
└── TextNode("Upload File")

 

하지만 Google Translate가 적용되면 실제 DOM 구조가 달라질 수 있다.

<button>
  <svg />
  <font><font>파일 업로드</font></font>
</button>

DOM 트리로 보면 이렇게 바뀐다.

button
├── svg
└── font
     └── font
         └── TextNode("파일 업로드")

즉, 번역 기능을 켜는 것만으로도 React가 렌더링한 실제 DOM 구조가 외부에서 변경된다.

여기서 중요한 점은 기존 TextNode("Upload File")이 그대로 유지된 상태에서 텍스트만 바뀌는 게 아니라는 점이다.

Google Translate는 기존 TextNode를 제거하고, font 태그를 포함한 새로운 DOM 구조로 교체한다.



(2) React가 기억하는 구조와 실제 DOM 구조가 달라진다

문제는 이 시점부터 React가 기억하는 DOM 구조와 실제 브라우저 DOM 구조가 달라진다는 점이다.

React는 처음 Button을 렌더링할 때 다음과 같은 구조를 만들었다고 생각한다.

<button>
  <svg />
  Upload File
</button>
<!-- Dom 트리 -->
button
├── svg
└── TextNode("Upload File")

React는 이 TextNode를 자신이 만든 노드로 인식하고 있다.



하지만 Google Translate가 적용된 뒤 실제 브라우저 DOM은 다음처럼 바뀌어 있다.

<!-- Dom 트리 -->
button
├── svg
└── font
     └── font
         └── TextNode("파일 업로드")

즉, React가 처음 만들었던 TextNode("Upload File")은 더 이상 button의 직접 자식이 아니다.

결과적으로 React가 기억하는 구조와 번역 결과 구조가 달라진다.



(3) 문제 발생: NotFoundError가 발생한다

Button의 isPending 상태가 변경되면 React는 Button 내부를 다음과 같은 구조로 업데이트하려고 한다.

<button>
 <Spinner />
  Loading... 
</button>

 

이 과정에서 React는 기존 TextNode를 기준으로 새 노드를 삽입하거나, 기존 노드를 제거한다.

예를 들어 로딩 스피너를 기존 텍스트 앞에 추가하기 위해 다음과 같은 DOM 조작이 수행될 수 있다.

button.insertBefore(spinnerNode, oldTextNode);

 

여기서 중요한 점은 insertBefore의 두 번째 인자인 oldTextNode가 반드시 button의 자식 노드여야 한다는 것이다.

referenceNodeparentNode의 자식이어야 한다.

parentNode.insertBefore(newNode, referenceNode);

 

하지만 Google Translate가 적용된 뒤 실제 DOM은 이미 다음처럼 바뀌어 있다.

<button>
  <svg />
  <font><font>파일 업로드</font></font>
</button>

즉, React가 기억하고 있던 oldTextNode는 더 이상 button의 직접 자식이 아니다.

이 상태에서 React가 다음과 같은 DOM 조작을 시도하면, 브라우저 입장에서는 button 안에 spinnerNode를 삽입하려고 할 것이다.

button.insertBefore(spinnerNode, oldTextNode);

 

하지만 기준점으로 전달된 oldTextNode는 button의 자식이 아니라, NotFoundError가 발생한다.

NotFoundError: Failed to execute 'insertBefore' on 'Node'
The node before which the new node is to be inserted is not a child of this node.

 

정리하면, NotFoundError는 React가 로딩 상태를 처리하지 못해서 발생한 문제가 아니다.

정확히는 React가 기준점으로 삼은 DOM 노드가 Google Translate에 의해 이미 사라졌기에 발생한 문제다.



전체 흐름은 다음과 같다.

1. React가 Button 내부에 TextNode를 렌더링한다. 

2. Google Translate가 해당 TextNode를 다른 DOM 구조로 교체한다. 

3. React는 여전히 기존 TextNode가 존재한다고 기억한다. 

4. isPending 상태가 변경되며 React가 기존 TextNode 기준으로 DOM을 업데이트하려고 한다. 

5. 하지만 해당 TextNode는 더 이상 button의 자식이 아니므로 NotFoundError가 발생한다.




3) 해결 방안: TextNode를 직접 업데이트 기준점으로 삼지 않게 한다

방향은 Google Translate로 변경될 수 있는 TextNode를 React가 업데이트 기준점으로 삼지 않게 하는 것이다.

앞에서 살펴본 문제는 React가 기존 TextNode를 기준으로 insertBefore를 수행하려고 할 때 발생했다.

하지만 Google Translate가 적용된 뒤에는 oldTextNode가 더 이상 button의 자식이 아닐 수 있다.

button.insertBefore(spinnerNode, oldTextNode);

따라서 상태가 변경될 때 React가 TextNode 단위로 업데이트하지 않고, 변경 영역을 wrapper로 감싸야 한다.



(1) 변경되는 children을 wrapper로 감싼다

예를 들어 Button의 children을 렌더링하는 구조가 다음과 같다고 가정해보자.

<ButtonPrimitive>
  {(renderProps) =>
    typeof children === "function" ? children(renderProps) : children
  }
</ButtonPrimitive>

 

이 구조에서는 Button 내부의 아이콘과 텍스트가 직접 sibling으로 렌더링될 수 있다.

<button>
  <svg />
  Upload File
</button>

이 경우 Google Translate가 TextNode를 교체하고,
이후 React가 로딩 스피너를 삽입하면 앞서 본 것처럼 NotFoundError가 발생할 수 있다.

 

그래서 이를 방지하기 위해 Button에서는 상태에 따라 바뀌는 children을 wrapper 안에 렌더링한다.

const hasPendingState = typeof isPending === "boolean";

<ButtonPrimitive isPending={isPending}>
  {(renderProps) => {
    // children이 함수라면 현재 Button 상태를 전달해 실제 렌더링 결과를 만든다.
    // 함수형 children이 아니라면 전달받은 children을 그대로 사용한다.
    const renderedChildren =
      typeof children === "function" ? children(renderProps) : children;

    // isPending을 사용하지 않는 Button은 기존 DOM 구조를 유지한다.
    // 불필요한 wrapper를 추가하지 않기 위함이다.
    if (!hasPendingState) {
      return renderedChildren;
    }

    return (
      <span
        key={renderProps.isPending ? "pending" : "idle"}
        data-slot="button-content"
        style={{ display: "contents" }}
      >
        {renderedChildren}
      </span>
    );
  }}
</ButtonPrimitive>

이 코드의 핵심은 모든 Button에 wrapper를 추가하는 것이 아니다.

isPending 상태가 명시된 Button에 한해서만 동적인 children 영역을 wrapper로 감싼다.

이렇게 하면 React가 Google Translate에 의해 교체된 TextNode를 직접 업데이트 기준점으로 삼는 대신, wrapper subtree 단위로 다시 렌더링할 수 있다.



(2) key를 바꿔 subtree 단위로 다시 mount한다

wrapper만 추가하는 것으로는 충분하지 않을 수 있다. 중요한 부분은 key다.

// pending 상태가 바뀌면 key도 함께 바뀐다.
// React가 기존 TextNode를 세밀하게 업데이트하지 않고,
// wrapper subtree를 새로 mount하도록 유도한다.

<span key={renderProps.isPending ? "pending" : "idle"} >

isPending 상태가 바뀔 때 wrapper의 key도 함께 바뀌면,
React는 기존 children 내부를 업데이트하기보다 wrapper subtree를 새로 mount하는 방향으로 동작한다.

즉, 업데이트 단위를 TextNode 수준이 아니라 wrapper subtree 수준으로 끌어올리는 것이다.

 

idle 상태에서는 다음과 같은 구조가 된다.

<button>
  <span data-slot="button-content" style="display: contents">
    <svg />
    Upload File
  </span>
</button>

 

pending 상태에서는 wrapper의 key가 변경되면서 다음 구조로 새로 렌더링된다.

<button>
  <span data-slot="button-content" style="display: contents">
    <Spinner />
    Loading...
  </span>
</button>

이렇게 하면 Google Translate가 내부 TextNode를 다른 DOM 구조로 바꾸더라도,
React가 사라진 TextNode를 직접 기준점으로 삼아 insertBeforeremoveChild를 수행할 가능성을 줄일 수 있다.

핵심은 React가 업데이트해야 하는 단위를 개별 TextNode가 아니라, 상태에 따라 교체되는 wrapper subtree로 바꾸는 것이다.



(3) display: contents로 레이아웃 영향을 줄인다

wrapper를 추가하면 React가 추적할 수 있는 DOM 경계를 만들 수 있다.

하지만 Button처럼 내부 아이콘과 텍스트의 배치가 중요한 컴포넌트에서는 wrapper가 예상하지 못한 레이아웃 변화를 일으킬 수 있다.

예를 들어 Button 내부가 flexgap을 사용하고 있다면, 중간에 <span> wrapper가 생기는 것만으로도 기존 아이콘과 텍스트의 배치가 달라질 수 있다.

그래서 wrapper에는 다음 스타일을 적용했다.

style={{ display: "contents" }}

display: contents를 사용하면 DOM에는 wrapper가 존재하지만,
레이아웃 계산에서는 wrapper 박스가 없는 것처럼 동작한다.

 

다만 모든 Button에 wrapper를 추가할 필요는 없다.

if (!hasPendingState) {
  return renderedChildren;
}

isPending을 사용하지 않는 일반 Button은 기존 렌더링 구조를 그대로 유지하고,
문제가 발생할 가능성이 높은 경우에만 방어 로직을 적용한다.






2. 구현할 때 주의해야 할 패턴

이 문제는 특정 Button 컴포넌트에서만 발생하는 것이 아니다.

React가 관리하는 TextNode를 외부 기능이 변경한 뒤, React가 그 TextNode를 기준으로 DOM을 업데이트하려 하면 비슷한 문제가 발생할 수 있다.

 

(1) TextNode와 sibling element가 함께 바뀌는 패턴을 주의해야 한다

특히 상태에 따라 TextNode와 sibling element를 함께 삽입하거나 제거하는 패턴은 주의해야 한다.

예를 들어 다음과 같은 코드는 브라우저 번역 기능과 충돌할 가능성이 있다.

{isLoading && <Spinner />}
{isLoading ? "Loading..." : "Submit"}

이 코드는 로딩 상태에 따라 Spinner를 추가하고, 동시에 텍스트도 변경한다.

 

React가 기대하는 구조는 다음처럼 바뀐다.

idle 상태

button
└── TextNode("Submit")
loading 상태

button
├── Spinner
└── TextNode("Loading...")

하지만 Google Translate가 TextNode("Submit")을 다른 DOM 구조로 교체한 뒤라면,
React가 기존 TextNode를 기준으로 Spinner를 삽입하는 과정에서 문제가 발생할 수 있다.

 

다음처럼 텍스트만 조건부로 바꾸는 코드도 주의가 필요하다.

{isOpen ? "닫기" : "열기"}

이 경우 sibling element가 없더라도, React가 기존 TextNode를 갱신하거나 교체하는 과정에서 외부 DOM 변경과 충돌할 수 있다.

 

children 자체를 조건부로 렌더링하는 패턴도 조심해야 한다.

{condition && children}

children 안에 텍스트 노드가 포함되어 있고, 그 영역이 브라우저 번역 기능에 의해 수정된 뒤 조건이 바뀌면 React가 기대한 DOM과 실제 DOM이 달라질 수 있다.

물론 조건부 렌더링 자체가 문제인 것은 아니며, 다음 조건이 함께 만족될 때 발생할 가능성이 높다.

1. 문자열 TextNode가 직접 렌더링된다.

2. 해당 TextNode 주변에서 상태 기반 element 삽입/삭제가 일어난다.

3. Google Translate 같은 외부 기능이 TextNode를 다른 DOM 구조로 교체한다.

4. 이후 React가 기존 TextNode를 기준으로 DOM을 업데이트하려고 한다.

즉, 핵심은 조건부 렌더링 그 자체가 아니라, 외부 DOM mutation 이후
React가 이미 사라진 TextNode를 업데이트 기준점으로 삼는 상황이다.



(2) UI 라이브러리 컴포넌트라면 더 보수적으로 확인해야 한다

특히 UI 라이브러리 컴포넌트라면 더 보수적으로 접근하는 편이 좋다.

일반 서비스 화면에서는 특정 조건에서만 문제가 발생할 수 있다.

하지만 Button, Menu, Select, Tooltip, Dialog 같은 공통 컴포넌트는 다양한 서비스와 브라우저 환경에서 폭넓게 사용된다.

사용자가 브라우저 번역 기능을 켜거나, 확장 프로그램이 DOM을 수정하는 상황도 충분히 발생할 수 있다.

따라서 동적 상태가 자주 바뀌는 컴포넌트라면 다음 원칙을 고려할 수 있다.

1. 상태 전환으로 children의 sibling 구조를 크게 바꾸지 않는다.

2. 필요한 경우 안정적인 wrapper를 둔다.

3. 로딩, 선택, 토글 상태처럼 자주 바뀌는 영역은 wrapper 단위로 교체한다.

4. wrapper가 레이아웃에 영향을 준다면 `display: contents` 사용을 검토한다.

5. `isPending`, `isLoading` 같은 상태 prop을 사용하는 컴포넌트는 브라우저 번역 환경에서도 확인한다.

중요한 건 React가 외부에서 변경된 TextNode를 직접 업데이트 기준점으로 삼지 않게 만드는 것이다.

상태 전환이 잦은 영역에는 React가 안정적으로 추적할 수 있는 경계를 의도적으로 만들어두는 편이 더 안전하다.





3. 마치며…

이번 문제는 처음 보면 단순한 React DOM 오류처럼 보인다.

NotFoundError: Failed to execute 'insertBefore' on 'Node'

하지만 실제 원인은 React 내부 로직 자체가 아니라, React가 관리하던 DOM을
Google Translate가 외부에서 직접 변경했다는 데 있다.

React는 자신이 렌더링한 DOM 구조를 기준으로 다음 업데이트를 계산한다.
그런데 외부 기능이 그 DOM을 제거하거나 다른 구조로 교체하면, React가 기억하는 DOM과
실제 브라우저 DOM이 달라질 수 있다.

그 상태에서 React가 기존 TextNode를 기준으로 insertBeforeremoveChild를 수행하면
오류가 발생할 수 있다.

이번 사례에서는 isPending 상태가 있는 Button children을 wrapper로 감싸고,
pending 상태에 따라 key를 바꿔 subtree 단위로 교체하도록 수정했다.

 

핵심은 다음 한 문장으로 정리할 수 있다.

📌 React가 번역 기능에 의해 교체된 TextNode를 직접 DOM 조작의 기준점으로 사용하지 않게 한다.

브라우저 번역 기능, 확장 프로그램, 외부 스크립트처럼 React 바깥에서 DOM을 수정하는 요소는 언제든 존재할 수 있다.

따라서 상태 전환이 잦고 TextNode와 element 구조가 함께 바뀌는 컴포넌트라면,
React가 안정적으로 추적할 수 있는 DOM 경계를 의도적으로 만들어두는 것이 필요하다.



반응형

댓글