본문으로 건너뛰기

Observer와 알림 UX의 윤리 - 디자인 패턴, 객체 수준 편 ep.09

한 사건이 일어났을 때 여러 자리가 그것을 알아야 한다면, 우리는 그 자리들을 직접 호출할 수도 있고 다른 답을 택할 수도 있습니다. 다른 답이 Observer입니다.


알림이라는 문제

객체 하나의 상태가 바뀌면, 다른 여러 객체가 그것을 알아야 하는 경우가 자주 생깁니다. 사용자가 로그인하면 헤더, 사이드바, 분석 모듈이 그 사실을 알아야 합니다. 장바구니에 상품이 추가되면 카운터, 추천 모듈, 결제 버튼이 갱신되어야 합니다.

가장 단순한 답은 직접 호출입니다.

class AuthService {
  login(user: User) {
    this.currentUser = user
    header.updateUserDisplay(user)
    sidebar.refresh()
    analytics.track('user.login', user)
    cart.loadFromServer(user.id)
  }
}

이 코드는 자랍니다. 새 화면을 만들 때마다 AuthService의 login 메서드를 고쳐야 합니다. AuthService는 자기 일이 아닌 다른 객체들의 존재까지 알게 됩니다. 한 변경이 여러 곳에 파급되는 결합이 만들어집니다.

Observer는 이 결합을 끊습니다. 사건이 일어나는 객체(Subject)는 “관심 있는 자가 있으면 등록하라”고 합니다. 관심 있는 객체(Observer)들은 자기를 등록하고, 사건이 일어나면 통지를 받습니다. Subject는 누가 듣고 있는지 알 필요가 없습니다.

핵심은 두 가지입니다. 사건의 발생과 사건의 처리를 분리한다. 그리고 Subject가 Observer를 알지 못해도 되도록 만든다.


Observer의 정석

기본 구조

SubjectObserver 인터페이스를 정의합니다.

interface Observer<T> {
  update(event: T): void
}

class Subject<T> {
  private observers = new Set<Observer<T>>()

  subscribe(observer: Observer<T>): () => void {
    this.observers.add(observer)
    // 구독 해제 함수를 돌려줍니다
    return () => this.observers.delete(observer)
  }

  notify(event: T): void {
    for (const observer of this.observers) {
      observer.update(event)
    }
  }
}

이제 구체 예제. 로그인 사건을 발행하는 AuthService와, 그것을 듣는 여러 객체들.

type LoginEvent = { userId: string; loginAt: Date }

class AuthService extends Subject<LoginEvent> {
  login(user: User) {
    this.currentUser = user
    this.notify({ userId: user.id, loginAt: new Date() })
  }
}

class HeaderObserver implements Observer<LoginEvent> {
  update(event: LoginEvent) {
    // 헤더 UI 갱신
  }
}

class AnalyticsObserver implements Observer<LoginEvent> {
  update(event: LoginEvent) {
    // 분석 이벤트 전송
  }
}

// 등록
const auth = new AuthService()
const unsubscribe1 = auth.subscribe(new HeaderObserver())
const unsubscribe2 = auth.subscribe(new AnalyticsObserver())

AuthService는 누가 듣고 있는지 모릅니다. 새 Observer를 추가해도 AuthService는 손대지 않습니다. 확장이 추가이지 수정이 아닙니다.

Observer 패턴의 구조 다이어그램. 좌측에 Subject 역할의 AuthService 클래스가 있고, 내부에 observers 집합 필드와 subscribe, notify 메서드가 표시됩니다. AuthService는 이벤트가 발생하면 자기에게 등록된 모든 Observer에게 update 호출을 보냅니다. 우측에는 세 개의 Observer가 격자로 배치되어 있습니다: HeaderObserver, AnalyticsObserver, CartObserver. 각 Observer는 Observer 인터페이스를 구현하며 update 메서드를 가집니다. 다이어그램 중앙에는 notify 흐름이 Subject에서 세 Observer로 동시에 갈라지는 화살표로 그려져 있어, 하나의 사건이 여러 자리에 동시에 도달하는 구조가 시각화됩니다. AuthService 측에는 Observer들이 누구인지 알지 못한다는 점이 별도 라벨로 강조되어 있습니다.

Push와 Pull

Observer는 통지를 두 가지 방식으로 보낼 수 있습니다.

  • Push: Subject가 사건의 데이터를 함께 보냅니다. observer.update({ userId, loginAt }). Observer는 받자마자 사용할 수 있습니다.
  • Pull: Subject는 사건이 일어났다는 사실만 알립니다. Observer는 필요한 데이터를 Subject에 다시 물어봅니다. observer.update(); ... subject.getCurrentUser().

Push는 단순합니다. Pull은 결합을 더 줄이지만 호출이 늘어납니다. 사건의 데이터가 작고 자주 쓰일 때는 Push가, 사건의 데이터가 크고 일부 Observer만 사용할 때는 Pull이 유리합니다. 현대 JavaScript에서는 거의 항상 Push를 씁니다.

안티패턴: 메모리 누수

Observer 패턴의 가장 흔한 함정은 구독 해제를 잊는 것입니다. Observer가 더 이상 필요하지 않은데 Subject가 여전히 그것을 보유하고 있으면, Observer는 가비지 컬렉션되지 않습니다.

// 위험한 코드
function setupPage() {
  const observer = new HeaderObserver()
  auth.subscribe(observer)
  // 페이지를 떠나도 observer는 auth에 묶여 있음
}

