[WebTranslator 개발기 #02] 단축키(Alt+A) 연타 레이스 컨디션 해결과 스토리지 분리
앞선 1편에서는 Background Service Worker를 중계 계층으로 활용하여 Chrome Manifest V3의 강력한 CSP(Content Security Policy) 네트워크 차단을 해결하고, 원본 웹페이지의 레이아웃을 훼손하지 않는 비파괴적 번역 파이프라인의 첫 단추를 꿰었습니다.
하지만 완성된 번역기를 실제 웹 서핑 환경에서 테스트하던 중 예상치 못한 치명적인 문제가 드러났습니다. 사용자가 Alt+A 단축키를 눌러 번역을 요청한 직후 마음이 바뀌어 다시 단축키를 눌렀을 때, 먼저 보낸 번역 요청의 응답이 뒤늦게 도착하면서 원본으로 복구된 화면 위에 번역문을 강제로 덧씌우는 비동기 레이스 컨디션(Race Condition)이 발생한 것입니다.
이번 글에서는 자바스크립트의 AbortController를 도입하여 진행 중인 비동기 네트워크 요청을 즉각 물리적으로 취소하는 상태 머신을 구축하고, 쿼터 제한이 있는 크롬 스토리지를 용도에 맞게 Sync와 Local로 분리 설계한 트러블슈팅 과정을 정리해 보고자 합니다.
1. 단축키 토글 기능과 비동기 레이스 컨디션의 발생
WebTranslator의 핵심 UX 중 하나는 마우스 조작 없이 키보드 단축키(Alt+A) 한 번으로 번역문을 표시하고, 다시 누르면 즉시 원본 상태로 깨끗하게 복원되는 토글(Toggle) 메커니즘입니다.
그러나 네트워크 통신은 필연적으로 지연 시간(Latency)을 동반합니다. 사용자가 번역을 요청하고 응답이 오기까지 1~2초가 소요되는 도중 단축키를 다시 누르면 다음과 같은 파괴적인 시나리오가 발생했습니다:
- 1차 단축키 입력:
Alt+A입력 $\rightarrow$ 웹페이지 텍스트를 수집하여 Background로 비동기 번역 API 요청 발송. - 2차 단축키 입력 (사용자 취소): 번역 도중 사용자가 다시
Alt+A입력 $\rightarrow$ 화면을 원본 텍스트로 즉각 롤백. - 뒤늦은 응답 도착: 1차 요청의 번역 결과가 1.5초 뒤 도착 $\rightarrow$ 이미 원본으로 복원된 DOM 위에 번역 태그를 강제로 삽입 $\rightarrow$ 원문과 번역문이 뒤섞이며 화면 붕괴.
2. AI의 단순 플래그 제어 실패와 스토리지 쿼터 위험
이 문제를 해결하기 위해 AI 어시스턴트에게 비동기 취소 제어와 사용자 설정/캐시 저장을 지시했습니다.
Alt+A 단축키를 연타해도 DOM이 깨지지 않도록 상태 머신을 만들고, 설정값과 캐시를 Chrome Storage에 저장하도록 구현해라.
지시를 받은 AI는 아래와 같이 단순 boolean 플래그 변수를 토글하는 코드를 작성해왔습니다.
// content.js: 단순 boolean 플래그를 통한 상태 제어 시도 (오류 코드)
let isTranslating = false;
async function toggleTranslation() {
if (isTranslating) {
isTranslating = false; // 플래그를 꺼도 이미 진행 중인 Promise 체인을 막지 못함!
revertTranslation();
return;
}
isTranslating = true;
const texts = collectTextNodes();
// 비동기 요청 시작
const result = await chrome.runtime.sendMessage({ action: "translate", payload: texts });
// 뒤늦게 도착한 응답이 DOM에 그대로 렌더링됨
renderTranslation(result);
isTranslating = false;
}
문제 원인 분석
- Promise 비동기 체인의 무방비성: 자바스크립트에서 단순 변수 값을 변경하는 것만으로는 백그라운드에서 이미 실행 중인 HTTP 네트워크 요청이나 파싱 작업을 중단시킬 수 없습니다. 비동기 작업이 완료되면
await이후의renderTranslation()이 무조건 실행됩니다. - 스토리지 쿼터 초과 위험 (QuotaExceededError): AI가 번역된 문단 캐시 데이터까지 기기간 동기화 스토리지인
chrome.storage.sync에 무차별적으로 저장하도록 작성했습니다.sync스토리지는 전체 용량이 100KB에 불과하여 긴 웹페이지 몇 개만 번역해도 즉시 쿼터 초과 에러가 발생할 위험이 있었습니다.
3. 해결책 1: AbortController 기반 물리 취소 및 3단계 상태 머신
비동기 흐름을 근본적으로 제어하기 위해 AbortController를 도입하고, document.body의 데이터셋을 단일 진실 공급원(SSOT)으로 삼는 3단계 상태 머신을 구축하도록 지시를 수정했습니다.
단순 boolean 변수로 때우지 마라. AbortController를 도입하여 단축키가 다시 눌리는 즉시 진행 중인 fetch 신호를 강제 abort()시키고, document.body의 데이터셋(data-wt-status)으로 현재 상태(idle, translating, translated)를 엄격히 통제해라.
| 상태 (data-wt-status) | 설명 | 단축키(Alt+A) 입력 시 동작 |
|---|---|---|
| idle | 번역이 적용되지 않은 기본 원본 상태 | AbortController 생성 $\rightarrow$ translating 전이 및 번역 요청 |
| translating | 네트워크 통신 및 DOM 파싱 진행 중 | controller.abort() 즉시 호출 $\rightarrow$ 롤백 후 idle 전이 |
| translated | 번역문 렌더링이 완료된 상태 | 번역 컨테이너 제거 $\rightarrow$ idle 전이 (원본 복원) |
리팩터링된 상태 제어 구현
// src/content/index.js: AbortController 기반 단축키 토글 상태 머신
let currentAbortController = null;
export async function toggleTranslation() {
const body = document.body;
const state = body.dataset.wtStatus || "idle";
// 1. 번역 진행 중 재입력: 즉각 취소 및 롤백
if (state === "translating") {
if (currentAbortController) {
currentAbortController.abort(); // 네트워크 및 비동기 작업 물리 취소
}
body.dataset.wtStatus = "idle";
revertTranslation();
return;
}
// 2. 이미 번역된 상태에서 재입력: 원본 복원
if (state === "translated") {
revertTranslation();
body.dataset.wtStatus = "idle";
return;
}
// 3. idle 상태에서 입력: 번역 프로세스 개시
body.dataset.wtStatus = "translating";
currentAbortController = new AbortController();
try {
const texts = collectTextNodes();
const translated = await requestTranslation(texts, currentAbortController.signal);
// 요청이 취소되지 않고 정상 완료된 경우에만 렌더링
renderTranslation(translated);
body.dataset.wtStatus = "translated";
} catch (err) {
if (err.name === "AbortError") {
// 사용자가 의도적으로 취소한 경우는 정상 흐름으로 간주하고 무시
return;
}
console.error("[WebTranslator] 번역 처리 중 오류 발생:", err);
body.dataset.wtStatus = "idle";
revertTranslation();
}
}
AbortController.abort()가 호출되면 연결된 Fetch 요청은 즉시 AbortError 예외를 발생시킵니다. catch 블록에서 err.name === 'AbortError'를 식별하여 에러 로그를 남기지 않고 조용히 리턴시키는 것이 중요합니다.
4. 해결책 2: Chrome Storage 이원화 설계 (Sync vs Local)
확장 프로그램이 다루는 데이터의 성격과 크기에 따라 저장소를 명확히 분리하여 쿼터 에러를 방지했습니다.
| 구분 | chrome.storage.sync | chrome.storage.local |
|---|---|---|
| 저장 대상 | API Key, 기본 타깃 언어, 테마 설정 | 번역 문단 캐시, 대용량 사용자 커스텀 사전 |
| 용량 한도 | 전체 100KB (항목당 8KB) | 기본 10MB (unlimitedStorage 가능) |
| 동기화 여부 | 구글 계정 기반 기기간 자동 동기화 | 해당 PC 브라우저 로컬 전용 |
| 선택 이유 | PC를 변경해도 설정값이 유지되어야 함 | 대용량 텍스트 캐시로 인한 쿼터 폭발 방지 |
// src/utils/storage.js: 스토리지 레이어 분리 인터페이스
export const SettingsStorage = {
// 사용자 환경설정은 sync 스토리지 활용 (기기 간 동기화)
async get() {
return await chrome.storage.sync.get({ apiKey: "", targetLang: "ko" });
},
async set(settings) {
return await chrome.storage.sync.set(settings);
}
};
export const CacheStorage = {
// 대용량 번역 캐시는 local 스토리지로 완전 격리
async getCache(hashKey) {
const data = await chrome.storage.local.get(hashKey);
return data[hashKey] || null;
},
async setCache(hashKey, translatedText) {
return await chrome.storage.local.set({ [hashKey]: translatedText });
}
};
5. 검증 결과 및 교훈
AbortController와 상태 머신을 결합한 결과, Alt+A를 1초에 5번 이상 빠르게 연타하거나 네트워크 응답이 극도로 지연되는 환경에서도 이전 요청이 즉각 중단되어 단 한 번의 DOM 충돌이나 레이아웃 깨짐도 발생하지 않았습니다.
- 프론트엔드 비동기 제어의 필수 도구: 비동기 UI에서는 단순 플래그 토글이 아닌, 네트워크 수준의 취소 제어(AbortSignal)가 필수적입니다.
- 확장 프로그램 스토리지의 한계 이해:
chrome.storage.sync는 편리하지만 용량이 매우 작으므로, 설정(Metadata)과 데이터(Payload)를 물리적으로 분리하는 설계가 필수적입니다.
마무리하며
단축키와 스토리지 기본기를 다지고 v0.1 스냅샷을 점검하던 중, 사용자가 직접 등록한 '단어 사용자 사전(Custom Dictionary)'이 번역 결과에 전혀 반영되지 않고 씹히는 심각한 번역 누락 버그가 발견되었습니다.
다음 글에서는 사용자 사전이 무시되었던 파이프라인의 구조적 원인을 분석하고, 전처리 선치환 방식의 한계를 극복한 트러블슈팅 과정을 다루어 보겠습니다.
대화 참여하기