Table of contents
- 알림을 보내야 한다
- Observer 패턴의 구조
- Observer 패턴 구현
- Observer 패턴의 실무 적용
- Command 패턴 — 요청을 객체로
- Command 패턴의 구조
- Command 패턴 구현
- Command 패턴의 확장
- Observer vs Command
- 실무에서의 주의점
- 핵심 정리
알림을 보내야 한다
쇼핑몰에서 상품 가격이 바뀌면 여러 곳에 알려야 한다. 장바구니에 담긴 상품의 총액을 다시 계산해야 하고, 가격 알림을 신청한 사용자에게 푸시를 보내야 하며, 관리자 대시보드의 통계도 갱신해야 한다.
// 가격 변경 시 관련된 모든 곳을 직접 호출하는 코드
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);
}
}
이 코드의 문제점이 보이는가? Product가 CartService, NotificationService, DashboardService를 직접 알고 있다. 새로운 서비스가 추가될 때마다 Product 클래스를 수정해야 한다. 가격 변경이라는 도메인 로직과 누구에게 알릴 것인가라는 통지 로직이 한 클래스에 뒤섞여 있는 셈이다.
Observer 패턴은 이 결합을 끊어준다.
Observer 패턴의 구조
Observer 패턴의 핵심은 발행-구독(Publish-Subscribe) 구조다. 상태가 변하는 쪽(Subject)이 변경 사실을 알리면, 관심 있는 쪽(Observer)이 그 알림을 받아서 각자 할 일을 한다. Subject는 구체적으로 누가 듣고 있는지 모른다. Observer 인터페이스만 알면 된다.
참여하는 역할은 두 가지다.
- Subject: 상태를 가지고 있으며, 변경 시 등록된 Observer들에게 알린다. Observer를 등록/해제하는 메서드를 제공한다
- Observer: Subject의 상태 변화를 통지받는 인터페이스. 각 구현체가 통지를 받아서 자기 할 일을 한다
아래 시퀀스 다이어그램은 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는 더 이상 CartService나 NotificationService를 직접 알지 못한다. 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) 로직이 함께 담긴다.
참여하는 역할은 네 가지다.
- Command: 실행할 명령의 인터페이스.
execute()와undo()를 정의한다 - ConcreteCommand: 실제 명령의 구현. 어떤 Receiver의 어떤 메서드를 호출할지 알고 있다
- Receiver: 실제 작업을 수행하는 객체. 텍스트 에디터 예시에서는
TextEditor가 Receiver다 - Invoker: 명령을 실행하고 이력을 관리하는 객체. Undo/Redo 스택을 관리한다
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
두 패턴 모두 이벤트와 관련이 깊지만, 풀려는 문제가 다르다.
| 기준 | Observer | Command |
|---|---|---|
| 핵심 질문 | 상태가 바뀌었을 때 누구에게 알릴 것인가? | 요청을 어떻게 캡슐화할 것인가? |
| 방향 | 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에 필요한 컨텍스트를 명령 객체 안에 보관해야 한다.
핵심 정리
- Observer 패턴은 상태 변화를 1:N으로 통지한다. Subject와 Observer가 느슨하게 연결되어, 새로운 Observer를 추가해도 Subject 코드가 바뀌지 않는다
- Command 패턴은 요청을 객체로 캡슐화한다. 실행(execute)과 되돌리기(undo)를 한 객체에 담아서, Undo/Redo, 매크로, 작업 큐 같은 기능을 가능하게 한다
- Observer는
누구에게 알릴 것인가를, Command는무엇을 실행할 것인가를 캡슐화한다는 점에서 초점이 다르다 - 두 패턴은 조합해서 쓸 수 있다. Command 실행 시 Observer에게 통지하는 구조가 대표적이다
- Observer는 흐름 추적이 어렵고, Command는 Undo 구현이 복잡할 수 있다는 트레이드오프가 존재한다
다음 편에서는 흐름을 제어하는 두 가지 방법을 다룬다. 알고리즘의 뼈대를 정의하는 Template Method와, 요청을 체인으로 넘기는 Chain of Responsibility를 살펴보자.




Loading comments...