Bull Queue와 RedLock으로 배운 Redis — 이커머스 대량 처리 실전기
배경: 수천 건의 주문을 한 번에 취소해야 했습니다
이커머스 플랫폼에서 관리자가 "대량 취소 승인" 버튼을 누르면, 수백에서 수천 건의 주문이 한꺼번에 취소 처리되어야 합니다. 각 주문마다 상태 전환, 쿠폰 반환, 포인트 반환, PG 환불 API 호출이 필요했습니다.
이것을 동기적으로 처리하면 HTTP 요청이 수십 초간 블로킹됩니다. 당연히 API 타임아웃에 걸리고, 사용자는 "뭔가 잘못된 건가" 싶어 버튼을 다시 누릅니다. 그러면 같은 주문이 이중으로 취소됩니다.
이 문제를 해결하기 위해 Bull Queue(Redis 기반 작업 큐)로 비동기 처리를, RedLock(Redis 기반 분산 락)으로 중복 방지를 구현했습니다.
Bull Queue: Redis를 메시지 브로커로
Bull Queue는 Redis를 백엔드로 사용하는 Node.js 작업 큐 라이브러리입니다. 작업을 Redis에 저장하고, Worker가 꺼내서 처리하는 구조입니다.
API 서버 Redis (Bull Queue) Worker (Jobs 서비스)
│ │ │
│ "주문 1000건 취소" │ │
├─ 주문 1건씩 큐에 등록 ──→ │ Queue: [job1, job2, ...] │
│ (즉시 202 응답) │ │
│ │ ← job1 꺼냄 ─────────────── ┤
│ │ ├─ 상태 전환
│ │ ├─ 쿠폰 반환
│ │ ├─ 포인트 반환
│ │ ├─ PG 환불
│ │ ← job2 꺼냄 ─────────────── ┤
│ │ └─ ...
API 서버는 작업을 큐에 넣고 즉시 202 (Accepted) 응답을 반환합니다. 실제 처리는 별도의 Jobs 서비스가 담당합니다.
concurrency: 4의 근거
Bull Queue의 concurrency는 Worker가 동시에 처리할 작업 수입니다.
@Process({ concurrency: 4 })
async consume({ data }: Job) {
// 주문 취소 처리
}
솔직히 말하면, 처음에는 직관적으로 4를 설정했습니다. "너무 적으면 느리고, 너무 많으면 DB에 부하가 갈 테니 적당히" 정도의 판단이었습니다.
하지만 운영하면서 왜 이 설정이 잘 동작하는지 이해하게 되었습니다.
한 Job의 실행 흐름:
DB조회 → [I/O 대기] → 상태전환 → [I/O 대기] → 쿠폰API → [I/O 대기]
→ 포인트API → [I/O 대기] → PG환불 → [1~5초 대기] → DB업데이트 → [I/O 대기]
하나의 Job에서 await를 만나는 지점: 40회 이상
전체 실행 시간 중 I/O 대기 비율: 95% 이상
Node.js는 싱글 스레드지만, await를 만나면 이벤트 루프에 제어를 양보합니다. concurrency가 4이면, Job 1이 PG 환불 응답을 1~5초 기다리는 동안 Job 2, 3, 4가 다른 단계를 처리할 수 있습니다.
Job 1: DB조회 → [await] → PG환불 → [1~5초 await] → DB업데이트
Job 2: ↑ 실행 → DB조회 → [await] → 쿠폰API → [await] → ...
Job 3: ↑ 실행 → DB조회 → [await] → ...
Job 4: ↑ 실행 → ...
→ 4개 Job이 I/O 대기 시간을 겹쳐서 활용
→ 처리량이 단순 순차보다 ~4배 향상
이것이 가능한 이유는 작업의 95% 이상이 I/O 바운드이기 때문입니다. CPU 바운드 작업이었다면 concurrency를 높여도 의미가 없었을 것입니다. 그리고 프로덕션 DB 커넥션 풀에 충분한 여유가 있었기에, 4개 Job이 동시에 DB 쿼리를 해도 풀 사용률에 영향이 거의 없었습니다.
RedLock: 왜 단순한 SETNX가 아니라 RedLock인가
대량 취소 버튼을 빠르게 두 번 누르면, 같은 요청이 동시에 두 서버에 도달할 수 있습니다. 같은 주문이 두 번 큐에 들어가면 이중 환불이 발생합니다.
단순한 방법: SETNX
Redis의 SET key value NX PX 5000으로 가장 단순한 분산 락을 구현할 수 있습니다.
SET order:cancel:12345 "lock_id" NX PX 5000
→ NX: 키가 없을 때만 SET (락 획득)
→ PX 5000: 5초 후 만료 (데드락 방지)
Redis가 싱글 스레드이므로 SET NX 자체는 원자적입니다. 두 프로세스가 동시에 시도해도 하나만 성공합니다.
대부분의 경우 이것으로 충분합니다. 그런데 왜 RedLock을 선택했을까요?
SETNX의 한계: Master 장애 시 락 유실
1. 프로세스 A → Redis Master: SET lock NX → OK (획득)
2. Redis Master 크래시 (Replica에 아직 복제 안 됨)
3. Sentinel이 Replica를 새 Master로 승격
4. 프로세스 B → 새 Master: SET lock NX → OK (또 획득!)
→ 두 프로세스가 동시에 락을 가진 상태
Redis Replication은 비동기가 기본입니다. Master가 죽으면 복제되지 않은 데이터(= 락)가 유실됩니다. 캐시 갱신 같은 작업이라면 두 프로세스가 동시에 실행돼도 큰 문제가 없겠지만, PG 환불은 이중 실행이 곧 금전 손실입니다.
RedLock의 해결 방식
RedLock은 독립된 N대의 Redis 노드에 과반수 합의로 락을 획득합니다.
RedLock 알고리즘:
1. 현재 시간 기록 (t1)
2. N대 모두에 SET lock NX PX ttl 시도
3. 과반수(N/2 + 1) 이상 성공 && 소요 시간 < ttl → 락 획득
4. 실패 시 → 모든 노드에 DEL lock (정리)
1~2대가 죽어도 나머지로 락의 유효성을 판단할 수 있습니다. 단일 Redis에 의존하는 SETNX와 달리 SPOF(Single Point of Failure)가 없습니다.
실제 코드에서의 사용
// 메시지 내용을 SHA256 해시로 락 키 생성
const lockKey = sha256(JSON.stringify({ topic, ...message }));
await redlock.using([lockKey], 5000, async () => {
// 같은 메시지가 이미 큐에 대기 중인지 확인
const alreadyQueued = await isWaitingInQueue(queue, message);
if (!alreadyQueued) {
await queue.add(message, opts);
}
});
메시지 내용의 SHA256 해시를 락 키로 사용합니다. 동일한 대량 취소 요청은 같은 해시를 생성하므로, 두 번째 요청은 락을 획득하지 못해 큐에 등록되지 않습니다.
TTL을 용도별로 분리
| 용도 | TTL | 근거 |
|---|---|---|
| 메시지 중복 방지 | 5초 | 큐 등록은 빠른 작업 |
| 주문 상태 전환 | 10초 | DB 트랜잭션 포함 |
| 송장 대량 처리 | 20초 | 외부 API 호출 포함 |
| 대량 작업 완료 | 15초 | 집계 + DB 업데이트 |
TTL이 너무 짧으면 작업 도중 락이 풀리고, 너무 길면 장애 시 다른 프로세스가 오래 대기합니다. 각 작업의 예상 소요 시간에 여유를 두되, 너무 길지 않게 설정하는 것이 중요합니다.
RedLock에 대한 비판과 우리의 판단
분산 시스템 전문가 Martin Kleppmann은 2016년 "How to do distributed locking"이라는 글에서 RedLock을 비판했습니다. 핵심 주장은 두 가지입니다.
1. GC pause 문제: 클라이언트가 락을 획득한 후 GC pause가 길어지면, TTL이 만료된 뒤에도 자신이 락을 가졌다고 착각하고 critical section에 진입할 수 있습니다.
2. Clock drift: RedLock은 각 노드의 시계가 정확하다는 가정에 의존합니다. NTP 점프가 발생하면 TTL 계산이 틀어집니다.
이론적으로 정당한 비판이지만, 우리 환경에서는 실무적으로 충분했습니다.
- Node.js의 V8 GC는 Java만큼 긴 pause가 발생하지 않습니다. 보통 ms 단위입니다.
- TTL이 5~20초이고, critical section(큐 등록, DB 트랜잭션)은 대부분 1초 미만입니다. GC pause가 TTL을 넘길 현실적 가능성은 극히 낮습니다.
- 완벽한 mutual exclusion이 목적이 아니었습니다. "대부분의 중복을 막되, 극단적 케이스는 DB 상태 코드와 멱등성으로 방어"하는 이중 구조였습니다.
Kleppmann이 제안하는 fencing token 방식은 리소스 서버가 토큰을 검증해야 하는데, PG API 같은 외부 시스템에 fencing token을 강제할 수 없었습니다.
ZooKeeper나 etcd를 쓰지 않은 이유도 단순합니다. 이미 Bull Queue 때문에 Redis를 쓰고 있었고, ZooKeeper/etcd를 별도로 운영하면 인프라 복잡도가 크게 증가합니다. 비용 대비 RedLock + 멱등성 방어가 최적의 선택이었습니다.
실패 처리: 완벽한 자동 복구는 없었습니다
대량 처리에서 실패는 피할 수 없습니다. 중요한 것은 실패를 어떻게 추적하고 복구하느냐입니다.
Bull Queue 레벨의 재시도
// Bull 기본 설정
defaultJobOptions: {
attempts: 1, // Bull 레벨 자동 재시도 없음
removeOnComplete: 1000,
removeOnFail: 1000,
}
Bull Queue 레벨에서는 재시도를 하지 않았습니다. 이유는 명확합니다. 주문 취소 프로세스에는 쿠폰 반환, 포인트 반환, PG 환불이 포함되는데, 이 중 어디서 실패했느냐에 따라 재시도 전략이 달라야 합니다. Bull의 일괄 재시도로는 이 세밀한 제어가 불가능합니다.
PG 환불만 애플리케이션 레벨 재시도
const PG_REFUND_RETRY_CONFIG = {
maxRetries: 2,
retryDelayMs: 1000,
retryableErrorCodes: ['NETWORK_ERROR', 'TIMEOUT', 'TEMPORARY_FAILURE'],
};
PG 환불 API만 재시도하는 이유는 멱등성이 보장되기 때문입니다. 같은 거래번호로 환불을 두 번 요청해도 PG사에서 중복 처리를 방지합니다. 쿠폰이나 포인트 반환은 트랜잭션 내에서 처리되므로, 실패하면 롤백됩니다.
DB 상태 코드로 추적
Dead Letter Queue 대신, 각 주문의 상태 코드로 처리 결과를 추적했습니다.
| 상태 코드 | 의미 |
|---|---|
CANCEL_SUCCESS | 정상 완료 |
MANUAL_REQUIRED | 수동 처리 필요 |
CANCEL_FAILED | 취소/처리 전체 실패 |
REFUND_FAILED | 취소 성공, 환불만 실패 |
EXTERNAL_PENDING | 외부 채널 비동기 처리 중 |
관리자 대시보드에서 REFUND_FAILED 상태인 주문을 필터링하면, "취소는 됐는데 환불이 안 된" 주문 목록을 바로 확인할 수 있습니다. 이것이 DLQ보다 실무적으로 더 유용했습니다. 운영팀이 Redis의 DLQ를 모니터링하는 것보다, 익숙한 Admin 화면에서 상태 코드를 확인하는 것이 훨씬 직관적이었기 때문입니다.
부분 성공 허용
Promise.allSettled()로 각 주문을 독립적으로 처리:
주문 A: 취소 성공 + 환불 성공 → CANCEL_SUCCESS
주문 B: 취소 성공 + 환불 실패 → REFUND_FAILED
주문 C: 취소 실패 → CANCEL_FAILED
→ A의 성공이 B, C에 영향받지 않음
Promise.allSettled()를 사용하여 한 주문의 실패가 다른 주문에 영향을 주지 않도록 했습니다. 1000건 중 3건이 실패해도 997건은 정상 처리됩니다.
Redis가 죽으면?
솔직한 답변을 하자면, 별도의 폴백 메커니즘은 없었습니다.
Redis 다운 시:
- Bull Queue 전체 불능 → API에서 503 반환
- 이미 큐에 있던 작업 → Redis 복구 시 자동 재개
- 큐에 못 들어간 작업 → 클라이언트 재시도 또는 수동 개입
인메모리 큐 폴백이나 DB 기반 작업 큐를 보조로 둘 수도 있었습니다. 하지만 그렇게 하면 "큐가 두 개인 시스템"의 복잡성을 떠안아야 합니다. 두 큐 간 순서 보장, 중복 방지, 상태 동기화를 관리하는 것은 또 다른 차원의 문제입니다.
우리 인프라에서 Redis는 충분히 안정적이었고, Redis 다운은 연 1~2회 발생할까 말까 한 이벤트였습니다. 그 빈도에 대비해서 시스템 전체를 복잡하게 만드는 것은 비용 대비 효과가 맞지 않다고 판단했습니다.
트랜잭션 구조: 사가 패턴은 아닙니다
"취소 승인 → 쿠폰 반환 → PG 환불 중 PG 환불만 실패하면?"이라는 질문에 대한 답입니다.
await db.transaction(async (trx) => {
// Phase 1: 취소 승인 (상태 전환)
await cancelOrder(order, trx);
// Phase 2: 쿠폰 반환
await restoreCoupons(order, trx);
// Phase 3: 포인트 반환
await restorePoints(order, trx);
// Phase 4: PG 환불 (재시도 포함)
await refundWithRetry(order, trx);
});
- Phase 1~3(쿠폰/포인트) 실패 → 트랜잭션 전체 롤백, 상태
CANCEL_FAILED - Phase 4(PG 환불)만 실패 → 취소·쿠폰·포인트는 유지, 상태
REFUND_FAILED - 관리자가 수동으로 환불 처리
엄밀한 사가 패턴(보상 트랜잭션)은 적용하지 않았습니다. PG 환불은 외부 시스템 호출이라 보상 트랜잭션으로 자동 복구가 어렵고, 금액 관련이라 수동 확인이 더 안전했기 때문입니다.
이 경험에서 배운 것
Redis를 캐시가 아닌 인프라의 핵심 컴포넌트로 사용하면서 몇 가지를 배웠습니다.
첫째, Redis의 원자성은 강력하지만 만능이 아닙니다. SET NX의 원자성은 단일 노드에서만 보장됩니다. 마스터 장애 시 복제 지연으로 락이 유실될 수 있고, 이것을 RedLock이 해결하지만 RedLock도 이론적 한계가 있습니다. 결국 "락만 믿지 말고, 비즈니스 로직에서도 멱등성을 확보하라"가 가장 안전한 접근입니다.
둘째, Node.js와 Redis의 비동기 모델은 시너지를 만듭니다. Node.js의 이벤트 루프가 I/O 대기 시간을 겹치고, Redis의 싱글 스레드가 명령을 원자적으로 처리합니다. 이 조합 덕분에 concurrency: 4만으로도 충분한 처리량을 얻었습니다.
셋째, 완벽한 시스템보다 추적 가능한 시스템이 실용적입니다. 자동 복구를 만능으로 만들려고 하면 시스템이 복잡해지고, 오히려 디버깅이 어려워집니다. DB 상태 코드로 "무엇이, 어디서, 왜 실패했는지"를 추적할 수 있게 만드는 것이 운영 관점에서 더 가치 있었습니다.
Redis는 단순한 도구처럼 보이지만, 그 내부 원리를 이해하고 나면 설계 판단의 근거가 생깁니다. "왜 이렇게 했는가"에 답할 수 있는 것과 없는 것의 차이는, 결국 원리에 대한 이해에서 옵니다.
관련 글
Redis가 싱글 스레드인데 왜 빠른가 — I/O 멀티플렉싱부터 6.0 스레드 모델까지
Redis가 싱글 스레드라는 말은 반만 맞습니다. 정확히 무엇이 싱글 스레드이고, I/O 멀티플렉싱은 어떻게 동작하며, Redis 6.0의 I/O 스레드는 무엇을 바꿨는지. 면접에서 "왜 빠른가?"에 확실히 답할 수 있도록 정리했습니다.
Redis 내부 원리 총정리 — 싱글 스레드부터 클러스터까지
Redis를 캐시로만 쓰다가 면접에서 "싱글 스레드인데 왜 빠른가?"라는 질문에 막힌 경험이 있습니다. 싱글 스레드 모델, 자료구조별 시간복잡도, RDB/AOF 영속성, 메모리 관리, Sentinel과 Cluster까지 — Redis 내부 원리를 한 글에 정리했습니다.
Redis Sentinel vs Cluster — 고가용성 아키텍처의 선택 기준
Redis 단일 노드가 죽으면 서비스 전체가 멈춥니다. Sentinel은 자동 failover를, Cluster는 샤딩과 failover를 제공합니다. 각각의 동작 원리, 장애 복구 시나리오, 그리고 어떤 상황에서 무엇을 선택해야 하는지 정리했습니다.