Observer 패턴: 상태 변화를 구독자에게 안전하게 통보하는 법

Observer 패턴의 Push/Pull 통보 모델, 구독 수명 주기, 동시성·실패 격리 설계를 정리하고 Pub-Sub·Reactive Streams와 비교한다.

2026-08-13 · 최초 발행 2025-10-14

버튼 클릭 하나에 로거를 남기고, 화면을 갱신하고, 캐시를 무효화해야 한다면, 이 세 가지 반응을 버튼 처리 코드 안에 나란히 적어 넣는 순간 반응이 하나 늘 때마다 원래 코드를 다시 건드리게 된다. Observer 패턴은 주체(Subject)와 관찰자(Observer) 사이에 일대다 의존 관계를 세워 이 결합을 끊는다. 주체는 상태가 바뀌었다는 사실만 알리고, 무엇을 할지는 각 관찰자가 결정한다.

통보 모델과 인접 개념

주체는 관찰자 목록을 등록·해지할 수 있게 관리하고, 상태가 바뀌면 목록에 등록된 관찰자에게 통지한다. 통보 방식은 두 갈래로 나뉜다. 변경된 데이터를 그대로 실어 보내는 Push 모델은 단순하지만 받는 쪽이 처리할 물량을 조절할 방법(백프레셔)이 필요하고, 변경 사실만 알리고 필요한 값은 관찰자가 다시 조회하는 Pull 모델은 통보 자체는 가볍지만 추가 조회 비용이 붙는다. 전달은 동기·비동기 모두 가능하다.

흔히 Pub-Sub와 섞어 쓰지만 둘은 결합 방식이 다르다. Observer는 주체가 관찰자 목록을 직접 들고 있는 인프로세스 패턴에 가깝고, Pub-Sub는 그 사이에 브로커를 두어 발행자와 구독자를 간접적으로 연결한다. 범위와 확장성, 장애가 났을 때 격리되는 정도가 이 차이에서 갈린다.

무엇을 결정해야 하는가

주체는 상태를 들고 통지 순서와 정책을 정하는 쪽이다. 동시에 여러 곳에서 상태를 바꾸는 상황이라면 리스트 스냅샷이나 Copy-on-Write로 관찰자 목록이 순회 도중 바뀌는 문제를 피해야 한다. 관찰자 쪽은 update 콜백에서 부작용을 최소화하고 멱등하게 동작하도록 짜는 게 안전하며, 무엇보다 예외를 스스로 격리해야 한다 — 한 관찰자의 실패가 다른 관찰자의 통지를 막아서는 안 된다.

구독 수명도 별도로 설계할 대상이다. 등록·해지 API와 함께 범위(전역/세션/요청)를 정의하고, 강한 참조로 붙잡은 채 해지를 잊으면 메모리 누수로 이어진다. 스코프 종료 시 자동 해지, 약한 참조, 타임아웃 기반 정리 중 하나를 택해야 한다. 전달 방식도 트레이드오프가 있다 — 동기 방식은 지연이 낮은 대신 관찰자의 예외가 그대로 전파될 위험이 있고, 비동기 방식은 격리와 처리량이 낫지만 큐·스레드 풀·재시도 정책을 따로 갖춰야 하며 순서 보장과 장애 시 역류 방지(dead-letter) 정책도 함께 설계해야 한다.

통보 흐름과 실패 처리

상태 변경이 커밋된 뒤에야 통지가 나가야 미완료 상태가 관찰자에게 새어나가지 않는다. 이후 관찰자 목록을 스냅샷으로 뜨고, 동기라면 순차 호출, 비동기라면 큐에 게시한 뒤 각각 실패를 격리하거나 재시도·죽은 편지 큐로 넘긴다.

NoYes동기비동기아니오성공실패입력: Subject 상태 변경트랜잭션 커밋 여부대기 또는 롤백관찰자 목록 스냅샷 생성전달 방식 선택순차 호출작업 게시Observer 처리 성공?다음 Observer예외 로깅·격리, 계속 진행워커 소비: 타임아웃/재시도정책성공/실패완료재시도 또는 죽은 편지

자바로 구현한 최소 버전

아래는 Java 17 표준 라이브러리만으로 구성한 예시다. 트랜잭션 커밋 이후 notifyObservers를 호출한다고 가정하고, 관찰자 예외는 safeCall에서 격리한다.

import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

interface Observer<T> {
    void onUpdate(T event);
    default void onError(Throwable t) { /* no-op */ }
}

final class Subject<T> {
    private final List<Observer<T>> observers = new CopyOnWriteArrayList<>();
    private final ExecutorService executor; // null이면 동기

    Subject(ExecutorService executor) {
        this.executor = executor;
    }

    AutoCloseable subscribe(Observer<T> o) {
        observers.add(Objects.requireNonNull(o));
        return () -> observers.remove(o);
    }