React에서 useEffect의 cleanup 함수가 이 문제를 다룹니다.

useEffect(() => {
  const unsubscribe = auth.subscribe(observer)
  return unsubscribe  // 컴포넌트 unmount 시 자동 해제
}, [])

구독은 짝이 있는 행동입니다. subscribe가 있으면 unsubscribe가 있어야 합니다.

EventEmitter와 Pub/Sub

Observer 패턴은 여러 이름으로 부릅니다. Node.js의 EventEmitter, 브라우저의 addEventListener, RxJS의 Observable, Redux의 subscribe 등이 있습니다. 모두 Observer의 변형입니다.

**Pub/Sub(발행-구독)**은 Observer의 한 변형입니다. 둘 사이에 **중개자(broker)**가 있다는 점이 다릅니다. Publisher는 broker에 발행하고, Subscriber는 broker에서 구독합니다. Publisher와 Subscriber는 서로의 존재를 더욱 알지 못합니다. 결합이 한 단계 더 끊어집니다.

분산 시스템(Kafka, Redis Pub/Sub)에서는 Pub/Sub이 표준입니다. 단일 프로세스 안에서는 직접 Observer가 더 가볍습니다.


같은 결정이 다시 나타나는 자리

Observer가 답하는 질문은 한 사건을 여러 자리에 전달하는 것입니다. 이 질문은 알림 UX에서 가장 직접적으로 나타납니다.

새 메시지가 도착했을 때, 사용자에게 알리는 자리는 여럿입니다. 헤더 배지, 좌측 사이드바, 푸시 알림, 이메일, OS 시스템 알림 등 각 자리가 같은 사건을 받습니다. 이것이 Observer입니다. 메시지 시스템(Subject)은 누가 듣고 있는지 모르고, 각 알림 채널(Observer)이 자기를 등록합니다.

이 구조가 강력한 만큼 윤리적 책임이 따라옵니다.

알림의 윤리

Observer는 누가 듣고 있는지 모릅니다. 그 비대칭이 사용자에게 알림이 닿는 자리에서는 다르게 작동합니다. 사용자는 자신이 무엇에 구독되어 있는지 잊습니다. 한 번 동의한 알림이 끊임없이 도착합니다.

다크 패턴과 가까워지는 경계.

  • 과도한 알림: 모든 사건을 알리면 알림이 노이즈가 됩니다. 진짜 중요한 알림이 묻힙니다.
  • 구독 해제의 비대칭: 구독은 한 번의 클릭이지만, 해제는 다섯 번의 클릭과 설정 페이지의 깊은 곳에 있습니다. 이것은 구조 자체의 dark pattern입니다.
  • 거짓 긴급성: “지금 즉시 확인하세요”라는 알림이 실제로는 마케팅 콘텐츠인 경우. 사용자의 주의를 부당하게 가져갑니다.
  • 알림 폭격: 같은 사건을 여러 채널로 동시에 보냄. 같은 메시지를 푸시, 이메일, SMS로 받는 사용자는 자기 선택권이 무력화되는 느낌을 받습니다.

알림 UX와 Observer의 대응을 보여주는 다이어그램. 좌측에는 코드의 Observer 구조가 그려져 있습니다. 중앙에 MessageSystem이 있고 그 우측으로 HeaderBadge, Sidebar, PushNotification, Email 네 Observer가 동시에 통지를 받습니다. 우측에는 같은 구조가 사용자 인터페이스 차원에서 다시 그려져 있습니다. 사용자 한 명에게 같은 메시지 하나가 헤더 배지, 사이드바 점, 푸시 알림, 이메일로 동시에 도달하는 모습이 표현되어 있습니다. 다이어그램 하단에는 윤리적 경계가 표시되어 있어, 알림의 빈도, 구독 해제의 비대칭, 거짓 긴급성에 따라 같은 Observer 구조가 정직한 알림 디자인과 dark pattern 사이 어디에 위치하는지가 시각적으로 강조됩니다.

좋은 알림 디자인의 조건은 Observer의 원칙을 정직하게 적용하는 일입니다.

  • 명시적 구독: 사용자가 무엇에 구독했는지 분명히 알 수 있어야 합니다.
  • 대칭적 해제: 구독만큼 해제도 쉬워야 합니다. 한 번의 클릭으로.
  • 분류된 채널: 어떤 채널로 받을지 사용자가 선택합니다. 모든 채널을 강제로 켜는 것은 폭력입니다.
  • 빈도 제어: “즉시”, “하루 한 번”, “주 한 번” 같은 선택지. Observer의 발행은 시스템이 하지만, 발행의 리듬은 사용자가 결정해야 합니다.

디자이너에게 이 패턴이 보이는 자리. 디자인 시스템에서 알림 컴포넌트(Toast, Banner, Badge)의 API를 만들 때, 같은 윤리적 결정이 반복됩니다. 그 알림이 사용자에게 무엇을 강제할 것인가. 무엇을 양보할 것인가.

Observer는 시스템 내부의 결합을 끊는 좋은 패턴입니다. 그러나 그 끝에 사용자가 있을 때, 패턴 자체보다 패턴이 만드는 인터페이스의 윤리가 더 중요해집니다.


다음 편 예고

ep.10 - State와 폼 상태 머신

Observer가 사건의 전달이라면, State는 객체 자신의 흐름입니다. 폼이 idle에서 validating으로, validating에서 submitting으로 흐르는 그 변화에 이름을 붙이는 패턴, 그것이 State입니다.