[Java & Spring] Pub/Sub은 어떻게 컴포넌트를 떼어놓을까

2026. 5. 25. 15:37·Java & Spring
반응형

백엔드 코드를 한참 짜다 보면 어느 순간 이런 코드를 만난다.

 

orderService.create(order);
inventoryService.decrease(order);
pointService.accumulate(order);
notificationService.sendOrderEmail(order);
analyticsService.track(order);

 

처음에는 두 줄이었다. 그러다 어느 날 마케팅이 SMS도 보내달라고 한다. 데이터팀이 추적 이벤트를 더 보내달라고 한다. 결제 팀이 후처리를 끼워달라고 한다. 한 메서드가 다섯 가지 다른 도메인을 알게 되고, 한 명만 실패해도 주문이 통째로 롤백되거나, 비동기로 빼려고 했더니 호출 순서와 트랜잭션 경계가 꼬인다.

 

이쯤 되면 "주문이 생성됐다는 사실만 어딘가에 던지고, 그걸 듣고 싶은 쪽이 알아서 가져가는" 구조가 필요해진다. 그 구조의 이름이 publish/subscribe, 줄여서 Pub/Sub이다. 이 글은 Pub/Sub을 "이벤트 보내고 받는 거"라는 흐릿한 이해에서 끝내지 않고, 어떤 결합도 문제를 풀기 위해 등장했는지, 내부는 어떻게 동작하는지, Kafka·Redis Pub/Sub·RabbitMQ 같은 구체적인 시스템에서 어떻게 다르게 구현되는지까지 짚는다.

 

이 패턴이 필요한 이유

직접 호출 모델(a.call(b))에는 세 가지 결합이 동시에 들어 있다.

 

첫째, 참조 결합이다. OrderService가 NotificationService의 인스턴스를 알아야 호출할 수 있다. 새 후속 처리를 추가할 때마다 OrderService 코드를 고쳐야 한다.

 

둘째, 시간 결합이다. 호출이 끝날 때까지 같은 스레드가 멈춰 있다. 후속 처리 한 곳에서 1초가 걸리면 전체 트랜잭션이 1초 길어진다.

 

셋째, 동기화 결합이다. 호출자와 피호출자가 동시에 살아 있어야 한다. 알림 서버가 잠시 죽어 있으면 주문 처리 자체가 영향을 받는다.

 

Pub/Sub의 목표는 이 세 가지를 한 번에 끊는 것이다. 발행자는 "주문이 생성됐다"는 이벤트만 알리고, 누가 듣는지 신경 쓰지 않는다. 구독자는 자기 페이스로 받아가고, 처리에 실패하면 자기 책임으로 재시도한다. 발행자와 구독자가 같은 순간에 살아 있을 필요도 없다.

 

이 세 가지 분리는 분산 시스템 연구에서는 각각 space decoupling, time decoupling, synchronization decoupling이라고 불린다. Eugster 등이 2003년에 정리한 The Many Faces of Publish/Subscribe 라는 논문이 Pub/Sub을 다른 메시징 패턴과 구분짓는 기준으로 이 세 가지를 든다.

먼저 알아야 할 배경 — Observer 패턴과의 관계

Pub/Sub을 처음 보면 GoF의 Observer 패턴과 거의 같아 보인다. 실제로 둘은 형제 관계다.

Observer 패턴에서는 subject가 자기를 듣고 있는 observer 목록을 직접 들고 있다가, 상태가 바뀌면 그 목록을 순회하며 메서드를 호출한다.

class Order {
    private List<OrderObserver> observers = new ArrayList<>();
    public void register(OrderObserver o) { observers.add(o); }
    public void create() {
        // ...
        for (OrderObserver o : observers) o.onCreated(this);
    }
}

이 구조는 한 프로세스 안에서만 동작한다. observer 목록이 메모리 안에 있고, 호출도 그 자리에서 일어난다. subject가 죽으면 목록도 사라진다.

 

