Skip to content
ioob.dev
Go back

디자인 패턴 3편 — Observer와 Command

· 11분 읽기
Design Pattern 시리즈 (3/8)
  1. 디자인 패턴 1편 — 패턴을 배우는 이유
  2. 디자인 패턴 2편 — Strategy와 State
  3. 디자인 패턴 3편 — Observer와 Command
  4. 디자인 패턴 4편 — Template Method와 Chain of Responsibility
  5. 디자인 패턴 5편 — Factory Method, Abstract Factory, Builder
  6. 디자인 패턴 6편 — Decorator와 Proxy
  7. 디자인 패턴 7편 — Adapter, Facade, Composite: 구조를 정리하는 패턴들
  8. 디자인 패턴 8편 — Singleton, Iterator, Prototype: 자주 쓰지만 오해도 많은 패턴들
Table of contents

Table of contents

알림을 보내야 한다

쇼핑몰에서 상품 가격이 바뀌면 여러 곳에 알려야 한다. 장바구니에 담긴 상품의 총액을 다시 계산해야 하고, 가격 알림을 신청한 사용자에게 푸시를 보내야 하며, 관리자 대시보드의 통계도 갱신해야 한다.

// 가격 변경 시 관련된 모든 곳을 직접 호출하는 코드
public class Product {
    private int price;
    private CartService cartService;
    private NotificationService notificationService;
    private DashboardService dashboardService;

    public void changePrice(int newPrice) {
        this.price = newPrice;

        // 알려야 할 곳이 늘어날 때마다 여기를 수정해야 한다
        cartService.recalculate(this);
        notificationService.notifyPriceChange(this);
        dashboardService.updateStats(this);
    }
}

이 코드의 문제점이 보이는가? ProductCartService, NotificationService, DashboardService를 직접 알고 있다. 새로운 서비스가 추가될 때마다 Product 클래스를 수정해야 한다. 가격 변경이라는 도메인 로직과 누구에게 알릴 것인가라는 통지 로직이 한 클래스에 뒤섞여 있는 셈이다.

Observer 패턴은 이 결합을 끊어준다.

Observer 패턴의 구조

Observer 패턴의 핵심은 발행-구독(Publish-Subscribe) 구조다. 상태가 변하는 쪽(Subject)이 변경 사실을 알리면, 관심 있는 쪽(Observer)이 그 알림을 받아서 각자 할 일을 한다. Subject는 구체적으로 누가 듣고 있는지 모른다. Observer 인터페이스만 알면 된다.

참여하는 역할은 두 가지다.

아래 시퀀스 다이어그램은 Observer 패턴의 동작 흐름을 보여준다.

sequenceDiagram
    participant Client
    participant Subject
    participant ObserverA
    participant ObserverB

    Client->>Subject: addObserver(ObserverA)
    Client->>Subject: addObserver(ObserverB)

    Client->>Subject: changeState()
    Subject->>Subject: 상태 변경

    Subject->>ObserverA: update(state)
    ObserverA->>ObserverA: 자신의 로직 실행

    Subject->>ObserverB: update(state)
    ObserverB->>ObserverB: 자신의 로직 실행

화살표 방향을 보면, Subject가 Observer를 호출하지만 구체적인 구현체(ObserverA, ObserverB)를 직접 알지는 않는다. Observer 인터페이스의 update() 메서드만 호출할 뿐이다.

Observer 패턴 구현

앞의 상품 가격 변경 예시를 Observer 패턴으로 리팩토링해보자. 먼저 Observer 인터페이스와 Subject를 정의한다.

// 가격 변경 이벤트를 수신하는 Observer 인터페이스
interface PriceObserver {
    fun onPriceChanged(productId: String, oldPrice: Int, newPrice: Int)
}

Subject 역할을 하는 Product 클래스는 Observer 목록을 관리하고, 가격이 바뀌면 전체에 통지한다.

// Subject: Observer를 등록/해제하고, 상태 변경 시 통지한다
class Product(
    val id: String,
    val name: String,
    private var price: Int
) {
    private val observers = mutableListOf<PriceObserver>()

    fun addObserver(observer: PriceObserver) {
        observers.add(observer)
    }

    fun removeObserver(observer: PriceObserver) {
        observers.remove(observer)
    }

    fun changePrice(newPrice: Int) {
        val oldPrice = this.price
        this.price = newPrice
        println("[$name] 가격 변경: ${oldPrice}원 → ${newPrice}원")
        notifyObservers(oldPrice, newPrice)
    }

    private fun notifyObservers(oldPrice: Int, newPrice: Int) {
        observers.forEach { it.onPriceChanged(id, oldPrice, newPrice) }
    }

    fun getPrice() = price
}

