NearPDF

2026-07-20

방문자 한 명, 레이스 컨디션 두 개: NearPDF의 워커 응답 버그와 화면 상태 레이스 고치기

NearPDF는 PDF를 합치고, 나누고, 편집하는 모든 과정을 사용자의 기기에서만 처리합니다 — pdf-lib가 Web Worker 안에서 돌아가고, 업로드도 백엔드도 전혀 없습니다(프로젝트 README의 "zero uploads" 문구, 그리고 public/_headers의 CSP로 강제되는 사실이기도 합니다). "서버가 없으니 동시성 버그도 없을 것"이라고 생각하기 쉽습니다 — 요청 큐도, 다른 테넌트도, 방문자 간 공유 상태도 없으니까요. 그 가정은 틀렸고, NearPDF 자체 테스트 스위트에 그걸 증명하는 회귀 테스트가 있습니다: 한 명의 방문자가, 한 페이지에서, 버튼 두 개를 짧은 간격으로 눌렀을 뿐인데 UI가 영영 멈춰버렸습니다. 이 글은 그 버그의 이야기입니다 — 알고 보니 서로 독립적인 두 개의 레이스 컨디션이 결합해야만 재현되는 버그였고, 둘 다 고쳐야 했습니다.

실제로 어떤 버그였나

재현 방법은 거의 민망할 정도로 단순합니다: PDF 두 개를 추가하고 merge를 클릭한 뒤 — 그 merge가 아직 Worker에서 실행 중일 때 — 세 번째 파일을 추가합니다. apps/nearpdf/tests/e2e-race.mjs는 자신의 헤더 주석에서 눈에 보이는 증상을 이렇게 담백하게 설명합니다: "merge를 클릭한 뒤 끝나기 전에 파일을 하나 더 추가하면, merge 자체는 백그라운드에서 성공했는데도 UI가 workspace 화면에 영영 멈춰 있었다." 크래시도 아니고 에러 토스트도 아닌, 조용한 멈춤입니다. merge 워커는 제 할 일을 다 하고 정상적인 결과를 돌려보냈는데, 페이지가 그걸 아예 처리하지 않았습니다. 이건 특히 고약한 종류의 버그입니다. 왜냐하면 UI 아래쪽의 모든 것이 각각 따로 보면 멀쩡해 보이기 때문입니다 — PDF 바이트는 메모리 안에 올바르게 합쳐진 채로 있는데, 그걸 알아챌 연결고리가 아무것도 없는 것입니다.

테스트 파일은 이 버그를 어떻게 찾았는지도 솔직하게 밝힙니다: 메인 e2e 스위트도, 유닛 테스트도, 코드를 읽고 눈으로 짚어낸 것도 아니었습니다 — 실제 Playwright Chromium에서 실제 페이지를 실제로 클릭해가며 라이브로 재현해야만 겨우 드러났습니다. 이런 타이밍 의존적인 버그는 두 동작을 실제로 벽시계 위에서 경쟁시키지 않는 어떤 방법으로도 숨어버립니다.

레이스 #1: 워커 응답에는 공유 슬롯이 아니라 자기만의 식별자가 필요하다

NearPDF의 merge 엔진은 Worker(src/merge.worker.js) 안에서 돌아가고, packages/near-kit/src/runtime.js의 작은 요청/응답 브리지인 makeWorker()를 통해 호출됩니다. 이 함수의 헤더 주석은 이게 어떤 모양의 버그를 대체한 것인지 담백하게 말해줍니다: 이전의, 손으로 직접 짠 버전(kit 헬퍼로 실제 공유된 적은 없고 앱마다 각자 자기 사본을 갖고 있었습니다)은 "매 메시지마다 초기화되는 공유 'done'/'error' 슬롯으로 대기 중인 응답을 관리했다"고 합니다. 문자 그대로 읽으면: "지금 진행 중인 호출의 결과"를 담는 상자가 하나뿐이고, 그걸 모든 호출이 재사용한다는 뜻입니다. 첫 번째 호출이 아직 대기 중일 때 두 번째 워커 호출을 발사하면, 두 번째 응답이 도착하면서 그 공유 상자를 지우거나 덮어씁니다 — 첫 번째 호출 자신의 핸들러는 사라지고, 그 프라미스를 resolve하거나 reject할 방법이 아무것도 남지 않습니다. 그냥 조용히, 영원히 멈춥니다.

현재의, 고쳐진 버전은 통째로 읽어도 될 만큼 작습니다. 모든 호출은 로컬 seq 카운터에서 증가하는 자기만의 id를 받고, 대기 중인 호출들은 하나의 공유 슬롯이 아니라 그 id를 키로 하는 Map으로 관리됩니다.

