본문으로 건너뛰기
2025. 10. 14
© WONKOOK LEE

이 응답은 누구 것인가요?

요청 두 개가 나가고 이름표 없는 응답 두 개가 돌아와 서로 뒤바뀌는 그림

화면에 다운로드 큐가 하나 있었습니다. 사용자가 여러 건을 걸어도 한 번에 하나씩만 서버로 나가는 구조였습니다. 파일을 묶어 압축하는 작업이라 서버가 오래 붙잡히고, 그래서 부하를 아끼려고 이렇게 짰겠거니 하고 저는 읽었습니다.

그런데 사용자가 열 건을 걸면 열 배로 기다려야 했습니다. 동시에 두 건씩만 처리해도 체감이 확 달라질 상황이었고, 코드도 상수 하나만 고치면 되는 자리였습니다. 저는 그 숫자를 1에서 2로 올렸습니다. 로컬에서도 스테이징에서도 멀쩡했습니다. 제가 시험 삼아 걸어본 파일들은 크기가 고만고만해서 끝나는 순서가 거는 순서와 늘 같았기 때문입니다.

얼마 뒤 이상한 제보가 들어왔습니다. 받아진 파일이 자기가 누른 것이 아니라는 것입니다. 급여명세서를 눌렀는데 퇴직정산서가 내려오고, 퇴직정산서를 눌렀는데 급여명세서가 내려왔습니다. 두 건을 동시에 걸었을 때만 그랬습니다.

원인은 제 커밋에 있었지만, 결함은 그보다 훨씬 전부터 거기 있었습니다. 서버가 보내주는 완료 신호에는 그게 어느 요청의 완료인지가 적혀 있지 않았습니다. 한 번에 하나씩만 돌던 동안에는 물어볼 필요가 없던 질문이라, 아무도 그 자리가 비어 있다는 걸 몰랐던 것입니다.

그 큐는 느리게 만들어진 게 아니었습니다. 무언가를 대신하고 있었습니다. 완료 신호가 도착했을 때, 그게 어느 요청의 것인지는 누가 알려주나요?


이 글에서 다루는 내용

이 글은 프론트엔드에서 동시성이 어려워지는 지점이 어디인지를 다룹니다. 결론부터 말하면 그 지점은 "병렬이 많아질 때"가 아니라 요청과 응답을 짝지어주던 장치가 사라질 때입니다.

그리고 그때 가장 먼저 손이 가는 해법 — 동시성을 1로 낮추기 — 이 왜 문제를 푸는 게 아니라 질문을 지우는 것인지, 그 대가로 무엇이 코드에 부채로 남는지를 봅니다. 주장만 하지 않고 서른 줄짜리 예제를 실제로 돌려 결과를 함께 보겠습니다.





1. 동시성은 속도 다이얼이 아닙니다

우리가 비동기를 배운 순서를 떠올려 보면 대체로 이렇습니다. 콜백을 배우고, 콜백 지옥에 질려 Promise를 배우고, async/await로 평평하게 펴는 법을 배우고, 마지막에 Promise.all을 배웁니다.

그리고 Promise.all이 등장하는 맥락은 거의 항상 속도입니다. 순차로 하면 3초 걸리는 걸 병렬로 하면 1초에 끝난다는 예제로 배웁니다. 그래서 동시성 수준(concurrency level, 동시에 몇 개를 진행시킬지)은 자연스럽게 성능 손잡이로 인식됩니다. 올리면 빨라지고 내리면 느려지는 다이얼입니다.

이 인식은 대체로 맞습니다. 그런데 동시성에는 잘 이야기되지 않는 두 번째 얼굴이 있습니다.

짝 맞추기가 명시돼 있지 않은 시스템에서, 동시성 수준은 "동시에 몇 개를 돌릴 것인가"이면서 동시에 "몇 개가 서로 구별되지 않은 채로 떠 있어도 되는가" 이기도 합니다.

진행 중인 작업이 하나뿐이라면 구별할 일이 없습니다. 결과가 돌아왔을 때 그게 누구 것인지 물어볼 필요 자체가 없으니까요. 후보가 하나면 답도 하나입니다.

