Observer 패턴과 Pub/Sub의 차이점

1. 개요

Observer와 Pub/Sub는 모두 ’이벤트가 발생하면 여러 곳에서 그것을 알아야 한다’는 문제를 해결합니다. 많은 개발자가 두 용어를 혼용하는데, 아이디어는 비슷하지만 구현 방식과 coupling 수준에서는 다릅니다. 이 글은 두 개념을 명확히 구분하고, 각각에 대한 Java 코드 예제를 제공하며, 순수한 Observer를 선택해야 할 때와 Pub/Sub를 message broker로 구현해야 할 때를 알려줍니다.

2. Observer 패턴이란

Observer는 GoF의 고전적인 행동 패턴(behavioral pattern) 중 하나입니다. 개념은 다음과 같습니다: Subject(또는 Publisher)가 Observer 목록을 관리하고, 상태가 변경되면 Subject가 등록된 각 Observer의 메서드를 직접 호출합니다.

public interface OrderObserver {
    void onOrderPaid(Order order);
}

public class Order {
    private final List<OrderObserver> observers = new ArrayList<>();
    private String status = "NEW";

    public void addObserver(OrderObserver observer) {
        observers.add(observer);
    }

    public void markAsPaid() {
        this.status = "PAID";
        for (OrderObserver observer : observers) {
            observer.onOrderPaid(this);
        }
    }
}

설명:

  • observers는 관심 있는 객체들에 대한 직접적인 참조 목록입니다.
  • markAsPaid()는 같은 thread 내에서 각 observer의 onOrderPaid()를 순서대로 동기적으로 호출합니다.
  • Order는 정확히 누가 자신을 구독하고 있는지 알고 있습니다 — 이것이 Pub/Sub와 구분하는 핵심 포인트입니다.
public class EmailNotifier implements OrderObserver {
    @Override
    public void onOrderPaid(Order order) {
        System.out.println("주문 결제 확인 이메일 발송");
    }
}
Order order = new Order();
order.addObserver(new EmailNotifier());
order.markAsPaid();
주문 결제 확인 이메일 발송

2.1. Observer의 핵심 특징

  • 직접 Coupling: Subject가 Observer에 대한 참조를 유지하며, 서로의 interface를 import해야 합니다.
  • 동기: notify 호출은 모든 observer의 처리가 완료될 때까지 차단됩니다.
  • 같은 프로세스 내: 일반적으로 하나의 JVM 내에서 실행되며, 네트워크를 거치지 않습니다.
  • Subject가 observer를 알고 있음: removeObserver()를 호출하지 않으면, Subject가 더 이상 사용하지 않는 observer에 대한 ‘활성’ 참조를 유지하기 때문에 메모리 누수가 발생할 수 있습니다.

주의: 고전적인 Observer 패턴은 안전한 unsubscribe 메커니즘이 기본으로 제공되지 않습니다. 오래 실행되는 애플리케이션(예: UI 컴포넌트, listener 등록 후 제거)에서 사용하는 경우, 객체가 소멸할 때 removeObserver()를 호출하여 메모리 누수를 방지해야 합니다.

3. Pub/Sub란

Pub/Sub(Publish/Subscribe)는 Publisher와 Subscriber를 중간 계층(broker, event bus 또는 topic이라고 부름)을 통해 완전히 분리합니다. Publisher는 누가 구독하고 있는지 알지 못하고, 단지 topic이나 channel에 메시지를 ’발행’할 뿐입니다.

public record OrderPaidEvent(String orderId, double amount) {}
public class OrderService {
    private final EventBus eventBus;

    public OrderService(EventBus eventBus) {
        this.eventBus = eventBus;
    }

    public void payOrder(String orderId, double amount) {
        // 결제 처리...
        eventBus.publish("order.paid", new OrderPaidEvent(orderId, amount));
    }
}

설명:

  • OrderService는 EmailNotifier나 InventoryService가 존재하는지 알지 못합니다.
  • order.paid 토픽에 발행하기만 하고, 나머지는 broker의 책임입니다.
  • Subscriber는 broker에 등록하며, OrderService에 직접 등록하지 않습니다.

3.1. Spring Events를 이용한 프로세스 내 Pub/Sub

Spring Boot는 ApplicationEventPublisher를 사용하는 간단한 event bus를 기본으로 제공합니다. 이것은 애플리케이션 수준의 Pub/Sub이며, Kafka/RabbitMQ 같은 외부 broker가 필요하지 않습니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
</dependency>
public class OrderPaidEvent extends ApplicationEvent {
    private final String orderId;

