비동기 메시징 이란
서비스끼리 직접 부르지 않고, 중간에 둔 저장소에 메시지를 넣어 주고받는 방식
- 보내는 쪽은 메시지를 넣고 바로 자기 일을 끝낸다. 받는 쪽이 지금 살아 있는지 신경 쓰지 않는다
- 받는 쪽은 자기 속도로 꺼내서 처리한다
- 중간에서 메시지를 맡아 두는 것을 브로커(Broker) 라고 한다. Kafka, RabbitMQ, AWS SQS 같은 것들이다
이름에 “비동기”가 들어가서 오해받는데, 자바의 CompletableFuture 같은 한 프로세스 안의 비동기와는 다른 이야기다. 그건 한 애플리케이션 안에서 스레드를 놀리지 않는 기법이고, 여기서는 서비스와 서비스 사이를 어떻게 잇느냐를 말한다.
왜 필요한가
주문을 받으면 재고를 줄이고 확인 메일을 보내는 흐름을 생각해 본다.
직접 호출로 짜면 주문 서비스가 재고 서비스를 부르고, 그 응답을 받은 뒤 메일 서비스를 부른다. 여기서 세 가지가 걸린다.
- 기다린다 — 재고와 메일이 끝날 때까지 주문 서비스의 스레드가 묶여 있다. 사용자는 그 합만큼 기다린다
- 같이 죽는다 — 메일 서버가 응답하지 않으면 주문까지 실패한다. 메일이 주문보다 덜 중요한데도 그렇다
- 부를 곳이 늘어난다 — 주문 완료 시 통계도 쌓기로 하면 주문 서비스 코드를 또 고친다
비동기 메시징은 이 셋을 “직접 부르지 말고 가운데에 맡겨라”는 한 가지 원칙으로 푼다.
동기 호출과 무엇이 다른가
가장 큰 차이는 응답을 기다리는가다.
| 동기 호출 (REST 등) | 비동기 메시징 | |
|---|---|---|
| 호출한 쪽 | 응답이 올 때까지 기다린다 | 넣고 바로 돌아온다 |
| 상대가 죽어 있으면 | 호출이 실패한다 | 메시지가 브로커에 남는다 |
| 결과를 아는 시점 | 즉시 | 나중에, 또는 모른다 |
| 받는 쪽을 늘리려면 | 부르는 쪽 코드를 고친다 | 구독만 추가한다 |
| 순간 부하 | 그대로 전달된다 | 브로커에 쌓였다가 처리된다 |
결합도가 낮아진다는 말
자주 나오는 표현인데, 정확히는 세 가지가 분리된다는 뜻이다.
- 시간 — 보내는 쪽과 받는 쪽이 동시에 살아 있지 않아도 된다
- 대상 — 보내는 쪽은 누가 받는지 모른다. 받는 쪽이 늘어도 코드가 안 바뀐다
- 속도 — 초당 1000건이 몰려도 받는 쪽은 자기 속도로 꺼낸다. 브로커가 완충 역할을 한다
세 번째를 백프레셔 완충 또는 버퍼링이라고 부른다. 트래픽이 튈 때 뒤쪽 서비스가 무너지지 않게 막아 준다.
그럼 동기 호출은 이제 안 쓰나
그렇지 않다. 바뀌는 건 문제의 종류지 난이도가 아니다.
- 동기 호출이 공짜로 주던 것들(즉시 결과 확인, 실패 시 바로 에러, 디버깅 시 스택 추적)을 메시징에서는 직접 챙겨야 한다
- 기다리지 않는 대신, 언제 처리됐는지 알 수 없는 구간이 생긴다
무엇을 얻고 무엇을 잃는지는 이 글 끝의 언제 쓰고, 언제 쓰지 않나
에서 정리한다. 그 전에 메시징이 실제로 어떻게 생겼는지부터 본다.
큐와 발행-구독
브로커에 메시지를 어떻게 두느냐에 따라 두 가지 모양이 있다. 이름은 달라도 “한 메시지를 몇 명이 받느냐” 하나로 갈린다.
큐 - 한 명만 받는다
큐(Queue) 에 넣은 메시지는 여러 소비자가 붙어 있어도 그중 한 명만 가져간다.
- 목적은 일을 나누는 것이다. 소비자를 3대로 늘리면 처리량이 3등분된다
- 주문 처리처럼 두 번 하면 안 되는 일에 쓴다
- 이 모양을 경쟁 소비자(Competing Consumers) 패턴이라고 부른다
발행-구독 - 모두가 받는다
토픽(Topic) 에 발행한 메시지는 구독 중인 모두가 각자 하나씩 받는다.
- 목적은 알리는 것이다. “주문이 완료됐다”를 메일·통계·추천이 각자 듣는다
- 구독자를 늘려도 발행하는 쪽 코드는 그대로다. 앞에서 말한 “부를 곳이 늘어난다” 문제가 여기서 사라진다
- 발행-구독(Publish-Subscribe, Pub/Sub) 이라고 부른다
카프카는 둘을 한 구조로 만든다
Kafka 에는 큐와 토픽이 따로 있지 않다. 토픽 하나에 컨슈머 그룹(Consumer Group) 개념을 얹어 둘 다 표현한다.
- 같은 그룹 안의 소비자들 — 메시지를 나눠 갖는다. 큐처럼 동작한다
- 서로 다른 그룹들 — 같은 메시지를 각자 다 받는다. 발행-구독처럼 동작한다
그래서 메일 그룹과 통계 그룹은 같은 주문 메시지를 각자 받고, 메일 그룹 안에서 서버를 3대로 늘리면 그 셋이 일을 나눈다.
메시지가 사라지지 않게 하는 법
“넣고 바로 돌아온다”는 건 넣은 뒤에 무슨 일이 생겼는지 모른다는 뜻이기도 하다. 메시징에서 가장 많은 고민이 여기에 몰려 있다.
ack - 처리했다는 신호
소비자가 메시지를 꺼내 갔다고 해서 브로커가 바로 지우면 안 된다. 처리 도중에 소비자가 죽으면 그 메시지는 사라진다.
그래서 소비자는 처리를 마친 뒤 ack(acknowledgement, 처리 완료 신호) 를 보낸다. 브로커는 ack 을 받은 뒤에야 그 메시지를 끝난 것으로 본다.
- ack 이 오지 않으면 브로커는 다시 준다. 다른 소비자에게 줄 수도 있다
- Kafka 에서는 이 위치를 오프셋(Offset, 어디까지 읽었는지 표시하는 번호) 커밋으로 관리한다
세 가지 전달 보장
“메시지가 몇 번 도착하는가”에 대한 약속이다. 브로커마다 지원 범위가 다르다.
| 보장 | 뜻 | 언제 |
|---|---|---|
| at-most-once | 최대 한 번. 유실될 수 있다 | 로그 수집처럼 몇 건 빠져도 되는 일 |
| at-least-once | 최소 한 번. 중복될 수 있다 | 대부분의 실무 기본값 |
| exactly-once | 정확히 한 번 | 비싸고 제약이 많다 |
현실에서 대부분 at-least-once 를 쓴다. 처리는 끝냈는데 ack 을 보내기 직전에 죽으면, 브로커는 못 받은 것으로 알고 다시 주기 때문이다. 중복은 막을 수 없고, 받는 쪽이 감당하는 쪽으로 설계한다.
멱등성 - 중복을 감당하는 법
멱등성(Idempotency, 여러 번 해도 결과가 한 번 한 것과 같은 성질) 을 소비자 쪽에 만들어 둔다.
- 메시지마다 고유 ID 를 넣고, 처리한 ID 를 저장해 두었다가 이미 있으면 건너뛴다
- “재고를 1 줄인다” 대신 “재고를 9로 만든다” 처럼 결과를 지정하는 형태로 바꾼다
- DB 유니크 제약으로 두 번째 삽입이 실패하게 두는 것도 방법이다
메시징을 처음 붙일 때 가장 자주 빠뜨리는 부분이다. 중복은 장애가 아니라 정상 동작이라고 보고 시작하는 게 맞다.
Spring Kafka 로 만들어보기
개념을 코드로 확인해 본다. Kafka 를 고른 이유는 예제가 많고 컨슈머 그룹으로 큐와 발행-구독을 한 번에 볼 수 있어서다.
의존성
dependencies {
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.kafka:spring-kafka'
}
브로커 띄우기
로컬에서는 도커로 띄우는 게 가장 간단하다.
# docker-compose.yml
services:
kafka:
image: apache/kafka:3.9.0
ports:
- "9092:9092"
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
설정
# application.yml
spring:
kafka:
bootstrap-servers: localhost:9092
consumer:
group-id: order-mail
auto-offset-reset: earliest
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
-
group-id가 앞에서 본 컨슈머 그룹이다. 이 값이 같은 인스턴스끼리 메시지를 나눠 갖는다 -
auto-offset-reset: earliest는 “읽은 기록이 없으면 맨 앞부터”라는 뜻이다.latest로 두면 붙기 전 메시지는 건너뛴다
메시지 정의
public record OrderCompleted(
String orderId,
String email,
int amount
) {}
보내기 - 생산자
@Service
public class OrderService {
private final KafkaTemplate<String, OrderCompleted> kafkaTemplate;
public OrderService(KafkaTemplate<String, OrderCompleted> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void complete(String orderId, String email, int amount) {
// 주문 저장 등 본래 할 일을 먼저 끝낸다
kafkaTemplate.send("order-completed", orderId, new OrderCompleted(orderId, email, amount));
// 여기서 메일을 기다리지 않고 바로 반환된다
}
}
- 두 번째 인자
orderId가 키(Key) 다. 같은 키는 항상 같은 파티션으로 가서 순서가 보장된다 - 키를 안 주면 여러 파티션에 흩어져 순서가 섞일 수 있다. 뒤에서 다시 다룬다
받기 - 소비자
@Component
public class MailConsumer {
@KafkaListener(topics = "order-completed", groupId = "order-mail")
public void handle(OrderCompleted event) {
mailSender.send(event.email(), "주문이 완료되었습니다: " + event.orderId());
}
}
-
@KafkaListener하나면 끝난다. 스프링이 폴링 루프와 오프셋 커밋을 대신 해 준다 - 메서드가 예외 없이 끝나면 스프링이 오프셋을 커밋한다. 예외가 나면 커밋하지 않아 다시 받는다
구독자 추가하기
통계를 붙인다고 해 보자. 생산자 코드는 한 줄도 고치지 않는다.
@Component
public class StatsConsumer {
@KafkaListener(topics = "order-completed", groupId = "order-stats")
public void handle(OrderCompleted event) {
statsRepository.add(event.amount());
}
}
-
groupId가 다르므로 메일 소비자와 각자 같은 메시지를 받는다 - 앞에서 본 발행-구독이 이렇게 나온다. 이게 결합도가 낮다는 말의 실체다
입문 다음에 만나는 것들
개념만 알고 실제로 붙이면 곧 만나는 문제들이다. 지금은 이름만 알아두면 된다.
순서 보장
Kafka 는 파티션(Partition, 토픽을 나눠 담는 단위) 안에서만 순서를 지킨다.
- 토픽이 파티션 3개면 메시지가 셋에 흩어지고, 소비자가 받는 순서는 발행 순서와 달라질 수 있다
- 그래서 순서가 중요한 것끼리 같은 키를 준다. 주문 ID 를 키로 주면 그 주문의 이벤트들은 한 파티션에 모여 순서가 지켜진다
- 대신 한 파티션은 그룹 안에서 한 소비자만 읽는다. 순서를 지키려 할수록 병렬 처리가 줄어드는 맞교환이다
중복 처리
앞서 본 at-least-once 때문에 생긴다. 재시도 중에도, 소비자가 재시작할 때도 같은 메시지를 다시 받는다.
- 해결책은 멱등성이다. 처리한 메시지 ID 를 남기거나 결과를 지정하는 연산으로 바꾼다
- Kafka 의
enable.idempotence는 생산자가 재시도할 때 중복 발행을 막는 것이지 소비자 쪽 중복까지 막아주지 않는다. 이름 때문에 자주 오해한다
실패한 메시지 - DLQ
계속 실패하는 메시지 하나가 뒤 메시지를 전부 막을 수 있다. 오프셋이 진행되지 않기 때문이다.
- 몇 번 재시도한 뒤 별도 토픽으로 보낸다. 이걸 DLQ(Dead Letter Queue) 라고 한다
- 스프링에서는
DefaultErrorHandler와DeadLetterPublishingRecoverer로 붙인다 - DLQ 는 만들어 두는 것보다 쌓인 걸 누가 언제 보느냐가 실제 문제다. 보지 않는 DLQ 는 조용한 유실이다
컨슈머 랙
브로커에 쌓인 마지막 메시지와 소비자가 읽은 위치의 차이다. 메시징 운영에서 가장 먼저 보는 지표다.
- 랙이 계속 늘면 소비자가 생산 속도를 못 따라간다는 뜻이다
- 늘리는 방법은 파티션과 소비자를 함께 늘리는 것이다. 소비자만 늘려도 파티션 수를 넘으면 노는 소비자가 생긴다
트랜잭션과의 관계
DB 에 저장하고 메시지를 보내는 두 가지를 하나로 묶을 수 없다는 문제다.
- 저장은 성공했는데 발행 직전에 죽으면 메시지가 안 나간다. 반대면 없는 주문에 대한 메일이 나간다
- 흔한 해법은 아웃박스 패턴(Outbox Pattern) — 메시지를 같은 DB 트랜잭션 안에서 별도 테이블에 저장하고, 별도 프로세스가 그걸 읽어 발행한다
- 입문 단계에서 직접 만들 일은 없지만, “두 시스템에 동시에 쓰는 건 원래 안 된다” 는 것만 알아두면 된다
언제 쓰고, 언제 쓰지 않나
앞 절까지가 메시징이 무엇을 주고 무엇을 요구하는지였다. 그걸 알고 나면 판단은
“좋은 기술인가”가 아니라 “내 상황에서 값을 하는가” 가 된다.
기준은 하나로 정리된다. 부르는 쪽이 결과를 지금 알아야 하는가.
알아야 한다면 메시징은 그 요구를 더 어렵게 만든다.
메시징이 값을 하는 경우
- 결과를 기다릴 이유가 없다 — 메일 발송, 알림, 통계 집계처럼 사용자가 응답에서 확인하지 않는 일이다. 이걸 동기로 붙들고 있는 건 순수한 손해다
- 받는 쪽이 계속 늘어난다 — 주문 완료 하나에 메일·통계·추천·정산이 붙는 식이다. 부르는 쪽을 매번 고치고 있다면 그 수정이 메시징이 없애주는 작업이다
- 트래픽이 튄다 — 이벤트나 배치 시작 시점에 몰리는 구조. 브로커가 완충해 뒤쪽 서비스가 무너지지 않는다
- 뒤쪽 장애가 앞쪽을 죽이면 안 된다 — 메일 서버 때문에 주문을 못 받는 상황을 막는다
- 처리에 시간이 걸린다 — 영상 인코딩, 리포트 생성처럼 초 단위를 넘는 작업
동기 호출이 나은 경우
- 결과를 즉시 보여줘야 한다 — 조회가 대표적이다. 사용자가 화면에서 기다리는 값을 메시징으로 받아오는 건 왕복만 늘린다
- 즉시 일관성이 필요하다 — 잔액 확인 후 차감처럼 순간의 정확함이 중요한 일. 메시징은 기본이 최종 일관성(Eventual Consistency, 언젠가는 맞아지는 것) 이다
- 호출이 한 곳뿐이고 늘어날 계획이 없다 — 바뀌지 않는 것에 유연함을 사면 비용만 남는다
- 운영할 사람이 없다 — 브로커는 애플리케이션이 아니라 인프라다. 디스크·파티션·랙 모니터링·버전 업그레이드가 따라온다. 관리형 서비스를 쓰더라도 장애 시 판단은 팀이 한다
- 팀에 경험이 없고 일정이 촉박하다 — 중복 처리·순서·DLQ 는 나중에 배우는 게 아니라 처음부터 필요하다. 없이 붙이면 조용히 틀린 데이터가 쌓인다
- 디버깅 난이도를 감당하기 어렵다 — 요청 하나가 여러 서비스를 건너뛰며 흐르므로 스택 추적이 끊긴다. 분산 추적(Distributed Tracing)을 같이 준비해야 한다
정리하면
| 동기 호출 | 비동기 메시징 | |
|---|---|---|
| 결과 확인 | 지금 필요하다 | 나중이어도 된다 |
| 받는 쪽 | 하나, 고정 | 여럿, 계속 는다 |
| 일관성 | 즉시 | 최종 |
| 트래픽 | 고르다 | 튄다 |
| 운영 | 추가 없음 | 브로커가 운영 대상이 된다 |
| 팀 상황 | 경험 유무 무관 | 학습 시간이 필요 |
둘 중 하나만 골라야 하는 건 아니다
실무에서는 한 서비스 안에서 섞어 쓴다.
- 조회와 결제 승인처럼 결과가 바로 필요한 것은 동기 호출로 두고
- 메일·통계·추천처럼 곁가지로 붙는 것만 메시징으로 뺀다
이미 동기 호출로 돌아가는 서비스라면 이 방식이 현실적이다. 전부 바꾸지 않고 가장 느리거나 가장 자주 같이 죽는 구간 하나부터 옮겨보면 값을 하는지 직접 확인할 수 있다.
정리
- 비동기 메시징은 직접 부르지 않고 브로커에 맡기는 통신 방식이다. 한 프로세스 안의 비동기와는 다른 층의 이야기다
- 큐는 한 명만 받아 일을 나누고, 발행-구독은 모두가 받아 알린다. Kafka 는 컨슈머 그룹으로 둘을 한 구조에서 만든다
- 실무 기본값은 at-least-once 다. 중복은 막는 게 아니라 멱등성으로 감당한다
- 순서는 파티션 안에서만 지켜진다. 같은 키를 주면 순서가 지켜지고, 대신 병렬 처리가 줄어든다
- 기다리지 않게 되는 대신 결과를 즉시 알 수 없고, 브로커라는 운영 대상이 하나 는다. 더 쉬워지는 게 아니라 문제의 종류가 바뀐다
용어 정리
| 용어 | 한 줄 정의 |
|---|---|
| 브로커(Broker) | 메시지를 맡아 두는 중간 서버. Kafka, RabbitMQ 등 |
| 생산자(Producer) | 메시지를 보내는 쪽 |
| 소비자(Consumer) | 메시지를 꺼내 처리하는 쪽 |
| 큐(Queue) | 메시지 하나를 한 소비자만 가져가는 구조 |
| 토픽(Topic) | 메시지를 구분하는 이름표. 구독자 모두가 받는다 |
| 발행-구독(Pub/Sub) | 구독 중인 모두가 같은 메시지를 각자 받는 방식 |
| 컨슈머 그룹(Consumer Group) | 메시지를 나눠 갖는 소비자 묶음. Kafka 가 큐를 만드는 방법 |
| 파티션(Partition) | 토픽을 나눠 담는 단위. 순서는 이 안에서만 지켜진다 |
| 오프셋(Offset) | 소비자가 어디까지 읽었는지 표시하는 번호 |
| ack | 처리를 마쳤다고 브로커에 보내는 신호 |
| at-least-once | 최소 한 번 도착 보장. 중복될 수 있다 |
| 멱등성(Idempotency) | 여러 번 처리해도 결과가 한 번과 같은 성질 |
| DLQ | 계속 실패한 메시지를 따로 모아 두는 토픽 |
| 컨슈머 랙(Lag) | 쌓인 위치와 읽은 위치의 차이. 밀림 정도 |
| 최종 일관성 | 지금은 달라도 언젠가는 맞아지는 상태 |
| 아웃박스 패턴 | DB 저장과 메시지 발행을 어긋나지 않게 맞추는 방법 |