이제 Observer 구현체를 만든다. 각각은 자기 관심사에 맞는 일만 처리한다.

// 장바구니 총액을 재계산하는 Observer
class CartObserver : PriceObserver {
    override fun onPriceChanged(productId: String, oldPrice: Int, newPrice: Int) {
        println("  [장바구니] 상품 $productId 가격 변경 감지 → 총액 재계산")
    }
}

// 사용자에게 푸시 알림을 보내는 Observer
class NotificationObserver : PriceObserver {
    override fun onPriceChanged(productId: String, oldPrice: Int, newPrice: Int) {
        val diff = newPrice - oldPrice
        val direction = if (diff > 0) "인상" else "인하"
        println("  [알림] 상품 $productId 가격이 ${Math.abs(diff)}$direction 되었습니다")
    }
}

// 관리자 대시보드를 갱신하는 Observer
class DashboardObserver : PriceObserver {
    override fun onPriceChanged(productId: String, oldPrice: Int, newPrice: Int) {
        println("  [대시보드] 상품 $productId 가격 통계 갱신")
    }
}

사용하는 쪽의 코드는 이렇게 된다.

fun main() {
    val product = Product("P001", "무선 키보드", 50000)

    // Observer 등록
    product.addObserver(CartObserver())
    product.addObserver(NotificationObserver())
    product.addObserver(DashboardObserver())

    // 가격 변경 → 등록된 모든 Observer에게 자동 통지
    product.changePrice(45000)

    // 출력:
    // [무선 키보드] 가격 변경: 50000원 → 45000원
    //   [장바구니] 상품 P001 가격 변경 감지 → 총액 재계산
    //   [알림] 상품 P001 가격이 5000원 인하 되었습니다
    //   [대시보드] 상품 P001 가격 통계 갱신
}

Product는 더 이상 CartServiceNotificationService를 직접 알지 못한다. PriceObserver 인터페이스만 알 뿐이다. 새로운 서비스를 추가하려면 PriceObserver를 구현한 클래스를 만들어서 등록하면 끝이다. Product 코드는 한 줄도 수정하지 않는다.

Observer 패턴의 실무 적용

Observer 패턴은 이미 수많은 프레임워크와 라이브러리에 내장되어 있다. Java의 java.util.EventListener, Spring의 @EventListener, Android의 LiveData, JavaScript의 DOM 이벤트 리스너가 모두 Observer 패턴의 변형이다.

Spring에서의 사용 예를 보자.

// Spring의 이벤트 시스템 — Observer 패턴의 프레임워크 레벨 구현
data class PriceChangedEvent(
    val productId: String,
    val oldPrice: Int,
    val newPrice: Int
)

@Service
class ProductService(
    private val eventPublisher: ApplicationEventPublisher
) {
    fun changePrice(productId: String, newPrice: Int) {
        val oldPrice = findPrice(productId)
        updatePrice(productId, newPrice)

        // 이벤트 발행 — 누가 듣는지 ProductService는 모른다
        eventPublisher.publishEvent(PriceChangedEvent(productId, oldPrice, newPrice))
    }
}

// 각 리스너가 독립적으로 이벤트를 수신한다
@Component
class CartEventListener {
    @EventListener
    fun handle(event: PriceChangedEvent) {
        println("장바구니 총액 재계산: ${event.productId}")
    }
}

@Component
class NotificationEventListener {
    @EventListener
    fun handle(event: PriceChangedEvent) {
        println("가격 변동 알림 발송: ${event.productId}")
    }
}

직접 Observer 인터페이스를 만들 필요 없이, 프레임워크가 발행-구독 인프라를 제공한다. @EventListener 어노테이션만 붙이면 자동으로 Observer로 등록된다.

Observer 패턴의 주의점

Observer 패턴에는 몇 가지 함정이 있다.

메모리 누수. Observer를 등록만 하고 해제하지 않으면, Subject가 Observer에 대한 참조를 계속 들고 있다. Observer가 가비지 컬렉션 대상이 되어야 할 때도 수거되지 않는다. Java에서는 WeakReference를 쓰거나, 라이프사이클에 맞춰 명시적으로 해제해야 한다.