    public OrderPaidEvent(String orderId) {
        super(orderId);
        this.orderId = orderId;
    }

    public String getOrderId() {
        return orderId;
    }
}
@Service
public class OrderService {
    private final ApplicationEventPublisher publisher;

    public OrderService(ApplicationEventPublisher publisher) {
        this.publisher = publisher;
    }

    public void payOrder(String orderId) {
        publisher.publishEvent(new OrderPaidEvent(orderId));
    }
}
@Component
public class EmailEventListener {

    @EventListener
    public void handle(OrderPaidEvent event) {
        System.out.println("주문 " + event.getOrderId() + "에 이메일 발송");
    }
}

설명:

  • OrderService는 ApplicationEventPublisher를 통해 event를 발행합니다 — Spring이 관리하는 bean으로, 내부 broker의 역할을 합니다.
  • EmailEventListener는 OrderService가 알지 못합니다. Spring이 @EventListener가 있는 메서드를 자동으로 스캔하고 type에 따라 올바른 handler로 event를 라우팅합니다.
  • 기본적으로 @EventListener는 publisher와 같은 thread에서 동기적으로 실행됩니다 — 이것은 Pub/Sub가 항상 비동기라고 생각하는 사람들이 혼동하기 쉬운 부분입니다.

진정한 비동기 실행을 원한다면 @Async를 추가하세요:

@Component
public class EmailEventListener {

    @Async
    @EventListener
    public void handle(OrderPaidEvent event) {
        System.out.println("주문 " + event.getOrderId() + "에 이메일 발송");
    }
}
@Configuration
@EnableAsync
public class AsyncConfig {
}
  • @EnableAsync는 proxy를 활성화하여 Spring이 @Async 메서드를 별도의 thread pool에서 실행하도록 합니다.
  • 이제 payOrder()는 즉시 반환되며, EmailEventListener의 처리가 완료될 때까지 기다리지 않습니다.

3.2. Message Broker를 통한 Pub/Sub (Kafka)

더 큰 규모에서는 ’broker’가 더 이상 JVM 내의 bean이 아니라 독립적인 시스템(Kafka, RabbitMQ, SQS 등)입니다. Publisher와 subscriber는 서로 다른 service, process, 또는 다른 머신에 있을 수 있습니다.

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
@Service
public class OrderService {
    private final KafkaTemplate<String, OrderPaidEvent> kafkaTemplate;

    public OrderService(KafkaTemplate<String, OrderPaidEvent> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    public void payOrder(String orderId, double amount) {
        kafkaTemplate.send("order-paid-topic", new OrderPaidEvent(orderId, amount));
    }
}
@Component
public class EmailConsumer {

    @KafkaListener(topics = "order-paid-topic", groupId = "email-service")
    public void consume(OrderPaidEvent event) {
        System.out.println("이메일 서비스가 주문 " + event.orderId() + " 처리");
    }
}
  • OrderService와 EmailConsumer는 완전히 다른 microservice에 있을 수 있으며, 독립적으로 deploy됩니다.
  • Kafka는 메시지를 토픽에 보관하고 consumer가 자신의 속도로 읽습니다 — 이것이 진정한 Pub/Sub이며, buffer, replay, 그룹별 독립적인 여러 subscriber를 지원합니다.

4. 직접 비교

기준 Observer Pub/Sub
Coupling Subject가 Observer를 직접 알고 있음 (interface를 통해) Publisher가 Subscriber의 존재를 모름
중간 계층 없음, 메서드 직접 호출 broker/event bus가 중간에 있음
동기/비동기 기본적으로 동기, 같은 thread 동기(Spring Event) 또는 비동기(Kafka, RabbitMQ) 가능
범위 하나의 process/module 내 process 내(event bus) 또는 여러 service/process에 걸쳐
메시지 지속성 없음, Subject가 죽으면 메시지도 손실됨 외부 broker(Kafka)가 저장 가능, replay 허용
Publisher-Subscriber 수 일반적으로 1 Subject - 여러 Observer 여러 Publisher - 여러 topic을 통한 여러 Subscriber

가장 중요한 차이점: Observer는 두 객체 간의 직접적인 관계이고, Pub/Sub는 익명의 중간 계층을 통한 관계입니다. 본질적으로 Pub/Sub는 broker를 삽입하여 직접적인 의존성을 제거함으로써 Observer 패턴이 ‘느슨해진’ 버전이라고 볼 수 있습니다.

5. Observer와 Pub/Sub: 언제 사용할 것인가

Observer를 사용하는 경우:

