이 응답은 누구 것인가요?

화면에 다운로드 큐가 하나 있었습니다. 사용자가 여러 건을 걸어도 한 번에 하나씩만 서버로 나가는 구조였습니다. 파일을 묶어 압축하는 작업이라 서버가 오래 붙잡히고, 그래서 부하를 아끼려고 이렇게 짰겠거니 하고 저는 읽었습니다.
그런데 사용자가 열 건을 걸면 열 배로 기다려야 했습니다. 동시에 두 건씩만 처리해도 체감이 확 달라질 상황이었고, 코드도 상수 하나만 고치면 되는 자리였습니다. 저는 그 숫자를 1에서 2로 올렸습니다. 로컬에서도 스테이징에서도 멀쩡했습니다. 제가 시험 삼아 걸어본 파일들은 크기가 고만고만해서 끝나는 순서가 거는 순서와 늘 같았기 때문입니다.
얼마 뒤 이상한 제보가 들어왔습니다. 받아진 파일이 자기가 누른 것이 아니라는 것입니다. 월간리포트를 눌렀는데 주간리포트가 내려오고, 주간리포트를 눌렀는데 월간리포트가 내려왔습니다. 두 건을 동시에 걸었을 때만 그랬습니다.
원인은 제 커밋에 있었지만, 결함은 그보다 훨씬 전부터 거기 있었습니다. 서버가 보내주는 완료 신호에는 그게 어느 요청의 완료인지가 적혀 있지 않았습니다. 한 번에 하나씩만 돌던 동안에는 물어볼 필요가 없던 질문이라, 아무도 그 자리가 비어 있다는 걸 몰랐던 것입니다.
그 큐는 느리게 만들어진 게 아니었습니다. 무언가를 대신하고 있었습니다. 완료 신호가 도착했을 때, 그게 어느 요청의 것인지는 누가 알려주나요?
이 글에서 다루는 내용
여러 요청의 완료 신호가 하나의 채널로 돌아올 때, 각 신호를 어느 요청과 연결할지 다룹니다. fetch가 이 연결을 어떻게 처리하는지 살펴본 뒤 상관키로 직접 짝을 찾는 코드를 봅니다.
이어서 동시성을 1로 제한하면 어떤 문제가 드러나지 않는지, 완료 신호를 잃으면 큐가 어떻게 멈추는지 실행 예제로 확인합니다.
동시성은 속도 다이얼이 아닙니다
우리가 비동기를 배운 순서를 떠올려 보면 대체로 이렇습니다. 콜백을 배우고, 콜백 지옥에 질려 Promise를 배우고, async/await로 평평하게 펴는 법을 배우고, 마지막에 Promise.all을 배웁니다.
그리고 Promise.all이 등장하는 맥락은 거의 항상 속도입니다. 순차로 하면 3초 걸리는 걸 병렬로 하면 1초에 끝난다는 예제로 배웁니다. 그래서 동시성 수준(concurrency level, 동시에 몇 개를 진행시킬지)은 자연스럽게 성능 손잡이로 인식됩니다. 올리면 빨라지고 내리면 느려지는 다이얼입니다.
이 인식은 대체로 맞습니다. 그런데 동시성에는 잘 이야기되지 않는 두 번째 얼굴이 있습니다.
짝 맞추기가 명시돼 있지 않은 시스템에서, 동시성 수준은 "동시에 몇 개를 돌릴 것인가"이면서 동시에 "몇 개가 서로 구별되지 않은 채로 떠 있어도 되는가" 이기도 합니다.
진행 중인 작업이 하나뿐이라면 구별할 일이 없습니다. 결과가 돌아왔을 때 그게 누구 것인지 물어볼 필요 자체가 없으니까요. 후보가 하나면 답도 하나입니다.
둘이 되는 순간 이야기가 달라집니다. 결과가 돌아왔을 때 둘 중 누구의 것인지를 판별해야 합니다. 그리고 이 판별을 누가 책임지는지는 코드마다 다릅니다. 어떤 코드에서는 우리가 직접 하고, 어떤 코드에서는 아래층이 대신 해줍니다. 프론트엔드 개발자가 오랫동안 후자만 겪어왔다는 것이 이 글의 출발점입니다.
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절부터 하게 될 이야기를 프로토콜이 먼저 한 셈입니다.
그리고 첫 번째 방식도 기억해 둘 필요가 있습니다. 순서로 짝을 지키는 방법은 순서가 보장될 때에만 통합니다. 뒤에서 만날 동시성 1 러너가 바로 이 방법을 쓰는데, 거기서는 아무도 순서를 보장해주지 않습니다.
그래서 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·네이티브 브릿지처럼 채널 하나에 메시지가 여러 개 흐르는 구조에서는 그 장치가 없습니다. 응답이 반환값이 아니라 흘러온 메시지가 되는 순간, 짝 맞추기는 우리 책임이 됩니다.
상관키
짝을 잃어버린 곳에서 짝을 되찾는 방법은 오래전에 이름이 붙어 있습니다. 상관 식별자(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 });
});
}
핵심은 대기자를 Map에 저장하고, 응답의 키로 조회하며, 요청마다 타임아웃을 두는 것입니다.
키가 나갔다가 그대로 돌아옵니다. 중간에 무엇이 몇 개나 오갔든 상관이 없습니다.
그런데 자주 반쪽만 됩니다
이 패턴이 어렵지 않은데도 실무에서 자주 어긋나는 방식이 있습니다. 키를 만들어서 보내기까지는 하는데, 받는 쪽에서 그 키를 안 보는 것입니다.
요청 헤더에는 X-Request-Id나 transactionId 같은 이름의 값이 성실하게 실려 나갑니다. 그런데 정작 돌아온 메시지를 거를 때는 그 키가 아니라 메시지의 종류만 봅니다. "이건 압축 완료 이벤트군" 하고 받아서, 지금 기다리고 있는 사람에게 그냥 건네주는 식입니다.
저는 이 형태를 서로 다른 두 곳에서 봤습니다. 한 번은 서버가 밀어주는 완료 이벤트 스트림이었고, 한 번은 웹뷰와 네이티브를 잇는 브릿지였습니다. 도메인도 팀도 언어도 달랐는데 결함의 모양은 같았습니다.
반복되는 데는 이유가 있다고 생각합니다. 상관 식별자는 패턴 자체로 보면 처음부터 짝을 맞추기 위한 장치입니다. 그런데 재미있는 것은, 웹 애플리케이션에 이미 굴러다니는 식별자 중 상당수는 관측 목적으로 들어왔다는 점입니다. 로그를 추적하려고, 분산 트레이싱을 붙이려고 X-Request-Id나 transactionId를 답니다. 그 시점에는 목적이 뚜렷하니 아무도 빠뜨리지 않습니다.
반면 받는 쪽에서 그 키로 짝을 맞추는 일은 지금 당장은 필요가 없습니다. 진행 중인 작업이 하나뿐이니까요. 그래서 키는 관측용으로 들어와 제어용으로는 쓰이지 못한 채 남습니다. 짝을 맞출 재료가 이미 손에 있는데 안 쓰는 상태가 되는 것입니다.
메시징 진영에서 20년 전에 이름이 붙은 패턴을 프론트엔드가 자꾸 다시 발견하게 되는 것도 같은 맥락일 겁니다. 요청-응답 뒤에 오래 숨어 있으면 이 문제를 만날 일이 없습니다.
동시성 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()는 언제나 맨 앞부터 꺼내므로 두 순서가 다른 순간 짝이 어긋납니다.
50ms에 주간리포트가 먼저 끝나고, 그 완료 이벤트가 배열 맨 앞에 있던 월간리포트의 대기자를 깨웁니다. 300ms에 월간리포트가 끝나면 남아 있던 주간리포트의 대기자가 그것을 받습니다. 배열이 아는 것은 "먼저 요청한 순서"뿐이고, 완료는 그 순서로 오지 않습니다.
서두의 제보가 정확히 이것이었습니다. 그리고 그 결함을 밖으로 꺼낸 커밋은 1을 2로 바꾼 것 하나뿐이었습니다.
무엇이 부채로 남는가
파일이 뒤바뀐 지점을 찾았다면, 다른 처리도 진행 중인 작업이 하나라는 전제에 기대고 있는지 확인해야 합니다.
| 코드에 있는 것 | 동시성 1에서는 | 동시성 2가 되면 |
|---|---|---|
대기자를 Map이 아니라 배열로 관리 | 원소가 하나뿐이라 아무 문제 없음 | 요청 순서와 완료 순서가 어긋남 |
| 중복 이벤트를 걸러내지 않음 | 다음 작업 시작 전에 오면 대기자가 없어 무시 | 다음 작업을 먼저 끝난 것으로 처리 |
| 순서 역전 방어가 없음 | 역전할 상대가 없음 | 역전 |
| 성공은 앞에서, 실패는 뒤에서 대기자를 꺼냄 | 원소가 하나라 앞뒤가 같음 | 엉뚱한 요청의 자리를 대신 풂 |
늦게 도착한 중복 이벤트는 동시성이 1이어도 다음 작업을 잘못 완료시킬 수 있습니다. 직렬화만으로 중복 처리가 해결되지는 않습니다.
마지막 줄은 특히 눈에 띄지 않습니다. 성공을 처리하는 코드와 실패를 처리하는 코드가 서로 다른 파일에 살면 이런 일이 자연스럽게 벌어집니다.
// 완료 콜백에서: 앞에서 꺼낸다
const resolver = waiters.shift();
// 에러 핸들러에서: 뒤에서 꺼낸다
const resolver = waiters.pop();
배열에 원소가 하나뿐인 동안에는 shift()와 pop()이 같은 것을 돌려줍니다. 그래서 이 불일치는 리뷰에서도, 테스트에서도, 운영 로그에서도 드러나지 않습니다. 원소가 둘이 되는 날 처음 드러납니다.
이 예제에서는 그 전제가 주석이나 타입에 드러나지 않습니다. 동시성 상수만 고치는 사람은 배열의 앞뒤에서 대기자를 꺼내는 코드까지 함께 확인해야 한다는 사실을 놓치기 쉽습니다.
한 문장으로 말해보기
동시성 제한의 이유를 적어보면 확인할 코드가 좁혀집니다. "완료 신호에 요청 ID가 없어서 한 번에 하나만 실행한다"면, 제한을 올리기 전에 이벤트 필터와 대기자 조회 방식을 함께 바꿔야 합니다. 요청 순서와 완료 순서를 뒤집은 테스트도 그 전제를 확인하는 데 도움이 됩니다.
Recap
이 예제는 진행 중인 작업이 하나라서 배열의 앞뒤 어디에서 꺼내도 같은 대기자를 얻었습니다. 동시성을 올리자 그 불일치와 완료 순서의 역전이 드러났습니다. 제한을 바꿀 때는 대기자 조회, 중복 이벤트 처리, 성공·실패 경로를 함께 확인해야 합니다.
직렬화는 실패의 모양도 바꿉니다
동시성을 낮추면 보통 안전해진다고 생각합니다. 동시에 벌어지는 일이 적으니 꼬일 일도 적을 것 같습니다. 그런데 완료 신호를 채널에서 받는 구조에서는 반대 방향의 일이 하나 벌어집니다.
직렬 큐는 앞의 작업이 끝나야 뒤가 돕니다. 그 "끝났다"를 판정하는 것은 Promise이고, 그 Promise를 끝낼 수 있는 것은 채널에서 오는 완료 신호뿐입니다. 작업 함수 안에서 await로 붙잡을 수 있는 대상이 아니라, resolve 함수를 밖으로 꺼내 어딘가에 보관해 두었다가 나중에 불러야 하는 구조입니다. 이렇게 밖에서 끝내주는 Promise를 디퍼드(deferred)라고 부릅니다.
그러면 이런 질문이 생깁니다. 그 신호가 안 오면 어떻게 되나요?
동시성 1이라는 우회로의 러너를 limit = 1로 그대로 두고, 첫 번째 작업의 완료 이벤트만 유실시켜 봤습니다.
600ms 뒤 완료된 작업: 0건
❌ 큐가 멈췄다: 예외도 reject 도 없다. 둘째 작업은 나가보지도 못했다
두 번째 작업은 서버에 나가보지도 못했습니다. 첫 번째의 Promise가 끝나지 않으니 워커의 while 루프가 그 자리에 서 있고, 뒤에 줄 선 작업 전부가 인질이 됩니다.
한 건의 신호가 사라졌을 뿐인데 뒤에 줄 선 작업이 전부 묶입니다. 그리고 아무도 이 사실을 보고받지 못합니다.
여기서 눈여겨볼 것은 실패의 모양입니다.
- 예외가 던져지지 않았으므로 에러 리포팅에 아무것도 올라가지 않습니다. Sentry는 조용합니다.
reject도 되지 않았으므로catch도,finally도 돌지 않습니다.- 사용자에게는 스피너만 남습니다. 무엇이 잘못됐는지 화면이 말해주지 않습니다.
직렬 큐에서는 이 대기가 뒤의 작업까지 막습니다. 이 예제를 동시성 2로 실행하면 다른 워커가 남은 작업을 처리할 수 있지만, 신호를 잃은 작업 자체는 여전히 끝나지 않습니다. 앞서 본 응답 뒤섞임까지 고려하면 동시성 수치만 바꿔서는 해결되지 않습니다. 기다림을 끝낼 별도 경로가 필요합니다.
타임아웃은 기다리는 쪽이 들고 있어야 합니다
resolve를 스코프 밖으로 꺼내는 순간 의무가 하나 생깁니다. 모든 실행 경로가 결국 settle과 뒷정리로 이어져야 한다는 것입니다. 그런데 이 의무를 타입 시스템이 전혀 추적해주지 않습니다. Promise를 만들어 놓고 아무도 끝내지 않는 코드는 완벽하게 컴파일됩니다.
그래서 타임아웃을 프레임워크나 상위 계층에 맡겨두면 위험합니다. 그쪽에서 던진 예외를 큐가 받지 못하면 큐는 그 사실을 영원히 모릅니다. 원칙으로 적으면 이렇습니다. 대기를 만든 계층은 스스로 그 대기를 끝낼 수 있어야 합니다. 외부 신호로만 끝나는 Promise라면 타임아웃이나 취소 같은 종료 경로도 같은 계층이 쥐고 있어야 합니다. 상관키를 붙인 request 함수가 setTimeout을 자기 Promise 안에 품고 있던 이유가 이것입니다.
조건은 그대로 두고 클라이언트만 바꿔 보겠습니다. 작업도 둘, limit도 1, 첫 건의 완료 이벤트를 유실시키는 것까지 같습니다. 상관키와 개별 타임아웃이 있는 쪽에서 돌리면 이렇게 됩니다.
실패: timeout req-1 (유실되는작업)
멀쩡한작업.zip
동시성은 여전히 1입니다. 타임아웃이 첫 작업의 대기를 끝내자 실패를 기록하고 다음 작업을 진행할 수 있었습니다.
여기까지 오면 상관키에서 함께 등장했던 두 장치가 사실은 서로 다른 실패를 막고 있었다는 것이 드러납니다.
| 장치 | 막는 질문 | 없으면 |
|---|---|---|
| 상관키 | 이 응답은 누구 것인가 | 남의 결과를 받는다 (정확성) |
| 타임아웃 | 응답이 오지 않아도 끝나는가 | 영원히 기다린다 (완료성) |
앞의 request 함수에서 Map은 응답에 맞는 대기자를 찾고, setTimeout은 응답이 오지 않는 대기자를 정리합니다. 둘 다 있어야 이 예제의 응답 뒤섞임과 무한 대기를 처리할 수 있습니다.
이 주제는 취소는 에러인가요? 비동기 에러의 최후 방어선과 AbortController에서 다룬 이야기와 이어집니다. 화면이 사라진 뒤에도 살아 있는 구독과 대기자를 어떻게 접을 것인가는 이 구조에서 반드시 따라오는 다음 질문입니다.
Recap
완료 신호만 기다리는 Promise는 신호가 유실되면 끝나지 않고, 직렬 큐의 뒤 작업까지 막습니다. 예외도 발생하지 않아 에러 리포팅만으로는 이 정지를 찾기 어렵습니다. 대기자를 관리하는 계층에 타임아웃이나 취소 경로를 두고, 종료할 때 대기자도 정리해야 합니다.
그래서 언제 직렬이 맞나
여기까지만 읽으면 직렬 큐가 나쁜 것처럼 읽힐 수 있는데, 그렇지 않습니다. 직렬이 정답인 경우는 분명히 있습니다.
- 순서 자체가 요구사항일 때. 앞 작업의 결과가 뒤 작업의 입력이 되는 경우입니다. 이때 병렬은 빠른 게 아니라 틀린 것입니다.
- 받는 쪽이 실제로 한 건만 수용할 때. 서버가 사용자당 한 건으로 제한하거나, 장치가 명령 하나씩만 처리하는 경우입니다.
- 자원을 아껴야 할 때. 무거운 작업이거나 레이트 리밋이 걸려 있어 의도적으로 흐름을 조이는 경우입니다.
이런 제약이 없다면 완료 신호를 구별하지 못해서 동시성을 제한했는지 확인합니다. 상관키를 붙였을 때 제한을 풀 수 있는지가 다음 판단 기준입니다.
상관키를 넣으려면 다른 팀의 코드를 고쳐야 할 수도 있습니다. 마감 안에 처리하기 어렵다면 동시성을 1로 유지할 수 있습니다.
다만 그때 필요한 것은 주석 한 줄입니다. "완료 이벤트에 상관키가 없어 동시 실행이 불가능함. 이 값을 올리려면 이벤트 필터부터 고칠 것." 이 한 줄이 있었다면 서두의 저는 그 상수를 건드리지 않았을 것입니다. 적어도 무엇을 건드리는지는 알고 건드렸을 것입니다.
References
패턴의 출처
- Gregor Hohpe, Bobby Woolf: Correlation Identifier (Enterprise Integration Patterns)
- Gregor Hohpe, Bobby Woolf: Request-Reply (Enterprise Integration Patterns)
HTTP 스택의 짝 맞추기
- RFC 9113: HTTP/2, §5 Streams and Multiplexing
- MDN: Using server-sent events
- MDN, Window: postMessage() method
- MDN: The WebSocket API
Promise와 디퍼드
이 블로그의 이어지는 글