둘이 되는 순간 이야기가 달라집니다. 결과가 돌아왔을 때 둘 중 누구의 것인지를 판별해야 합니다. 그리고 이 판별을 누가 책임지는지는 코드마다 다릅니다. 어떤 코드에서는 우리가 직접 하고, 어떤 코드에서는 아래층이 대신 해줍니다. 프론트엔드 개발자가 오랫동안 후자만 겪어왔다는 것이 이 글의 출발점입니다.


Recap

우리는 동시성을 성능 맥락에서 배웁니다. 그래서 동시성 수준을 올리면 빨라지고 내리면 느려지는 다이얼로 인식합니다. 하지만 동시성 수준에는 정확성 쪽 얼굴이 하나 더 있습니다 — 동시에 몇 개가 구별되지 않은 채로 떠 있어도 되는가입니다. 진행 중인 작업이 하나면 구별이 필요 없고, 둘이 되는 순간 판별의 책임이 생깁니다.




2. fetch가 대신 내주던 비용

간단한 질문 하나로 시작하겠습니다. 아래 코드는 왜 안전할까요?

// 100개의 요청을 한꺼번에 날린다
const results = await Promise.all(ids.map((id) => fetch(`/api/items/${id}`).then((r) => r.json())));

results[7]ids[7]의 응답이 들어 있다는 것을 우리는 의심하지 않습니다. 그 안심의 절반은 Promise.all 덕분입니다. 명세가 결과를 입력 순서대로 담아주기 때문에, 어느 요청이 먼저 끝났는지는 결과 배열의 순서와 아무 상관이 없습니다.

그런데 나머지 절반이 이 글의 주제입니다. Promise.all이 순서를 지켜준다 해도, fetch(A)가 돌려준 Promise가 하필 A의 응답으로 끝난다는 보장은 어디서 오는 것일까요? 응답 100개가 뒤엉켜 돌아오는 와중에 여덟 번째 Promise에 여덟 번째 응답을 넣어주는 일은 누가 합니까?

HTTP 요청-응답 스택입니다. HTTP/1.1에서는 같은 커넥션에서 요청이 겹치는 경우에도 응답 순서에 제약이 걸려 있어, 순서가 곧 짝의 근거가 됩니다. HTTP/2에서는 한 커넥션에 요청 여러 건이 동시에 흐를 수 있기 때문에 각 요청을 독립적인 스트림으로 나누고, 스트림 식별자(stream identifier)로 어느 응답이 어느 요청에 속하는지를 구별합니다.

짝을 지키는 방법은 둘 중 하나입니다. 순서를 지키거나, 순서를 포기하는 대신 이름표를 달거나.

두 번째 방식을 눈여겨볼 만합니다. 한 통로에 여러 건을 순서 보장 없이 겹쳐 흘리기로 결정한 순간, HTTP/2는 식별자를 도입해야만 했습니다. 겹쳐 흘리는 것과 이름표를 다는 것은 선택지 두 개가 아니라 한 몸입니다. 이 글이 3절부터 하게 될 이야기를 프로토콜이 먼저 한 셈입니다.

그리고 첫 번째 방식도 기억해 둘 필요가 있습니다. 순서로 짝을 지키는 방법은 순서가 보장될 때에만 통합니다. 4절에서 만날 코드가 바로 이 방법을 쓰는데, 거기서는 아무도 순서를 보장해주지 않습니다.

그래서 fetch의 세계에서는 "이 응답은 누구 것인가"라는 질문이 아예 생기지 않습니다. 응답이 요청의 반환값이기 때문입니다. 반환값에는 주인이 이미 적혀 있습니다.


짝 맞추기가 사라지는 자리

문제는 프론트엔드 개발자가 커리어의 상당 기간을 이 요청-응답 세계 안에서만 보낸다는 점입니다. 그래서 짝 맞추기를 한 번도 직접 해본 적 없이 다음 단계로 넘어갑니다. 그리고 그 다음 단계에는 짝 맞추기를 대신해 주는 사람이 없습니다.