  • logic이 같은 module/class 내에 있고, observer의 수가 고정되어 거의 변경되지 않는 경우
  • 즉각적인 동기 응답이 필요한 경우(예: 저장 전 데이터 검증)
  • 과도한 decoupling이 필요 없는 경우 — 예를 들어 UI 컴포넌트가 model의 변경을 감시하는 경우
class ValidationObserver implements OrderObserver {
    @Override
    public void onOrderPaid(Order order) {
        if (order.getStatus() == null) {
            throw new IllegalStateException("Order에 유효한 status가 없습니다");
        }
    }
}

Pub/Sub를 사용하는 경우:

  • 여러 독립적인 service가 하나의 이벤트를 알아야 하지만 서로 의존하면 안 되는 경우
  • publisher의 code를 변경하지 않고 subscriber를 추가/제거할 수 있어야 하는 경우
  • 메시지 지속성이 필요한 경우(subscriber가 다운되어 있어도 메시지가 기다렸다가 online이 될 때 전달)
  • 시스템이 microservices이고 네트워크를 통해 통신하는 경우

주의: 같은 JVM 내의 List<Observer>를 대체하기 위해서만 Kafka/RabbitMQ를 사용하지 마세요 — broker 운영 비용이 작은 문제에는 맞지 않습니다. 반대로 cross-service logic을 순수한 Observer 패턴에 밀어넣으려고 하지 마세요. 왜냐하면 broker가 이미 제공하는 network, retry, serialization 메커니즘을 직접 다시 구현해야 하기 때문입니다.

6. Observer 행동 테스트

class OrderObserverTest {

    @Test
    void shouldNotifyAllObserversWhenOrderIsPaid() {
        Order order = new Order();
        List<String> notified = new ArrayList<>();

        order.addObserver(o -> notified.add("email"));
        order.addObserver(o -> notified.add("inventory"));

        order.markAsPaid();

        assertThat(notified).containsExactly("email", "inventory");
    }
}

설명:

  • interface가 메서드 하나만 가지고 있으므로 OrderObserver를 구현하는 class 대신 lambda를 사용합니다 — test를 작성할 때 더 간결합니다.
  • assertThat(...).containsExactly(...)는 observer 호출 순서가 등록 순서와 일치하는지 확인합니다 — 이것은 동기 Observer의 중요한 특징이며, broker를 통한 Pub/Sub는 보장할 수 없습니다(Kafka는 여러 consumer instance에서 병렬로 처리할 수 있음).

7. 자주 발생하는 실수

  • Spring @EventListener를 비동기라고 착각하기: 기본적으로 동기적으로 실행되고, listener의 오류는 처리되지 않으면 publisher로 exception이 전파됩니다.
  • Observer 참조를 유지하면서 제거를 잊음: 메모리 누수 발생, 특히 객체 생명주기가 짧은(request-scope) 애플리케이션에서 singleton에 등록한 경우
  • 일관된 transaction이 필요한 logic에 Pub/Sub을 남용하기: publisher와 subscriber가 함께 성공하거나 함께 실패해야 하는(atomic) 경우, 비동기 Pub/Sub는 적합하지 않습니다 — Saga pattern이나 transactional outbox를 다시 검토하세요.
  • Topic 이름을 명확하게 지정하지 않기: 큰 시스템에서 "event1" 같은 모호한 이름의 topic을 사용하면 누가 무엇을 구독하는지 알 수 없어, decoupling의 모든 이점을 잃게 됩니다.

8. 결론

Observer와 Pub/Sub는 같은 목표를 공유합니다 — 여러 이해관계자에게 변경을 알림 — 하지만 coupling 수준과 작동 범위가 다릅니다. Observer는 직접적이고 동기적인 관계, 같은 process 내에서 적합합니다. Pub/Sub는 publisher와 subscriber를 완전히 분리해야 하고, 특히 메시지 지속성과 각 consumer별 독립적인 확장성이 필요한 microservices 시스템에 적합합니다. 이 차이를 명확히 이해하면 올바른 도구를 선택하는 데 도움이 됩니다: Kafka가 항상 필요한 것은 아니고, List<Observer>가 모든 문제의 해결책도 아닙니다.

design-patternobserverpub-subjavaspring-boot