export function makeWorker(url) {
  const worker = new Worker(url, { type: 'module' });
  const pending = new Map();
  let seq = 0;
  worker.onmessage = (e) => {
    const { id } = e.data;
    const handler = pending.get(id);
    if (!handler) return;
    pending.delete(id);
    if (e.data.type === 'error') handler.reject(new Error(e.data.message));
    else handler.resolve(e.data);
  };
  return (payload, transfers = []) => {
    const id = ++seq;
    return new Promise((resolve, reject) => {
      pending.set(id, { resolve, reject });
      worker.postMessage({ ...payload, id }, transfers);
    });
  };
}

이 계약의 나머지 절반은 워커 쪽에 있습니다: merge.worker.js의 self.onmessage 핸들러는 들어온 메시지에서 id를 그대로 꺼내서, 돌려보내는 모든 postMessage에 빠짐없이 실어 보냅니다 — 'inspected' 결과, 'merged' 결과, 그리고 그만큼 중요한 catch 블록의 'error' 케이스까지도요. pending에 대응하는 항목이 없는 응답(이미 resolve됐거나, 어쩌다 들어온 메시지인 경우)은 그냥 무시됩니다 — if (!handler) return; — 엉뚱한 호출자의 프라미스를 잘못 붙잡는 대신 말이죠. 구조적으로 이것이 바로 NearPDF의 app.js가 버튼 클릭으로 시작된 'merge' 호출이 아직 끝나지 않았는데도 새로 추가된 파일에 대해 'inspect' 호출을 안전하게 발사할 수 있게 해주는 이유입니다: 각 호출의 응답은 오직 그 호출 자신의 프라미스만 resolve할 수 있습니다. 호출 A가 아직 대기 중인 상태에서 호출 B의 응답이 도착해도, 이제 둘 사이의 선이 서로 엇갈릴 수 없습니다.

레이스 #2: await 하나가 오래된 화면 상태를 사실인 것처럼 만들었다

워커 브리지만 고친다고 이 증상이 전부 설명되지는 않았습니다. NearPDF 자체의 intake() 함수(src/app.js, 대략 76~97번째 줄)에도 바로 옆에 두 번째, 독립적인 레이스 컨디션이 있었기 때문입니다. intake()는 파일이 추가될 때마다 실행됩니다 — 랜딩 화면에서든, 첫 배치 이후 workspace에서든 — 그리고 서로 완전히 다른 두 가지 중 무엇을 할지 결정해야 합니다: 첫 파일들이면 enterWorkspace()를, 이미 만들어진 workspace에 더 추가하는 것이면 addFiles()를 호출해야 합니다. 이 결정은 사용자가 "이미 workspace에 들어와 있는지"에 달려 있습니다.

이걸 확인하는 자연스러워 보이는 방법은 DOM을 보는 것입니다: 지금 #screen-workspace가 숨겨져 있는가, 아닌가? 문제는 정확히 이 체크가 await에 대해 어느 시점에 실행되었는가에 있었습니다. intake()는 드롭된 파일 중 어떤 것이 진짜로 유효한 PDF인지 알기 위해 await validateFiles(fileList, ...)를 해야 하는데, 이 await가 대기하는 동안 페이지의 나머지 부분은 계속 실행됩니다. 만약 이미 진행 중이던 merge가 그 사이에 끝나서 show('screen-done')을 호출한다면, 이제 DOM은 "workspace는 숨겨져 있고, done 화면에 있다"고 말합니다 — 사실이긴 하지만, 지금 진행 중인 이 add와는 아무 관계도 없는 이유로 사실이 된 것입니다. await 이후에 실행되는 화면 체크는 이 새로운 상태를 읽고 "아직 workspace에 들어간 적이 없다"고 잘못 결론 내려 enterWorkspace()를 호출합니다 — 그런데 이 함수는 자체적으로 show('screen-workspace')를 호출하므로, merge가 방금 완료하며 만든 done 화면으로의 전환을 그대로 짓밟고 다시 workspace로 되돌려버립니다. 사실은 이미 성공적으로 끝난 결과 화면을 사용자가 볼 기회를 통째로 날려버리는 셈입니다.

해결책은 코드 한 줄을 올바른 위치로 옮기는 것이었습니다 — 그 사실을 await 이후에 DOM에서 다시 읽어내는 대신, await 이전에 동기적으로 캡처하는 것입니다.

async function intake(fileList) {
  const fromIdx = state.files.length;
  // Captured synchronously, BEFORE the validateFiles() await below — checking
  // $('screen-workspace').hidden AFTER that await was racy...
  const alreadyEntered = fromIdx > 0;
  const { accepted, errors } = await validateFiles(fileList, ...);
  ...
  if (!alreadyEntered) await enterWorkspace();
  else await addFiles(fromIdx);
}

