이번에 실리콘밸리에서 우리 서비스를 이야기 할 수 있는 자리가 생겼따.
너무나도 갑작스럽게 생긴 자리여서 영어 버전을 개발해서 대응하기는 어려웠고, 따라서 (구리긴하지만) Chrome에 내재된 Google Translate를 사용해서 서비스를 소개하기로 했다. (흑흑)
그런데 잘 동작할 것만 같던 이 Google Translate가 제대로 동작하지 않았다. 🥲
이상하게 저 번역 버튼을 누르고 나면 몇 초 뒤 혹은 다른 페이지로 이동했을 때 DOM Exception이 뜨면서 페이지가 마비됐다.
콘솔에 찍힌 오류는 아래와 같았다. (2번은 다른 사람들이 요 오류도 많이 난다길래 ^^.. 가져와봤읍니다.. 저는 1번 오류만 났어요..)
// 1. removeChild() 문제
DOMException: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.
// 2. insertBefore() 문제
DOMException: Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node.
콘솔에 나타난 오류와 개발자도구를 통해 확인해본 결과, 짐작하건대 Google Translate이 자체적으로 내가 만든 페이지에 한글이 입력되어있는 부분의 텍스트를 지우고, Translate한 내용을 <font> Tag로 감싸 append 해주면서 생긴 문제인 것 같았다.
Google Translate이 DOM Tree 에 있는 한글로 된 TextNode를 지우고 자기네들이 만든 내용(Google Translate가 새로 만든, 영어로 만들어진 TextNode)을 append 하게되면서, React상에서는 더이상 DOM Tree에 없는 TextNode(Google Translate가 지워버린, 한글로 만들어진 TextNode) 에 대한 참조를 계속 유지하고 있기 때문에, 나 여기 참조하고 있었는데..! 있었는데..! 하면서 당황하다가 페이지 오류가 나게 되는 것이라고 생각했다.
실제로 Google Translate로 사이트를 번역하면 아래와 같이 DOM이 변경된다.
// Translate 하기 전 DOM
<div>안녕하세요!</div>
// Translated 되고 난 이후의 DOM
<div>
<font style="vertical-align: inherit;">
<font style="vertical-align: inherit;">Hello!</font>
</font>
</div>
코드 상의 오류가 아닌, 리액트와 구글 번역 사이의 문제였기 때문에 리액트를 사용하는 많은 사람들이 함께 이런 오류를 겪고 있었다. 그래서인지 구글 검색을 통해 정말 다양한 방법들을 찾을 수 있었는데..!
그 중에서도 React 공식 Github에 Dan Abramov와 Shuhei의 답변이 눈에 띄었다.
답변에 대해서 이야기해보기 전에, 먼저 문제가 생긴 insertBefore() 와 removeChild()에 관해서 짚고 넘어가면 좋을 것 같아서, 한번 적어보도록 하겠다.
- Node.insertBefore(새로운 Node, 참조된 Node)
https://developer.mozilla.org/ko/docs/Web/API/Node/insertBefore
이 메소드는 참조된 Node 앞에 새로운 Node를 삽입한다.
(만약 참조된 Node가 null이라면 새로운 Node는 parent가 포함하는 자식 Node 중 가장 마지막에 추가된다.)
한번 직접 확인해보자.
구글 사이트에 들어가 검색하는 곳 앞에 "<span>새로운 노드입니다</span>" 를 붙여볼 것이다. (참고사진 1-1)
먼저 구글 사이트에 들어가 개발자도구를 켜서 아래를 따라해본다.
// 1. Search Element를 찾는다.
const search = document.querySelector('.RNNXgb')
// 2. Search Element를 포함하고 있는 parentNode를 찾는다.
const parentNode = search.parentNode
// 3. 내가 보여주고싶은 새로운 노드를 만든다.
const newNode = document.createElement('span')
// 4. 새로운 노드에 텍스트를 넣어준다.
newNode.textContent = '새로운 노드입니다.'
// 5-1. parentNode에 newNode를 search 앞에 insert한다.
// 참고사진 1-1.
parentNode.insertBefore(newNode, search)
// 5-2. 만약 참조된 Node가 null이라면, parentNode의 자식 Node 중 가장 마지막 순서에 들어간다.
// 참고사진 1-2.
parentNode.insertBefore(newNode, null)
// 5-3. 만약 참조된 Node가 undefined라면, Error가 뜬다.
// Uncaught DOMException: Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node.
parentNode.insertBefore(newNode, undefined)
// 5-4. 만약 참조된 Node가 parentNode의 자식 Node가 아니라면, Error가 뜬다.
// 예시) 구글 로고 이미지를 포함하는 Element를 가져온다. (parentNode 외부에 있는 Div)
const imgDiv = document.querySelector('.k1zIA')
// parentNode에 insert 해본다.
// Uncaught DOMException: Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node.
parentNode.insertBefore(newNode, imgDiv)
이 과정을 통해 내가 직면했던 오류는 참조된 Node가 undefined 였거나,
parentNode의 자식이 아닌 Node에 무언가를 집어넣으려 했을 때 발생한다는 것을 알게 되었다.
- Node.removeChild(DOM에서 지워질 child)
https://developer.mozilla.org/en-US/docs/Web/API/Node/removeChild
요 메소드는 Node.remove() 메소드와 헷갈릴 가능성이 좀 있다. 비슷하게 생겨먹었기 때문^^... ㅎㅎ,,
remove()는 Node를 메모리에서 삭제하고 종료하는 반면, removeChild()는 Node를 삭제하는 것이 아니라, parent Node와의 부모-자식 관계를 끊어 DOM Tree에서 해제하는 것 일 뿐이다. 즉, 메모리에 그대로 존재한다.
removeChild()는 그 반환값으로 관계를 해제한 Node의 참조를 반환하게 되는데, 이 반환값을 어디에 저장해두고 사용하지 않으면 Garbage Collector에 의해 나중에 메모리에서 삭제된다.
반환된 Node의 참조값은 다른 변수에 담아 다른 DOM 위치에 붙일 수 있다.
이 또한 직접 개발자도구에서 확인해보자.
// 1. 검색 input을 찾는다.
const input = document.querySelector('.RNNXgb')
// 2-1. input의 parentNode에서 input을 찾아 removeChild한다.
// 검색 input이 원래 자리에서 사라진 것을 볼 수 있다.
// (반환 값은 input의 reference로, inputRef에 저장된다.)
const inputRef = input.parentNode.removeChild(input)
// 2-2. input의 parentNode에서 undefined, null을 지우려고 할 때에는 오류가 생긴다.
// Uncaught TypeError: Failed to execute 'removeChild' on 'Node': parameter 1 is not of type 'Node'.
input.parentNode.removeChild(null)
input.parentNode.removeChild(undefined)
// ...
// 3. 저장된 inputRef를 document.body에 붙여넣는다.
document.body.prepend(inputRef)
removeChild() 도 마찬가지로 만약 parentNode가 자신이 포함하지 않는 Node를 지우려고 하거나, undefined 또는 null을 지우려고 할 때에는 오류가 발생한다.
아래는 Shuhei가 CodeSandbox에 해당 문제를 직접 만들어 본 내용이다. 확인해보면서 많이 도움이 됐다.
https://codesandbox.io/s/74k8lz417x
https://codesandbox.io/s/5kljl5l5yx
https://codesandbox.io/s/q7n4mk7m86
해결방법
1. Google Translate 막아버리기
사실 당장 Globalization이 필요해서 영어 번역을 제공해야하지 않고, 단순히 어떤 사용자가 저걸 누르게 되었을 때를 대비하는거라면, Google Translate을 하지 못하도록 막아두게 하는 방법이 있다.(실제로 이 답변이 정말 많이 보였다)
하지만 나는 일단 영어 번역이 필요하기도 했고, 이렇게 단순히 막아두는 방식으로 문제를 해결하고 싶지 않아서 이 방법을 고려하진 않았다.
2. 모든 TextNode 들을 Tag들로 감싸기
TextNode들을 Tag들로 감싸면, Google Translate을 통해 그 내용들이 <font> 태그로 대체되더라도 React에서 참조하는 노드가 그대로 DOM Tree에 남아있기 때문이다. 프로젝트가 워낙 커서 해당 내용을 모두 찾아서 바꾸기 어려울 것 같아서 이 방법도 내 선택지에서 제외했다..^..^
3. i18n 등을 사용해 번역을 직접 제공하기
사실 이 방법이 가장 정석적이고 좋은 방법이라고 생각한다. 하지만 생각보다 들여야하는 노력이 꽤나 크다고 생각한다. . .
지금 당장 필요한지 잘 판단해서 선택해야 할 부분이라고 생각한다.
4. Workaround 사용하기
Dan은 아래와 같은 Workaround를 제시했다. 말 그대로 "Workaround"일 뿐이여서, 문제를 근본적으로 해결하는 건 아니고, 오류를 없애고 페이지가 다운되는 것을 막아주는 역할을 한다. 대신 사이트의 성능이 문제가 생길 수 있다는 점은 알아두라고 한다.
그리고 React 자체에서 아래의 내용을 제공하기에는 Overhead가 너무 커진다고 생각해 정말 필요한 사람들은 Workaround를 직접 코드에 넣어서 해결하라고 한다. Google에 버그리포트도 올려두신 것 같은데 아직도 해결이 안된걸 보니 앞으로도 계속 해결이 안될 것 같다^^ (2018년 글이라^^.. )
작성하면 되는 코드의 내용은 아래와 같다. 내가 해석해 본 내용을 주석으로 조금 달아봤다.
if (typeof Node === 'function' && Node.prototype) {
const originalRemoveChild = Node.prototype.removeChild;
Node.prototype.removeChild = function(child) {
// 여기에서 this는 textNode의 parentNode이다.
if (child.parentNode !== this) {
// child의 parentNode와 this가 다르면, child를 return한다.
// (child가 textNode면, 그 parentNode가 null로 찾을 수 없기 때문에 이 분기로 들어온다.)
if (console) {
console.error('Cannot remove a child from a different parent', child, this);
}
return child;
}
// child가 textNode가 아니면, this가 가르키는 context를 변경해서 바로 removeChild를 실행한다.
return originalRemoveChild.apply(this, arguments);
}
const originalInsertBefore = Node.prototype.insertBefore;
Node.prototype.insertBefore = function(newNode, referenceNode) {
if (referenceNode && referenceNode.parentNode !== this) {
// referenceNode가 존재하고, referenceNode의 parentNode가 this가 다르면, 새로운 Node를 반환한다.
// (child가 textNode면, 그 parentNode가 null로 찾을 수 없기 때문에 이 분기로 들어올 것이라고 생각한다.)
if (console) {
console.error('Cannot insert before a reference node from a different parent', referenceNode, this);
}
return newNode;
}
// child가 textNode가 아니면, this가 가르키는 context를 변경해서 바로 insertBefore를 실행한다.
return originalInsertBefore.apply(this, arguments);
}
}
removeChild같은 경우는 실제로 코드를 돌려보면서 확인을 했는데, 안타깝게도 insertBefore와 같은 경우는 해당 오류(referenceNode가 존재하고, referenceNode의 parentNode가 this와 다른 경우)가 안타깝게도(?) 몇번이나 테스트해봤는데 오류가 나지 않아서 확인해보지 못했다. 아마 removeChild와 같은 형태로 오류가 날 것 같기는 하다.
(참고)
- apply 메소드는 this가 가리키는 Context를 변경해서, 바로 실행하는 메소드이다.
https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Function/apply
아래는 관련 논의들을 보게 된 Github Issue이다.
다른 사람들이 어떻게 이 문제를 해결하려 했고,
또 어떤 논의들이 오고 갔는지 확인하면서 많이 배운 것 같아서 궁금하신 분들은 처음부터 끝까지 쭉 읽어보시는 걸 추천드린다!
https://github.com/facebook/react/issues/11538
Google Translate를 사용하면서 나는 오류가 왜 나는지 궁금해서 한번 정리해 봤는데, 생각보다 내용이 까다로웠다^^..
그래도 다른 사람들의 토론을 읽어보면서 그 흐름을 좀 따라갈 수 있게 된 것 같다.
혹시 제가 잘못 이해하고 있는 부분이 있다면 알려주십시오.. 흑흑..
'Programming > React' 카테고리의 다른 글
React에서 Modal을 만드는 다양한 방법(feat. Promise) (10) | 2022.08.16 |
---|---|
Monaco Editor를 활용해서 React 기반 프로젝트에 코드 에디터 적용하기! (8) | 2022.03.18 |
React에서 상태관리하기 (feat. Context API, Redux, React Query) (3) | 2022.02.27 |
.env를 사용하기 위한 webpack 설정 (0) | 2022.01.14 |
[JS] Object.assign과 spread operator의 차이점 (0) | 2020.07.31 |
댓글