Pub/Sub은 이 관계를 외부 시스템으로 끄집어낸 것이다. observer 목록을 들고 있는 일은 broker 라는 별도의 컴포넌트가 맡고, 호출 대신 메시지 전달 로 바뀐다. subject와 observer는 같은 프로세스, 같은 머신에 있을 필요가 없다. broker가 살아 있는 한 두 쪽의 생명주기가 달라도 된다.

직접 호출 모델과 Pub/Sub 모델의 차이. 직접 호출은 발행자가 모든 수신자를 알아야 하지만, Pub/Sub은 broker가 발행자와 구독자를 연결한다.

 

Pub/Sub의 정의

Pub/Sub은 다음 네 가지 요소로 구성된다.

  • Publisher: 메시지를 발행한다. 어디로 가는지는 알 필요 없고, 어떤 토픽으로 보낼지만 안다.
  • Subscriber: 토픽에 구독을 걸어두고 메시지를 받는다.
  • Broker: 메시지를 받아서 라우팅한다. 시스템에 따라 잠시 또는 영구적으로 메시지를 보관한다.
  • Topic(또는 subject, channel): 메시지의 분류 키. 같은 토픽으로 발행된 메시지는 같은 구독자 집합에 전달된다.

이 모델은 발행자와 구독자가 서로의 존재를 모른 채로 통신하게 한다는 점이 핵심이다. 발행자는 broker의 주소와 토픽 이름만 알면 되고, 구독자도 broker와 토픽 이름만 알면 된다. 둘은 서로 다른 시간, 다른 머신, 다른 언어여도 된다.

구독 방식은 크게 세 종류로 나뉜다.

  • Topic-based: 사전에 정의된 토픽 이름으로 구독한다. JMS Topic, Kafka topic, NATS subject, MQTT topic이 모두 여기 속한다.
  • Content-based: 메시지의 내용을 조건식으로 구독한다(price > 10000 AND symbol = "AAPL" 같은). JMS의 message selector나 일부 ESB가 지원한다.
  • Type-based: 메시지의 타입을 기준으로 구독한다. 객체지향 메시징에서 가끔 보인다.

실제로 가장 많이 쓰이는 건 topic-based다. 일부 시스템은 토픽 이름을 계층으로 두고 wildcard로 한꺼번에 구독할 수 있게 한다. MQTT에서는 home/+/temperature로 한 단계 와일드카드를, home/#로 다단계 와일드카드를 쓸 수 있고, NATS에서는 orders.*.created, orders.> 같은 형태를 쓴다.

 

broker가 실제로 하는 일

broker는 그림에서는 흔히 동그라미 하나로 그려지지만, 실제로는 몇 가지 일을 동시에 한다. 먼저 구독 테이블을 유지한다. "이 토픽에는 누가 구독 중이다"라는 매핑을 메모리(또는 디스크)에 들고 있다. publish 요청이 들어오면 이 테이블을 보고 누구에게 보낼지 결정한다.

 

다음으로 메시지 버퍼를 관리한다. 구독자가 한 명도 없거나 느린 경우, 메시지를 어딘가 잠시 들고 있어야 한다. 시스템에 따라 이 보관 정책이 완전히 다르다. Redis Pub/Sub은 들고 있지 않고 바로 버린다. Kafka는 디스크 로그에 영속화해서 retention 기간 동안 보관한다. RabbitMQ는 queue에 쌓아두고 ack를 받아야 지운다. 또한 장애 복구를 처리한다. broker가 클러스터링되어 있으면 메시지를 복제하고, 한 노드가 죽어도 다른 노드에서 같은 메시지를 읽을 수 있게 한다.

 

broker가 들고 있는 구독 테이블, 메시지 버퍼, 라우팅 로직의 개념도.

 

