[WebTranslator 개발기 #04] 스팀 링크 색상 오추출과 고아 텍스트 노드 래핑

크롬 확장 프로그램 개발기 4편: 스팀(Steam)의 복잡한 Flex/Grid 레이아웃에서 발생한 링크 색상 전이 버그와 고아 텍스트 노드로 인한 레이아웃 붕괴를 CSS display: contents 가상 래퍼로 해결한 과정을 정리합니다.
스팀 DOM 고아 텍스트 노드 래핑 및 display: contents 가상 래퍼 아키텍처 다이어그램

지난 3편에서는 번역 API 호출 전 원문을 훼손하던 전처리(Pre-processing) 방식의 오류를 바로잡고, 사후 덧씌우기(Post-processing) 파이프라인과 DOM 자가 오염 방지 가드를 구축하여 프로젝트의 기초 안정 버전인 v0.1 스냅샷을 완성했습니다.

기초 아키텍처를 다진 후, WebTranslator의 주 테스트 환경이자 현대 웹 기술이 복합적으로 적용된 스팀(Steam) 상점 및 커뮤니티 페이지에서 본격적인 렌더링 검증을 진행했습니다. 그러나 단순한 정적 웹문서와 달리, 스팀의 고도로 중첩된 Flex/Grid 환경에서는 링크 태그의 파란색 글자 색상이 전체 텍스트로 잘못 번지거나 버튼 배치가 세로로 찢어지는 심각한 DOM 렌더링 붕괴가 발생했습니다.

이번 글에서는 고아 텍스트 노드(Orphan Text Node)의 레이아웃 간섭을 무력화하는 CSS display: contents 가상 래퍼의 도입 배경과, 텍스트 노드 직속 부모의 계산된 스타일(Computed Style)을 1:1로 추출하여 색상 오염을 해결한 과정을 정리해 보고자 합니다.

1. 실전 복합 DOM의 난관: 스팀(Steam) 상점 페이지

스팀 상점 페이지는 게임 설명 문단, 가격표, 할인율 배지, 장바구니 버튼 등이 display: flexdisplay: grid로 정밀하게 맞물려 있는 대표적인 복합 웹 애플리케이션입니다.

이러한 페이지에서 기존 방식으로 번역을 실행하자마자 다음과 같은 2가지 치명적인 시각적 결함이 나타났습니다.

현상 구분 발생 증상 원인 요약
레이아웃 붕괴 버튼 위치가 밀려나거나 요소들이 아래로 줄바꿈됨 고아 텍스트 노드를 일반 <span>으로 감싸며 Flex Item 증가
색상 오염 링크 바깥의 일반 본문 번역문까지 전부 파란 링크 색으로 칠해짐 상위 컨테이너 내 <a> 태그의 CSS 색상이 무차별 상속됨

2. AI의 일반적인 노드 래핑과 색상 오추출의 한계

AI 어시스턴트에게 레이아웃 깨짐을 방지하고 텍스트 노드를 안전하게 감싸도록 지시했습니다.

스팀 페이지의 레이아웃 깨짐을 분석하고, 고아 텍스트 노드를 Flex 레이아웃 영향 없이 감싸는 DOM 순회 수집기를 작성해라. 또한 링크 색상이 전체 번역문으로 번지는 버그를 고쳐라.

지시를 받은 AI는 아래와 같이 일반 <span>으로 텍스트를 감싸고 상위 요소에서 링크 태그를 찾아 색상을 복사하는 코드를 작성해왔습니다.

// dom_collector.js: 일반 span 래핑 및 상위 색상 일괄 조회 (오류 코드)
function wrapTextNode(textNode) {
  // 1. 일반 span을 생성하여 텍스트 노드 감싸기
  const span = document.createElement("span");
  span.className = "wt-text-wrapper";
  textNode.parentNode.insertBefore(span, textNode);
  span.appendChild(textNode);

  // 2. 부모 컨테이너 내의 a 태그 색상을 무조건 복사하여 적용
  const linkEl = span.parentElement.querySelector("a");
  const targetColor = window.getComputedStyle(linkEl || span.parentElement).color;
  span.style.color = targetColor;
}

발생한 기술적 원인 분석

  • Flex 컨테이너의 Item 수 증가: 부모가 display: flex인 경우, 자식 텍스트 노드는 직접적인 Flex Item으로 취급되지 않거나 익명 래퍼로 묶입니다. 하지만 여기에 일반 <span>이 삽입되는 순간 브라우저는 이를 새로운 독립 Flex Item으로 인식하여 Flex 정렬과 너비 계산을 다시 수행하므로 배치가 산산조각 났습니다.
  • `querySelector('a')`의 과도한 색상 전파: 문단 내에 단 하나의 링크만 포함되어 있어도, 문단 전체의 텍스트 노드가 querySelector('a')에 의해 파란색 링크 글자색을 강제 상속받아 시각적 위계가 완전히 무너졌습니다.

3. 해결책 1: CSS `display: contents` 가상 래퍼 도입

부모의 레이아웃 계산에 일절 간섭하지 않으면서 텍스트 노드만을 논리적으로 감싸기 위해 CSS display: contents 기법을 도입하도록 지시를 수정했습니다.

일반 span으로 감싸서 Flex Item을 늘리지 마라. CSS display: contents를 적용하여 부모 레이아웃 엔진에는 래퍼가 없는 것처럼 투명하게 동작하면서 오직 텍스트 노드만 통제하는 가상 래퍼(.wt-text-wrapper)를 적용해라.
💡 `display: contents`의 동작 원리: 해당 요소 자신의 박스(Box) 모델(margin, border, padding 등)이 렌더 트리에서 완전히 생략됩니다. 부모의 Flex/Grid 엔진은 자식 텍스트와 하위 태그들만 직접 바라보므로 기존 레이아웃이 0.1px도 변하지 않습니다.

4. 해결책 2: 직속 부모 기준 1:1 글자 색상 정밀 추출

색상 오염을 방지하기 위해 컨테이너 내부 검색(querySelector)을 전면 금지하고, 각 텍스트 노드의 직속 부모(parentElement)의 computedStyle.color만 엄격하게 조회하도록 파이프라인을 정비했습니다.

// src/content/dom_collector.js: display: contents 가상 래퍼 및 직속 색상 추출
export function wrapTextRuns(element) {
  const childNodes = Array.from(element.childNodes);

  for (const node of childNodes) {
    if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) {
      // 1. 가상 래퍼 생성
      const wrapper = document.createElement("span");
      wrapper.className = "wt-text-wrapper";
      wrapper.style.display = "contents"; // 레이아웃 간섭 제로

      // 2. 직속 부모의 실제 계산된 색상만 1:1 추출
      const parentStyle = window.getComputedStyle(node.parentElement);
      wrapper.dataset.computedColor = parentStyle.color;

      // 3. 텍스트 노드 안전 래핑
      node.parentNode.insertBefore(wrapper, node);
      wrapper.appendChild(node);
    }
  }
}
/* content.css: 가상 래퍼 스타일 보장 */
.wt-text-wrapper {
  display: contents !important;
}

