Standard Queue
고가용성과 높은 처리량을 우선하고, 중복 전달과 best-effort ordering을 허용한다.
이메일, 알림, 이미지 처리처럼 멱등성으로 중복을 방어할 수 있을 때분류: Layer 6 - 운영 심화: 관측성 & 복원력
Queue는 처리할 작업 메시지를 보관하는 버퍼이고, Worker는 그 버퍼에서 작업을 꺼내 실제 처리를 수행하는 별도 실행 단위이다. Queue/Worker 패턴의 목적은 “사용자 요청을 받는 흐름”과 “느리거나 실패할 수 있는 작업을 처리하는 흐름”을 분리하는 것이다.
이 패턴을 이해할 때 중요한 단어는 두 개다.
회원가입, 주문 생성, 이미지 업로드 같은 API 요청 안에는 “지금 끝나야 하는 일”과 “나중에 처리해도 되는 일”이 섞인다. DB에 회원을 저장하는 일은 응답 전에 끝나야 하지만, 환영 이메일 발송은 보통 몇 초 뒤에 끝나도 된다. 이 둘을 같은 요청 안에서 동기로 처리하면 가장 느린 외부 호출이 전체 응답 시간을 결정한다.
예를 들어 회원가입 API가 DB 저장 80ms, 이메일 API 2초, CRM 동기화 800ms를 모두 기다리면 사용자는 최소 2.88초를 기다린다. 이메일 API가 5초 타임아웃을 채우면 DB 저장은 성공했는데 API 응답은 실패하는 어색한 상태도 생긴다. 사용자는 “가입 실패”라고 보지만, 실제 DB에는 계정이 생겼을 수 있다.
Queue/Worker는 이 문제를 “작업 접수”와 “작업 완료”를 분리해서 푼다. API 서버는 DB 저장 후 이메일 작업을 Queue에 넣고 “가입 접수 완료”를 응답한다. Worker는 Queue에서 이메일 작업을 꺼내 나중에 처리한다. 이메일 API가 느려져도 API 서버의 응답 시간은 크게 흔들리지 않는다.
Queue/Worker 이전의 단순한 방식은 API 요청 안에서 모든 일을 끝내는 것이다. 이 방식은 코드가 단순하지만, 세 가지 한계를 만든다.
setTimeout, in-process job, 단순 Promise 분리는 서버 재시작과 장애에 약하다.Queue/Worker의 핵심 철학은 느린 일을 숨기는 것이 아니라, 느린 일을 견딜 수 있는 위치로 옮기는 것이다. Queue는 내구성 있는 버퍼가 되고, Worker는 실패해도 다시 시작할 수 있는 소비자가 된다. 단, 이 대가로 “즉시 완료” 대신 “나중에 완료”, “정확히 한 번” 대신 “중복될 수 있으므로 안전하게 처리”라는 사고방식이 필요하다.
Amazon SQS(Simple Queue Service, AWS의 관리형 메시지 큐)는 이 패턴의 대표 구현이다. 이 문서는 Queue/Worker 일반 모델에서 출발하되, 운영 수치와 실패 신호는 SQS를 기준 예시로 자주 사용한다. SQS Standard Queue는 메시지를 여러 Availability Zone에 중복 저장해 단일 서버나 AZ 장애로 메시지가 사라질 가능성을 낮춘다. 메시지 보존 기간은 기본 4일, 최대 14일까지 설정할 수 있다. 대신 Standard Queue는 순서와 정확히 한 번 처리를 강하게 보장하지 않고, 최소 1회 전달(at-least-once delivery)을 선택한다.
출처: Amazon SQS queue types - AWS 공식 문서, SQS queue parameters - AWS 공식 문서
Queue/Worker 문서는 약어가 많이 나온다. 아래 표의 용어는 이후 문장에서 다시 길게 풀지 않고 사용한다.
| 용어 | 첫 정의 | 왜 필요한가 |
|---|---|---|
| Queue | 처리할 작업 메시지를 저장하는 대기열 | Worker가 당장 처리하지 못하는 작업을 잃지 않고 보관한다. |
| Worker | Queue에서 메시지를 꺼내 실제 작업을 수행하는 프로세스, 컨테이너, Lambda 함수 | API 서버와 별도 속도로 처리량을 조절한다. |
| Producer | Queue에 메시지를 넣는 쪽. 보통 API 서버나 이벤트 발행자 | 작업 생성 흐름과 처리 흐름을 분리한다. |
| Consumer | Queue에서 메시지를 읽는 쪽. Worker와 거의 같은 의미로 쓴다 | 메시지 처리를 병렬화하고 장애를 격리한다. |
| Message / Job | Queue에 들어가는 작업 단위. 예: { orderId, type: "send-email" } | Worker가 무엇을 해야 하는지 알려 주는 최소 입력이다. |
| SQS | Amazon SQS(Simple Queue Service). AWS가 운영하는 관리형 Queue 서비스 | Queue 서버 운영 없이 내구성 있는 메시지 큐를 쓸 수 있다. |
| Visibility Timeout | Worker가 메시지를 가져간 뒤 다른 Worker에게 잠시 숨기는 시간 | 처리 중인 메시지가 동시에 여러 Worker에게 잡히는 것을 줄인다. |
| At-least-once delivery | 메시지를 최소 한 번은 전달하겠다는 보장. 같은 메시지가 두 번 올 수 있다 | 메시지 유실보다 재전달을 우선하는 Queue의 기본 안전장치다. |
| Idempotency | 같은 작업을 여러 번 실행해도 최종 결과가 한 번 실행한 것과 같게 만드는 성질 | at-least-once 전달에서 중복 이메일, 중복 결제 같은 부작용을 막는다. |
| DLQ | Dead Letter Queue. 반복 실패한 메시지를 격리하는 별도 Queue | 계속 실패하는 메시지가 정상 메시지 처리를 막지 않게 한다. |
| Poison message | 데이터 형식, 상태, 코드 버그 때문에 처리할 때마다 실패하는 메시지 | 재시도만 반복하면 Worker 처리량을 갉아먹으므로 격리가 필요하다. |
| Redrive | DLQ에 격리된 메시지를 원본 Queue로 다시 보내는 작업 | 원인을 고친 뒤 실패 메시지를 재처리한다. |
| Backpressure | 소비 속도보다 생산 속도가 빠를 때 시스템이 압력을 드러내거나 속도를 늦추는 메커니즘 | 무한 적체, 비용 폭증, 메시지 만료를 막는다. |
| Concurrency | 동시에 처리하는 작업 수. Worker 수와 Worker 내부 병렬 처리 수를 모두 포함한다 | 처리량과 중복 위험, 외부 API 부하를 함께 결정한다. |
프론트엔드의 Web Worker처럼 “무거운 일을 메인 흐름 밖으로 보낸다”는 감각은 출발점으로만 유용하다. 백엔드 Queue/Worker에서는 브라우저 탭 안의 스레드 분리가 아니라, 네트워크와 장애를 넘어서는 내구성, 재시도, 중복 처리, 운영 지표가 핵심이다.
Queue/Worker의 기본 흐름은 단순하다. Producer가 메시지를 넣고, Queue가 보관하고, Worker가 가져가 처리하고, 성공하면 삭제한다. 실패하면 일정 시간 뒤 메시지가 다시 보이거나 DLQ로 이동한다.
flowchart TD
Producer["Producer(API 서버)"] --> Send["SendMessage: 작업 메시지 등록"]
Send --> Store["Queue가 메시지 보관"]
Store --> Receive["Worker가 ReceiveMessage로 수신"]
Receive --> Hidden["Visibility Timeout 동안 숨김"]
Hidden --> Work["작업 처리"]
Work --> Result{"처리 성공?"}
Result -->|"예"| Delete["DeleteMessage: Queue에서 제거"]
Result -->|"아니오 또는 Worker 종료"| Timeout["Visibility Timeout 만료"]
Timeout --> Retry{"maxReceiveCount 초과?"}
Retry -->|"아니오"| Store
Retry -->|"예"| DLQ["DLQ로 격리"] 이 흐름에서 초심자가 가장 자주 놓치는 지점은 ReceiveMessage가 메시지를 삭제하지 않는다는 점이다. Worker가 메시지를 가져가면 Queue는 그 메시지를 잠시 숨긴다. Worker가 성공 후 DeleteMessage를 호출해야 최종 삭제된다. Worker가 죽거나, 예외가 나거나, 삭제 호출이 실패하면 Visibility Timeout 만료 후 메시지는 다시 보인다.
maxReceiveCount는 같은 메시지가 몇 번 다시 보인 뒤 DLQ로 격리될지 정하는 값이다. 이 값은 “몇 번까지 다시 기회를 줄 것인가”와 “poison message가 정상 처리를 얼마나 오래 방해하게 둘 것인가” 사이의 선택이다.
동기 처리와 Queue/Worker 처리를 같은 입력으로 비교해 보자.
| 단계 | 동기 처리 | Queue/Worker 처리 |
|---|---|---|
| 1 | API가 회원 DB 저장을 한다. | API가 회원 DB 저장을 한다. |
| 2 | API가 이메일 API 호출을 기다린다. | API가 send-welcome-email 메시지를 Queue에 넣는다. |
| 3 | 이메일 API가 느리면 사용자 응답도 늦어진다. | API는 “가입 완료, 이메일 발송 예정”으로 빠르게 응답한다. |
| 4 | 이메일 실패가 API 실패로 보일 수 있다. | Worker가 이메일 실패를 재시도하거나 DLQ로 격리한다. |
| 5 | 트래픽이 몰리면 API 서버가 이메일 대기 시간까지 떠안는다. | Worker만 늘려 이메일 처리량을 조절할 수 있다. |
사용자에게 꼭 필요한 결과가 “회원 계정 생성”이라면 이메일은 Queue에 넣을 수 있다. 반대로 “결제 승인 성공 여부”처럼 사용자가 즉시 알아야 하고 실패 시 주문 생성 자체를 막아야 하는 작업은 Queue 뒤로 숨기면 안 된다. Queue는 실패를 없애는 도구가 아니라, 실패를 사용자 응답 흐름 밖에서 다룰 수 있게 만드는 도구다.
아래 코드는 전체 설정 파일이 아니라 역할을 보여 주는 최소 예시다. 핵심은 Producer가 작업을 만들고, Worker가 같은 작업을 나중에 처리한다는 점이다.
// Producer: API 요청 흐름 안에서 작업을 Queue에 등록한다.@Injectable()export class OrderService { constructor(private readonly sqsService: SqsService) {}
async createOrder(dto: CreateOrderDto) { const order = await this.orderRepository.save(dto);
await this.sqsService.send("email-queue", { id: `order-${order.id}-email`, body: JSON.stringify({ jobId: `order:${order.id}:email`, orderId: order.id, userId: order.userId, to: order.email, template: "order_created", }), });
return order; }}
// Worker: Queue에서 받은 메시지를 별도 프로세스에서 처리한다.@Injectable()export class EmailConsumer { @SqsMessageHandler("email-queue", false) async handleEmailJob(message: Message) { const payload = JSON.parse(message.Body!);
await this.emailService.send({ to: payload.to, template: payload.template, orderId: payload.orderId, }); }}이 예시는 아직 완성된 운영 코드가 아니다. 실제 Worker는 메시지 파싱 실패, 이메일 API 실패, 중복 처리, 로깅, 관측 지표를 다뤄야 한다. 그러나 학습 단계에서는 “API는 메시지를 넣고 끝난다”, “Worker는 메시지를 읽어 처리한다”, “성공해야 메시지가 삭제된다”를 먼저 잡으면 된다.
SQS Standard Queue처럼 at-least-once delivery를 택한 Queue에서는 같은 메시지가 두 번 전달될 수 있다. Worker가 이메일을 보낸 직후 네트워크 오류로 DeleteMessage에 실패하면 Queue 입장에서는 성공을 확인하지 못했다. Visibility Timeout이 지나면 같은 메시지가 다시 나온다.
그래서 Worker는 메시지 ID만 믿기보다 비즈니스 관점의 idempotency key를 가져야 한다. 예를 들어 order:{orderId}:email:order_created 같은 키를 두고, 이미 처리 완료된 키라면 이메일을 다시 보내지 않는다. 자세한 재시도·백오프·멱등성 설계는 다음 문서인 Retry / Backoff / Idempotency에서 다루지만, Queue/Worker를 쓰는 순간 “중복될 수 있다”는 전제를 먼저 받아들여야 한다.
Queue 메시지는 “Worker가 나중에 독립적으로 처리할 수 있는 최소 입력”이어야 한다. 너무 적게 넣으면 Worker가 처리 시점에 필요한 상태를 다시 조회해야 하고, 너무 많이 넣으면 오래된 스냅샷으로 잘못 처리할 수 있다.
좋은 메시지는 보통 아래 정보를 가진다.
| 필드 | 예시 | 이유 |
|---|---|---|
jobId | order:123:email:created | 중복 처리 방지와 추적에 쓴다. |
type | send_order_created_email | Worker가 어떤 작업인지 분기한다. |
| 비즈니스 ID | orderId: 123, userId: 7 | 최신 상태가 필요하면 DB에서 다시 읽을 기준이 된다. |
| 작은 payload | template: "order_created" | Worker가 바로 처리할 수 있는 최소 입력을 준다. |
| schema version | version: 1 | 메시지 형식 변경 시 구버전 메시지를 해석할 수 있게 한다. |
| trace/correlation ID | requestId: "..." | API 요청 로그와 Worker 로그를 이어 본다. |
반대로 아래 메시지는 위험하다.
{ "user": { "id": 7, "email": "old@example.com", "marketingOptIn": true, "address": "...", "grade": "VIP" }, "order": { "id": 123, "status": "CREATED", "items": ["...large snapshot..."] }}이 메시지는 Worker가 편하게 처리할 수 있지만, Queue에 몇 시간 머무는 동안 사용자의 이메일이나 수신 동의 상태가 바뀔 수 있다. 이메일 발송처럼 최신 동의 상태가 중요한 작업은 userId와 template만 넣고 Worker가 처리 직전에 최신 사용자 상태를 다시 읽는 편이 안전하다. 반대로 감사 로그처럼 “그 시점의 상태”가 중요하면 snapshot을 넣는 것이 맞다. 메시지 설계는 “나중에 처리해도 어떤 상태를 기준으로 처리해야 하는가”를 먼저 묻는다.
Queue는 메시지를 보관하지만, 비즈니스 상태를 모두 대신하지는 않는다. 사용자가 “내 이미지 변환이 끝났나요?”라고 묻는 기능이 필요하면 Queue 메시지 존재 여부가 아니라 DB의 job 상태를 봐야 한다.
일반적인 상태 모델은 단순하다.
| 상태 | 의미 | 전환 조건 |
|---|---|---|
queued | 작업이 Queue에 들어갔다 | API가 작업을 접수하고 메시지 전송 성공 |
processing | Worker가 작업을 시작했다 | Worker가 메시지를 받고 상태 업데이트 |
succeeded | 작업이 성공했다 | Worker 처리 성공 후 결과 저장 |
failed_retryable | 다시 시도할 수 있는 실패 | 외부 API timeout, 일시적 DB 오류 |
failed_permanent | 재시도해도 의미 없는 실패 | 잘못된 payload, 삭제된 사용자, 검증 오류 |
모든 작업에 DB 상태 테이블이 필요한 것은 아니다. 단순 알림처럼 사용자가 상태를 조회하지 않고, 중복 부작용이 작고, DLQ로 실패 분석이 충분하면 Queue만으로도 시작할 수 있다. 하지만 사용자에게 진행률을 보여 주거나, 실패를 수동 재처리하거나, 결과 파일 URL을 저장해야 한다면 Queue와 별도로 job state를 둔다.
Queue는 추상적인 안정성 패턴이지만, 운영 판단은 숫자로 한다. 중요한 숫자는 적체량, 처리율, 가장 오래된 메시지 나이, Visibility Timeout, 메시지 보존 기간이다.
처리율은 대략 아래처럼 계산한다.
Worker 처리율 = Worker 수 × Worker당 concurrency ÷ 평균 작업 시간적체 증가율 = 메시지 유입률 - Worker 처리율적체 소진 시간 = 현재 적체량 ÷ max(Worker 처리율 - 메시지 유입률, 0)예를 들어 Worker 5대가 있고, 각 Worker가 동시에 4개 작업을 처리하며, 평균 작업 시간이 2초라면 처리율은 5 × 4 ÷ 2 = 초당 10개다.
| 상황 | 계산 | 해석 |
|---|---|---|
| 유입률 8개/s, 적체 10,000개 | 순처리율 2개/s → 약 83분 | 느리지만 언젠가 따라잡는다. |
| 유입률 10개/s, 적체 10,000개 | 순처리율 0개/s | 현재 적체가 줄지 않는다. Worker 증설이나 작업 시간 개선이 필요하다. |
| 유입률 15개/s, 적체 10,000개 | 초당 5개씩 더 쌓임 | Queue는 버퍼가 아니라 병목을 감추는 저장소가 된다. |
ApproximateNumberOfMessagesVisible은 대기 중인 메시지 수이고, ApproximateAgeOfOldestMessage는 가장 오래 기다린 메시지의 나이다. 메시지 수가 높아도 오래된 메시지 나이가 낮으면 순간 피크일 수 있다. 반대로 메시지 수가 적어도 가장 오래된 메시지 나이가 계속 증가하면 특정 poison message나 처리 불능 상태를 의심해야 한다.
Visibility Timeout은 Worker가 메시지를 가져간 뒤 다른 Worker에게 보이지 않게 숨기는 시간이다. 기본값은 30초이고, SQS에서는 최대 12시간까지 설정할 수 있다.
너무 짧으면 처리 중인 메시지가 다시 보이면서 중복 처리된다. 너무 길면 Worker가 죽었을 때 재시도가 늦어진다. 따라서 “길수록 안전”도 아니고 “짧을수록 빠른 복구”도 아니다. 작업 시간 분포와 실패 복구 시간을 같이 봐야 한다.
Lambda와 SQS를 Event Source Mapping(Lambda가 SQS를 폴링해 함수를 호출하도록 묶는 설정)으로 연결할 때는 AWS가 더 보수적인 기준을 권장한다.
SQS Visibility Timeout >= Lambda 함수 타임아웃 × 6SQS Visibility Timeout >= Lambda 함수 타임아웃 × 6 + MaximumBatchingWindowInSeconds
예시:Lambda 타임아웃: 5분(300초)Batch window: 0초 → 권장 Visibility Timeout: 300 × 6 = 1800초(30분)Batch window: 60초 → 권장 Visibility Timeout: 300 × 6 + 60 = 1860초(31분)이 공식은 Lambda가 배치 처리 중 스로틀링되거나 재시도될 시간을 확보하기 위한 것이다. 직접 폴링하는 NestJS Worker라면 공식 자체보다 원리가 중요하다. p99(99번째 백분위) 처리 시간, graceful shutdown 시간, 외부 API timeout, 재시도 비용을 보고 숨김 시간을 잡는다.
작업 시간이 입력마다 크게 달라지면 ChangeMessageVisibility로 특정 메시지의 숨김 시간을 연장하는 선택지도 있다. 다만 이것은 “긴 작업을 정상 완료하게 돕는 장치”이지 무제한 보류 장치가 아니다. Worker timeout보다 Visibility Timeout이 짧으면 중복 처리 위험이 커지고, 반대로 숨김 시간이 과도하게 길면 Worker가 죽었을 때 복구가 늦어진다.
출처: Creating and configuring an Amazon SQS event source mapping - AWS Lambda 공식 문서, Amazon SQS Visibility Timeout - AWS 공식 문서
SQS 메시지 보존 기간은 기본 4일, 최대 14일이다. Worker가 며칠 동안 밀릴 수 있는 배치라면 Queue가 메시지를 영구히 보관해 준다고 가정하면 안 된다. 적체 소진 시간이 보존 기간을 넘을 수 있으면 retention을 늘리거나, Kafka나 DB job table처럼 재처리 가능한 저장소를 검토한다.
Long Polling은 Worker가 메시지를 받을 때 Queue에 메시지가 생길 때까지 최대 20초 기다리는 방식이다. Short Polling은 메시지가 없어도 즉시 빈 응답을 돌려주므로 호출 수가 늘어난다. 직접 폴링 Worker에서는 waitTimeSeconds: 20처럼 설정해 빈 응답 비용을 줄일 수 있다.
Worker 처리량을 올리는 방법은 Worker 수를 늘리는 것만이 아니다. 한 번에 여러 메시지를 가져오는 batch size, Worker 내부 concurrency, 외부 API 호출 병렬도를 같이 본다.
| 조정값 | 늘리면 좋아지는 점 | 늘리면 나빠지는 점 |
|---|---|---|
| Worker 수 | Queue를 더 빨리 비운다 | 배포 비용, DB 연결 수, 외부 API 호출량이 늘어난다. |
| Worker 내부 concurrency | 같은 컨테이너로 더 많은 작업을 처리한다 | CPU, 메모리, event loop 지연, 장애 전파 범위가 커진다. |
| Batch size | Queue API 호출 수가 줄고 처리량이 오른다 | 배치 안 일부 실패 처리와 Visibility Timeout 계산이 복잡해진다. |
| Visibility Timeout | 긴 작업의 중복 처리가 줄어든다 | Worker 장애 시 재시도가 늦어진다. |
| Retry 횟수 | 일시 장애를 더 잘 흡수한다 | poison message가 오래 리소스를 잡아먹을 수 있다. |
예를 들어 평균 500ms 작업이라면 concurrency 20도 감당할 수 있어 보인다. 하지만 각 작업이 DB connection을 하나씩 잡고 외부 이메일 API를 호출한다면 Worker 10대만으로도 동시 외부 호출이 200개가 된다. Queue 처리량 계산은 CPU뿐 아니라 DB pool, 외부 API rate limit, 네트워크 timeout을 함께 넣어야 한다.
Queue를 넣으면 API 응답 latency는 줄어들지만, 사용자 관점의 전체 완료 시간은 새로 생긴다.
전체 완료 시간 = API 처리 시간 + Queue 대기 시간 + Worker 처리 시간 + 후속 저장/알림 시간환영 이메일은 전체 완료 시간이 1분이어도 괜찮을 수 있다. 반면 업로드한 이미지의 썸네일을 사용자가 바로 보려는 화면에서는 1분 대기가 UX 장애일 수 있다. Queue가 맞는지는 “API p95가 줄었는가”만이 아니라 “사용자가 기대하는 완료 시간 안에 들어오는가”로 봐야 한다.
작은 판단 기준을 세우면 다음과 같다.
queued, processing, succeeded, failed 상태를 노출한다.SQS에는 Standard Queue와 FIFO Queue가 있다. 둘 다 Queue지만 설계 철학이 다르다.
고가용성과 높은 처리량을 우선하고, 중복 전달과 best-effort ordering을 허용한다.
이메일, 알림, 이미지 처리처럼 멱등성으로 중복을 방어할 수 있을 때MessageGroupId 안의 순서와 deduplication ID(중복 억제용 식별자) 기반 중복 억제를 우선한다.
재고 차감, 주문 상태 전이처럼 순서 오류를 비즈니스 로직으로 흡수하기 어려울 때Standard Queue의 기본 선택 이유는 처리량과 단순성이다. 중복 전달과 순서 흔들림을 Worker 로직으로 흡수할 수 있다면 Standard Queue가 대개 운영하기 쉽다.
FIFO Queue는 순서가 중요한 작업에서 유용하지만, “전체 Queue의 모든 메시지가 전역 순서로 빠르게 처리된다”는 뜻은 아니다. FIFO의 순서 보장은 MessageGroupId 단위다. 모든 메시지를 하나의 group에 넣으면 순서는 단순해지지만 병렬성이 거의 사라진다. 사용자별 순서만 중요하다면 MessageGroupId = userId, 주문별 순서만 중요하다면 MessageGroupId = orderId처럼 순서 경계를 좁혀야 한다.
FIFO Queue는 기본 처리량이 API 작업별 초당 300회, 배치 10개 사용 시 초당 3,000개 메시지 수준으로 제한된다. 고처리량 FIFO 모드를 켜면 더 늘릴 수 있지만, 순서 보장은 여전히 message group 설계에 묶인다. 따라서 선택 원칙은 간단하다.
출처: Amazon SQS FIFO queue quotas - AWS 공식 문서, Amazon SQS queue types - AWS 공식 문서
주문 상태 변경 이벤트를 FIFO Queue에 넣는다고 하자.
MessageGroupId = "orders"messages = [ order-1: CREATED, order-2: CREATED, order-3: CREATED, ...]모든 메시지가 같은 orders group에 들어가면 Queue는 순서를 지키기 위해 사실상 한 줄로 처리한다. Worker를 20대로 늘려도 같은 group의 앞 메시지가 끝나기 전에는 뒤 메시지를 병렬 처리하기 어렵다. “FIFO니까 안전하다”는 판단은 맞지만, “FIFO니까 Worker를 늘리면 처리량도 쉽게 늘어난다”는 판단은 틀릴 수 있다.
더 나은 설계는 순서가 필요한 경계를 좁히는 것이다.
MessageGroupId = orderIdorder-1: CREATED -> PAID -> SHIPPEDorder-2: CREATED -> CANCELLEDorder-3: CREATED -> PAID이렇게 하면 같은 주문 안의 상태 순서는 지키면서, 서로 다른 주문은 병렬 처리할 수 있다. FIFO의 핵심 질문은 “전체 순서가 필요한가, 특정 엔티티 안의 순서만 필요한가”다.
Backpressure는 소비자가 처리할 수 있는 속도보다 생산자가 더 빨리 메시지를 만들 때, 그 압력을 지표나 제한으로 드러내는 메커니즘이다. Queue가 있으면 API 서버는 빠르게 응답할 수 있지만, Worker 처리량이 부족한 사실이 사라지는 것은 아니다. 압력은 Queue depth, 오래된 메시지 나이, DLQ, 비용, 외부 API rate limit 형태로 이동한다.
좋은 Queue/Worker 설계는 “쌓일 수 있다”에서 끝나지 않고 “얼마나 쌓이면 위험한가”를 정한다.
| 관찰 값 | 의미 | 먼저 볼 질문 |
|---|---|---|
ApproximateNumberOfMessagesVisible 증가 | 대기 메시지가 늘어난다 | 유입률이 늘었는가, Worker 처리율이 줄었는가? |
ApproximateNumberOfMessagesNotVisible 증가 | Worker가 잡고 숨긴 메시지가 많다 | 처리 시간이 길어졌는가, Worker가 멈췄는가? |
ApproximateAgeOfOldestMessage 증가 | 오래 기다리는 메시지가 있다 | poison message인가, 전체 처리율 부족인가? |
| DLQ 메시지 증가 | 반복 실패한 메시지가 격리된다 | 코드 버그, 데이터 문제, 외부 서비스 장애 중 무엇인가? |
| 외부 API 429 증가 | Worker가 외부 의존성을 과도하게 때린다 | concurrency를 낮추거나 backoff를 넣어야 하는가? |
Backpressure 대응은 보통 세 갈래다. Worker를 늘려 처리율을 높인다. Producer에서 생성 속도를 제한한다. 작업을 더 작게 쪼개거나 외부 API 호출을 줄여 평균 처리 시간을 낮춘다. Queue depth만 보고 Worker를 무작정 늘리면 외부 API rate limit이나 DB 병목을 더 심하게 만들 수 있다.
장애 상황에서 바로 Worker 수를 늘리면 눈앞의 queue depth는 줄 수 있다. 하지만 병목이 DB나 외부 API라면 Worker 증설은 병목을 더 때리는 행동이다. 대응 순서는 보통 아래처럼 잡는다.
Queue가 backpressure를 잘 드러내려면 알람도 “메시지 수” 하나로 끝내지 않는다. 메시지 수, oldest age, DLQ 수, Worker error rate, 외부 API 429, 처리 시간 p95를 함께 본다. 메시지 수만 보면 큰 배치 직후의 정상 피크와 진짜 처리 불능을 구분하기 어렵다.
Queue 도입 기준은 “비동기가 멋있어 보이는가”가 아니라 “작업의 완료 시점과 실패를 사용자 응답에서 분리해도 되는가”다.
네 항목 중 앞의 두 항목이 특히 중요하다. 실패해도 나중에 복구할 수 없는 작업을 Queue 뒤로 숨기면 장애가 늦게 발견된다.
| 상황 | Queue 적합도 | 이유 |
|---|---|---|
| 회원가입 후 환영 이메일 | 높음 | 이메일 발송이 몇 초 늦어져도 가입 자체는 완료될 수 있다. |
| 이미지 리사이즈와 썸네일 생성 | 높음 | 원본 업로드 후 파생 파일은 비동기로 만들어도 된다. |
| 외부 CRM 동기화 | 높음 | 외부 서비스 장애를 내부 API 응답과 분리할 수 있다. |
| 카드 승인 결과 확인 | 낮음 | 승인 실패 시 주문 생성 자체를 막아야 하므로 즉시 결과가 필요하다. |
| 매일 자정 정산 | 보통 낮음 | 이벤트가 아니라 시간 자체가 트리거라면 Cron Job이나 Scheduler가 단순하다. |
| 단일 서버 안의 짧은 CPU 작업 | 낮음 | Worker Thread나 in-process async로 충분할 수 있다. |
| 장기 이벤트 재생이 필요한 데이터 파이프라인 | SQS보다 Kafka 쪽 | SQS는 장기 이벤트 로그와 offset replay를 목적으로 하지 않는다. |
아래 질문을 위에서부터 내려가면 Queue가 필요한지 더 빨리 판단할 수 있다.
1. 사용자가 이 작업의 성공/실패를 즉시 알아야 하는가? ├─ 예: 동기 처리 또는 별도 상태 화면이 필요하다. └─ 아니오: 2. 작업이 실패해도 나중에 재시도하면 되는가? ├─ 아니오: Queue 뒤로 숨기면 실패가 늦게 발견된다. └─ 예: 3. 작업량이 API 서버 처리량과 다르게 출렁이는가? ├─ 아니오: in-process async나 scheduler가 더 단순할 수 있다. └─ 예: 4. 중복 처리와 DLQ 운영을 감당할 수 있는가? ├─ 아니오: 먼저 작업 상태와 운영 지표를 설계한다. └─ 예: Queue/Worker 후보가 된다.이 트리는 Queue를 금지하려는 것이 아니다. 오히려 Queue를 넣었을 때 생기는 새 책임을 미리 드러내기 위한 것이다. Queue를 넣으면 API 코드는 가벼워지지만, 메시지 스키마, retry, DLQ, redrive, idempotency, 지표 알람이 새 설계 대상이 된다.
세부 구현은 팀의 인프라와 운영 역량에 따라 다르다. 처음에는 “어떤 문제가 중심인가”로 좁혀 본다.
| 도구 | 강점 | 약점 | 잘 맞는 사용처 |
|---|---|---|---|
| SQS | 관리형, AWS 통합, 운영 부담 낮음 | 메시지 재생과 복잡한 라우팅에는 약함 | 이메일, 알림, 이미지 처리, 단순 비동기 작업 |
| BullMQ | Redis 기반, 로컬 개발과 NestJS 연동이 쉬움 | Redis 메모리와 job 기록 관리가 필요 | 작은 팀의 백그라운드 job, 로컬/내부 도구 |
| RabbitMQ | Exchange 기반 라우팅과 프로토콜 지원 | 브로커 운영이 필요 | 복잡한 라우팅, 여러 소비자 패턴 |
| Kafka | 파티션, offset, 장기 보존, 이벤트 재생 | 운영 난도가 높고 단순 작업 큐로는 무거움 | 데이터 파이프라인, 이벤트 스트리밍, 재처리 |
SQS와 Kafka를 같은 “비동기 메시징”으로 묶으면 판단이 흐려진다. SQS는 작업을 맡기고 성공하면 삭제하는 Queue에 가깝다. Kafka는 이벤트 로그를 보존하고 여러 Consumer Group(같은 스트림을 나누어 읽는 소비자 묶음)이 각자 offset(어디까지 읽었는지의 위치)을 움직이며 다시 읽을 수 있는 스트림에 가깝다. “작업을 한 번 처리하고 끝낼 것인가”와 “이벤트 기록을 여러 시스템이 다시 읽어야 하는가”를 먼저 나눈다.
Queue/Worker 장애는 대부분 “메시지가 사라졌다”보다 “메시지가 계속 쌓인다”, “같은 메시지가 반복된다”, “실패 메시지가 격리되지 않는다”로 나타난다. 긴 명령어보다 먼저 증상과 원리를 연결해 보자.
| 실패 신호 | 뜻 | 개념적으로 의심할 지점 |
|---|---|---|
| Queue에 메시지가 계속 쌓인다 | 생산 속도가 소비 속도보다 빠르다 | Worker 수, concurrency, 외부 API 지연, backpressure |
| NotVisible 메시지가 높게 유지된다 | Worker가 잡은 메시지를 끝내지 못한다 | 긴 처리 시간, hang, Visibility Timeout, graceful shutdown |
| DLQ에 메시지가 쌓인다 | 재시도해도 실패하는 메시지가 있다 | poison message, 코드 버그, 외부 서비스 장애 |
| 같은 이메일이 두 번 발송된다 | 같은 작업이 중복 처리됐다 | at-least-once delivery, DeleteMessage 실패, idempotency 부재 |
| 가장 오래된 메시지 나이가 계속 증가한다 | 일부 메시지가 오래 방치된다 | 처리율 부족, FIFO message group 병목, poison message |
| Redrive 후 다시 DLQ로 돌아온다 | 원인을 고치지 않고 재처리했다 | 메시지 형식, 데이터 정합성, 코드 배포 상태 |
| 메시지가 처리 전에 만료된다 | retention 기간보다 적체 시간이 길다 | 보존 기간, Worker 처리율, 배치 크기 |
DLQ는 실패를 버리는 곳이 아니다. DLQ는 정상 처리 흐름을 보호하면서, 사람이 원인을 분석할 수 있게 실패 메시지를 격리하는 곳이다. Redrive는 원인 해결 후 재처리하는 행위다. 원인을 고치지 않은 Redrive는 같은 poison message를 다시 원본 Queue로 밀어 넣는 것에 가깝다.
Worker가 아래처럼 email 필드가 항상 있다고 가정한다고 하자.
const payload = JSON.parse(message.Body!);await emailService.send(payload.email, payload.template);어떤 메시지가 { "userId": "1", "template": "welcome" }처럼 email 없이 들어오면 이 메시지는 처리할 때마다 실패한다. Worker를 1대에서 10대로 늘려도 해결되지 않는다. 처리량 문제가 아니라 데이터 검증과 격리 문제이기 때문이다. 이때 DLQ가 없다면 같은 메시지가 계속 재노출되어 정상 메시지 처리까지 방해할 수 있다.
DLQ로 보내기 전 재시도 횟수를 정하는 값이 maxReceiveCount다. 너무 낮으면 일시 장애 메시지가 빨리 DLQ로 빠지고, 너무 높으면 poison message가 오래 Worker를 괴롭힌다.
간단한 기준은 실패 종류별로 생각하는 것이다.
| 실패 종류 | 예시 | 재시도 가치 | 설정 감각 |
|---|---|---|---|
| 일시 장애 | 이메일 API timeout, 네트워크 오류 | 높음 | retry/backoff와 함께 몇 차례 재시도 |
| rate limit | 외부 API 429 | 중간 | 즉시 반복보다 backoff와 concurrency 제한이 중요 |
| payload 오류 | 필수 필드 누락, JSON 파싱 실패 | 낮음 | 빠르게 DLQ로 보내고 코드/데이터 수정 |
| 비즈니스 불일치 | 삭제된 사용자, 취소된 주문 | 낮음 또는 수동 판단 | DLQ나 별도 실패 상태로 사람이 판단 |
AWS 문서와 실무 예시는 maxReceiveCount를 5 이상으로 잡는 경우가 많지만, 숫자 자체보다 재시도 간격과 실패 원인이 중요하다. maxReceiveCount = 10이어도 backoff 없이 즉시 재시도하면 짧은 시간에 같은 실패를 10번 반복할 뿐이다. 재시도 정책은 다음 문서의 주제지만, 이 문서에서는 DLQ가 “무한 재시도 방지 장치”라는 점을 기억한다.
Queue/Worker의 가장 흔한 배포 형태는 API 서버와 Worker를 별도 서비스로 두는 것이다.
API 서버 (ECS Service, 최소 2대) └─ SendMessage ↓SQS email-queue ↓Email Worker (ECS Service, 1~10대 오토스케일)스케일링 기준도 분리된다.
예를 들어 ApproximateNumberOfMessagesVisible > 100이면 Worker ECS 태스크를 2개 늘리고, < 10이 오래 유지되면 줄일 수 있다. 다만 이 숫자는 예시일 뿐이다. 평균 작업 시간이 100ms인 Queue와 30초인 Queue의 100 messages는 전혀 다른 의미다.
AWS SQS는 로컬 개발과 테스트에서 불편할 수 있다. Redis 기반 BullMQ를 로컬 job queue로 쓰고, 운영에서는 SQS를 쓰는 팀도 있다. 이때 중요한 것은 API 코드가 “작업 등록” 인터페이스에만 의존하고, 실제 Queue 구현은 환경에 따라 바뀌게 하는 것이다.
// BullMQ Producer의 핵심 모양await this.emailQueue.add("welcome", { jobId: `user:${userId}:welcome-email`, userId, email,});
// BullMQ Worker의 핵심 모양@Processor("email")export class EmailConsumer extends WorkerHost { async process(job: Job<{ userId: string; email: string }>) { await this.emailService.sendWelcome(job.data.email); }}BullMQ는 Redis에 job 상태와 기록을 저장한다. 완료·실패 기록을 무한히 남기면 Redis 메모리를 압박하므로 removeOnComplete, removeOnFail 같은 보존 정책도 설계 대상이다. SQS의 message retention을 정하듯, Redis job 기록도 “얼마나 남겨야 디버깅에 충분한가”로 결정한다.
직접 폴링 Worker는 메시지가 없을 때도 Queue를 계속 조회할 수 있다. Long Polling은 메시지가 생길 때까지 최대 20초 기다려 빈 응답 비용을 줄인다.
SqsModule.register({ consumers: [ { name: "email-queue", queueUrl: process.env.SQS_EMAIL_QUEUE_URL, region: "ap-northeast-2", waitTimeSeconds: 20, }, ],});Lambda를 SQS Trigger로 연결한 경우에는 Event Source Mapping이 Queue를 폴링한다. NestJS Worker처럼 직접 폴링하는 경우에 Long Polling 설정을 더 직접적으로 신경 쓴다.
긴 실습 절차보다 “무엇을 보면 어떤 개념이 보이는가”에 집중한다.
rg "SqsModule|BullModule|RabbitMQ|@SqsMessageHandler|Queue<|WorkerHost" src/.fifo로 끝나면 FIFO다.Messages Available, Messages in Flight, Age of Oldest Message를 보고 대기, 처리 중, 오래된 메시지를 구분한다. Messages in Flight는 CloudWatch의 ApproximateNumberOfMessagesNotVisible과 같은 관점, 즉 Worker가 가져가 숨겨진 메시지 수를 보는 지표다.| 개념 A | 개념 B | 차이점 |
|---|---|---|
| 동기 처리 | 비동기 처리 | 동기는 완료까지 기다리고, 비동기는 접수 후 백그라운드에서 처리한다. |
| Queue | Pub/Sub | Queue는 보통 한 메시지를 한 Consumer가 처리하고, Pub/Sub은 여러 Subscriber에게 같은 이벤트를 배포한다. |
| Retry | DLQ | Retry는 다시 시도하는 것이고, DLQ는 반복 실패한 메시지를 격리하는 곳이다. |
| Visibility Timeout | Message Retention | Visibility Timeout은 처리 중 숨김 시간이고, retention은 Queue가 메시지를 보관하는 총 기간이다. |
| Standard Queue | FIFO Queue | Standard는 높은 처리량과 중복 가능성을 받아들이고, FIFO는 group 단위 순서를 우선한다. |
| Backpressure | Autoscaling | Backpressure는 압력을 드러내는 원리이고, autoscaling은 그 압력에 대한 대응 중 하나다. |
| SQS | Kafka | SQS는 작업 큐에 가깝고, Kafka는 재생 가능한 이벤트 로그에 가깝다. |
Queue/Worker의 일반형은 “생산자와 소비자를 분리하고, 사이에 버퍼를 둔다”이다. 이 모델은 여러 레이어에서 반복된다.
| 레이어 | 생산자 | 버퍼 | 소비자 |
|---|---|---|---|
| 브라우저 | 메인 스레드 | postMessage() 채널 | Web Worker |
| OS 프로세스 | 부모 프로세스 | IPC 파이프/소켓 | 자식 프로세스 |
| 백엔드 서비스 | API 서버 | SQS / RabbitMQ / BullMQ | Worker 프로세스 |
| 이벤트 스트리밍 | 서비스 | Kafka 토픽 | Consumer Group |
다만 같은 모양이라고 같은 의미는 아니다. 브라우저의 Web Worker는 UI thread 차단을 줄이는 것이 중심이고, SQS Worker는 내구성, 재시도, 중복 처리, 운영 지표가 중심이다. 형태의 유사성은 출발점이고, 판단 기준은 각 레이어의 실패 모델에서 나온다.
Queue/Worker를 이해하면 CQRS나 Event Sourcing도 더 쉽게 읽힌다. CQRS는 쓰기와 읽기의 모델을 분리하고, Event Sourcing은 상태 변경을 이벤트로 저장한다. 둘 다 “지금 처리하는 흐름”과 “나중에 읽거나 처리하는 흐름”을 분리한다는 점에서 Queue/Worker와 같은 사고 모델을 공유하지만, 저장 기간과 재생 가능성, 정합성 요구가 더 강해진다.
Event-Driven Architecture, FIFO Queue, Message Deduplication, Fan-out Pattern, Backpressure, At-least-once vs Exactly-once, Long Polling, BullMQ, CQRS, Event Sourcing, Poison Message, Redrive Policy
본문을 이해하는 데 필수는 아니지만, 실제 장애에서 개념을 확인할 때 유용한 짧은 절차다. 원칙은 “원인 해결 전 redrive 금지”, “Worker 증설 전 외부 병목 확인”, “지표를 메시지 생명주기와 연결”이다.
ApproximateNumberOfMessagesVisible을 본다. 계속 증가하면 Worker 처리율이 유입률보다 낮다.ApproximateNumberOfMessagesNotVisible을 본다. 높게 유지되면 Worker가 메시지를 잡고 완료하지 못하는 상태일 수 있다.messageId, jobId, correlationId, error class를 함께 남겼는지 확인한다. 이 값이 없으면 DLQ 샘플과 애플리케이션 로그를 연결하기 어렵다.DeleteMessage 전에 종료됐는지 로그를 본다.Standard Queue에서 DLQ로 이동한 메시지는 원본 enqueue timestamp를 기준으로 나이를 계산한다. 원본 Queue retention이 4일이고 메시지가 3일 동안 재시도된 뒤 DLQ로 이동했는데, DLQ retention도 4일이면 운영자는 약 1일 뒤 메시지를 잃을 수 있다. 분석이 필요한 DLQ는 원본 Queue보다 retention을 길게 잡는 편이 안전하다.
aws sqs get-queue-attributes \ --queue-url https://sqs.ap-northeast-2.amazonaws.com/123456/email-dlq \ --attribute-names MessageRetentionPeriod \ --region ap-northeast-2예상 출력이 "MessageRetentionPeriod": "1209600"이면 14일이다. "345600"이면 4일이다.
BullMQ에서는 완료·실패 기록이 Redis 메모리를 차지하므로 보존 정책을 둔다.
await this.emailQueue.add( "welcome", { userId, email }, { removeOnComplete: { count: 1000 }, removeOnFail: { count: 5000 }, attempts: 3, backoff: { type: "exponential", delay: 1000 }, },);SQS에서는 retention과 DLQ를 보고, BullMQ에서는 Redis 메모리와 job 기록 보존 수를 본다. 둘 다 “실패 분석에 필요한 만큼 남기되, 무한히 쌓이게 두지 않는다”는 기준은 같다.
출처: Setting-up dead-letter queue retention in Amazon SQS - AWS 공식 문서