여기서 한 번 짚어둘 점이 있다. "broker" 라는 단어는 시스템마다 다른 무게를 가진다. Redis 인스턴스 하나가 broker 역할을 할 때와, Kafka 클러스터 수십 대가 broker 역할을 할 때, 같은 단어를 쓰지만 안에서 일어나는 일은 굉장히 다르다. 그래서 "Pub/Sub을 쓴다"는 말은 거의 의미가 없고, "어떤 보장 수준과 어떤 보존 정책의 Pub/Sub을 쓰느냐"를 항상 같이 말해야 한다.

 

메시지가 도달하기까지의 흐름

가장 일반적인 흐름은 다음과 같다.

  1. publisher가 broker에 연결되어 있다.
  2. publisher가 토픽 이름과 메시지 본문을 담아 publish 요청을 보낸다.
  3. broker는 토픽의 구독 테이블을 본다.
  4. 구독자 목록에 따라 메시지를 복제하거나 라우팅한다.
  5. 구독자에게 push로 밀어주거나, 구독자의 pull 요청을 기다린다.
  6. 구독자가 메시지를 처리하고, 시스템 정책에 따라 ack/commit을 보낸다.
  7. broker는 ack를 보고 메시지의 운명을 결정한다(삭제, 유지, 재전송).

 

이 흐름에서 시스템마다 다른 결정 포인트가 두 군데 있다.

 

push vs pull. broker가 구독자에게 능동적으로 밀어주는 모델과, 구독자가 알아서 가져가는 모델이 있다. Redis Pub/Sub, MQTT, RabbitMQ는 push 중심이다. Kafka, 매니지드 Pub/Sub 서비스는 pull 중심이다(Kafka는 long-poll 방식으로 거의 push처럼 보이지만 내부는 consumer.poll이다).

 

push는 지연이 짧지만 슬로우 컨슈머가 broker에 압박을 준다. pull은 구독자가 자기 페이스로 받아갈 수 있어 backpressure를 자연스럽게 만들지만, 빈 토픽에서도 주기적으로 폴링하는 비용이 든다.

 

언제 메시지를 삭제하느냐. 구독자가 받고 나면 삭제하는 모델(queue 계열)과, 시간/용량 기반 retention으로 보관하는 모델(로그 계열)이 갈린다. 후자는 새 구독자가 들어와서 처음부터 다시 읽을 수 있다는 강력한 성질을 가진다.

publisher → broker → subscriber로 이어지는 일반적인 메시지 흐름과, push 모델과 pull 모델의 차이.

 

전달 보장 — at-most-once / at-least-once / exactly-once

Pub/Sub에서 가장 자주 오해되는 부분이 "메시지가 잘 전달되는가"이다. 분산 시스템에서는 다음 세 가지 보장이 표준 분류다.

 

at-most-once 는 "최대 한 번"이다. 잃어버릴 수는 있어도 같은 메시지를 두 번 전달하지는 않는다. 메시지 손실이 허용되는 시나리오(실시간 시세, 알림 미리보기 등)에서 가장 빠르다.

 

at-least-once 는 "최소 한 번"이다. 메시지는 반드시 전달되지만, 네트워크 지연이나 재시도 때문에 같은 메시지가 두 번 이상 전달될 수 있다. 이 모드에서는 구독자가 멱등성 을 책임져야 한다.

 

exactly-once 는 "정확히 한 번"이다. 분산 환경에서는 일반적으로 만들기 어렵고, 특정 시스템이 특정 조건 아래에서만 제공한다.

전달 보장은 broker 하나의 속성이 아니라 다음 세 구간의 곱이다.

  • publisher → broker 구간
  • broker 안에서 복제·영속화
  • broker → subscriber 구간

"exactly-once delivery"라고 마케팅하는 시스템도 자세히 보면 한 구간에서만 보장하는 경우가 많다. 운영자는 항상 "어느 구간의 exactly-once인가"를 확인해야 한다. 대표 시스템이 어디에 해당하는지 짧게 정리하면 다음과 같다.

 