5. 검증 결과 및 핵심 교훈

display: contents 가상 래퍼와 직속 부모 색상 추출을 적용한 뒤 스팀 상점 페이지를 다시 테스트했습니다.

  1. 완벽한 레이아웃 보존: 복잡한 Flex 가격표와 장바구니 버튼이 래퍼 삽입 이전과 100% 동일한 위치와 크기를 유지했습니다.
  2. 정확한 텍스트 색상 분리: 링크 태그 내부의 텍스트만 링크 색상을 유지하고, 바깥의 일반 본문 번역문은 본문 고유의 회색/흰색 폰트 색상을 완벽하게 유지했습니다.

마무리하며

고아 텍스트 노드와 링크 색상 전이 문제를 깔끔하게 해결했으나, 스팀 상점의 장바구니에 추가 (Add to Cart) 같은 작은 녹색 버튼 안에서 번역문이 <div> 블록처럼 줄바꿈 렌더링되어 버튼이 세로로 거대하게 뚱뚱해지는 또 다른 렌더링 붕괴가 발견되었습니다.

다음 글에서는 태그의 속성과 글자 길이를 기반으로 인라인(Inline)과 블록(Block) 렌더링을 지능적으로 분기하는 듀얼 렌더러(Dual Renderer) 구축 과정을 다루어 보겠습니다.

대화 참여하기