어디서무엇이 흐르나응답에 주인이 적혀 있나
fetch / XHR요청 하나에 응답 하나반환값이므로 자명
SSE (EventSource)채널 하나에 서버가 미는 메시지 N개우리가 넣지 않으면 없음
WebSocket소켓 하나에 양방향 메시지 N개우리가 넣지 않으면 없음
postMessage (iframe · Web Worker)포트 하나에 메시지 N개우리가 넣지 않으면 없음
웹뷰 ↔ 네이티브 브릿지브릿지 하나에 호출 N개우리가 넣지 않으면 없음
BroadcastChannel · Service Worker채널 하나에 브로드캐스트 N개우리가 넣지 않으면 없음

목록의 공통점은 하나입니다. 응답이 "요청의 반환값"이 아니라 "채널에 흘러온 메시지"라는 것입니다.

왼쪽에서는 화살표가 짝을 말해줍니다. 오른쪽에서는 메시지 두 개가 채널에서 나올 뿐, 어느 쪽이 누구의 것인지를 그림이 말해주지 않습니다.

제품이 복잡해질수록 이런 채널 기반 인터페이스를 만날 가능성도 높아집니다. 요청-응답의 가정을 몸에 익힌 채로 그 가정이 성립하지 않는 곳에 도착하게 되는 것입니다.


Recap

fetch로 요청을 100개 동시에 날려도 응답이 섞이지 않는 것은 HTTP 요청-응답 스택이 짝 맞추기를 대신 해주기 때문입니다. HTTP/1.1은 응답 순서로, HTTP/2는 스트림 식별자로 그 일을 하고, 우리는 응답을 요청의 반환값으로 받으므로 주인을 물을 일이 없습니다. 반면 SSE·WebSocket·postMessage·네이티브 브릿지처럼 채널 하나에 메시지가 여러 개 흐르는 구조에서는 그 장치가 없습니다. 응답이 반환값이 아니라 흘러온 메시지가 되는 순간, 짝 맞추기는 우리 책임이 됩니다.




3. 상관키

짝을 잃어버린 곳에서 짝을 되찾는 방법은 오래전에 이름이 붙어 있습니다. 상관 식별자(Correlation Identifier)입니다. Gregor Hohpe와 Bobby Woolf가 2003년 『Enterprise Integration Patterns』에서 정리한, 메시징 시스템의 기본 패턴 중 하나입니다.

하는 일은 이름 그대로입니다. 요청을 보낼 때 고유한 키를 하나 붙이고, 응답에 그 키를 그대로 실어 보내게 하고, 받는 쪽은 그 키로 대기자를 찾습니다.

type Job = { label: string }; // 보낼 작업
type Pending = { resolve: (v: string) => void; timer: ReturnType<typeof setTimeout> };

const pending = new Map<string, Pending>(); // 상관키 -> 기다리는 사람

channel.subscribe((event) => {
const entry = pending.get(event.correlationId); // ① 키로 주인을 찾는다
if (!entry) return; // 내 것이 아니거나 이미 끝난 것 — 중복 이벤트도 여기서 걸러진다
pending.delete(event.correlationId);
clearTimeout(entry.timer);
entry.resolve(event.result);
});

function request(job: Job): Promise<string> {
const correlationId = crypto.randomUUID(); // ② 보낼 때 키를 만든다
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(correlationId);
reject(new Error(`timeout: ${correlationId}`)); // ③ 신호가 안 와도 반드시 끝난다
}, 30_000);
pending.set(correlationId, { resolve, timer });
send({ ...job, correlationId });
});
}

개념의 난이도는 0에 가깝습니다. 배열 대신 Map을 쓰고, 키로 찾고, 타임아웃을 각자 들려 보내는 것이 전부입니다.

키가 나갔다가 그대로 돌아옵니다. 중간에 무엇이 몇 개나 오갔든 상관이 없습니다.


그런데 자주 반쪽만 됩니다

이 패턴이 어렵지 않은데도 실무에서 자주 어긋나는 방식이 있습니다. 키를 만들어서 보내기까지는 하는데, 받는 쪽에서 그 키를 안 보는 것입니다.

요청 헤더에는 X-Request-IdtransactionId 같은 이름의 값이 성실하게 실려 나갑니다. 그런데 정작 돌아온 메시지를 거를 때는 그 키가 아니라 메시지의 종류만 봅니다. "이건 압축 완료 이벤트군" 하고 받아서, 지금 기다리고 있는 사람에게 그냥 건네주는 식입니다.