시스템 기본 보장 비고
Redis Pub/Sub at-most-once broker에 메시지를 보관하지 않음
Kafka at-least-once (기본) idempotent producer + transaction으로 사실상 exactly-once
RabbitMQ at-least-once (ack 사용 시) publisher confirm + consumer ack 필요
MQTT QoS 0/1/2로 선택 broker ↔ client 구간 한정

 

여기서 표를 짧게 쓴 이유는, 보장 수준이 단순한 비교가 아니라 위에서 말한 "어느 구간"에 따라 달라지기 때문이다. 표 한 줄로 끝낼 수 없다.

구현체별 내부 모델

같은 Pub/Sub이라는 이름을 달고 있어도 내부 구조는 셋 다 다르다. 가장 많이 쓰이는 세 가지를 짚어보자.

Redis Pub/Sub

Redis Pub/Sub의 모델은 가장 단순하다. broker는 활성 구독자의 목록만 들고 있다. PUBLISH chat.room1 "hello" 명령이 들어오면 서버는 chat.room1을 구독 중인 클라이언트 객체를 전부 찾아서 메시지를 그 클라이언트의 출력 버퍼에 써준다. 그것으로 끝난다.

 

메시지를 어디에도 보관하지 않는다. 구독자가 잠시 끊겨 있던 시간 동안의 메시지는 사라진다. 클러스터 모드에서는 같은 메시지를 모든 노드에 internal 메시지로 전파한다. 트래픽이 큰 채널은 이 비용이 무시할 수 없어서, Redis 7.0부터는 sharded pub/sub (SSUBSCRIBE/SPUBLISH)가 추가되어 같은 해시 슬롯 안에서만 전파된다.

 

보장은 at-most-once. 슬로우 컨슈머가 client-output-buffer-limit pubsub 한도를 넘으면 broker가 그 클라이언트 연결을 강제로 끊는다. 이런 성질 때문에 Redis Pub/Sub은 "지금 이 순간의 알림" 용도에 잘 맞는다. 보관이 필요하면 Redis Streams가 별도로 있다.

Kafka

Kafka는 broker가 아니라 분산 로그다. 이 한 줄이 다른 모든 차이를 만든다.

 

토픽은 N개의 partition 으로 나뉜다. 각 partition은 append-only log 파일이다. publisher가 메시지를 보내면 broker는 그 메시지를 partition의 끝에 추가하고, offset을 매긴다. 메시지는 그 자리에 retention 기간(또는 용량)만큼 그대로 남는다.

 

소비는 consumer group 단위로 일어난다. 같은 그룹 안에서는 한 partition을 한 consumer만 읽는다(파티션 분할). 다른 그룹은 같은 partition을 독립적으로 읽는다. 즉 publisher 하나가 발행한 메시지를 100명이 듣고 싶다면, consumer group을 100개 만들면 된다.

 

partition 내에서는 순서가 보장된다. 토픽 전체의 글로벌 순서는 보장되지 않는다. partition 분할 키(partition key)를 잘못 잡으면 같은 엔티티의 이벤트가 다른 partition으로 흩어져 순서가 꼬인다.

 

새 consumer group이 들어와서 earliest 부터 다시 읽을 수 있다. 이게 Kafka의 강력한 성질이다. acks=all + enable.idempotence=true + transactional producer를 사용하면 producer → broker 구간에서 사실상 exactly-once가 된다.

 

Kafka를 단순한 메시지큐라고 부르기 어려운 이유가 여기에 있다. 메시지를 한 번 읽고 버리는 큐가 아니라, 이벤트 로그를 어디서부터 어떻게 읽을지 구독자가 결정할 수 있는 구조다.

RabbitMQ

RabbitMQ는 AMQP 0-9-1 모델을 따른다. 핵심 객체는 exchange, queue, binding 세 가지다.

 

