LabHub

큐와 비동기 API · 큐 설계(순서·중복·유실) · 이론

리스트 큐가 메시지를 잃는 지점

LabHub 에서 이어서 보기

한 줄 요약

BRPOP 은 메시지를 큐에서 꺼내는 동시에 지워 버린다. 꺼낸 직후 소비자가 죽으면 그 메시지는 세상에서 사라진다.

왜 이게 필요했나

Redis 리스트로 큐를 만드는 것은 두 줄이면 됩니다. LPUSH q:jobs "..." 로 넣고 BRPOP q:jobs 0 으로 꺼냅니다. 간단하고 빠르고, 대부분의 사이드 프로젝트가 이것으로 충분합니다.

문제는 실패 경로에 있습니다. BRPOP 이 반환한 순간 그 메시지는 이미 Redis 에서 사라졌습니다. 소비자가 그것을 처리하다가 죽으면 재시작해도 그 메시지는 없습니다. 배포 중에 워커를 재시작하는 것만으로 처리 중이던 작업이 증발합니다.

어떻게 동작하나

첫 번째 해법은 LMOVE(구버전의 RPOPLPUSH)입니다. 꺼내는 동시에 "처리 중" 리스트로 원자적으로 옮깁니다. 소비자는 처리를 마친 뒤 처리 중 리스트에서 지웁니다. 죽으면 메시지는 처리 중 리스트에 남아 있고, 별도 회수기가 오래된 항목을 원래 큐로 되돌립니다. 이것이 가시성 타임아웃의 손수 만든 버전입니다.

두 번째 해법이 Redis Stream 입니다. 스트림은 처음부터 이 문제를 염두에 두고 만들어졌습니다. XADD 로 추가하고, 컨슈머 그룹을 만들고, XREADGROUP 으로 읽습니다. 읽은 메시지는 지워지지 않고 PEL(Pending Entries List)에 들어갑니다. 처리가 끝나면 XACK 으로 지웁니다. 죽으면 PEL 에 남아 있고, XPENDING 으로 확인하고 XCLAIM 으로 다른 소비자가 가져갈 수 있습니다.

세 가지 성질을 정리하면 이렇습니다.

| 성질 | 리스트 | 스트림 |
| --- | --- | --- |
| 소비 후 보존 | 없음 (즉시 삭제) | PEL 에 보존, XACK 로 삭제 |
| 여러 소비자 그룹 | 불가 (한 번 꺼내면 끝) | 그룹별로 독립 소비 |
| 처리 실패 회수 | 직접 구현 | XPENDING / XCLAIM |
| 메모리 | 작음 | 큼 (히스토리 유지, MAXLEN 필요) |

현장에서 만나는 모습

순서는 어디까지 보장될까요. 단일 리스트, 단일 소비자면 FIFO 입니다. 소비자를 늘리는 순간 순서 보장은 사라집니다. 두 소비자가 각각 메시지 A 와 B 를 가져가면 어느 쪽이 먼저 끝날지 알 수 없기 때문입니다. 순서가 필요한 것은 대개 특정 엔터티 단위이므로, 엔터티 ID 로 큐를 샤딩하고 샤드당 소비자를 하나로 두는 것이 실용적 해법입니다.

그리고 스트림을 쓸 때 MAXLEN 을 잊으면 안 됩니다. 스트림은 XACK 해도 엔트리 자체는 남습니다. XADD q:orders MAXLEN ~ 100000 * ... 처럼 상한을 두지 않으면 메모리를 계속 먹습니다. 물결표(~)는 근사 트리밍으로 훨씬 쌉니다.

다음 실습에서 할 것

리스트로 큐를 만들어 FIFO 를 확인하고, 유실 지점을 재현하고, LMOVE 로 막고, 그다음 스트림으로 옮겨 컨슈머 그룹과 PEL 을 직접 다뤄 봅니다. 마지막에 둘을 비교하는 표를 스스로 씁니다.