저는 이 형태를 서로 다른 두 곳에서 봤습니다. 한 번은 서버가 밀어주는 완료 이벤트 스트림이었고, 한 번은 웹뷰와 네이티브를 잇는 브릿지였습니다. 도메인도 팀도 언어도 달랐는데 결함의 모양은 같았습니다.

반복되는 데는 이유가 있다고 생각합니다. 상관 식별자는 패턴 자체로 보면 처음부터 짝을 맞추기 위한 장치입니다. 그런데 재미있는 것은, 웹 애플리케이션에 이미 굴러다니는 식별자 중 상당수는 관측 목적으로 들어왔다는 점입니다. 로그를 추적하려고, 분산 트레이싱을 붙이려고 X-Request-IdtransactionId를 답니다. 그 시점에는 목적이 뚜렷하니 아무도 빠뜨리지 않습니다.

반면 받는 쪽에서 그 키로 짝을 맞추는 일은 지금 당장은 필요가 없습니다. 진행 중인 작업이 하나뿐이니까요. 그래서 키는 관측용으로 들어와 제어용으로는 쓰이지 못한 채 남습니다. 짝을 맞출 재료가 이미 손에 있는데 안 쓰는 상태가 되는 것입니다.

메시징 진영에서 20년 전에 이름이 붙은 패턴을 프론트엔드가 자꾸 다시 발견하게 되는 것도 같은 맥락일 겁니다. 요청-응답 뒤에 오래 숨어 있으면 이 문제를 만날 일이 없습니다.


Recap

채널에서 짝을 되찾는 정공법은 상관 식별자입니다. 요청에 고유 키를 붙이고, 응답에 그 키를 실어 보내고, Map으로 대기자를 찾습니다. 개념은 어렵지 않은데도 자주 반쪽만 구현됩니다 — 키를 만들어 보내면서 받는 쪽에서는 메시지 종류만 보고 거르는 형태입니다. 상관키가 관측 목적으로 태어나고, 제어 목적의 짝 맞추기는 진행 중인 작업이 하나뿐인 동안에는 없어도 티가 나지 않기 때문입니다.




4. 동시성 1이라는 우회로

짝 맞추기를 하지 않고도 넘어가는 방법이 하나 더 있습니다. 후보를 하나로 만드는 것입니다.

진행 중인 작업이 항상 하나뿐이라면, 완료 신호가 왔을 때 그게 누구 것인지 물어볼 필요가 없습니다. 답이 하나밖에 없으니까요. 직렬 큐가 하는 일이 정확히 이것입니다.

// 완료 신호는 채널로 온다. 이벤트에는 종류와 결과만 있고 상관키는 없다.
function createClient(server: Server) {
const waiters: Array<(v: string) => void> = [];

server.subscribe((event) => {
if (event.type !== 'zip-done') return; // 종류만 본다. 상관키는 보지 않는다
waiters.shift()?.(event.result); // 맨 앞 사람에게 준다. 그게 누구든
});

return (job: Job) =>
new Promise<string>((resolve) => {
waiters.push(resolve);
server.submit(job);
});
}

이 코드는 정말로 동작합니다. 그리고 그게 문제의 핵심입니다.


실제로 돌려보기

동시에 몇 건을 돌릴지는 러너가 정합니다. 흔한 형태의 동시성 제한 코드이고, 손잡이는 limit 하나뿐입니다.

async function runAll(jobs: Job[], request: (job: Job) => Promise<string>, limit: number) {
const results: string[] = [];
let cursor = 0;
const worker = async () => {
while (cursor < jobs.length) {
const i = cursor++;
results[i] = await request(jobs[i]); // 하나가 끝나야 이 워커가 다음을 집는다
}
};
await Promise.all(Array.from({ length: limit }, worker)); // 워커 수 = 동시성 수준
return results;
}

작업은 두 건입니다. 느린 쪽을 먼저 요청하고, 빠른 쪽이 먼저 끝나게 합니다.

const jobs = [
{ label: '급여명세서', ms: 300 }, // 느린 쪽이 먼저 요청된다
{ label: '퇴직정산서', ms: 50 }, // 빠른 쪽이 먼저 끝난다
];

await runAll(jobs, createClient(server), 1);
급여명세서 요청이 받은 것: 급여명세서.zip
퇴직정산서 요청이 받은 것: 퇴직정산서.zip
✅ 정상