publisher는 exchange에 메시지를 보낸다. exchange는 binding 규칙에 따라 메시지를 0개 이상의 queue로 라우팅한다. consumer는 queue에 붙어서 메시지를 받는다.

 

Pub/Sub은 fanout exchange로 만든다. fanout exchange는 자기에게 바인딩된 모든 queue로 같은 메시지를 복제한다. 구독자별로 자기 queue를 만들고 같은 fanout exchange에 바인딩하면, 그 구독자 모두가 발행된 메시지를 받는다. queue 안의 메시지는 그 queue를 듣는 consumer 중 하나에게만 전달된다(라운드로빈). queue 자체는 점대점이다. 메시지에 ack를 사용하면 at-least-once. ack 없이 처리만 하면 메시지가 손실될 수 있다. publisher confirm을 켜면 publisher → broker 구간의 손실을 검출할 수 있다.

 

RabbitMQ는 "큐를 어떻게 조합해서 라우팅 그래프를 만들 것인가"가 설계의 중심이다. Kafka의 "로그 위에서 어떻게 읽을 것인가"와는 완전히 다른 정신이다.

Redis Pub/Sub의 fire-and-forget, Kafka의 partition log + consumer group, RabbitMQ의 fanout exchange + queue 비교.

 

코드로 보는 Pub/Sub

Spring 기반 백엔드에서 Pub/Sub을 처음 도입할 때 자주 그리는 흐름을 코드로 보자. 주문 생성 후 후속 처리들이 직접 호출로 묶여 있을 때, 이를 이벤트로 풀어내는 과정이다.

잘못된 코드: 직접 호출에 묶인 후속 처리

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final InventoryService inventoryService;
    private final PointService pointService;
    private final NotificationService notificationService;
    private final AnalyticsClient analyticsClient;

    @Transactional
    public Order create(OrderCommand command) {
        Order order = orderRepository.save(Order.from(command));

        inventoryService.decrease(order);
        pointService.accumulate(order);
        notificationService.sendOrderEmail(order);
        analyticsClient.track("order_created", order);

        return order;
    }
}

이 코드의 문제는 세 가지다.

 

첫째, OrderService가 후속 처리 네 종류를 전부 알고 있다. 새 후속 처리가 추가될 때마다 OrderService가 변경된다(개방-폐쇄 원칙 위반).

 

둘째, 후속 처리가 같은 트랜잭션 안에 있어 한 곳만 느려도 트랜잭션 시간이 길어지고, 한 곳만 실패해도 주문 자체가 롤백된다. "재고는 빠졌는데 알림 SMTP 일시 장애로 주문이 통째로 롤백된다"는 상황이 생긴다.

 

셋째, AnalyticsClient가 외부 HTTP 호출이라면 DB 트랜잭션이 외부 네트워크 호출에 묶인다. 외부 응답이 5초씩 걸리는 순간 트랜잭션 holding time이 폭발한다.

 

개선 코드: 이벤트 발행과 비동기 구독

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final KafkaTemplate<String, OrderCreatedEvent> kafkaTemplate;

    @Transactional
    public Order create(OrderCommand command) {
        Order order = orderRepository.save(Order.from(command));

        OrderCreatedEvent event = OrderCreatedEvent.from(order);
        kafkaTemplate.send("order.created", order.getId(), event);

        return order;
    }
}
@Component
public class InventoryEventListener {

    private final InventoryService inventoryService;

    @KafkaListener(topics = "order.created", groupId = "inventory")
    public void on(OrderCreatedEvent event) {
        inventoryService.decrease(event.toOrderRef());
    }
}

@Component
public class NotificationEventListener {

    private final NotificationService notificationService;

    @KafkaListener(topics = "order.created", groupId = "notification")
    public void on(OrderCreatedEvent event) {
        notificationService.sendOrderEmail(event.toOrderRef());
    }
}

