Post

[대규모 시스템] Day 2: 메시지 큐 - 트래픽 급증을 흡수하고 시스템을 분리하기

[대규모 시스템] Day 2: 메시지 큐 - 트래픽 급증을 흡수하고 시스템을 분리하기

이 글은 AI(Claude)의 도움을 받아 작성하고, 작성자가 검토·편집했습니다.

서론: 모든 것을 즉시 처리할 필요는 없다

사용자가 동영상을 업로드하면 인코딩에 수 분이 걸린다. 그동안 요청을 붙들고 있으면 서버가 막힌다. 메시지 큐는 “지금 받아두고 나중에 처리”를 가능케 해, 트래픽 급증을 흡수하고 컴포넌트를 분리한다. Day 1의 캐싱이 읽기를 줄였다면, 오늘은 쓰기와 무거운 작업을 다룬다.

1. 큐가 푸는 세 가지 문제

1
2
3
4
5
6
7
8
9
10
1. 부하 평탄화(buffering):
   초당 1만 요청이 몰려도 처리량이 초당 1천이면 큐가 흡수
   → 스파이크에도 시스템이 안 죽고, 처리는 자기 속도로

2. 결합도 분리(decoupling):
   생산자는 소비자를 몰라도 됨 → 독립 배포·확장 가능
   주문 서비스 ─큐─> [결제, 재고, 알림, 분석] 각자 소비

3. 비동기 처리:
   느린 작업(인코딩·이메일·리포트)을 백그라운드로 → 응답 즉시 반환

2. 두 가지 모델: 큐 vs 로그

1
2
3
4
5
6
7
8
전통 메시지 큐 (RabbitMQ, SQS):
  소비자가 메시지를 가져가면 큐에서 사라짐 (작업 분배)
  여러 워커가 나눠 처리 → 작업 큐(task queue)에 적합

이벤트 로그 (Kafka):
  메시지가 로그에 남고, 여러 소비자 그룹이 독립적으로 읽음
  소비자가 자기 오프셋(위치)을 관리 → 재처리·다중 구독 가능
  → 이벤트 스트리밍·여러 시스템이 같은 이벤트를 소비하는 데 적합

작업 분배는 전통 큐, 이벤트를 여러 곳이 소비하면 Kafka 류를 택한다.

3. 작업 큐 패턴

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 생산자: 무거운 작업을 큐에 넣고 즉시 응답
def upload_video(request):
    video_id = save_raw(request.file)
    queue.publish("video.encode", {"video_id": video_id})  # 큐에 작업 투입
    return {"status": "processing", "id": video_id}        # 즉시 반환

# 소비자(워커): 자기 속도로 하나씩 처리
def worker():
    for msg in queue.consume("video.encode"):
        try:
            encode(msg["video_id"])      # 수 분 걸리는 작업
            msg.ack()                    # 성공 → 큐에서 제거
        except Exception:
            msg.nack(requeue=True)       # 실패 → 재시도 위해 되돌림

워커 수를 늘리면 처리량이 수평 확장된다. 큐 길이가 쌓이면 워커를 더 띄우는 오토스케일링의 신호가 된다.

4. 전달 보장: at-least-once의 현실

1
2
3
at-most-once:   최대 한 번 (유실 가능, 중복 없음)
at-least-once:  최소 한 번 (유실 없음, 중복 가능) ← 대부분의 큐 기본
exactly-once:   정확히 한 번 (이상적이나 분산 환경에선 매우 비쌈)

현실은 at-least-once + 멱등 소비자다. ack를 보내기 전에 워커가 죽으면 메시지가 재전달되어 중복 처리될 수 있다(분산 시스템 Day 5의 부분 실패).

1
2
3
4
5
6
def process_payment(msg):
    # 멱등성 키로 중복 처리 방지 (분산 시스템 Day 5, gRPC Day 4와 동일 원리)
    if db.exists(msg["idempotency_key"]):
        return  # 이미 처리됨 → 건너뜀
    charge(msg["amount"])
    db.record(msg["idempotency_key"])

5. 죽은 메시지와 재시도

처리할 수 없는 메시지가 무한 재시도로 큐를 막는 것을 막아야 한다.

1
2
3
4
5
6
7
재시도 + DLQ(Dead Letter Queue):
  메시지 처리 실패 → 지수 백오프로 N회 재시도
  N회 초과 → DLQ로 격리 (큐를 막지 않음)
  → 운영자가 DLQ를 조사·수동 재처리

독성 메시지(poison message): 항상 실패하는 메시지가
  재시도를 무한 반복하며 워커를 묶는 것을 DLQ가 차단

6. 순서 보장과 파티셔닝

큐는 기본적으로 전역 순서를 보장하지 않는다. 순서가 필요하면 파티셔닝을 쓴다(분산 시스템 Day 3).

1
2
3
4
5
6
Kafka: 토픽을 여러 파티션으로 나눔
  같은 키(예: user_id)의 메시지는 같은 파티션 → 그 안에서 순서 보장
  파티션 간에는 순서 보장 없음 (병렬 처리로 처리량 ↑)

예: 한 사용자의 주문 이벤트는 순서대로,
    다른 사용자들끼리는 병렬 처리

순서 보장과 병렬성은 트레이드오프다. 키 단위 순서만 필요하면 파티션 키로 둘 다 얻는다.

7. 백프레셔: 큐가 무한정 쌓이면

1
2
3
4
5
6
7
생산 속도 > 소비 속도가 지속되면 큐가 무한히 쌓임 → 결국 붕괴

대응:
  - 모니터링: 큐 길이·소비 지연(lag)을 지표로 (Day 5)
  - 오토스케일: lag이 임계 초과 시 워커 증설
  - 생산 측 제한: 큐가 가득 차면 생산자에 백프레셔 (분산 시스템 Day 5)
  - 우선순위 큐 / 로드 셰딩: 과부하 시 낮은 우선순위 작업 폐기

큐는 무한 버퍼가 아니다. 소비가 못 따라가면 결국 터진다는 것을 전제로 모니터링한다.

8. Day 2 체크리스트

  1. 큐가 부하 평탄화·결합도 분리·비동기 처리를 푼다는 것을 이해했다.
  2. 작업 분배(전통 큐)와 이벤트 로그(Kafka)의 차이를 구분했다.
  3. 작업 큐 패턴으로 무거운 작업을 비동기 처리하고 워커로 확장했다.
  4. at-least-once + 멱등 소비자로 중복을 안전하게 다뤘다.
  5. 재시도·DLQ·파티셔닝·백프레셔로 큐를 안정 운영하는 법을 파악했다.

다음 편 예고

캐시와 큐로 단일 서버의 한계를 넘었다. 이제 서버 자체를 여러 대로 늘려야 한다. Day 3에서는 로드밸런싱과 수평 확장 — 트래픽을 여러 서버에 분산하고 무상태로 설계하는 법을 다룬다.

This post is licensed under CC BY 4.0 by the author.