서비스가 커질수록 다른 서비스의 기능을 호출하는 일도 복잡해집니다.
이 글에서는 메시지 큐가 필요한 이유와 Kafka의 기본 구성 요소를 정리합니다.
메시지 큐란 무엇인가?
메시지 큐는 한 서비스가 만든 메시지를 저장해 두었다가, 다른 서비스가 나중에 읽어 처리하도록 연결하는 중간 통로입니다.
가령 주문이 생성된 뒤 재고를 예약해야 하는 상황을 생각해 보겠습니다.
직접 호출하는 방식
Order Service ──HTTP 요청──> Inventory Service
│
├─────────HTTP 요청──> Notification Service
│
└─────────HTTP 요청──> Analytics Service
이 방식은 처음에는 이해하기 쉽습니다. 하지만 서비스가 늘어나면 주문 서비스는 여러 서비스의 주소, 응답 형식, 장애 상황을 모두 알아야 합니다.
특히 재고 서비스가 일시적으로 장애인 경우 다음과 같은 선택지가 생깁니다.
- 주문 자체를 실패시킬 것인지 결정해야 합니다.
- 주문은 성공시키고 나중에 재고 처리를 다시 시도할지 결정해야 합니다.
- 재고는 성공했지만 알림 전송이 실패하면 어디까지 되돌릴지 결정해야 합니다.
동기 호출은 호출자가 응답을 기다리는 구조입니다. 따라서 뒤쪽 서비스의 처리 시간과 장애가 앞쪽 서비스에 쉽게 전파됩니다.
메시지 기반 통신 방식
Order Service ──발행──> Message Broker <──소비── Inventory Service
│
├──────────────> Notification Service
│
└──────────────> Analytics Service
주문 서비스는 “주문이 생성되었다”는 사실을 메시지로 발행하고, 각 서비스는 자신에게 필요한 메시지를 독립적으로 소비합니다.
이 방식의 장점은 다음과 같습니다.
- 생산자와 소비자의 결합도가 낮아집니다.
- 소비자가 잠시 중단되어도 메시지를 보관할 수 있습니다.
- 같은 이벤트를 여러 서비스가 각자의 목적에 맞게 처리할 수 있습니다.
- 처리량이 급증해도 소비자 수를 늘려 확장할 수 있습니다.
물론 메시지 큐가 모든 문제를 없애 주지는 않습니다. 비동기 처리에서는 “언제 처리 결과를 알 수 있는가”, “같은 메시지가 두 번 오면 어떻게 할 것인가”, “처리에 실패한 메시지는 어디로 갈 것인가”를 별도로 설계해야 합니다.
Kafka란 무엇인가?
Kafka는 이벤트 스트림을 저장하고 전달하는 분산 메시지 브로커입니다. 생산자는 메시지를 Kafka에 기록하고, 소비자는 원하는 위치부터 메시지를 읽습니다.
일반적인 큐를 우편함에 비유한다면, Kafka는 일정 기간 보관되는 추가 전용 로그에 가깝습니다. 소비자가 메시지를 읽었다고 해서 메시지가 즉시 사라지지 않습니다. 보관 기간 안이라면 다른 소비자가 같은 메시지를 읽거나, 장애 이후 특정 위치부터 다시 읽을 수 있습니다.
재고 서비스는 재고 수량과 예약 상태가 변경될 때 이벤트를 Kafka로 발행합니다. 이를 구독하는 WMS, 매장 시스템 같은 서비스는 자신의 역할에 필요한 이벤트를 소비할 수 있습니다.
예를 들면 다음과 같은 흐름입니다.
재고 서비스
└─ 재고 수량 변경 또는 예약 상태 변경
└─ Kafka 이벤트 발행
├─ OMS: 주문 상태 또는 예약 상태 반영
├─ WMS: 출고·입고 작업 반영
└─ Store: 매장 재고 정보 갱신
Kafka는 다음과 같은 상황에서 특히 잘 맞습니다.
- 하나의 도메인 이벤트를 여러 서비스가 구독해야 할 때
- 요청 처리와 후속 작업을 분리하고 싶을 때
- 이벤트를 다시 재생하거나, 처리 이력을 일정 기간 보관해야 할 때
- 높은 처리량을 병렬로 처리해야 할 때
반대로 즉시 결과가 꼭 필요한 단순 조회나 강한 일관성이 필요한 단일 트랜잭션 작업까지 Kafka로 바꿀 필요는 없습니다. 메시지 기반 설계는 운영과 실패 처리를 위한 추가 복잡도를 함께 가져옵니다.
Kafka의 핵심 구성 요소
Kafka를 이해할 때는 Producer, Consumer, Topic, Partition, Offset, Consumer Group의 관계를 먼저 잡는 것이 좋습니다.
Producer
│
│ 메시지 발행
▼
Topic
├─ Partition 0: [0] [1] [2] [3]
├─ Partition 1: [0] [1] [2]
└─ Partition 2: [0] [1] [2] [3] [4]
│
▼
Consumer Group
├─ Consumer A → Partition 0
├─ Consumer B → Partition 1
└─ Consumer C → Partition 2
Producer
Producer는 Kafka에 메시지를 발행하는 애플리케이션입니다.
재고 서비스에서는 재고, 예약, 상품 같은 집계의 상태가 바뀔 때 이벤트를 발행합니다. 이벤트에는 본문(payload)뿐 아니라 이벤트 ID, 이벤트 유형, 발생 시각 같은 메타데이터도 함께 담을 수 있습니다.
Topic
Topic은 메시지를 분류하는 이름입니다. 데이터베이스의 테이블이나 이벤트 채널처럼 생각할 수 있습니다.
재고 서비스는 집계 유형별로 토픽을 나누는 방식을 사용할 수 있습니다.
inventory.stock
inventory.reservation
inventory.product
이렇게 나누면 구독자는 자신에게 필요한 도메인의 이벤트만 구독할 수 있습니다.
Partition
Partition은 하나의 Topic을 여러 개의 로그로 나눈 단위입니다. Kafka의 병렬 처리와 순서 보장의 기준이기도 합니다.
중요한 규칙은 다음과 같습니다.
메시지 순서는 Topic 전체가 아니라 같은 Partition 안에서만 보장됩니다.
따라서 서로 순서가 보장되어야 하는 이벤트에는 같은 Key를 사용해야 합니다.
재고 이벤트는 창고 ID:상품 ID를 키로 사용할 수 있습니다. 같은 창고의 같은 상품에 대한 재고 변경은 같은 Partition으로 향하게 되고, 그 안에서는 발행 순서를 유지할 수 있습니다.
Key: warehouse-1:product-100
재고 +10 → Partition 2
재고 -3 → Partition 2
재고 +5 → Partition 2
반면 서로 다른 키는 다른 Partition으로 갈 수 있으므로, 두 키 사이의 처리 순서까지 보장할 수는 없습니다.
Offset
Offset은 Partition 안에서 각 메시지의 위치를 나타내는 번호입니다.
Partition 2
Offset: 0 1 2 3
[입고] [예약] [출고] [조정]
Consumer는 “어디까지 읽었는가”를 Offset으로 기록합니다. 장애가 발생했을 때 마지막으로 기록된 Offset 이후부터 다시 처리할 수 있는 이유도 여기에 있습니다.
Consumer와 Consumer Group
Consumer는 Kafka 메시지를 읽고 처리하는 애플리케이션입니다. 같은 Consumer Group에 속한 Consumer들은 하나의 Partition을 나누어 맡습니다.
예를 들어 reservation-worker 그룹에 Consumer가 2개이고 Topic의 Partition이 3개라면, 최대 2개 Partition만 동시에 처리합니다. 반대로 Consumer가 5개여도 Partition이 3개라면 2개 Consumer는 할 일이 없습니다.
한 Consumer Group 안에서 병렬 처리의 최대치는 Partition 수를 넘을 수 없습니다.
다만 서로 다른 Consumer Group은 같은 Topic을 독립적으로 읽을 수 있습니다. OMS와 WMS가 같은 재고 이벤트를 각자 처리할 수 있는 이유입니다.
정리
- 메시지 큐는 생산자와 소비자를 분리해 서비스 간 결합도를 낮춥니다.
- Kafka는 메시지를 일정 기간 보관하는 로그 기반의 분산 메시지 브로커입니다.
- Topic은 이벤트를 분류하고, Partition은 병렬 처리와 순서 보장의 기준이 됩니다.
- 같은 순서가 필요한 이벤트에는 같은 Key를 사용해 같은 Partition으로 보내야 합니다.
- Consumer Group의 병렬 처리 최대치는 Partition 수를 넘을 수 없습니다.