OrderService는 이제 "주문이 생성됐다"는 사실만 발행한다. 새 후속 처리는 새 listener를 추가하는 것으로 끝난다. OrderService는 건드리지 않는다. 이 구조는 직접 호출보다 강력하지만, 동시에 새로운 책임을 만든다.

 

트랜잭션과 이벤트 발행의 경계 — outbox 패턴

위 코드에는 미묘한 문제가 하나 있다. kafkaTemplate.send()는 트랜잭션 커밋 전에 Kafka로 메시지가 나간다. 만약 send 직후 트랜잭션이 롤백되면 "DB에는 주문이 없는데 이벤트는 발행된" 상태가 된다.

 

이 문제는 표준 해결 패턴이 있다. transactional outbox 패턴 이라고 부르고, 흐름은 다음과 같다.

  1. 주문 저장과 같은 트랜잭션 안에서 outbox 테이블에도 이벤트를 INSERT한다.
  2. 별도 워커(또는 CDC 도구)가 outbox 테이블을 폴링하거나 변경 로그를 읽어서 broker로 발행한다.
  3. broker 발행이 성공하면 outbox row를 처리 완료로 표시한다.
@Transactional
public Order create(OrderCommand command) {
    Order order = orderRepository.save(Order.from(command));

    outboxRepository.save(OutboxRecord.builder()
        .topic("order.created")
        .key(order.getId().toString())
        .payload(OrderCreatedEvent.from(order))
        .build());

    return order;
}

 

이렇게 하면 DB 트랜잭션과 이벤트 발행 의도가 같은 commit 경계 안에 들어간다. 트랜잭션이 롤백되면 outbox 레코드도 같이 사라진다. Spring 환경에서 좀 더 가벼운 대안으로는 @TransactionalEventListener(phase = AFTER_COMMIT) 이 있다. 커밋 후에만 이벤트 핸들러가 실행되도록 보장하는데, 이건 인프로세스 이벤트(ApplicationEventPublisher)에 한해서 적용되고, broker 발행 자체의 신뢰성은 별도로 챙겨야 한다는 점이 다르다.

 

운영하면서 부딪히는 함정들

코드 모양만 보면 Pub/Sub은 깔끔해 보인다. 실제로 운영해 보면 다음 네 가지가 거의 반드시 등장한다.

슬로우 컨슈머와 lag

가장 흔한 사고 패턴이다. 구독자가 처리 속도를 따라가지 못해 lag이 누적된다.

 

Kafka에서는 consumer_lag 메트릭으로 잡힌다. lag이 retention보다 길어지면 그 메시지는 영영 소실된다. RabbitMQ에서는 queue length가 늘어나서 메모리/디스크 alarm이 발생한다. Redis Pub/Sub에서는 output buffer가 한도를 넘으면 broker가 연결을 끊어 그 사이 메시지가 사라진다.

 

대응 패턴은 일관되어 있다. 컨슈머 인스턴스를 늘려서 처리량을 높이거나, 메시지를 더 잘게 나누거나(예: aggregation 윈도우 축소), 처리 자체를 비동기 워커풀로 분리해 컨슈머 스레드가 빨리 다음 메시지로 넘어가게 한다. consumer 스레드 안에서 외부 API를 동기 호출하는 패턴은 거의 항상 lag의 원흉이다.

 

멱등성

at-least-once 시스템을 쓰는 한, 같은 메시지가 두 번 도착하는 것은 정상이다. consumer 재시작, rebalance, 네트워크 재전송, broker 장애 복구 등 여러 경로로 중복이 만들어진다.

 

해결 패턴은 "처리한 메시지의 id를 어딘가에 기록하고, 같은 id가 다시 오면 무시"하는 것이다. 메시지에 producer 측에서 고유 id를 박아주고, consumer는 처리 전에 processed_event 같은 테이블에 unique 제약으로 INSERT를 시도한다. unique 위반이 나면 이미 처리된 것이다.

 