통지 순서 의존. Observer들이 통지받는 순서에 의존하는 로직을 작성하면 안 된다. Subject는 누가 먼저 받는가를 보장하지 않는 경우가 많다. Observer A의 결과를 Observer B가 참조하는 구조라면 Observer 패턴이 아니라 다른 방식을 써야 한다.

연쇄 업데이트. Observer A가 통지를 받고 자기 상태를 바꿨는데, 그것이 또 다른 Subject의 변경을 유발하고, 그게 또 Observer B에게 통지되고… 이런 연쇄 반응이 발생하면 디버깅이 극도로 어려워진다. 통지 핸들러 안에서는 다른 Subject의 상태를 바꾸지 않는 것이 안전하다.

Command 패턴 — 요청을 객체로

Observer가 상태가 바뀌었음을 알린다에 초점을 맞춘다면, Command 패턴은 요청 자체를 객체로 만든다에 초점을 맞춘다. 방향성이 좀 다르다.

텍스트 에디터를 만든다고 해보자. 사용자가 글자를 입력하고, 삭제하고, 굵게 만들고, 되돌리기(Undo)를 누른다. 이 동작들을 직접 구현하면 이렇게 될 수 있다.

// 각 동작을 직접 실행하는 방식 — Undo가 매우 어렵다
public class TextEditor {
    private StringBuilder text = new StringBuilder();

    public void type(String input) {
        text.append(input);
    }

    public void delete(int count) {
        text.delete(text.length() - count, text.length());
    }

    // Undo를 구현하려면? 이전 상태를 어디에 저장하지?
    // 각 동작마다 Undo 로직이 달라야 한다.
}

Undo를 구현하려면 무슨 동작을 했는지를 기록해둬야 한다. 그리고 되돌릴 때 그 동작의 역연산을 실행해야 한다. Command 패턴은 바로 이 구조를 제공한다.

Command 패턴의 구조

Command 패턴의 핵심은 요청을 객체로 캡슐화하는 것이다. 글자를 입력하라라는 명령 자체가 하나의 객체가 되고, 그 객체 안에 실행(execute)과 되돌리기(undo) 로직이 함께 담긴다.

참여하는 역할은 네 가지다.

classDiagram
    class Invoker {
        -history: Stack~Command~
        +executeCommand(Command)
        +undo()
        +redo()
    }

    class Command {
        <<interface>>
        +execute()
        +undo()
    }

    class TypeCommand {
        -receiver: TextEditor
        -text: String
        +execute()
        +undo()
    }

    class DeleteCommand {
        -receiver: TextEditor
        -count: int
        -deleted: String
        +execute()
        +undo()
    }

    class BoldCommand {
        -receiver: TextEditor
        -range: Range
        +execute()
        +undo()
    }

    class TextEditor {
        +insert(text)
        +remove(count)
        +applyBold(range)
    }

    Invoker --> Command : executes
    Command <|.. TypeCommand
    Command <|.. DeleteCommand
    Command <|.. BoldCommand
    TypeCommand --> TextEditor
    DeleteCommand --> TextEditor
    BoldCommand --> TextEditor

Invoker는 Command 인터페이스만 안다. 구체적으로 어떤 명령인지, 어떤 Receiver를 조작하는지 관심이 없다. 덕분에 새로운 종류의 명령을 추가해도 Invoker 코드는 변하지 않는다.

Command 패턴 구현

텍스트 에디터의 Undo/Redo 기능을 Command 패턴으로 구현해보자.

// 명령의 공통 인터페이스: 실행과 되돌리기
interface Command {
    fun execute()
    fun undo()
    fun description(): String
}

Receiver인 TextEditor는 텍스트 조작의 기본 기능만 제공한다.

// Receiver: 텍스트 조작의 실제 구현
class TextEditor {
    private val content = StringBuilder()

    fun insert(position: Int, text: String) {
        content.insert(position, text)
    }

    fun remove(position: Int, length: Int): String {
        val removed = content.substring(position, position + length)
        content.delete(position, position + length)
        return removed
    }

    fun getText(): String = content.toString()
    fun length(): Int = content.length
}

이제 구체적인 명령을 구현한다. 입력 명령부터 보자.

