BullMQ로 알림 시스템 운영하기 5편 - 큐는 순서를 보장하지 않는다, 그룹 발송의 동시성
시리즈BullMQ로 알림 시스템 운영하기5/5
- BullMQ로 알림 시스템 운영하기 1편 - BullMQ란
- BullMQ로 알림 시스템 운영하기 2편 - Rate Limit과 그에 따른 설계
- BullMQ로 알림 시스템 운영하기 3편 - 발송 이력을 어디에 쌓을 것인가
- BullMQ로 알림 시스템 운영하기 4편 - 쌓아둔 이력을 어떻게 읽을 것인가
- BullMQ로 알림 시스템 운영하기 5편 - 큐는 순서를 보장하지 않는다, 그룹 발송의 동시성
들어가며
2편에서 rate limit을 맞췄고 3편과 4편에서 발송 이력을 다뤘다. 그동안 발송 자체는 단순했다. 청크 job 하나가 자기 몫을 바로 보내면 끝이었다.
이번 편은 대량 발송을 그룹으로 모으는 기능을 붙인 이야기다. 기능 자체보다 기능을 붙이면서 생긴 문제가 주제다. batch 서버가 던진 job들을 notification 서버가 비동기로 소비하는 순간부터 도착 순서도 처리 시점도 보장되지 않는데, 그 위에서 그룹은 하나여야 하고 발송은 한 번이어야 한다. 이 동시성을 다루는 과정에서 BullMQ의 부모 자식 job(waiting-children)을 쓰게 됐다. waiting-children이 맞는 자리와 맞지 않는 자리, 그리고 이 흐름이 만든 동시성 경합을 하나씩 풀고 빠뜨린 것이 없는지 검증하는 과정까지 이번 편에 담았다.
그룹 발송, 전부 담은 뒤에 한 번에 보낸다
리타게팅 같은 대량 발송에서는 같은 분류의 알림톡이 한 번에 1만 건을 넘게 나간다. 지금까지는 batch 서버가 대상을 청크로 잘라 job 여러 개로 적재하고 각 job이 자기 몫을 바로 발송하는 구조였는데, 이 구조에서 묶음은 낱개 발송의 무더기일 뿐이다. 캠페인이 어디까지 나갔고 결과가 무엇인지를 묶음 단위로 물을 자리가 없다.
서드파티에는 이걸 위한 그룹 API가 있다. 그룹을 만들고, 메시지를 나눠 담고, 전부 담긴 뒤에 발송을 한 번 트리거하는 방식이다. 같은 분류를 그룹 하나로 묶으면 발송도 결과 조회도 그룹 단위로 떨어진다. 담는 것과 보내는 것이 분리되어 있어서 도중 실패가 “잘못 발송됨”이 아니라 “발송 안 됨”으로 끝나는 안전성(fail-closed)도 덤으로 따라온다.
우리 쪽 흐름은 이렇다. 대상 조회가 수백만 건이라 batch 서버는 한 번에 조회할 수 없고 서드파티도 요청 하나에 담을 수 있는 크기를 제한한다. 그래서 batch 서버가 대상을 청크 단위로 잘라 job으로 적재하고 각 청크 job은 발송 대신 그룹에 적재만 하면서 Redis 해시에 자기 몫을 기록한다. 기록을 남긴 뒤 “이제 전부 담겼는가”를 확인해서, 완성을 본 job이 발송을 트리거한다. 마지막으로 적재된 청크가 아니라 마지막으로 처리를 끝낸 청크가 방아쇠를 당긴다. 큐는 순서를 보장하지 않기 때문이다.
흩어진 청크가 그룹 하나로 모이는 전체 구조는 이렇다.
동시성은 큐에서 시작된다
이 구조의 어려움은 batch 서버가 job을 던지는 순간부터 시작된다. 큐는 비동기다. 청크가 어떤 순서로 도착할지, 언제 처리될지, 어느 워커가 집을지 아무것도 보장되지 않는다. 그런데 흐름의 목적지는 하나다. 흩어져 소비되는 청크들이 같은 그룹 하나에 모여야 하고 발송은 묶음당 한 번이어야 한다. 소비는 흩어지는데 목적지는 하나라는 이 조합이 이번 편의 동시성 문제의 원형이다. 그룹이 없으면 여러 워커가 동시에 만들려 들고, “전부 담겼는가”를 여러 청크가 동시에 볼 수 있고, 재시도로 같은 청크가 두 번 온다.
제대로 다루지 못하면 어떻게 될까. 마지막 표시가 붙은 청크가 중간 청크보다 먼저 끝나면 그룹은 덜 담긴 채 발송된다.
여기에 호출의 자리 문제가 하나 얹힌다. 그룹에 얽힌 서드파티 호출은 적재만이 아니라 그룹 생성과 발송 트리거가 있고 처음에는 둘을 청크 job을 처리하던 중에 인라인으로 불렀다. 그런데 인라인 호출은 2편에서 맞춘 limiter가 세지 못하는 호출이고, job이 큐에서 그냥 받는 재시도와 백오프, 실패 기록도 핸들러 안에 다시 지어야 하는 호출이다. 그래서 서드파티로 나가는 모든 호출은 job이어야 한다는 원칙으로 둘을 내부 job으로 뺐다. 적재는 청크 job 하나가 요청 하나를 보내는 구조라 그 자체로 이미 job이다.
job 사이의 의존, waiting-children
호출을 job으로 빼는 순간 job 사이에 의존이 생긴다. 청크는 그룹이 생긴 뒤에야 적재를 이어갈 수 있다.
미리 선언할 수 없는 트리
BullMQ에는 job 사이의 의존을 선언하는 FlowProducer가 있다. 부모 job과 자식 job을 트리로 선언하면 자식들이 모두 끝난 뒤 부모가 실행된다. “그룹 생성 → 청크 적재 → 발송”이 정확히 트리 모양이므로 처음에는 이것을 검토했다.
접은 이유는 우리 흐름이 정적이지 않아서다. FlowProducer는 적재 시점에 트리 전체를 선언해야 한다. 그런데 그룹이 필요한지는 처리 시점에야 결정된다. 이미 그룹이 있으면 생성 단계 자체가 없어야 하고 발송을 누가 트리거할지는 어느 청크가 마지막으로 끝나느냐에 달렸다. 적재 시점에 아는 것이 아무것도 없다.
선언은 적재 시점에 하는데 결정은 처리 시점에 난다.
접기는 했지만 이 트리로 얻는 것도 있었다. 트리에서는 후속 실행의 주인이 부모 하나다. 그룹을 만드는 자도 발송을 트리거하는 자도 하나뿐이면, 그 자리의 경합은 처음부터 없다. 쓰는 자가 하나면 상호배제가 필요 없다는 single-writer 원칙 그대로다. 결정을 producer가 청크를 쪼개는 시점으로 옮겨 묶음마다 조정자 job을 세우면 트리를 정적으로 만들 수도 있었는데, 그 길은 뒤에 나올 세대 확장에 막힌다. 그룹이 몇 개가 될지는 담아 봐야 알고, 미리 선언하는 트리에는 담기지 않는다. 이 선택의 값도 뒤에 그대로 나온다. 경합을 다섯이나 세게 되는 이유가 여기서 시작된다.
트리 대신, 같은 목적을 동적으로 이루는 저수준 도구가 있었다. moveToWaitingChildren이다.
부모와 자식, 그룹이 생길 때까지 재운다
내부 job을 두 개 만들었다. 같은 큐에 들어가고 producer의 payload와 겹치지 않게 job 이름으로 라우팅한다.
group.create— 그룹을 만들고 id를 Redis에 남긴다.group.send— 완성된 그룹의 발송을 트리거한다.
group.send는 단순하다. 완성을 본 청크가 큐에 넣으면 끝이다. 어려운 쪽은 group.create다. 그룹이 없다는 사실을 알게 되는 것은 청크 job이 적재를 하려는 순간인데, 그 청크는 그룹이 생길 때까지 기다렸다가 이어서 적재해야 한다. 큐는 비동기라 자식 job의 결과를 그 자리에서 받을 수 없다.
이 “기다렸다가 이어서”를 맡는 것이 waiting-children이다. 코드 뼈대는 이렇다.
// 청크 job 처리 중, 그룹이 없다는 신호를 받으면
await queue.add(
"group.create",
{ groupKey },
{
jobId: `create-${seq}-${groupKey}`,
parent: { id: job.id, queue: job.queueQualifiedName },
failParentOnFailure: true,
},
);
if (await job.moveToWaitingChildren(token)) {
throw new WaitingChildrenError();
}
순서대로 읽으면 이렇다.
- 자식 job을 큐에 넣으면서
parent로 자신을 지정한다. 이 연결이 “자식이 끝나면 나를 깨워달라”는 선언이다. moveToWaitingChildren(token)으로 자신을 waiting-children 상태로 옮긴다. 미완료 자식이 있으면true가 돌아온다.WaitingChildrenError를 던진다. 이 에러는 실패가 아니라 상태 전환 신호다. 워커가 이 타입만은 실패 처리를 건너뛰므로 재시도 횟수도 소모되지 않는다.
자식이 끝나면 부모는 대기열로 돌아와 처음부터 다시 실행된다. 중단 지점부터 이어지는 것이 아니다. 그래서 재진입 멱등성이 필요하다. 우리 구현에서는 처리 첫머리의 Redis 조회가 그 역할을 한다. 재진입하면 이번에는 그룹 id가 조회되므로, 생성 신호 없이 적재로 넘어간다.
이 방식에는 비용이 있다. 그룹이 없던 청크는 첫 실행에서 호출 없이 잠들었다가 재실행에서야 적재를 보내므로 워커의 슬롯을 두 번 쓰고, 이미 저장된 id를 보고 그냥 반환하는 자식도 슬롯 하나를 쓴다. 처리량을 조금 깎는 손해지만 그룹이 만들어지는 짧은 창에 한정되는 값이라 받아들였다.
부모가 잠들고, 자식이 limiter를 지나 그룹을 만들고, 부모가 처음부터 재실행된다.
동시에 오면 어떻게 되나
5,000건짜리 청크 20개가 들어왔다고 하자. job-0이 그룹 생성을 기다리는 동안 job-1과 job-2가 도착하면 어떻게 될까.
답은 “각자 자식을 낳고 각자 잠든다”이다. job-1도 그룹이 없다는 것을 보고 자기 자식을 낳는다. 자식이 둘 이상 생기지만 문제가 되지 않는다. 그룹 생성 핸들러가 Redis의 SET NX로 경합을 풀기 때문이다. 늦게 실행된 자식은 이미 저장된 id를 보고 그대로 반환하고, 정말 동시에 만들어 경합이 났다면 진 쪽이 자기가 만든 빈 그룹을 지운다.
여기서 직관과 달라지는 지점이 하나 있다. 자식 jobId를 묶음 키가 아니라 청크(seq)별로 지었다. “그룹 생성은 묶음당 한 번이니 jobId도 묶음당 하나”가 자연스러워 보이지만, BullMQ에서 같은 jobId로 두 번째 add를 하면 기존 job에 흡수되면서 parent 연결은 걸리지 않는다. job-1의 자식이 job-0의 자식에 흡수되면 job-1은 깨워줄 자식이 없는 채로 잠든다. 그래서 부모마다 자식을 하나씩 낳게 하고 중복 생성 방지는 jobId가 아니라 핸들러의 SET NX에 맡겼다.
발송은 job을 기다릴 이유가 없다
group.send에는 부모 자식 관계를 쓰지 않았다. 구분 기준은 하나다. 결과를 기다렸다가 이어서 할 일이 있는가.
그룹 생성은 있다. 청크가 적재를 이어가야 한다. 발송은 없다. 완성을 본 청크는 발송 job을 큐에 넣는 것으로 할 일이 끝나고 발송의 결과를 기다리는 job은 어디에도 없다. 기다릴 사람이 없는 곳에 부모 자식을 쓰면 대기 상태만 낭비된다.
대신 발송 job에는 다른 경합이 있다. 여러 청크가 거의 동시에 완성을 볼 수 있다. 이것은 jobId로 풀었다. 발송 jobId를 그룹 id에서 send-${groupId}처럼 파생시키면 동시에 add해도 BullMQ가 원자적으로 하나만 받는다. 자식 jobId를 seq별로 만들게 했던 바로 그 성질, 즉 같은 jobId는 흡수된다는 성질이 여기서는 해법으로 쓰인다. 같은 성질이 자리에 따라 함정도 되고 도구도 된다.
동시성 문제를 하나씩 해결한다
여기까지 만들고 나서 배포 전에 이 흐름에 존재하는 동시성 이슈를 전부 세어 봤다. 큐는 비동기이고, 워커는 여럿이고, 같은 job이 재시도로 두 번 올 수 있다. 이 조건에서 같은 것을 동시에 건드리는 자리를 하나씩 보고 마지막에 이것이 전부인지 검증한다.
동시성 처리는 Redis로 한다. 이유는 둘이다. 워커가 여러 프로세스에 흩어져 있으니 함께 보는 상태는 프로세스 밖에 있어야 하는데, BullMQ가 Redis 위에서 돌아 모든 워커가 이미 Redis에 붙어 있다. 그룹 id도 적재 기록도 결말 마커도 같은 곳에 두면 인프라가 하나도 늘지 않는다. 그리고 이 흐름의 경합은 락으로 막을 일이 아니라 먼저 쓴 쪽이 이기면 되는 문제라, SET NX 명령 하나의 원자성으로 충분하다. 잡고 풀 것이 없고 진 쪽은 물러나면 된다. 다만 Redis를 끝까지 믿지는 않는다. 마커가 유실될 수 있다는 전제는 재발송 절의 두 번째 게이트로 이어진다.
1. 그룹 생성 — 만드는 자가 여럿이다
부모마다 자식을 낳게 했으니 그룹 생성 자식도 여럿이 동시에 돈다. 앞에서 그룹 생성 핸들러가 SET NX로 경합을 푼다고 했다. 그 실제 모습을 워커가 Redis·큐·서드파티와 주고받는 명령 수준으로 재생하면 아래와 같다. 둘 다 그룹을 만드는 것까지는 막지 않는다. 심판은 그룹 생성이 아니라 id를 Redis에 쓰는 순간에 선다.
2. 발송 트리거 — 완성을 보는 자가 여럿이다
마지막 두 청크가 거의 동시에 끝나면 둘 다 “이제 전부 담겼다”를 본다. 관측자가 둘이어도 발송 job은 하나여야 한다.
완성 판정에 붙는 경합이 하나 더 있는데, 위에서 이미 지나갔다. 도착 순서 역전이다. 마지막 표시인 isLast가 붙은 청크가 중간 청크보다 먼저 끝날 수 있다. 이것은 게이트가 아니라 완성 판정 자체가 흡수한다. 판정이 “마지막 청크가 왔는가”가 아니라 건수 방정식이기 때문이다. 마지막 표시는 “끝 번호가 N이다”라는 정보를 적을 뿐이고 발송은 0부터 N까지 전부 기록됐을 때만 성립한다. 중간이 비어 있으면 방정식이 성립하지 않아 아무 일도 일어나지 않는다.
3. 재적재 — 재시도가 통째로 다시 담는다
적재 도중 끊긴 청크는 재시도로 돌아와 자기 몫을 통째로 다시 담는다. 여기의 방어는 장치가 아니라 쓰기 순서다. 적재 기록은 마지막 조각까지 성공한 뒤에만 남긴다. 중간에 끊긴 청크는 기록이 없으므로 재시도가 통째로 다시 담고 이미 들어간 건은 그룹의 중복 판정이 떨군다. 기록을 먼저 남기고 적재를 나중에 했다면, 끊긴 자리부터 이어 담는 로직과 그 로직의 버그를 함께 얻었을 것이다. 쓰기 순서 하나가 복구 로직 전체를 대체한다.
끊긴 청크가 돌아와 다시 담는 왕복에서는 Redis 기록이 언제 쓰이는지를 보면 된다.
4. 재발송 — 응답만 끊긴 재시도
발송 job이 호출을 보낸 뒤 응답이 끊기면, 재시도가 발송을 한 번 더 하려 든다. send의 멱등성은 jobId가 지키는 것이 아니다. jobId dedupe는 add 시점의 장치라 job이 하나만 생기게 할 뿐이고, 완료된 job이 보존 기한이 지나 지워진 뒤 늦은 관측자가 같은 jobId로 다시 적재하면 새 job이 실제로 돈다. 발송의 “한 번”은 실행 시점의 두 게이트가 지킨다. 1차는 결말 마커다. 발송이 끝났다는 표식을 SET NX로 남겨 두고 진입할 때마다 확인한다. 2차는 서드파티의 상태 조회다. 발송 호출이 성공하고 응답만 끊긴 재시도는 마커를 남기기 전에 끊겼으니 마커가 없는 채로 돌아오는데, 그때는 우리 기억이 아니라 서드파티가 보는 사실로 판단한다. 마커 하나만 믿으면 Redis 유실이 곧 이중 발송이고, 상태 조회만 믿으면 그 응답 하나에 수십만 건을 거는 셈이라 두 겹을 포갠다. “정확히 한 번”은 중간 장치가 아니라 종단의 확인으로 완성된다는 end-to-end argument의 결론이 여기서도 반복된다.
5. 리포트 — 결말을 보는 자리가 여럿이다
묶음의 결말을 보는 자리가 여럿이니, 리포트를 내보내려는 자리도 여럿이다. 리포트는 묶음당 한 번이어야 하고 이것도 SET NX가 맡는다. 먼저 쓴 하나만 통과하고 나머지는 그대로 접는다.
관측자 둘이 게이트 앞에서 갈린다.
다 풀었는지 어떻게 아나
하나씩 푸는 방식에는 질문이 남는다. 이게 전부인가. “어딘가 하나쯤 놓쳤겠지”라는 불안은 코드를 다시 읽는 것으로는 사라지지 않는다. 누가, 언제, 같은 것을 동시에 건드릴 수 있는가를 목록으로 만들어야 사라진다.
목록의 기준은 경합이 아니라 불변식이다. 경합을 머리로 떠올리는 목록에는 다 떠올렸다는 보장이 없어서, 이 흐름이 지켜야 할 문장을 먼저 적었다.
- 묶음마다 그룹은 정확히 하나다.
- 발송은 전부 담겼을 때만, 묶음당 정확히 한 번 일어난다.
- 메시지는 그룹에 최대 한 번 담긴다.
- 리포트는 묶음당 한 번 나간다.
문장마다 “이걸 깨려면 누가 어디로 동시에 들어와야 하는가”를 물으면, 경합은 찾는 것이 아니라 따라 나온다. 그렇게 대조해 나온 것이 위의 다섯이다. 정리하면 아래 표와 같고 제어 장치는 사실상 둘뿐이다. Redis의 SET NX, 그리고 BullMQ의 jobId dedupe다.
| 경합 | 언제 나나 | 제어 |
|---|---|---|
| 그룹 생성 | 여러 자식이 동시에 그룹을 만든다 | SET NX — 승자 하나, 패자는 빈 그룹 삭제 |
| 발송 트리거 | 여러 청크가 동시에 완성을 본다 | send jobId dedupe — 큐가 하나만 받는다 |
| 재적재 | 끊긴 청크의 재시도가 통째로 다시 담는다 | 그룹의 중복 판정 — 등록 수가 늘지 않는다 |
| 재발송 | 발송 후 응답이 끊긴 job이 재시도로 돌아온다 | 결말 마커(1차) + 서드파티 상태 조회(2차) |
| 리포트 | 결말을 보는 자리가 여럿이다 | SET NX — 묶음당 한 번만 통과 |
다섯이라는 수는 우연이 아니라 구조가 정한 수다. 생성·트리거·리포트 세 줄은 여러 워커가 같은 역할을 겸해서 나는 경합이라, FlowProducer를 접을 때 포기한 single-writer가 있었다면 자리 자체가 없었을 종류다. 재적재·재발송 두 줄은 같은 job이 재시도로 두 번 와서 나는 경합이라, 역할을 모아도 남는다.
상한을 넘기면, 경합이 늘어난다
서드파티 그룹에는 그룹당 담을 수 있는 건수의 상한이 있다. 상한을 넘겨 담으면 초과분이 조용히 거부되는데, 발송 전 건수 대조는 그 미달을 정상적인 개별 거부와 구분하지 못한다. 조용한 부분 발송이다. 그래서 상한에 닿으면 묶음을 세대(generation)로 나눈다. 가득 찬 그룹은 즉시 내보내고 새 그룹을 만들어 이어 담는 확장이다. 이 확장을 붙이면 경합이 몇 개 더 생긴다.
- 세대를 닫는 자는 하나여야 한다. 둘이 닫으면 세대 번호가 두 번 올라 빈 세대가 생긴다. 롤오버 수행자도
SET NX로 선점하고 진 쪽은 재시도 가능한 에러로 물러났다가 올라간 세대에 합류한다. - 승인 카운터는 반납하지 않는다. 세대별 승인은
HINCRBY반환값으로 판정하는데, 실패한 청크의 몫을 되돌리면 닫힌 세대에 늦은 승인이 비집고 들어올 창이 생긴다. 반납이 없으면 그 창이 산술적으로 없다. 재시도의 이중 증가는 카운터만 부풀리는데, 조기 롤오버라는 안전한 방향의 오차다. - 배정은 적재보다 먼저 기록한다. 롤오버 뒤에 도착한 재시도가 새 세대에 담기면, 그룹이 달라 중복 판정이 작동하지 않고 그대로 두 번 나간다. seq가 어느 세대 소속인지를 적재 전에 박아두면 재시도는 항상 원래 세대의 그룹으로 돌아간다.
- 닫힌 세대는 자기 몫이 다 기록됐을 때만 나간다. 배정은 됐는데 아직 담는 중인 청크(스트래글러)가 있으면 발송 job이 던지고 재시도가 기다린다. 지금 내보내면 그 청크가 빠진 부분 발송이다.
네 줄을 푸는 데 새 장치는 거의 들지 않았다. 새로 등장한 것은 승인을 세는 HINCRBY 카운터 하나뿐이고, 닫는 자를 세우는 일은 그룹 생성과 같은 SET NX가, 배정은 쓰기 순서가, 스트래글러는 큐의 재시도가 맡는다. 이 가운데 첫 줄, 세대를 닫는 경합이 갈리는 왕복이 아래다. 두 청크가 나란히 상한 초과를 보고, 승자가 세대를 닫고, 진 쪽이 새 세대에 합류한다.
막지 못한 것은 시끄럽게
전부 셌다고 전부 막은 것은 아니다. 두 개는 막지 못했고 막지 못했다는 사실을 기록하는 쪽을 골랐다.
하나는 중단된 워커의 이중 실행이다. 워커가 30초 이상 멈추면 BullMQ는 job을 stalled로 판정하고 다른 워커가 재처리하는데, 원래 워커가 살아 있다면 같은 job이 동시에 두 번 돈다. jobId dedupe는 add 시점의 장치라 이미 큐에 들어간 job의 이중 실행에는 관여하지 않는다. 발송 job의 상태 검사는 조회와 발송 사이에 틈이 있는 check-then-act라, 이 창에서는 둘 다 발송을 호출할 수 있다. 방어는 서드파티가 이미 트리거된 그룹의 재발송을 거부하는지에 달려 있고 그것은 코드가 아니라 실측으로 확인할 일이다.
다른 하나는 승인과 배정 기록 사이의 마이크로 갭이다. 두 Redis 명령 사이에서 프로세스가 수 초 멈추면, 그 사이 닫힌 세대의 발송이 스트래글러를 못 보고 나갈 수 있다. Lua 스크립트로 원자화하면 막을 수 있다. 그래도 접은 이유는 단일 명령 원자성만 쓴다는 제약 자체보다, 이 창을 막아도 위의 이중 실행 창이 남는다는 데 있다. 어차피 아래의 감시가 두 창을 다 받아내야 한다면, 그중 하나를 위해 원자성 계층을 하나 더 들이는 값이 이득보다 크다.
두 창의 공통점은 결과가 조용하지 않다는 것이다. 이중 실행은 두 번째 호출이 에러로 드러나고, 갭에 빠진 청크는 발송된 그룹에 적재를 시도하다 실패를 반복해 묶음이 미완성으로 남는다. 그리고 미완성 묶음은 별도의 감시가 잡는다. 묶음마다 마지막 적재 시각을 하트비트로 남기고 주기적으로 훑어 오래 조용한 묶음을 알리는 방식이다. 이 감시에도 동시성의 함정이 있었다. 지우는 조건(최종 결말)과 쓰는 조건(모든 처리)이 어긋나면, 결말 뒤에 도착한 재시도가 지워진 항목을 되살려 영구 거짓 경보가 된다. 지우기는 모든 세대의 결말이 확인될 때만 하고, 그래도 부활한 항목은 감시 자신이 결말을 판독해 지우게(self-heal) 했다. 막는 장치와 잡는 장치가 어긋나면, 잡는 장치 쪽에 최종 판정을 줘야 한다.
갭에 빠진 청크가 에러로 드러나고, 멈춘 하트비트를 감시가 걸러내는 장면이다.
경험으로 남은 것
첫째, 동시성 제어는 장치를 늘리는 일이 아니라 불변식을 먼저 적는 일이었다. 지켜야 할 문장 네 개를 적고 나니 경합 다섯이 그 문장들을 깨는 자리로 따라 나왔고, 전부 SET NX와 jobId dedupe 두 장치로 정리됐다. 남은 두 창은 막는 대신 시끄럽게 실패하게 두는 쪽이 쌌다. 다섯이라는 수 자체도 설계의 결과다. 역할을 한 곳에 모으는 설계는 경합을 세는 대신 없애고, 우리는 세대 확장과 맞바꿔 세는 쪽을 골랐다. 장치가 적을수록 각각이 무엇을 막고 무엇을 못 막는지는 선명해진다. 이중 실행을 jobId dedupe가 못 막는다는 사실은 dedupe가 add 시점의 장치라는 정의에서 바로 나온다.
둘째, 부모 자식 job의 자리는 생각보다 좁다. 결과를 기다렸다가 이어서 할 일이 있는 자리에만 맞고 그 외의 의존은 독립 job과 jobId dedupe가 더 싸고 단순했다. 이번 구현에서 waiting-children을 쓴 곳은 그룹 생성 단 한 곳이다.
셋째, 서드파티로 나가는 모든 호출을 job으로 만들어 두면 재시도와 실패 기록과 유량 제어가 큐 하나로 모인다. 인라인 호출은 limiter가 세지 못할 뿐 아니라, 실패했을 때의 재시도도 핸들러 안에 다시 지어야 한다. jobId dedupe 같은 동시성 장치도 호출이 job이라는 전제 위에서 성립한다.


