[WebTranslator 개발기 #09] 선택 번역 팝업과 전체 번역(Alt+A) 간 상태 충돌 해결
지난 8편에서는 NMT 기계 번역과 LLM 생성형 AI의 본질적 차이를 분리하고, 마우스 드래그 시 IPA 발음기호, 품사, 핵심 뜻 3개, 실생활 예문을 풍부하게 추출하는 LLM 전용 구조화 사전 파이프라인을 구축했습니다.
마우스 드래그를 통한 '선택 번역(Word & Selection Lookup)'과 단축키(Alt+A)를 통한 '페이지 전체 번역(Full Page Translation)'이라는 두 가지 핵심 인터랙션이 모두 완성되었으나, 실전 브라우징 환경에서 두 기능이 교차하는 순간 치명적인 상태 충돌(State Collision)이 발생했습니다.
사용자가 모르는 단어를 드래그하여 사전 팝업을 열어둔 상태에서 "페이지 전체를 번역해야겠다"고 Alt+A를 누르면, 기존 팝업이 닫히지 않고 잔존하여 전체 번역 수집기가 팝업 내부 텍스트까지 번역 대상 노드로 긁어가 글자가 이중으로 뒤엉키고 화면이 하얗게 굳어버리는 결함이 나타난 것입니다.
이번 글에서는 비동기 수집 파이프라인 전반의 순서를 바로잡은 선행 소탕(Cleanup-First) 패턴과, 두 인터랙션이 서로를 파괴하지 않고 자연스럽게 공존하는 단일 상태 머신 설계 과정을 정리해 보고자 합니다.
1. 다중 인터랙션 환경에서의 상태 충돌
웹 브라우저 확장 프로그램에서 복수의 UI 인터랙션이 한 화면에 공존할 때는 각 기능의 라이프사이클이 정밀하게 조율되어야 합니다.
| 인터랙션 | DOM 조작 방식 | 생명주기 관리 |
|---|---|---|
| 선택 번역 / 사전 팝업 | 선택 좌표 근처에 독립 플로팅 오버레이 삽입 | 외부 클릭 또는 ESC 입력 시 닫힘 |
| 전체 페이지 번역 (`Alt+A`) | 웹페이지 내 모든 텍스트 노드 순회 및 인라인/블록 래핑 | 단축키 재입력 시 전체 원문 복원 |
문제는 사용자가 사전 팝업을 닫지 않고 곧바로 Alt+A를 눌렀을 때, 팝업 닫기 이벤트와 전체 번역 수집 로직이 동시에 충돌하면서 발생했습니다.
2. AI의 후행 제거 실수와 텍스트 재수집 참사
AI 어시스턴트에게 단축키 입력 시 기존 팝업을 정리하고 전체 번역을 수행하도록 지시했습니다.
선택 영역 번역 이후 Alt+A를 눌렀을 때 선택 영역 번역 팝업이 사라지지 않고 전체 번역과 충돌하는 문제를 해결해라. 단축키 입력 시 기존 팝업을 모두 닫고 전체 번역이 깔끔하게 실행되도록 수정해라.
지시를 받은 AI는 아래와 같이 전체 번역을 먼저 실행하고 그 뒤에 팝업을 닫으려는 코드를 작성해왔습니다.
// index.js: 후행 팝업 제거로 인한 파싱 참사 (오류 코드)
function togglePageTranslation() {
// 1. 비동기 전체 번역 시작 (DOM 트리 순회 및 텍스트 수집 가동)
startFullPageTranslation();
// 2. 뒤늦게 팝업 요소 제거 시도 (비동기 지연으로 이미 늦음!)
document.querySelectorAll(".wt-dictionary-popup").forEach(el => el.remove());
}
결함 메커니즘 분석
startFullPageTranslation()은 비동기(Async)로 동작하며 호출 즉시 document.body의 모든 텍스트 노드를 긁어모읍니다.
팝업 제거 함수가 뒤에 배치되어 있었기 때문에, 텍스트 수집기가 화면에 떠 있던 사전 팝업 내부의 단어, 발음기호, 예문 텍스트까지 번역 대상 원문으로 착각하고 수집해 버렸습니다. 결국 전체 번역이 끝난 뒤 웹페이지 원문 아래에 사전 텍스트의 번역문이 기괴하게 덧붙여지는 DOM 오염이 발생했습니다.
3. 해결책: 선행 소탕(Cleanup-First) 패턴 도입
파이프라인 순서를 강제하여, 단축키가 입력되는 즉시 동기(Sync)적으로 closeAllPopups()를 1순위로 호출하여 DOM을 완벽히 정제한 뒤에 텍스트 수집을 시작하도록 지시를 수정했습니다.
번역을 시작한 뒤에 지우지 마라. Alt+A가 눌리는 즉시 동기적으로 closeAllPopups()를 호출하여 화면의 모든 플로팅 팝업 요소를 DOM에서 완전히 제거하고, 그 직후에 깨끗해진 DOM 트리를 수집하여 번역을 시작하도록 순서를 강제해라.
| 단계 | 실행 시점 | 수행 작업 | 결과 상태 |
|---|---|---|---|
| 1단계: 선행 소탕 | 단축키 수신 즉시 (동기) | closeAllPopups() 강제 호출 |
플로팅 팝업 노드 100% 제거 완료 |
| 2단계: 상태 검사 | 소탕 직후 (동기) | dataset.wtStatus 상태 머신 분기 |
translating / idle 전환 |
| 3단계: 텍스트 수집 | 깨끗한 DOM 트리 (비동기) | collectTextNodes() 실행 |
원문 텍스트만 안전하게 수집 |
선행 소탕 및 상태 관리 구현 코드
// src/content/popup_manager.js: 전역 팝업 즉시 제거 모듈
export function closeAllPopups() {
const popups = document.querySelectorAll(
".wt-dictionary-popup, .wt-selection-card, .wt-selection-tooltip"
);
popups.forEach(popup => popup.remove());
}
// src/content/index.js: 선행 소탕 기반 전체 번역 토글 핸들러
import { closeAllPopups } from "./popup_manager.js";
export async function toggleTranslation() {
// 1. 단축키 입력 즉시 화면의 모든 활성 팝업을 동기적으로 소탕
closeAllPopups();
const body = document.body;
const state = body.dataset.wtStatus || "idle";
// 이미 번역된 상태인 경우 원문 복원 후 종료
if (state === "translating" || state === "translated") {
revertTranslation();
body.dataset.wtStatus = "idle";
return;
}
// 2. 팝업이 완전히 사라진 깨끗한 DOM에서 전체 번역 시작
body.dataset.wtStatus = "translating";
await executePageTranslation();
body.dataset.wtStatus = "translated";
}
4. 전체 번역 상태에서의 사전 팝업 독립 공존
반대의 시나리오도 완벽하게 지원했습니다. 전체 페이지 번역이 이미 켜져 있는 상태(translated)에서 사용자가 특정 단어를 다시 드래그했을 때는, 전체 번역된 DOM 구조를 건드리지 않고 독립된 최상위 오버레이 레이어로 사전 팝업을 부드럽게 띄우도록 이벤트 전파를 격리했습니다.
5. 검증 결과
- 팝업 잔존 및 텍스트 중첩 제로: 사전 팝업이 떠 있는 상태에서
Alt+A를 연타해도 팝업이 즉시 닫히며 전체 페이지 번역이 한 치의 어긋남 없이 깔끔하게 동작했습니다. - 원활한 양방향 공존: 전체 번역 상태에서 단어 조회 $\leftrightarrow$ 단어 조회 상태에서 전체 번역 간의 전환이 100% 매끄럽게 유지되었습니다.
마무리하며
클라이언트 측 인터랙션과 상태 머신이 완전히 정돈되자, 실제 사용량이 많은 환경에서 새로운 인프라 문제가 터져 나왔습니다. Gemini API를 사용하여 100개 이상의 문단을 가진 긴 기술 문서를 번역할 때 구글의 무료 티어 쿼터(15 RPM) 제한에 걸려 429 Too Many Requests 에러가 터지고, 여러 모델 간에 프롬프트 형식이 달라 번역 품질이 들쑥날쑥해지는 현상이 발생한 것입니다.
다음 글에서는 Gemini API의 요청 쿼터를 제어하는 동적 배치 스로틀링(Throttling)과 모든 LLM 모델의 프롬프트를 중앙에서 일원화 관리하는 통합 프롬프트 빌더 구축 과정을 다루어 보겠습니다.
대화 참여하기