Queue와 Worker로 비동기 작업 분리하기
Queue와 Worker는 요청 흐름과 느린 작업 흐름을 분리해 서버 과부하와 실패 복구 문제를 다루는 기본 패턴이다. SQS의 전달 특성, Visibility Timeout, DLQ, 큐 선택 기준과 대표 장애 대응을 함께 정리한다.
Script Companion
오디오와 함께 스크립트 보기
- 01
Queue와 Worker의 출발점은 모든 요청을 즉시 처리하지 않는 데 있다. 이메일 발송, 이미지 처리, 알림 전송처럼 지금 당장 끝나지 않아도 되는 작업을 API 요청 안에서 모두 처리하면, 가장 느린 외부 호출이 사용자 응답 시간을 결정한다. Queue는 이런 작업을 잠시 맡아 두는 대기열이고, Worker는 그 대기열에서 작업을 꺼내 처리하는 별도 프로세스다. 그래서 API 서버는 작업을 접수했다는 사실만 남기고 빠르게 응답할 수 있다.
- 02
프론트엔드 관점에서는 Web Worker와 Service Worker를 떠올리면 구조가 잡힌다. 브라우저에서 메인 스레드를 막지 않기 위해 postMessage()로 별도 Worker와 메시지를 주고받듯, SQS Queue와 Worker도 API 서버의 메인 흐름에서 무거운 작업을 분리한다. 차이는 규모다. Web Worker는 같은 브라우저 탭 안의 분리이고, SQS는 별도 서버 프로세스나 Lambda로 확장 가능한 분리다. 핵심은 메인 흐름이 작업을 직접 붙잡고 있지 않게 만드는 것이다.
- 03
Queue가 없을 때 흔한 방식은 회원가입 API 안에서 DB 저장, 이메일 발송, 이미지 리사이즈, 외부 CRM 동기화를 모두 동기로 끝내는 것이다. 이때 await emailApi.send()가 2초 걸리거나 타임아웃 5초를 채우면 사용자는 기다려야 한다. 더 나쁜 경우 DB 저장은 이미 성공했는데 외부 호출 때문에 실패 응답을 받는 불일치도 생긴다. 서버 안의 setTimeout, in-process job, 브라우저 Web Worker 같은 프로세스 내부 비동기로 밀어도, 프로세스가 죽으면 작업 상태가 함께 사라지고 API 서버와 작업 처리량을 독립적으로 늘리기 어렵다.
- 04
SQS Standard Queue는 이 문제를 내구성 있는 버퍼로 푼다. 메시지를 여러 Availability Zone에 중복 저장해 단일 서버나 AZ 장애로 메시지가 사라지지 않도록 설계되어 있고, 메시지 보존 기간은 기본 4일, 최대 14일까지 설정할 수 있다. 대신 이 설계는 순서와 정확히 한 번 처리를 포기하고 at-least-once 전달을 선택한다. 같은 메시지가 두 번 전달될 수 있다는 뜻이며, 이는 버그라기보다 고가용성을 위한 트레이드오프다. 따라서 Worker는 같은 메시지를 두 번 처리해도 결과가 망가지지 않도록 멱등성을 가져야 한다.
- 05
Standard Queue와 FIFO Queue의 선택은 순서, 중복, 처리량의 균형이다. Standard Queue는 순서를 best-effort로만 다루고 중복 가능성이 있지만 처리량이 거의 무제한에 가깝고 비용도 낮다. 이메일 발송이나 이미지 처리처럼 순서가 치명적이지 않은 작업에 맞다. FIFO Queue는 MessageGroupId 안의 순서를 보장하고 deduplication ID로 중복을 줄이지만, 기본 처리량은 API 작업별 초당 300회, 배치 10개 사용 시 초당 3,000개 메시지로 제한된다. 모든 메시지를 하나의 group에 넣으면 Worker를 늘려도 병렬성이 거의 나오지 않는다는 점도 중요하다.
- 06
SQS, Kafka, RabbitMQ도 모두 비동기 처리를 제공하지만 선택 기준은 다르다. AWS 인프라 기반이고 운영팀이 작으며 단순 큐잉이 필요하면 SQS가 맞다. 데이터 파이프라인, 이벤트 재처리, 장기 보관된 이벤트를 다시 읽어야 하는 경우에는 Kafka가 더 어울린다. 복잡한 라우팅 규칙이나 멀티 프로토콜이 중요하면 RabbitMQ가 후보가 된다. Bull은 Redis 기반 Queue로, NestJS에서는 @nestjs/bull 패키지로 쉽게 연동할 수 있다. 로컬에서는 Redis 기반 BullMQ를 쓰고 프로덕션에서 SQS로 전환하는 패턴도 언급된다.
- 07
Queue가 항상 정답은 아니다. 매일 자정 정산처럼 시간만 중요한 작업은 Cron Job이 더 단순하고, 카드 승인 결과처럼 사용자가 즉시 성공이나 실패를 알아야 하는 흐름은 직접 API 호출이 맞다. 단일 서버 안의 CPU 집약적 작업은 Promise나 Worker Thread 같은 in-process async로 충분할 수 있다. 판단 기준은 세 가지다. Worker가 죽어도 재시도가 필요한가, 작업 규모가 API 요청과 독립적으로 스케일링되어야 하는가, 운영팀이 Queue 모니터링과 DLQ 관리를 할 수 있는가. 셋 다 예가 아니면 Queue의 설정과 모니터링 비용이 이점을 넘을 수 있다.
- 08
운영에서 가장 중요한 설정 중 하나는 Visibility Timeout이다. Worker가 메시지를 꺼내면 다른 Worker가 같은 메시지를 가져가지 못하도록 일정 시간 동안 숨기는데, 기본값은 30초이고 최대 12시간이다. 처리 시간이 이 시간을 넘으면 메시지가 다시 큐에 보이고 다른 Worker가 가져가 중복 처리가 생길 수 있다. 처리 시간이 길거나 가변적이면 ChangeMessageVisibility API로 연장해야 한다. Lambda와 SQS를 함께 쓸 때는 Lambda 타임아웃이 30초인데 SQS Visibility Timeout도 30초로 두는 실수가 흔하다. 문서의 원칙대로 Lambda + SQS 조합에서는 Visibility Timeout을 Lambda 타임아웃 곱하기 6으로 잡아야 한다.
- 09
실패한 메시지를 격리하는 장치가 Dead Letter Queue, 줄여서 DLQ다. 몇 번 시도해도 처리되지 않는 메시지를 별도 Queue로 보내 나중에 사람이 확인하게 한다. DLQ에 메시지가 쌓이면 CloudWatch Alarm으로 알림을 걸고, 원인을 고친 뒤 Redrive로 원본 큐에 다시 넣을 수 있다. Producer와 Consumer를 분리 배포하면 API 서버와 Worker를 독립적으로 스케일링할 수 있고, Long Polling을 쓰면 메시지가 생길 때까지 최대 20초 기다려 빈 응답 폴링 비용을 줄일 수 있다. @ssut/nestjs-sqs를 쓰는 경우에는 @SqsConsumerEventHandler로 처리 중 예외를 잡아 CloudWatch 로그와 알림으로 연결할 수 있다.
- 10
대표 장애도 Queue의 기본 메커니즘에서 나온다. SQS 콘솔에서 ApproximateNumberOfMessagesVisible이 계속 올라가면 Worker 프로세스가 종료되었거나, 처리 중 예외로 DeleteMessage를 못 했거나, Visibility Timeout이 처리 시간보다 짧을 수 있다. email-dlq에 메시지가 쌓이면 메시지 Body를 확인해 파싱 문제인지 외부 서비스 연결 문제인지 나누고, 해결 후 Redrive한다. 같은 작업이 두 번 처리되면 DeleteMessage 실패나 at-least-once 전달을 의심하고 processed_messages처럼 message_id와 processed_at을 저장해 이미 처리된 메시지를 스킵한다. 정리하면 Queue는 느린 작업을 분리하는 장치이고, Worker의 멱등성, Visibility Timeout, DLQ 운영까지 함께 설계해야 의미가 있다.
같은 레이어