state.files.length는 이전의 모든 intake() 호출이 동기적으로 갱신해 놓은 값이므로, await 이전에 이 값을 읽으면 비동기 검증 작업이 끝날 때쯤 화면에 무엇이 떠 있든 상관없이, 바로 이번 add가 시작된 그 순간의 진실을 그대로 담아낼 수 있습니다. 작은 변경이지만 일반화하기 쉬운 원칙이 하나 있습니다: 무언가를 await한 다음에는, 그 이후에 읽는 어떤 사실이든 함수가 시작됐을 때와 여전히 같을 거라고 가정해서는 안 되고, 그 이후 시점에도 여전히 유효한 상태에서 다시 이끌어내야 한다는 것입니다.

왜 두 버그가 함께 있어야만 증상이 나타났나

이 이야기에서 어느 한쪽 레이스만으로는 완전한 설명이 되지 않습니다 — 둘이 결합해야 했습니다. 레이스 #1은 애초에 merge 호출과 inspect 호출을 동시에 진행 중인 상태로 둬도 안전하게 만들어주는 전제 조건입니다. 이게 고쳐지지 않았다면 두 번째 호출을 발사하는 것만으로 첫 번째 호출의 핸들러가 직접 날아갈 수 있었습니다. 레이스 #2는 두 호출이 동시에 진행되는 게 허용된 상태에서, 하필 merge의 완료 시점이 intake()가 검증을 기다리는 바로 그 구간에 겹칠 때 벌어지는 일입니다: 이미 완료된 merge 자신의 화면 전환이, 오래된 상태에 기반한 잘못된 판단에 의해 조용히 되돌려집니다. 둘 중 하나만 고쳤어도 여전히 UI가 멈출 방법이 남아 있었을 것입니다 — 이 버그를 드러낸 재현 시나리오 자체가 두 메커니즘 중 하나가 아니라 그 상호작용을 건드리고 있었기 때문에, 둘 다 고쳐야 했습니다.

믿는 게 아니라 증명하기

e2e-race.mjs는 재현을 한 번만 돌리고 끝내지 않습니다 — 이 버그는 타이밍에 의존적이라서, 파일 자체가 그렇게 직접 밝히고 있습니다: "이 버그는 타이밍에 의존적이었다(고치기 전 약 40%의 실패율) — 한 번만 돌려서는 거의 아무것도 증명하지 못한다." 그래서 이 테스트는 실제로 빌드된 페이지를 실제 Playwright Chromium에서 같은 시퀀스로 열다섯 번 연속 돌립니다 — 파일 두 개를 추가하고, workspace와 썸네일이 렌더링될 때까지 기다리고, merge를 클릭하고, 그 merge가 아직 실행 중인 동안 즉시 세 번째 파일을 추가한 뒤, 8초 타임아웃 안에 #screen-done이 나타나는지 기다립니다. 열다섯 번의 실행은 각각 독립적으로 채점되고, 열다섯 번 모두 screen-done에 도달해야만 전체 테스트가 통과합니다. 10번 중 4번만 재현되는 버그는 단 한 번만 시도하는 테스트를 쉽게 통과해버립니다 — 열다섯 번을 돌리고 그 전부가 통과해야 한다고 요구하는 것이야말로 "고쳐졌다"는 말이 "오늘은 우연히 안 터졌다"보다 더 큰 의미를 갖게 만드는 지점입니다.

솔직한 요약

동시성 버그는 서버도, 다른 사용자도 필요로 하지 않습니다 — 방문자 한 명이 merge를 클릭하고 끝나기 전에 파일을 하나 더 끌어다 놓는 것만으로 충분합니다. 브라우저 탭 하나 안에서, 네트워크 요청은 단 한 건도 없이 벌어지는 일입니다. NearPDF의 수정은 하나가 아니라 둘이었습니다 — 우연히 겹쳐서 하나의 눈에 보이는 멈춤을 만들어낸, 서로 진짜로 독립적인 두 레이스 컨디션을 다뤘습니다. 워커 응답은 이제 지우고 재사용 가능한 공유 슬롯이 아니라 호출별 id로 상호 연결되고, intake()는 이제 await 이후에 다시 읽은 상태가 아니라 await 이전에 캡처해 둔 상태로 분기를 결정합니다. 둘 다 작지만 구조적인 수정입니다 — 발견된 그 재현 하나만 땜질하는 게 아니라, 다음 트리거가 이번과 정확히 똑같은 모양이 아니더라도 이런 종류의 버그 자체가 벌어질 수 있는 여지를 아예 없애는 종류의 수정입니다.

광고
← NearPDF

이 페이지는 동의하신 경우에만 광고를 표시합니다.