React Fast Refresh에서 DOM 직접 조작 시 블록이 사라지는 문제 해결하기

sumi-0011·2025년 3월 30일
0

문제 상황 👿

이메일 빌더 애플리케이션을 개발하는 중 이상한 문제가 발생했습니다. Fast Refresh가 실행된 후 (코드를 수정하고 저장할 때마다 발생) 이메일 블록들이 화면에서 완전히 사라지는 현상이 일어났습니다.

콘솔에는 "Fast Refresh done in 228ms"라는 메시지만 표시될 뿐, 이메일 콘텐츠 영역은 텅 비어 있었습니다. 저장할 때마다 작업 내용이 모두 사라지니 개발 효율이 크게 떨어지는 상황이었습니다.

솔직히 말하면, React를 꽤 오래 사용했음에도 불구하고 이런 문제가 발생할 거라고는 전혀 예상하지 못했습니다. 처음에는 단순히 구현 방식의 문제인 줄 알았는데, 알고 보니 React의 핵심 원칙을 위반한 결과였습니다.

문제 분석 🔍

문제의 본질을 파악하기 위해 코드를 면밀히 분석했습니다. 원래 코드는 다음과 같은 방식으로 작동했습니다.
1. 템플릿 HTML을 렌더링하는 함수가 있었습니다.
2. 숨겨진 div에 블록 컴포넌트들을 렌더링했습니다.
3. useEffect 훅 내에서 innerHTMLappendChild를 사용해 DOM을 직접 조작했습니다.

// 이전 코드 (문제가 있던 부분)
useEffect(() => {
  const contentElement = document.getElementById('email-content');
  if (contentElement && content) {
    contentElement.innerHTML = '';
    contentElement.appendChild(content);
  }
}, [template, blocks, blockOrder, content]);

이 부분이 문제의 핵심이었습니다. React 내에서 DOM을 직접 조작하고 있었고, 이것이 Fast Refresh와 충돌을 일으키고 있었습니다. 생각해보니 React의 핵심 철학인 "선언적 UI 업데이트"와 정면으로 충돌하는 접근 방식이었습니다.

원인 파악 🤔

문제의 원인이 무엇일지에 대해 다음과 같은 가설을 세웠습니다.

  1. React의 가상 DOM과 충돌: React는 가상 DOM을 통해 UI를 업데이트합니다. 직접적인 DOM 조작은 이 메커니즘을 우회해 React가 DOM의 현재 상태를 알 수 없게 만듭니다. 그 과정에서 충돌이 있었던 걸까..?
  2. Fast Refresh와의 충돌: Fast Refresh는 React 컴포넌트의 상태를 보존하면서 변경된 코드만 새로 로드합니다. DOM을 직접 조작해 React가 관리하지 않는 DOM 요소가 생겨 Fast Refresh 후 이 요소들이 사라지고 생성이 되지 않은걸까
  3. 컴포넌트 언마운트 시 DOM 참조 유실: Fast Refresh 과정에서 컴포넌트가 일시적으로 언마운트되면 DOM 참조가 유실, 없는 참고에 접근을 해서 생기지 않는걸까..

이런 분석을 통해 문제의 근본 원인이 명확해졌지만, 솔직히 처음부터 React의 방식을 따르지 않은 제 실수였습니다. "빠르게 구현하려다" 오히려 더 큰 문제를 만든 셈이죠.

해결 방법 💡

이 문제를 해결하기 위해 코드를 완전히 리팩토링했습니다.

1. DOM 직접 조작 제거

가장 중요한 변경은 innerHTMLappendChild를 사용한 직접적인 DOM 조작을 완전히 제거했습니다. React의 선언적 렌더링 방식을 활용하는 것이 핵심입니다.

2. 템플릿 처리 방식 개선

템플릿을 헤더와 푸터 부분으로 나누어 처리하고, {{content}} 부분을 분기점으로 사용했습니다:

// 개선된 코드
// 템플릿 HTML의 헤더 부분
const templateHeader = template
  ? template.content
      .split('{{content}}')[0]
      .replace('{{meta-title}}', '이메일 제목')
      .replace('{{title}}', '이메일 제목')
  : '';