이제 상수 하나만 바꿉니다. 클라이언트도, 러너도, 서버도 그대로입니다.

await runAll(jobs, createClient(server), 2); // ← 1 에서 2 로
급여명세서 요청이 받은 것: 퇴직정산서.zip
퇴직정산서 요청이 받은 것: 급여명세서.zip
❌ 어긋남

두 결과가 통째로 뒤바뀌었습니다. 무슨 일이 일어났는지는 shift() 한 줄에 다 있습니다.

완료 이벤트는 끝난 순서로 도착하는데 대기자는 요청한 순서로 서 있어, shift()가 코랄 완료를 블루 대기자에게 배달하는 그림

왼쪽은 완료가 도착한 순서, 오른쪽은 요청이 줄 선 순서입니다. shift()는 언제나 맨 앞부터 꺼내므로 두 순서가 다른 순간 색이 어긋납니다.

50ms에 퇴직정산서가 먼저 끝나고, 그 완료 이벤트가 배열 맨 앞에 있던 급여명세서의 대기자를 깨웁니다. 300ms에 급여명세서가 끝나면 남아 있던 퇴직정산서의 대기자가 그것을 받습니다. 배열이 아는 것은 "먼저 요청한 순서"뿐이고, 완료는 그 순서로 오지 않습니다.

서두의 제보가 정확히 이것이었습니다. 그리고 그 결함을 밖으로 꺼낸 커밋은 12로 바꾼 것 하나뿐이었습니다.


무엇이 부채로 남는가

여기서 진짜로 봐야 할 것은 뒤바뀐 파일이 아닙니다. 동시성이 1이라는 사실에 조용히 기대는 코드가 그동안 얼마나 쌓였는가 입니다.

코드에 있는 것동시성 1에서는동시성 2가 되면
대기자를 Map이 아니라 배열로 관리원소가 하나뿐이라 아무 문제 없음요청 순서와 완료 순서가 어긋남
중복 이벤트를 걸러내지 않음두 번째로 꺼낼 대기자가 없어 무해다음 작업을 먼저 끝난 것으로 처리
순서 역전 방어가 없음역전할 상대가 없음역전
성공은 앞에서, 실패는 뒤에서 대기자를 꺼냄원소가 하나라 앞뒤가 같음엉뚱한 요청의 자리를 대신 풂

마지막 줄은 특히 눈에 띄지 않습니다. 성공을 처리하는 코드와 실패를 처리하는 코드가 서로 다른 파일에 살면 이런 일이 자연스럽게 벌어집니다.

// 완료 콜백에서 — 앞에서 꺼낸다
const resolver = waiters.shift();

// 에러 핸들러에서 — 뒤에서 꺼낸다
const resolver = waiters.pop();

배열에 원소가 하나뿐인 동안에는 shift()pop()같은 것을 돌려줍니다. 그래서 이 불일치는 리뷰에서도, 테스트에서도, 운영 로그에서도 드러나지 않습니다. 원소가 둘이 되는 날 처음 드러납니다.

네 줄 모두 현재의 전제 안에서는 즉시 틀렸다고 말하기 어렵습니다. 리뷰에서 지적하기도 애매합니다. 전부 "진행 중인 작업이 하나뿐"이라는 전제 위에서만 참인데, 그 전제는 주석에도 타입에도 테스트에도 적혀 있지 않습니다.

그래서 청구서는 한꺼번에 옵니다. 성능 개선이라는 이름으로 상수 하나가 1에서 2로 바뀌는 순간, 네 개가 동시에 깨어납니다. 그 커밋을 올린 사람은 자기가 무엇을 건드렸는지 알 방법이 없습니다. 코드 어디에도 안 적혀 있으니까요.


한 문장으로 말해보기

그래서 저는 이런 질문을 스스로에게 던지게 됐습니다.

이 코드가 안전한 이유를 한 문장으로 말해보십시오. 그 문장에 "지금은 하나씩만 도니까"가 들어간다면, 그건 설계가 아니라 상황입니다.

