Messaging

비동기 메시징 입문 - 동기 호출과 무엇이 다른가

  • #messaging
  • #kafka
  • #spring
  • #backend

비동기 메시징 이란


서비스끼리 직접 부르지 않고, 중간에 둔 저장소에 메시지를 넣어 주고받는 방식

  • 보내는 쪽은 메시지를 넣고 바로 자기 일을 끝낸다. 받는 쪽이 지금 살아 있는지 신경 쓰지 않는다
  • 받는 쪽은 자기 속도로 꺼내서 처리한다
  • 중간에서 메시지를 맡아 두는 것을 브로커(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) 라고 한다
  • 스프링에서는 DefaultErrorHandlerDeadLetterPublishingRecoverer 로 붙인다
  • 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 저장과 메시지 발행을 어긋나지 않게 맞추는 방법