// 템플릿 HTML의 푸터 부분
const templateFooter = template ? template.content.split('{{content}}')[1] : '';

이렇게 하면 템플릿의 앞뒤 부분을 구분해서 처리할 수 있고, 콘텐츠는 React 컴포넌트로 직접 렌더링할 수 있습니다.

3. useState에서 useRef로 변경

상태 관리를 단순화하기 위해 useState와 hidden div 접근법 대신 useRef를 사용했습니다

const contentRef = useRef<HTMLDivElement>(null);

이를 통해 DOM 요소를 참조하되 React의 렌더링 사이클에 영향을 주지 않도록 했습니다.

4. 직접적인 콘텐츠 렌더링

이전에는 숨겨진 div에 렌더링한 후 DOM 조작으로 이동시켰지만, 이제는 직접 콘텐츠 영역에 블록들을 렌더링합니다

<div
  id="email-content"
  ref={contentRef}
>
  {blockOrder.map((blockId, index) => (
    <BlockItem
      key={blockId}
      isFirst={index === 0}
      isLast={index === blockOrder.length - 1}
      {...blocks[blockId]}
    />
  ))}
</div>

이 방식은 React의 선언적 패러다임을 따르므로 Fast Refresh와 완벽하게 호환됩니다.

지금 생각해보면 처음부터 이렇게 구현했어야 했는데, 어떻게든 빨리 동작하는 코드를 만들고 싶다는 욕심이 불필요한 복잡성을 가져왔습니다.

문제 해결 결과 ✅

위 변경사항을 적용한 후, Fast Refresh가 작동할 때도 이메일 블록들이 정상적으로 화면에 유지되었습니다. 코드를 수정하고 저장해도 더 이상 콘텐츠가 사라지지 않았고, 개발 효율이 크게 향상되었습니다.

가장 기뻤던 건, 이제 코드를 저장할 때마다 모든 내용이 사라져서 다시 상태를 만들어야 하는 스트레스에서 벗어났다는 점입니다. 이런 경험을 통해 "빠른 구현"과 "올바른 구현" 사이에서 후자를 선택하는 것이 결국 더 효율적이라는 점을 다시 한번 깨달았습니다.

알게 된 점 📚

이 문제를 해결하며 React 개발에서 몇 가지 중요한 점을 알게 되었습니다

  1. React 내에서 DOM 직접 조작은 피해야 합니다. React는 가상 DOM을 통해 UI를 관리하는데, 직접 DOM을 조작하면 이 메커니즘이 무너집니다.
  2. Fast Refresh는 React의 선언적 렌더링에 의존합니다. DOM을 직접 조작하면 Fast Refresh 후 React가 UI 상태를 복원할 수 없습니다.
  3. React의 선언적 렌더링 방식을 따르는 것이 중요합니다. 상태 변화에 따라 UI가 자동으로 업데이트되는 React의 방식이 더 예측 가능한 결과를 가져옵니다.
  4. useEffect의 의존성 배열은 신중하게 설계해야 합니다. 특히 DOM 조작이 포함된 경우 의존성 관리가 복잡해질 수 있습니다.

솔직히 이런 기본적인 React 원칙을 어기면서 코드를 작성했다는 게 아쉬웠습니다. "빠르게 동작하는 코드"를 만들려다가 오히려 개발 과정을 더 복잡하게 만든것 같네요. React의 선언적 방식을 믿고 따랐다면 이런 문제로 시간을 낭비하지 않았을 텐데, 이번 경험을 통해 다시 한번 프레임워크의 철학을 따르는 것의 중요성을 깨달았습니다.

결론적으로, 이 경험은 빠른 해결책보다 올바른 해결책을 찾는 것의 중요성을 다시 일깨워주었습니다. 아마 대부분의 개발자들이 비슷한 실수를 한 번쯤은 해봤을 거라 생각합니다. 하지만 중요한 건 실수를 인정하고, 더 나은 방향으로 코드를 개선해 나가는 과정이 아닐까 싶습니다.

profile
안녕하세요 😚

0개의 댓글