외부 효과가 있는 처리(외부 결제, 외부 SMS)는 그 외부 시스템에도 idempotency key를 전달해야 한다. 우리 쪽에서 중복 차단을 했더라도, 우리가 외부에 두 번 요청했다면 외부에서 두 번 처리된다.

메시지 순서

partition / subject / ordering key 단위로만 순서가 보장된다는 점을 자주 잊는다. "같은 사용자의 이벤트는 항상 같은 partition으로 가야 한다"는 요구가 있으면, partition key를 사용자 id로 설정해야 한다. 그렇지 않으면 같은 사용자의 order.created 와 order.paid 가 서로 다른 partition으로 흩어져, consumer가 order.paid를 먼저 받을 수 있다.

 

반대로 partition key를 너무 좁게 잡으면 hot partition 문제가 생긴다. 트래픽이 한 사용자에게 집중되는 케이스가 있으면 그 partition을 처리하는 consumer만 lag이 쌓인다. partition key는 "순서 보장 단위"와 "트래픽 분산 단위" 사이의 트레이드오프다.

처리 실패와 DLQ

영원히 실패하는 메시지(poison message) 한 건 때문에 전체 consumer가 막히는 사고가 종종 일어난다. 어떤 필드 포맷이 깨져 있어서 deserialize 단계부터 예외가 나는 상황이 대표적이다.

 

이 경우의 표준 대응은 dead letter queue (DLQ) 다. 일정 횟수 재시도 후에도 실패하면 그 메시지를 별도 DLQ 토픽으로 보내고, 본 처리 흐름은 다음 메시지로 진행한다. DLQ에 쌓인 메시지는 사람이 보고 결정한다. DLQ 자체에 다시 lag이 쌓이면 운영팀에 알람이 가야 한다. DLQ가 없는 시스템은 운영 중에 반드시 한 번은 멈춘다.

Pub/Sub이 어울리지 않는 자리

마지막으로, Pub/Sub이 만능이 아니라는 점을 짚어둔다.

 

사용자가 버튼을 누르고 결과를 바로 받아야 하는 즉시 응답이 필요한 동기 요청에는 어울리지 않는다. 그건 RPC/HTTP의 영역이다. 같은 DB 안에서 두 테이블을 함께 갱신해야 하는 트랜잭션이 한 단위로 묶여야 하는 처리도 이벤트로 풀면 안 된다. 같은 트랜잭션 안에서 처리해야 한다.

 

컴포넌트가 두세 개뿐인 소규모 시스템에서 Pub/Sub을 끼우는 건 오히려 복잡도만 늘린다. broker 운영 비용, 모니터링 비용, 디버깅 비용이 같이 따라온다. Pub/Sub은 컴포넌트 사이의 결합을 "런타임 시점이 아니라 토픽 계약 시점"으로 옮기는 도구다. 결합 자체가 사라지는 게 아니라, 결합의 위치와 모양이 바뀐다. 이 트레이드오프가 맞는 자리에서만 그 가치를 발휘한다.

마무리

처음에 보였던 다섯 줄짜리 직접 호출 코드로 다시 돌아가 보자. 그 코드의 진짜 문제는 줄 수가 많은 게 아니라, OrderService 한 곳에 참조 결합·시간 결합·동기화 결합이 동시에 들어 있었다는 점이다. Pub/Sub은 이 세 가지를 broker라는 중간 계층으로 외부화하고, 토픽 이름이라는 얇은 계약 위에서 발행자와 구독자가 서로를 모르게 만든다.

 

이 패턴 자체는 1990년대부터 있었지만, 현대 백엔드에서 다시 중요해진 이유는 마이크로서비스, 이벤트 소싱, 실시간 fan-out 같은 구조들이 전부 Pub/Sub을 기본 인프라로 깔고 가기 때문이다. Kafka, Redis Pub/Sub, RabbitMQ 같은 시스템들은 같은 이름의 패턴을 구현하지만, 보존 정책과 전달 보장과 라우팅 모델에서 완전히 다른 결정을 내린 도구들이다. "Pub/Sub을 쓰자" 보다는 "어떤 모양의 Pub/Sub이 우리 문제에 맞는가" 가 실제 설계 질문이다.