설계도 전제가 깨지면 무너집니다. 둘의 차이는 거기가 아닙니다. 설계는 자신이 기대는 조건을 드러내고, 상황에 기댄 코드는 그 조건을 숨깁니다. 그리고 숨겨진 조건을 바꾸는 사람은 보통 원래 코드를 쓴 사람이 아닙니다.

이 모양은 프론트엔드 바깥에서도 반복됩니다. 행 단위 잠금을 설계하는 대신 테이블 전체를 잠그는 것, 워커 풀의 동시성을 1로 고정해두는 것, 인스턴스를 하나로 유지해 상태 충돌을 피하는 것. 전부 직렬화로 식별자를 대신하는 패턴입니다. 잘 동작하고, 대체로 합리적인 선택이며, 왜 그렇게 했는지가 적혀 있지 않다는 점까지 닮았습니다.


Recap

동시성을 1로 낮추면 후보가 하나뿐이라 짝 맞추기 없이도 코드가 동작합니다. 실제로 잘 동작하기 때문에 아무도 고치지 않습니다. 문제는 그동안 동시성 1에 조용히 기대는 코드가 쌓인다는 것입니다 — 배열로 관리한 대기자, 중복 이벤트 미처리, 순서 방어 부재는 개별로는 틀리지 않았지만 전부 "진행 중인 작업이 하나뿐"이라는 적히지 않은 전제 위에 있습니다. 그래서 동시성을 2로 올리는 한 줄이 여러 개를 동시에 깨웁니다. 안전한 이유를 한 문장으로 말했을 때 "지금은 하나씩만 도니까"가 들어가면 그것은 설계가 아니라 상황입니다.




5. 직렬화는 실패의 모양도 바꿉니다

동시성을 낮추면 보통 안전해진다고 생각합니다. 동시에 벌어지는 일이 적으니 꼬일 일도 적을 것 같습니다. 그런데 완료 신호를 채널에서 받는 구조에서는 반대 방향의 일이 하나 벌어집니다.

직렬 큐는 앞의 작업이 끝나야 뒤가 돕니다. 그 "끝났다"를 판정하는 것은 Promise이고, 그 Promise를 끝낼 수 있는 것은 채널에서 오는 완료 신호뿐입니다. 작업 함수 안에서 await로 붙잡을 수 있는 대상이 아니라, resolve 함수를 밖으로 꺼내 어딘가에 보관해 두었다가 나중에 불러야 하는 구조입니다. 이렇게 밖에서 끝내주는 Promise디퍼드(deferred)라고 부릅니다.

그러면 이런 질문이 생깁니다. 그 신호가 안 오면 어떻게 되나요?

4절의 러너를 limit = 1로 그대로 두고, 첫 번째 작업의 완료 이벤트만 유실시켜 봤습니다.

600ms 뒤 완료된 작업: 0건
❌ 큐가 멈췄다 — 예외도 reject 도 없다. 둘째 작업은 나가보지도 못했다

두 번째 작업은 서버에 나가보지도 못했습니다. 첫 번째의 Promise가 끝나지 않으니 워커의 while 루프가 그 자리에 서 있고, 뒤에 줄 선 작업 전부가 인질이 됩니다.

한 건의 신호가 사라졌을 뿐인데 뒤에 줄 선 작업이 전부 묶입니다. 그리고 아무도 이 사실을 보고받지 못합니다.

여기서 눈여겨볼 것은 실패의 모양입니다.

  • 예외가 던져지지 않았으므로 에러 리포팅에 아무것도 올라가지 않습니다. Sentry는 조용합니다.
  • reject도 되지 않았으므로 catch도, finally도 돌지 않습니다.
  • 사용자에게는 스피너만 남습니다. 무엇이 잘못됐는지 화면이 말해주지 않습니다.

에러가 아니라 정지가 실패의 모양이 됩니다. 그리고 직렬이기 때문에 그 정지는 한 건이 아니라 뒤의 전부에 번집니다. 동시성이 2였다면 신호를 잃은 한 건만 영원히 기다리고 나머지 워커는 제 갈 길을 갔을 텐데, 직렬화가 실패의 폭발 반경을 오히려 키운 것입니다.

