8초를 걸었는데 8초가 오지 않았다
하던 일은 단순했다. 블로그 편집기에 사진 여덟 장을 넣어야 했고, 그 사진들은 내 서버에 임시로 올려 둔 상태였다. 편집기가 열린 탭에서 fetch 로 파일을 받아 File 객체를 만들고, 편집기 본문에 드롭하면 되는 일이었다. 전날까지 잘 되던 경로다.
그런데 그날은 첫 장에서 멈췄다. 브라우저 창은 하나였고, 그 창의 활성 탭이 다른 탭으로 바뀌어 있었다. 내 편집기 탭은 «같은 창의 배경 탭»이 된 상태였다.
const c = new AbortController();
setTimeout(() => c.abort(), 8000); // 8초면 끊는다
const r = await fetch(url, { signal: c.signal });
// → 8초가 지나도 아무 일도 일어나지 않았다
여기서 이상한 것은 요청이 실패하지 않았다는 점이다. 실패도 안 하고 성공도 안 했다. 더 이상한 것은 내가 걸어 둔 타임아웃마저 오지 않았다는 것이다. 8초 뒤에 abort() 가 불렸다면 최소한 에러라도 났을 텐데, 그것도 없었다. 실패 처리를 걸어 두면 최악의 경우에도 «에러는 온다»고 생각했는데 그 전제가 틀렸다.
먼저 한 일은 탭을 앞으로 세우는 것이었다. 창을 최상위로 올리고, 최소화했다 복원하고, 창 목록에서 직접 집어 활성화하는 것까지 네 가지 방법을 시도했고 네 번 다 거부됐다. 운영체제가 포그라운드 창을 바꾸는 권한을 아무 프로세스에나 주지 않기 때문이다. 결국 사람이 탭을 한 번 눌러서 풀렸다. 그때 든 생각이 이 글의 출발점이다 — 사람을 매번 부를 수 없다면, 배경 탭에서도 되는 길이 하나는 있어야 한다.
멎은 것은 네트워크가 아니라 스케줄러였다
처음에는 「배경 탭에서는 네트워크가 막힌다」고 생각했다. 그렇게 적어 두기도 했다. 그런데 그 설명으로는 타임아웃까지 안 온 것이 설명되지 않는다. 네트워크가 막힌 것이라면 setTimeout 은 왜 안 불렸나.
브라우저는 탭이 화면에 보이지 않으면 그 탭이 쓰는 자원을 줄인다. 줄이는 대상은 «요청»이 아니라 «작업을 언제 처리할지 정하는 쪽»이다. 배경 탭의 타이머는 실행 간격이 크게 늘어나고, 조건이 맞으면 분 단위까지 벌어진다. 그 늘어남은 setTimeout 하나에만 적용되는 것이 아니라 큐에 들어가 차례를 기다리는 모든 것에 걸린다.
fetch 는 결과를 프로미스로 넘긴다. 응답이 도착해도 그 뒤에 붙은 처리는 작업 큐를 거쳐야 실행된다. 큐가 늦게 돌면 결과도 늦게 손에 들어온다. 내가 본 「fetch 가 멎었다」는 「응답을 꺼내 줄 차례가 안 왔다」에 가까웠다.
나는 이 현상을 8월에 「탭이 뒤에 있으면 fetch 가 멎는다」로 적어 뒀었다. 증상으로는 맞지만 원인을 네트워크로 지목한 것이 틀렸다. 틀린 원인을 적어 두면 우회로를 엉뚱한 데서 찾는다 — 실제로 나는 프록시와 재시도 쪽만 들여다보고 있었다.
같은 탭에서 동기 XHR 은 290ms 에 돌아왔다
원인을 «큐»로 바꿔 보면 답이 하나 나온다. 큐를 거치지 않는 길을 쓰면 된다. 동기 XMLHttpRequest 가 그것이다. send() 가 반환되는 시점에 응답이 이미 그 자리에 들어 있다. 콜백도 프로미스도 없으니 «차례를 기다릴» 자리 자체가 없다.
const x = new XMLHttpRequest();
x.open('GET', url, false); // ← 세 번째 인자 false 가 동기
x.overrideMimeType('text/plain; charset=x-user-defined'); // 바이너리를 위해
x.send(null);
const s = x.responseText;
같은 배경 탭에서 그대로 돌렸더니 231ms 에 102,791바이트가 들어왔다. 앞 몇 바이트를 찍어 보니 ff d8 ff — JPEG 헤더였다. 파일이 온전히 온 것이다.
다음 날 다른 글을 올리며 같은 자리에서 다시 쟀다. 이번에는 290ms 에 299,702바이트였다. 크기가 세 배인데 시간은 1.25배다. 두 번 다 document.visibilityState 는 hidden 이었고, 바로 옆에서 같은 주소로 부른 fetch 는 여전히 돌아오지 않았다.
한 번 됐을 때는 «그날 그 순간만 그런 것»일 수 있다. 하루 뒤 다른 글, 다른 파일, 다른 크기에서 같은 결과가 나오고 나서야 규칙으로 적었다. 한 번의 성공은 우연과 구별되지 않는다.
바이너리를 받는 법 — charset 하나가 가른다
동기 XHR 에는 제약이 하나 있다. responseType 을 쓸 수 없다. arraybuffer 나 blob 으로 받으려고 하면 브라우저가 거부한다. 명세가 동기 요청에서 그 조합을 금지하고 있다. 그래서 이미지를 문자열로 받아 바이트로 되돌려야 한다.
여기서 overrideMimeType('text/plain; charset=x-user-defined') 가 필요하다. 이 charset 은 바이트를 문자로 바꾸되 «버리지 않는» 것이 목적이다. UTF-8 로 해석하게 두면 유효하지 않은 바이트가 대체 문자로 바뀌어 원본이 망가진다. 이 charset 에서는 각 바이트가 문자 코드의 하위 8비트에 그대로 남는다.
const u = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) u[i] = s.charCodeAt(i) & 0xff; // 하위 8비트만
const f = new File([u], 'p1.jpg', { type: 'image/jpeg' });
텍스트는 더 간단하다. 같은 자리에서 charset=utf-8 로 열면 한글까지 그대로 읽힌다. 나는 본문 원고를 텍스트 파일로 올려 두고 이 방법으로 읽어 왔다. 긴 원고를 콘솔 명령 안에 문자열로 욱여넣지 않아도 되는 것이 덤으로 딸려 온 이득이었다 — 따옴표와 역슬래시가 섞인 원고를 손으로 이스케이프하다 보면 언젠가 한 글자가 어긋난다.
그래서 어디까지 됐나
파일을 손에 넣은 다음이 진짜 문제였다. 배경 탭에서 «가져오기»만 되고 «넣기»가 안 되면 소용이 없다. 결과부터 적으면 그 뒤 단계가 전부 통했다.
const dt = new DataTransfer(); dt.items.add(f);
const mk = t => new DragEvent(t, { bubbles: true, cancelable: true, dataTransfer: dt });
body.dispatchEvent(mk('dragenter'));
body.dispatchEvent(mk('dragover'));
const ok = body.dispatchEvent(mk('drop')); // false 면 편집기가 받았다는 뜻
여기서 판정이 뒤집혀 있다는 점을 짚어 둘 만하다. dispatchEvent 는 이벤트가 취소되면 false 를 돌려준다. 편집기가 드롭을 받아 자기 방식으로 처리하면 기본 동작을 막으므로 false 가 나온다. 즉 false 가 성공 신호다. 참을 성공으로 읽으면 여덟 번 다 실패로 보인다.
업로드가 끝난 뒤 본문을 조립하고, 카테고리를 고르고, 발행 버튼을 누르는 것까지 같은 배경 탭에서 됐다. 드롭다운도 열렸다. 요약하면 「배경 탭에서는 아무것도 안 된다」가 아니라 「비동기로 결과를 기다리는 것만 안 된다」였다.
여덟 장을 연달아 드롭했더니 본문에는 1, 8, 7, 6, 5, 4, 3, 2 순으로 쌓였다. 업로드가 끝나는 순서가 드롭 순서와 다르기 때문이다. 그래서 «몇 번째로 넣었는가»로 자리를 잡으면 안 된다. 나는 파일명을 붙여 두고 그것으로 잡았다.
이것은 «동기 XHR 을 쓰라»는 글이 아니다
동기 XHR 은 메인 스레드를 통째로 멈춘다. 응답이 올 때까지 화면이 굳고 클릭도 안 먹는다. 브라우저 콘솔에도 경고가 찍히고, 명세에서도 쓰지 말라고 한다. 웹 페이지 코드에 넣으면 안 되는 것이 맞다.
그런데 이 글의 상황은 페이지 코드가 아니다. 내가 그 페이지 «위에서» 한 번 돌리는 자동화 스크립트다. 조건이 다르다.
규칙은 대개 조건과 함께 만들어지는데, 옮겨 적히는 과정에서 조건만 떨어져 나간다. 「동기 XHR 은 나쁘다」도 그렇다. 나쁜 이유가 사라진 자리에서는 그 규칙도 같이 검토 대상이 된다. 반대로 페이지 코드에서는 이 글을 근거로 쓰지 않기 바란다. 거기서는 이유가 그대로 살아 있다.
여전히 안 되는 것
배경 탭에서 다 되는 것은 아니다. 오히려 제일 위험한 자리는 그대로 남았다.
가장 크게 데인 것은 포커스다. 배경 탭에서는 클릭을 보내도 그 요소가 포커스를 못 받는 경우가 있다. 문제는 이때 화면상 DOM 에는 글자가 들어간 것처럼 보인다는 점이다. 나는 그렇게 글이 통째로 빠진 채 게시물을 올린 적이 있다. 검산을 innerText 로 했기 때문에 통과해 버렸다. 편집기 «내부 상태»를 읽는 값으로 재야 잡힌다.
스크린샷도 배경 탭에서는 시간이 오래 걸리거나 실패한다. 클립보드 읽기·쓰기도 매달린다. 이 둘은 탭이 보인다는 전제가 API 설계에 들어가 있다.
그래서 순서를 이렇게 잡았다. 가져오기·주입·조립처럼 «내가 값을 다루는 일»은 배경 탭에서 다 끝내고, 사람 손이나 화면이 필요한 것만 창을 세워 달라고 부탁한다. 처음부터 창을 세우려고 싸우는 것보다 이 순서가 훨씬 덜 막혔다.
규칙으로 남긴 네 줄
document.visibilityState 부터 잰다. 「안 된다」의 절반은 «탭이 뒤에 있다»였다. 원인을 모른 채 코드를 고치기 전에 이 한 줄을 먼저 찍는다.이 네 줄 중 실제로 시간을 가장 많이 아껴 준 것은 첫 줄이다. 한 줄을 찍는 데 1초가 걸리는데, 그것을 안 찍어서 날린 시간이 그날만 한 시간이 넘는다.
자주 묻는 것
왜 fetch 는 안 돌아오는데 XHR 은 돌아오나요?
요청이 나가고 응답이 오는 것 자체는 둘 다 됩니다. 갈리는 것은 «응답을 받은 뒤»입니다. fetch 는 프로미스로 결과를 넘기므로 그 처리가 이벤트 루프의 작업 큐를 거쳐야 하는데, 브라우저는 배경 탭에서 이 큐의 처리를 크게 늦춥니다. 반면 동기 XHR 은 `send()` 가 반환될 때 응답이 이미 그 자리에 들어 있습니다. 큐를 거치지 않으니 늦춰질 자리가 없습니다. 「네트워크가 막혔다」가 아니라 「결과를 꺼내 줄 순서가 안 온다」에 가깝습니다.
동기 XHR 은 쓰지 말라고 하지 않나요?
맞습니다. 메인 스레드를 통째로 멈추기 때문에 웹 페이지 코드에는 쓰면 안 됩니다. 브라우저 콘솔에도 경고가 찍히고, 명세에서도 권장하지 않습니다. 이 글이 다루는 것은 페이지 코드가 아니라 «내가 그 페이지 위에서 한 번 돌리는 자동화 스크립트»입니다. 멈춰서 곤란한 사용자가 없고, 그 순간 필요한 것은 반응성이 아니라 «결과가 오는 것»입니다.
Web Worker 를 쓰면 되지 않나요?
워커 안의 타이머도 배경 탭에서 함께 늦춰집니다. 그리고 이 작업에서 결과를 써야 하는 곳은 편집기가 들어 있는 DOM 이라 어차피 메인 스레드로 돌아와야 합니다. 워커에서 받아 `postMessage` 로 넘기면 그 메시지 처리가 또 큐를 거칩니다. 실제로 필요한 것은 병렬 처리가 아니라 «지금 이 자리에서 값이 손에 들어오는 것»이었습니다.
그냥 탭을 앞으로 세우면 되는 것 아닌가요?
그게 첫 번째 답이고, 될 때는 그렇게 하는 편이 낫습니다. 문제는 안 될 때가 있다는 것입니다. 이날은 창을 앞으로 세우는 시도가 네 가지 방법에서 모두 거부됐습니다. 운영체제가 «포그라운드 창을 바꾸는 권한»을 아무 프로세스에나 주지 않기 때문입니다. 결국 사람이 탭을 한 번 눌러서 풀렸는데, 사람을 매번 부를 수 없으니 우회로가 필요했습니다.
이 방법이 앞으로도 계속 통할까요?
보장할 수 없습니다. 배경 탭에서 무엇을 얼마나 늦출지는 명세가 아니라 브라우저 구현이 정하는 영역이고, 판마다 바뀝니다. 그래서 저는 이것을 «규칙»이 아니라 «그날 실측»으로 적어 둡니다. 다음에 같은 자리에서 막히면 먼저 재 보고, 값이 다르면 그때 다시 적을 생각입니다.
참고 자료
- WHATWG. XMLHttpRequest Standard — synchronous flag and the InvalidAccessError restriction. xhr.spec.whatwg.org
- MDN Web Docs. XMLHttpRequest.overrideMimeType() — receiving binary data as a raw byte string. developer.mozilla.org
- Chrome for Developers. Timer throttling in background tabs — intensive throttling of timers and tasks. developer.chrome.com
- MDN Web Docs. Page Visibility API — document.visibilityState. developer.mozilla.org