    void notifyObservers(T event) {
        for (Observer<T> o : observers) {
            if (executor == null) {
                safeCall(o, event);
            } else {
                executor.submit(() -> safeCall(o, event));
            }
        }
    }

    private void safeCall(Observer<T> o, T event) {
        try {
            o.onUpdate(event);
        } catch (Throwable t) {
            try { o.onError(t); } catch (Throwable ignore) { /* 격리 */ }
        }
    }
}

// 예시 도메인 이벤트
record PriceUpdate(String symbol, double price, Instant ts) {}

public class Demo {
    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(4); // 비동기 전달
        Subject<PriceUpdate> subject = new Subject<>(pool);

        AutoCloseable sub1 = subject.subscribe(new Observer<>() {
            public void onUpdate(PriceUpdate e) {
                System.out.println("Logger -> " + e.symbol() + " " + e.price());
            }
            public void onError(Throwable t) { System.err.println("Logger error: " + t.getMessage()); }
        });

        AutoCloseable sub2 = subject.subscribe(new Observer<>() {
            public void onUpdate(PriceUpdate e) {
                if (e.price() <= 0) throw new IllegalArgumentException("invalid price");
                // 멱등 처리 로직 가정
            }
        });

        // 트랜잭션 커밋 후 호출 가정
        subject.notifyObservers(new PriceUpdate("AAPL", 230.12, Instant.now()));
        subject.notifyObservers(new PriceUpdate("AAPL", -1.0, Instant.now())); // 오류 발생 예시

        // 구독 해지
        sub1.close();
        sub2.close();
        pool.shutdown();
    }
}

이 패턴이 실제로 쓰이는 곳

  • UI 이벤트 처리: 버튼 클릭, 키 입력 등 위젯 상태 변화 통보
  • 도메인 이벤트 전파: 주문 상태 변경, 결제 완료 등 애그리게이트 내부 통보 및 동일 프로세스 내 사후 처리
  • 캐시 무효화/동기화: 데이터 갱신 시 관련 캐시 엔트리 무효화 통보
  • 설정 변경 핫리로드: 구성 저장소 변경을 관찰해 컴포넌트 재구성
  • 모니터링/알림: 메트릭 임계치 도달 통보, 경량 이벤트 파이프라인 구성

이 구조를 쓰면 결합도가 낮아지고 변경 전파가 체계화되며, 가짜 Observer를 주입해 시나리오를 검증할 수 있어 테스트도 수월해진다. 동기 호출을 비동기로 옮기면 지연이 분리되고 관찰자 실패가 서로에게 번지지 않는다.

Observer vs Pub-Sub vs Reactive Streams

접근 방식 성능(지연/오버헤드) 확장성(수평 확장) 일관성(순서/중복) 안정성(격리/내고장성) 운영 편의(가시성/운영툴)
Observer(인프로세스) 매우 낮은 지연, 오버헤드 적음 프로세스 한계, 낮음~중간 단일 프로세스 내 순서 보장 용이 관찰자 예외 격리 필요, 중간 간단, 디버깅 용이
Event Bus(인메모리) 낮음, 라우팅 오버헤드 소폭 프로세스 내 중간 토픽 단위 순서 보장 옵션 핸들러 격리/재시도 옵션 제한적 중간, 프레임워크 의존
Pub-Sub(브로커 기반) 네트워크 지연, 중간 매우 높음 파티션 기반, 정확-1회는 복잡 브로커 격리·내고장성 높음 높음, 모니터링/툴 풍부
Reactive Streams 낮음~중간, 연산자 오버헤드 높음(비동기/논블로킹) backpressure로 제어된 처리 보장 에러 전파/재시도 패턴 체계화 중간~높음, 학습 곡선 존재

운영에서 지켜야 할 것들

상태를 커밋한 뒤에만 통보해야 한다는 원칙은 코드로도 강제해야 한다. 관찰자 목록은 Copy-on-Write나 스냅샷으로 다루고, 순서 보장이 필요하면 단일 스레드 실행기를 쓴다. 관찰자 예외는 절대 상위로 전파하지 말고 onError 콜백과 재시도·죽은 편지 큐로 받아낸다. 비동기 큐에는 제한을 걸고 드롭·버퍼·블록 정책과 관찰자 타임아웃을 명시한다.

구독 해지는 명시적으로 제공하되 약한 참조나 스코프 기반 자동 해지를 함께 두어 메모리 누수를 막는다. 중복 통보에 대비한 멱등 처리와, 재진입 위험이 있는 경우 가드 플래그나 큐잉으로 순차화하는 것도 필요하다. 큐 길이·처리 시간·실패율 같은 메트릭을 수집하고 로깅에 상관 ID를 심어두면 통보 경로 전체를 추적할 수 있다. 저지연이 필요한 작은 범위에는 Observer 그대로가 맞고, 시스템 경계를 넘어서는 순간부터는 Pub-Sub나 Reactive Streams와 섞어 쓰는 편이 낫다.

Observer 패턴이벤트 기반 설계Pub-Sub디자인 패턴비동기 처리