오해를 막기 위해 짚어두면, 그러니 병렬이 낫다는 말이 아닙니다. 4절에서 봤듯 상관키 없는 병렬은 결과를 뒤섞습니다. 요점은 이것입니다 — 타임아웃이 없으면 어느 쪽이든 무너지는데, 직렬은 거기에 전파를 하나 더 얹습니다. 동시성을 낮춘 것이 안전을 사준 것처럼 보였지만, 실제로 산 것은 정확성 하나뿐이고 가용성은 오히려 내준 셈입니다.


타임아웃은 기다리는 쪽이 들고 있어야 합니다

resolve를 스코프 밖으로 꺼내는 순간 의무가 하나 생깁니다. 모든 실행 경로가 결국 settle과 뒷정리로 이어져야 한다는 것입니다. 그런데 이 의무를 타입 시스템이 전혀 추적해주지 않습니다. Promise를 만들어 놓고 아무도 끝내지 않는 코드는 완벽하게 컴파일됩니다.

그래서 타임아웃을 프레임워크나 상위 계층에 맡겨두면 위험합니다. 그쪽에서 던진 예외를 큐가 받지 못하면 큐는 그 사실을 영원히 모릅니다. 원칙으로 적으면 이렇습니다 — 대기를 만든 계층은 스스로 그 대기를 끝낼 수 있어야 합니다. 외부 신호로만 끝나는 Promise라면 타임아웃이나 취소 같은 종료 경로도 같은 계층이 쥐고 있어야 합니다. 3절의 request 함수가 setTimeout을 자기 Promise 안에 품고 있던 이유가 이것입니다.

조건은 그대로 두고 클라이언트만 바꿔 보겠습니다. 작업도 둘, limit도 1, 첫 건의 완료 이벤트를 유실시키는 것까지 같습니다. 상관키와 개별 타임아웃이 있는 쪽에서 돌리면 이렇게 됩니다.

실패: timeout req-1 (유실되는작업)
멀쩡한작업.zip

유실된 건만 실패로 떨어지고 뒤의 작업은 그대로 끝났습니다. 실패가 에러의 모양을 되찾았고, 폭발 반경도 한 건으로 줄었습니다. 동시성은 여전히 1이라는 점을 짚어두겠습니다 — 이 개선은 병렬로 바꿔서 얻은 것이 아니라 타임아웃을 기다리는 쪽으로 옮겨서 얻은 것입니다.

여기까지 오면 3절에서 함께 등장했던 두 장치가 사실은 서로 다른 실패를 막고 있었다는 것이 드러납니다.

장치막는 질문없으면
상관키이 응답은 누구 것인가남의 결과를 받는다 (정확성)
타임아웃응답이 오지 않아도 끝나는가영원히 기다린다 (완료성)

둘은 대체재가 아닙니다. 상관키만 있으면 결과는 정확하지만 신호가 유실될 때 멈추고, 타임아웃만 있으면 멈추지는 않지만 엉뚱한 결과를 받습니다. 3절의 request 함수가 MapsetTimeout을 함께 들고 있던 것은 취향이 아니라 두 축을 모두 닫기 위해서였습니다.

이 주제는 취소는 에러인가요? — 비동기 에러의 최후 방어선과 AbortController에서 다룬 이야기와 이어집니다. 화면이 사라진 뒤에도 살아 있는 구독과 대기자를 어떻게 접을 것인가는 이 구조에서 반드시 따라오는 다음 질문입니다.


Recap

직렬 큐에서 Promise를 끝내는 신호가 채널에서만 온다면, 그 신호가 유실될 때 큐는 에러를 내지 않고 그냥 멈춥니다. 예외도 reject도 없으므로 에러 리포팅에 잡히지 않고 사용자에게는 스피너만 남습니다. 직렬이기 때문에 정지는 한 건이 아니라 뒤의 전부로 번져, 동시성을 낮춘 것이 오히려 실패의 폭발 반경을 키웁니다. resolve를 스코프 밖으로 꺼내면 모든 실행 경로가 결국 settle과 뒷정리로 이어져야 하는데 타입이 이를 지켜주지 않으므로, 대기를 만든 계층이 그 대기를 끝낼 방법도 함께 쥐고 있어야 합니다. 상관키와 타임아웃은 대체재가 아니라 서로 다른 실패를 막는 두 축입니다 — 상관키는 "이 응답은 누구 것인가"를, 타임아웃은 "응답이 오지 않아도 끝나는가"를 보장합니다.