// 텍스트 입력 명령: 실행 시 텍스트를 삽입하고, Undo 시 삽입한 만큼 제거한다
class TypeCommand(
    private val editor: TextEditor,
    private val position: Int,
    private val text: String
) : Command {
    override fun execute() {
        editor.insert(position, text)
    }

    override fun undo() {
        editor.remove(position, text.length)
    }

    override fun description() = "입력: '$text' (위치: $position)"
}

// 삭제 명령: 실행 시 텍스트를 제거하고, Undo 시 제거했던 텍스트를 복원한다
class DeleteCommand(
    private val editor: TextEditor,
    private val position: Int,
    private val length: Int
) : Command {
    private var deletedText: String = ""  // Undo를 위해 삭제된 텍스트를 기억한다

    override fun execute() {
        deletedText = editor.remove(position, length)
    }

    override fun undo() {
        editor.insert(position, deletedText)
    }

    override fun description() = "삭제: $length 글자 (위치: $position)"
}

Invoker는 명령을 실행하고 이력을 스택으로 관리한다.

// Invoker: 명령 실행과 Undo/Redo 이력을 관리한다
class CommandInvoker {
    private val undoStack = ArrayDeque<Command>()
    private val redoStack = ArrayDeque<Command>()

    fun execute(command: Command) {
        command.execute()
        undoStack.addLast(command)
        redoStack.clear()  // 새 명령 실행 시 Redo 이력은 초기화
        println("실행: ${command.description()}")
    }

    fun undo() {
        if (undoStack.isEmpty()) {
            println("되돌릴 명령이 없다")
            return
        }
        val command = undoStack.removeLast()
        command.undo()
        redoStack.addLast(command)
        println("Undo: ${command.description()}")
    }

    fun redo() {
        if (redoStack.isEmpty()) {
            println("다시 실행할 명령이 없다")
            return
        }
        val command = redoStack.removeLast()
        command.execute()
        undoStack.addLast(command)
        println("Redo: ${command.description()}")
    }

    fun getHistory(): List<String> = undoStack.map { it.description() }
}

전체 흐름을 실행해보자.

fun main() {
    val editor = TextEditor()
    val invoker = CommandInvoker()

    // 텍스트 입력
    invoker.execute(TypeCommand(editor, 0, "Hello"))
    println("현재: '${editor.getText()}'")  // Hello

    invoker.execute(TypeCommand(editor, 5, " World"))
    println("현재: '${editor.getText()}'")  // Hello World

    invoker.execute(TypeCommand(editor, 11, "!"))
    println("현재: '${editor.getText()}'")  // Hello World!

    // Undo 두 번
    invoker.undo()
    println("현재: '${editor.getText()}'")  // Hello World

    invoker.undo()
    println("현재: '${editor.getText()}'")  // Hello

    // Redo
    invoker.redo()
    println("현재: '${editor.getText()}'")  // Hello World

    // 명령 이력 확인
    println("이력: ${invoker.getHistory()}")
}

각 명령이 자신의 실행과 되돌리기 로직을 가지고 있기 때문에, Invoker는 어떤 종류의 명령이든 동일한 방식으로 관리할 수 있다. 새로운 명령(굵게 하기, 들여쓰기, 치환 등)을 추가해도 Invoker 코드는 바뀌지 않는다.

Command 패턴의 확장

Command 패턴의 힘은 요청이 객체라는 점에서 나온다. 객체이기 때문에 저장하고, 전달하고, 큐에 넣고, 로그로 남길 수 있다. 이런 특성을 활용하는 대표적인 패턴 두 가지를 살펴보자.

매크로 명령

여러 명령을 하나로 묶어서 한 번에 실행하는 매크로(Macro)를 만들 수 있다.

// 여러 명령을 하나로 묶는 매크로 명령
class MacroCommand(
    private val commands: List<Command>
) : Command {
    override fun execute() {
        commands.forEach { it.execute() }
    }

    override fun undo() {
        // 역순으로 Undo해야 원래 상태로 돌아간다
        commands.reversed().forEach { it.undo() }
    }

    override fun description() = "매크로: [${commands.joinToString(", ") { it.description() }}]"
}

// 사용 예: "Hello World!" 전체를 한 번에 입력하는 매크로
val macro = MacroCommand(listOf(
    TypeCommand(editor, 0, "Hello"),
    TypeCommand(editor, 5, " World"),
    TypeCommand(editor, 11, "!")
))
invoker.execute(macro)  // 세 명령이 한 번에 실행
invoker.undo()           // 세 명령이 한 번에 되돌려진다

명령 큐