참고자료

  • Eugster, Felber, Guerraoui, Kermarrec — The Many Faces of Publish/Subscribe, ACM Computing Surveys 35(2), 2003.
  • Gamma, Helm, Johnson, Vlissides — Design Patterns: Elements of Reusable Object-Oriented Software, 1994. Observer pattern.
  • Apache Kafka 공식 문서 — https://kafka.apache.org/documentation/
  • Apache Kafka KIP-98: Exactly Once Delivery and Transactional Messaging.
  • AMQP 0-9-1 Specification.
  • MQTT Version 5.0 OASIS Standard.
  • Redis 공식 문서 — Pub/Sub, Streams.
  • NATS 공식 문서 — Subjects, JetStream.
  • Chris Richardson — Microservices Patterns, Manning, 2018. Transactional outbox pattern.
반응형

'Java & Spring' 카테고리의 다른 글

[Java & Spring] 실시간 주식 차트는 어떻게 만들까 / feat.시세 fan-out 아키텍처  (1) 2026.05.22
[Java] JVM 메모리 구조는 실제 서버 성능과 어떤 관계가 있을까  (0) 2026.05.08
[Java & Spring] 낙관적 락, 비관적 락 (with. JPA와 AOP)  (6) 2025.08.02
[Java & Spring] ReentrantLock, 쿠폰 발급으로 실습하고 테스트 코드로 증명해보기  (0) 2025.07.02
[Java] ReentrantLock 정리 (with. synchronized)  (0) 2025.06.27
'Java & Spring' 카테고리의 다른 글
  • [Java & Spring] 실시간 주식 차트는 어떻게 만들까 / feat.시세 fan-out 아키텍처
  • [Java] JVM 메모리 구조는 실제 서버 성능과 어떤 관계가 있을까
  • [Java & Spring] 낙관적 락, 비관적 락 (with. JPA와 AOP)
  • [Java & Spring] ReentrantLock, 쿠폰 발급으로 실습하고 테스트 코드로 증명해보기
Penguin Dev
Penguin Dev
What does the Penguin say?
    글쓰기 관리
  • Penguin Dev
    Pengha!
    Penguin Dev
  • 전체
    오늘
    어제
    • 분류 전체보기 (157) N
      • Java & Spring (9)
      • Redis (1) N
      • System Hacking (4)
      • Algorithm (8)
        • Sorting algorithm (3)
      • Python (6)
      • Web (2)
        • Web Hacking & Security (2)
      • Write-Up (108)
        • pwnable.kr (17)
        • HackCTF (16)
        • 해커스쿨 FTZ (21)
        • LOB(lord of bufferoverflow) (19)
        • LOS (lord of sql injection) (28)
        • XSS-game (6)
        • Webhacking.kr (1)
      • SUA (19)
        • 오픈소스 보안 (19)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    computeifabsent
    putval()
    코드트리조별과제
    reentrantlock실습
    spring
    CountDownLatch
    비관적 락
    쿠폰발급
    nop sled
    computeifpresent()
    enumerate #list comprehension
    spring boot
    CacheHit
    computeifabsent()
    Lock
    lord of bufferoverflow
    computeifpresent
    tabat
    ConcurrentHashMap
    동시성처리
    ReentrantLock
    DB정리
    SpringBoot
    thread-safe
    낙관적 락
    Java
    hashmap vs concurrenthashmap
    concurrenthashmap vs hashmap
    코드트리
    동시성
  • 최근 댓글

  • 반응형
  • hELLO· Designed By정상우.v4.10.3
Penguin Dev
[Java & Spring] Pub/Sub은 어떻게 컴포넌트를 떼어놓을까
상단으로

티스토리툴바