6. 그래서 언제 직렬이 맞나

여기까지만 읽으면 직렬 큐가 나쁜 것처럼 읽힐 수 있는데, 그렇지 않습니다. 직렬이 정답인 경우는 분명히 있습니다.

  • 순서 자체가 요구사항일 때. 앞 작업의 결과가 뒤 작업의 입력이 되는 경우입니다. 이때 병렬은 빠른 게 아니라 틀린 것입니다.
  • 받는 쪽이 실제로 한 건만 수용할 때. 서버가 사용자당 한 건으로 제한하거나, 장치가 명령 하나씩만 처리하는 경우입니다.
  • 자원을 아껴야 할 때. 무거운 작업이거나 레이트 리밋이 걸려 있어 의도적으로 흐름을 조이는 경우입니다.

이 셋의 공통점은 직렬인 이유를 한 문장으로 말할 수 있고, 그 문장에 "구별을 못 해서"가 없다는 것입니다.

그래서 판별은 이렇게 하면 됩니다.

직렬이어야 할 다른 이유가 없는데, 상관키만 붙이면 병렬로 갈 수 있습니까? 그렇다면 지금의 직렬은 설계가 아니라 회피입니다.

왼쪽 세 갈래는 이유를 말할 수 있는 직렬입니다. 마지막 갈래만 이유가 "못 해서"입니다.

회피가 늘 나쁜 것은 아닙니다. 마감이 있고, 지금 필요한 것은 동작하는 화면이며, 상관키를 넣으려면 남의 팀 코드를 고쳐야 할 수도 있습니다. 그럴 때 동시성을 1로 두는 것은 값싸고 합리적인 선택입니다.

다만 그때 필요한 것은 주석 한 줄입니다. "완료 이벤트에 상관키가 없어 동시 실행이 불가능함. 이 값을 올리려면 이벤트 필터부터 고칠 것." 이 한 줄이 있었다면 서두의 저는 그 상수를 건드리지 않았을 것입니다. 적어도 무엇을 건드리는지는 알고 건드렸을 것입니다.

결국 동시성 수준은 성능 파라미터처럼 생겼지만, 사실은 진술에 가깝습니다. "이 시스템은 동시에 N개의 미완료 작업을 서로 구별할 수 있다"는 진술입니다. 코드가 그 진술을 지킬 수 없다면 N은 1이어야 하고, 왜 1인지는 코드에 적혀 있어야 합니다. 적혀 있지 않은 1은 언젠가 누군가가 2로 바꿉니다. 대체로 좋은 의도로 바꿉니다.


Recap

직렬이 정답인 경우는 순서가 요구사항이거나, 받는 쪽이 한 건만 수용하거나, 자원을 의도적으로 조일 때입니다. 셋 다 직렬인 이유를 한 문장으로 말할 수 있고 그 문장에 "구별을 못 해서"가 없습니다. 그런 이유가 따로 없는데 상관키만 붙이면 병렬로 갈 수 있는 상황이라면 지금의 직렬은 설계가 아니라 회피이며, 회피 자체는 합리적일 수 있으나 그 사실이 코드에 적혀 있어야 합니다. 동시성 수준은 성능 파라미터가 아니라 "동시에 몇 개의 미완료 작업을 구별할 수 있는가"에 대한 진술이기 때문입니다.




References

패턴의 출처

  1. Gregor Hohpe, Bobby Woolf — Correlation Identifier (Enterprise Integration Patterns)
  2. Gregor Hohpe, Bobby Woolf — Request-Reply (Enterprise Integration Patterns)

HTTP 스택의 짝 맞추기

  1. RFC 9113 — HTTP/2, §5 Streams and Multiplexing
  2. MDN — Using server-sent events
  3. MDN — Window: postMessage() method
  4. MDN — The WebSocket API

Promise와 디퍼드

  1. MDN — Promise.withResolvers()
  2. MDN — Promise.allSettled()

이 블로그의 이어지는 글

  1. 취소는 에러인가요? — 비동기 에러의 최후 방어선과 AbortController
  2. 타입스크립트로 잘못된 상태 막기


좋은 사람들과 재미있는 일을 하며 열정적이고 즐겁게 살고 싶은 개발자