명령을 즉시 실행하지 않고 큐에 쌓아두었다가 나중에 실행하는 방식이다. 작업 스케줄러나 비동기 처리에서 흔히 쓰인다.

// 명령을 큐에 쌓아두고 나중에 일괄 실행하는 구조
class CommandQueue {
    private val queue = ArrayDeque<Command>()

    fun addCommand(command: Command) {
        queue.addLast(command)
        println("큐에 추가: ${command.description()}")
    }

    fun processAll() {
        println("--- 큐 처리 시작 (${queue.size}개) ---")
        while (queue.isNotEmpty()) {
            val command = queue.removeFirst()
            command.execute()
            println("처리 완료: ${command.description()}")
        }
        println("--- 큐 처리 완료 ---")
    }
}

이 구조는 메시지 큐(Message Queue) 기반 시스템의 근간이 되는 아이디어다. Kafka나 RabbitMQ로 전달되는 메시지를 직렬화된 Command 객체로 생각하면 구조가 자연스럽게 이해된다.

Observer vs Command

두 패턴 모두 이벤트와 관련이 깊지만, 풀려는 문제가 다르다.

기준ObserverCommand
핵심 질문상태가 바뀌었을 때 누구에게 알릴 것인가?요청을 어떻게 캡슐화할 것인가?
방향1:N — 하나의 Subject가 여러 Observer에게 통지1:1 — 하나의 Invoker가 하나의 Command를 실행
결합도Subject와 Observer가 느슨하게 연결Invoker와 Command가 느슨하게 연결
실행 시점상태 변경 시 즉시 통지즉시 실행, 나중에 실행, 큐에 저장 등 유연
Undo 지원일반적으로 없음패턴의 핵심 기능
대표적 사용처이벤트 리스너, 알림 시스템, 데이터 바인딩Undo/Redo, 트랜잭션, 매크로, 작업 큐

실무에서는 이 두 패턴을 조합해서 쓰는 경우도 많다. 예를 들어, Command가 실행될 때 Observer에게 통지하는 구조를 만들 수 있다. 사용자가 텍스트를 입력하면(Command 실행) 화면에 즉시 반영되고(Observer 통지) 동시에 Undo 스택에 기록된다(Command 이력 관리).

실무에서의 주의점

Observer를 남용하면 디버깅 지옥

Observer 패턴은 느슨한 결합의 대가로 흐름 추적이 어려워진다. product.changePrice(45000)을 호출했을 때 어떤 Observer가 반응하는지 코드만 봐서는 알기 힘들다. IDE에서 onPriceChanged의 구현체를 다 찾아봐야 한다.

Spring의 @EventListener를 쓸 때 이 문제가 특히 두드러진다. 이벤트를 발행하는 코드와 수신하는 코드가 완전히 분리되어 있어서, “이 이벤트를 누가 처리하는 거지?”를 찾으려면 프로젝트 전체를 검색해야 할 때가 있다.

해결 방법은 명확한 네이밍 컨벤션과 이벤트 카탈로그를 유지하는 것이다. 이벤트 클래스를 한 패키지에 모아두고, 각 이벤트의 발행자와 구독자를 문서화해두면 추적이 수월해진다.

Command의 Undo는 생각보다 어렵다

단순한 텍스트 편집이라면 Undo가 간단하지만, 실무에서는 까다로운 경우가 많다. 외부 API를 호출하는 명령은 되돌릴 수 없을 수도 있고, DB에 쓴 데이터를 Undo하려면 보상 트랜잭션(Compensating Transaction)이 필요하다.

Command 패턴에서 Undo를 구현할 때는 두 가지를 먼저 확인해야 한다. 이 명령이 정말 되돌릴 수 있는가? 그리고 Undo에 필요한 정보를 execute 시점에 충분히 저장하고 있는가? DeleteCommand에서 삭제된 텍스트를 deletedText에 저장한 것처럼, Undo에 필요한 컨텍스트를 명령 객체 안에 보관해야 한다.

핵심 정리


다음 편에서는 흐름을 제어하는 두 가지 방법을 다룬다. 알고리즘의 뼈대를 정의하는 Template Method와, 요청을 체인으로 넘기는 Chain of Responsibility를 살펴보자.

4편: Template Method와 Chain of Responsibility


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
디자인 패턴 2편 — Strategy와 State
Next Post
디자인 패턴 4편 — Template Method와 Chain of Responsibility