행위 패턴 1: 전략·옵저버·커맨드·상태·템플릿 메서드
알고리즘을 갈아 끼우고, 변화를 알리고, 요청을 객체로 만들고, 상태마다 행동을 바꾸는 법이다.
목차 (9)
행위 패턴이 다루는 것: 책임과 대화
생성 패턴이 객체를 만드는 일을, 구조 패턴이 객체를 엮는 일을 다룬다면, 행위 패턴은 객체들이 일을 나누고 말을 주고받는 방식을 다룬다. 누가 무엇을 책임지는지, 누가 누구를 부르는지, 그 관계를 언제 바꿀 수 있는지가 관심사다. 패턴이라는 말이 어디서 왔고 GoF 23가지가 어떻게 나뉘는지는 디자인 패턴이란 글에, 그 바탕이 되는 SOLID 같은 설계 원칙은 소프트웨어 공학 노트에 있다.
이 글의 다섯 패턴은 모두 "바뀌는 부분을 객체 하나로 떼어 낸다"는 같은 생각에서 출발한다. 무엇을 떼어 내느냐가 다르다. 전략은 계산법을, 옵저버는 "누가 듣는가"를, 커맨드는 요청 자체를, 상태는 상태마다의 행동을, 템플릿 메서드는 순서 속 몇 단계를 떼어 낸다.
패턴마다 같은 틀로 쓴다: 의도(한 문장) → 문제 상황(어떤 코드 냄새에서 시작하나) → 구조 그림 → 예시 코드(Java) → 실제 쓰임 → 장단점 → 주의 → 다른 언어에서는. 구조 그림은 UML 클래스 그림을 줄여 그렸고, 읽는 법은 아래 범례 하나로 갈음한다.
전략(Strategy): 알고리즘을 갈아 끼운다
의도 — 같은 일을 하는 여러 방법을 각각 객체로 감싸, 쓰는 쪽 코드를 고치지 않고 방법만 바꿔 끼우게 한다.
문제 상황
쇼핑몰 결제 금액을 계산하는 메서드에 할인 규칙이 if로 쌓인다. 회원 등급 할인, 쿠폰, 기간 행사가 늘 때마다 같은 메서드를 열어 고치고, 고칠 때마다 다른 할인까지 다시 시험해야 한다. "새 종류를 더하려면 기존 분기문을 고쳐야 한다"가 전략이 필요하다는 신호다.
// 할인 종류가 늘 때마다 이 메서드를 열어 고친다
long pay(long amount, String grade) {
if (grade.equals("VIP")) {
return amount * 90 / 100;
} else if (grade.equals("COUPON")) {
return Math.max(0, amount - 3_000);
}
return amount;
}구조
예시 코드
interface DiscountPolicy {
long apply(long amount);
}
final class RateDiscount implements DiscountPolicy {
private final int percent;
RateDiscount(int percent) {
this.percent = percent;
}
@Override
public long apply(long amount) {
return amount * (100 - percent) / 100;
}
}
final class Checkout {
private final DiscountPolicy policy;
Checkout(DiscountPolicy policy) {
this.policy = policy;
}
long pay(long amount) {
return policy.apply(amount);
}
}
// 정책은 바깥에서 골라 넣는다
var checkout = new Checkout(new RateDiscount(10));
long total = checkout.pay(20_000);Java 8부터는 메서드가 하나뿐인 인터페이스를 람다로 바로 만들 수 있어서, 전략 클래스를 따로 두는 일이 크게 줄었다. 패턴이 사라진 것이 아니라 "전략 객체 = 함수 하나"로 문법이 가벼워진 것이다.
// 메서드가 하나뿐인 인터페이스는 람다로 바로 만든다
DiscountPolicy none = amount -> amount;
DiscountPolicy fixed = amount -> Math.max(0, amount - 3_000);
// Comparator도 전략이다: 정렬 알고리즘은 그대로, 비교 규칙만 갈아 끼운다
products.sort(Comparator.comparingLong(Product::price));
products.sort(
Comparator.comparingDouble(Product::rating)
.reversed()
.thenComparingLong(Product::price)
);실제 쓰임
java.util.Comparator: 정렬 알고리즘(List.sort,Collections.sort)은 그대로 두고 비교 규칙만 바꾼다.@FunctionalInterface라 람다와 메서드 참조로 만들고,comparing,reversed,thenComparing으로 조합한다.- 압축 방식, 재시도 정책(고정 간격, 지수 백오프), 요금제 계산, 경로 탐색 방식처럼 "같은 질문에 답하는 방법이 여러 개"인 곳이면 어디든 쓴다.
장단점
- 장점: 새 방법을 더할 때 기존 코드를 고치지 않는다(개방-폐쇄 원칙). 방법마다 따로 시험할 수 있다. 실행 중에 바꿀 수 있다.
- 단점: 쓰는 쪽이 어떤 전략이 있는지 알고 골라야 한다. 방법이 둘뿐이고 바뀔 일이 없다면
if하나가 더 읽기 쉽다.
주의
- 전략마다 필요한 입력이 다르면 인터페이스가 모든 경우의 인자를 떠안아 비대해진다. 그럴 때는 전략을 만들 때 생성자로 필요한 값을 넣는다(위의
RateDiscount(10)). - 비교자는 일관되어야 한다.
a < b이고b < c인데a > c라고 답하는 비교자는 정렬을 망가뜨리거나 예외를 낸다.
다른 언어에서는
함수가 일급 값인 언어에서는 전략이 그냥 함수다. Python은 sorted와 list.sort에 key 함수를 넘기고(키 함수는 원소마다 한 번만 불린다), 옛 방식의 비교 함수는 functools.cmp_to_key로 감싼다.
def rate_discount(percent: int):
"""비율 할인 정책(함수)을 만든다."""
return lambda amount: amount * (100 - percent) // 100
def pay(amount: int, policy=lambda amount: amount) -> int:
"""정책 함수에 계산을 맡긴다."""
return policy(amount)
total = pay(20_000, rate_discount(10))
products.sort(key=lambda product: (-product.rating, product.price))Go는 함수 타입 하나로 전략을 표현한다. sort.Slice는 비교 함수를 받고(안정 정렬은 아니라 sort.SliceStable이 따로 있다), net/http의 HandlerFunc는 보통 함수를 Handler 인터페이스로 바꿔 주는 어댑터다.
// 함수 타입 하나가 곧 전략 인터페이스다
type DiscountPolicy func(amount int64) int64
func Pay(amount int64, policy DiscountPolicy) int64 {
return policy(amount)
}
sort.Slice(products, func(i, j int) bool {
return products[i].Price < products[j].Price
})옵저버(Observer): 바뀌면 알린다
의도 — 한 객체의 상태가 바뀔 때 그것에 관심 있는 객체들이 자동으로 알림을 받게 하되, 알리는 쪽은 듣는 쪽이 누구인지 모르게 한다.
문제 상황
주식 시세가 바뀌면 차트를 다시 그리고, 기준가를 넘으면 알림을 보내고, 거래 기록도 남겨야 한다. 시세 클래스가 chart.redraw(), alert.check(), log.write()를 직접 부르면, 화면이 하나 늘 때마다 시세 클래스를 고쳐야 하고 시세 클래스가 모든 화면에 의존하게 된다. 버튼을 눌렀을 때 일어날 일을 버튼이 다 알 수 없는 UI 이벤트도 같은 문제다.
구조
예시 코드
interface PriceListener {
void onPrice(String symbol, long price);
}
final class StockTicker {
// 알림 도중 구독이 바뀌어도 순회가 깨지지 않는 스냅숏 목록
private final List<PriceListener> listeners = new CopyOnWriteArrayList<>();
/** 구독하고, 해제할 때 부를 손잡이를 돌려준다 */
Runnable subscribe(PriceListener listener) {
listeners.add(listener);
return () -> listeners.remove(listener);
}
void publish(String symbol, long price) {
for (PriceListener listener : listeners) {
listener.onPrice(symbol, price);
}
}
}
var ticker = new StockTicker();
Runnable unsubscribe = ticker.subscribe((symbol, price) -> chart.addPoint(price));
ticker.publish("A", 10_200);
// 화면을 닫을 때 반드시 해제한다
unsubscribe.run();subscribe가 해제 함수를 돌려주게 하면 "구독한 곳에서 해제도 한다"는 짝이 코드에 드러난다. 목록에 CopyOnWriteArrayList를 쓴 것은 이 목록의 반복자가 만들어질 때의 스냅숏을 돌기 때문이다. 알림을 받던 구독자가 그 자리에서 구독을 해제해도 ConcurrentModificationException이 나지 않는다. 대신 바꿀 때마다 배열 전체를 복사하므로, 순회가 변경보다 훨씬 잦은 리스너 목록 같은 곳에 맞다.
처음: 시세 티커만 있고 구독자는 없다. 가격이 바뀌어도 아무도 모른다.
실제 쓰임
- UI 이벤트: 브라우저의
addEventListener처럼 "이벤트가 나면 등록해 둔 함수를 부른다"는 구조는 옵저버다. 버튼은 누가 듣는지 모른 채 이벤트만 알린다. java.beans.PropertyChangeSupport: 속성이 바뀔 때 리스너에게 알리는 도우미다. 이전 값과 새 값이 같고 null이 아니면 알리지 않는다.java.util.Observer는 쓰지 않는다: Java 9에서Observable과 함께 deprecated 되었다. 문서는 이벤트 모델이 빈약하고, 알림 순서가 정해져 있지 않으며, 상태 변화와 알림이 일대일로 맞지 않는다는 점을 이유로 들고, 대신java.beans,java.util.concurrent의 자료구조,FlowAPI를 권한다.
장단점
- 장점: 주체를 고치지 않고 구독자를 더하고 뺀다. 주체와 구독자를 따로 시험할 수 있다.
- 단점: 흐름이 코드에 드러나지 않는다. "가격을 바꾸면 무슨 일이 일어나나"를 알려면 실행 중에 누가 구독했는지 봐야 한다. 구독자가 다시 주체를 바꾸면 알림이 연쇄되거나 무한히 돈다.
주의
- 메모리 누수(리스너 해제): 주체는 구독자를 강한 참조로 들고 있다. 닫힌 화면이 구독을 해제하지 않으면 주체가 살아 있는 한 그 화면도 수거되지 않는다(lapsed listener 문제). 구독과 해제를 같은 수명 주기(열 때와 닫을 때)에 짝지어 둔다. 약한 참조로 들고 있는 방법도 있지만 언제 사라질지 모른다는 다른 문제가 생긴다.
- 알림 순서: 순서에 기대지 않는다. 위 구현은 등록 순서대로 돌지만
Observable문서도 순서를 약속하지 않는다고 적었다. "차트가 먼저 그려진 다음 알림이 떠야 한다" 같은 요구가 있으면 그것은 옵저버가 아니라 순서를 가진 별도 흐름으로 만든다. - 예외와 스레드: 구독자 하나가 예외를 던지면 뒤 구독자가 알림을 못 받는다. 구독자가 느리면
publish를 부른 스레드 전체가 느려진다. 필요하면 구독자마다 예외를 잡거나 알림을 다른 스레드로 넘긴다. - 이벤트 버스, 발행-구독과의 관계: 옵저버는 주체가 구독자를 직접 들고 보통 같은 호출 안에서 동기로 부른다. 이벤트 버스나 메시지 브로커를 쓰는 발행-구독(pub/sub)은 둘 사이에 중개자를 두어 서로의 존재조차 모르게 하고, 비동기와 분산에 맞다. 결합이 더 느슨해지는 대신 "누가 이 이벤트를 듣는가"는 더 찾기 어렵다.
- Rx와
Flow: ReactiveX는 스스로를 옵저버 패턴을 "값의 연속(시퀀스)"으로 넓힌 것이라 소개한다. 구독자가 값을 끌어오는 이터레이터와 반대로 값이 밀려오고, 그 흐름에map이나filter같은 연산을 건다. Java 9의java.util.concurrent.Flow는 같은 생각의 표준 인터페이스로, 구독자가request(n)으로 요청한 만큼만 받아 빠른 발행자가 느린 구독자를 덮치지 않게 한다(배압).
다른 언어에서는
JavaScript의 이벤트 리스너가 가장 흔한 옵저버다. 해제하려면 등록할 때와 같은 함수 참조를 넘겨야 하므로 익명 함수로 등록하면 해제할 수 없다. AbortController의 signal 옵션으로 여러 리스너를 묶어 한 번에 해제하거나, once: true로 한 번만 받고 저절로 빠지게 할 수 있다.
const controller = new AbortController();
const { signal } = controller;
saveButton.addEventListener('click', onSave, { signal });
window.addEventListener('resize', onResize, { signal });
// 화면을 떠날 때 한 번에 모두 해제한다
controller.abort();Go는 함수 타입의 슬라이스로 간단히 만들고, 구독자가 다른 고루틴에서 받아야 하면 채널을 구독자로 둔다. Python은 콜백 함수 목록을 그대로 쓰거나, 해제를 잊어도 되게 weakref로 약한 참조를 든다.
type PriceListener func(symbol string, price int64)
type Ticker struct {
listeners []PriceListener
}
func (t *Ticker) Subscribe(listener PriceListener) {
t.listeners = append(t.listeners, listener)
}
func (t *Ticker) Publish(symbol string, price int64) {
for _, listener := range t.listeners {
listener(symbol, price)
}
}커맨드(Command): 요청을 객체로 만든다
의도 — "누구에게 무엇을 어떤 인자로 시킬지"를 객체 하나에 담아, 요청을 저장하고, 미루고, 줄 세우고, 되돌릴 수 있게 한다.
문제 상황
문서 편집기에 실행 취소를 넣으려고 하니, 메뉴, 단축키, 도구 모음 버튼이 각자 document.insert(…)를 직접 부르고 있어 "방금 무엇을 했는지"가 어디에도 남지 않는다. 같은 동작을 여러 곳에서 부르는 중복, 나중에 실행하거나 다시 실행할 방법이 없는 것도 같은 뿌리다. 메서드 호출은 일어나는 순간 사라지기 때문이다.
구조
예시 코드
interface Command {
void execute();
void undo();
}
final class InsertText implements Command {
private final Document document;
private final int position;
private final String text;
InsertText(Document document, int position, String text) {
this.document = document;
this.position = position;
this.text = text;
}
@Override
public void execute() {
document.insert(position, text);
}
@Override
public void undo() {
document.delete(position, text.length());
}
}
final class History {
private final Deque<Command> done = new ArrayDeque<>();
private final Deque<Command> undone = new ArrayDeque<>();
void run(Command command) {
command.execute();
done.push(command);
// 새 갈래가 생겼으니 다시 실행할 거리는 버린다
undone.clear();
}
void undo() {
Command command = done.poll();
if (command == null) {
return;
}
command.undo();
undone.push(command);
}
void redo() {
Command command = undone.poll();
if (command == null) {
return;
}
command.execute();
done.push(command);
}
}명령이 "무엇을 했고 어떻게 되돌리는지"를 스스로 들고 있어서, History는 명령의 종류를 몰라도 스택 두 개로 실행 취소와 다시 실행을 해낸다. 되돌린 뒤 새 명령을 실행하면 다시 실행 스택을 비우는 것이 핵심이다. 사용자 쪽에서 실행 취소를 언제 어떻게 보여 줄지(확인 대화상자 대신 되돌리기)는 UX/UI 인터랙션과 상태 노트의 실행 취소 절에 있다.
빈 문서에서 시작한다. 두 스택도 비어 있다.
명령은 다른 명령을 담을 수도 있다. 여러 명령을 하나로 묶으면 매크로가 되고, 되돌릴 때는 거꾸로 되돌린다. 되돌리기가 필요 없다면 Runnable 하나가 곧 명령이고, 이것을 큐에 넣으면 작업 큐가 된다.
/** 여러 명령을 한 명령으로 묶는다. 되돌릴 때는 거꾸로 */
final class Macro implements Command {
private final List<Command> steps;
Macro(List<Command> steps) {
this.steps = List.copyOf(steps);
}
@Override
public void execute() {
steps.forEach(Command::execute);
}
@Override
public void undo() {
for (int index = steps.size() - 1; index >= 0; index--) {
steps.get(index).undo();
}
}
}
// 작업 큐: 되돌리기가 없는 명령이 Runnable이다
ExecutorService pool = Executors.newFixedThreadPool(4);
pool.execute(() -> mailer.sendReceipt(order));실제 쓰임
java.lang.Runnable: 결과를 돌려주지 않는 작업 하나를 뜻하는 함수형 인터페이스다.ExecutorService.execute가 이것을 받고,Thread와TimerTask도 이 인터페이스를 구현한다. 작업을 만드는 쪽과 실행하는 스레드가 서로를 모른다.- Swing
UndoManager: 편집 목록과 "다음 편집" 위치로 실행 취소를 관리한다. 새 편집이 들어오면 되돌려 둔 편집을 지우고, 기본으로 100개까지 들고 있다(setLimit로 바꾼다). - GUI의 Action 객체(이름, 아이콘, 단축키와 동작을 한 객체에), 매크로 기록과 재생, 데이터베이스 작업을 모아 실패하면 되돌리는 트랜잭션에도 쓰인다.
장단점
- 장점: 요청을 값처럼 다룬다. 저장하고(기록, 재시도), 미루고(큐, 예약), 묶고(매크로), 되돌린다. 호출하는 쪽과 일하는 쪽이 갈라진다.
- 단점: 동작마다 클래스가 하나씩 늘어난다. 단순한 호출까지 명령으로 감싸면 코드를 따라가기가 한 단계 멀어진다.
주의
- 되돌리기에 필요한 것을 실행할 때 저장한다: 삭제 명령은 지운 글자를 들고 있어야 되돌릴 수 있다. 실행 전에 알 수 없는 값은
execute안에서 기록한다. - 되돌릴 수 없는 일도 있다: 메일 발송이나 결제처럼 바깥으로 나간 일은
undo로 지울 수 없다. 이런 명령은 되돌리기 대신 보상 동작(취소 메일, 환불)을 따로 둔다. - 스택은 끝없이 자라지 않게: 명령이 큰 데이터를 쥐고 있으면 기록이 메모리를 잡아먹는다. 개수 한도를 두거나 연속 입력을 한 명령으로 합친다.
다른 언어에서는
함수가 일급 값이면 되돌리기가 필요 없는 명령은 클로저 하나로 끝난다. Python은 functools.partial로 함수와 인자를 묶어 나중에 부르고, JavaScript는 { execute, undo } 두 함수를 가진 객체로 실행 취소를 만든다. Go는 func() 슬라이스를 큐로 쓰거나, 되돌리기가 필요하면 Execute()와 Undo()를 가진 인터페이스를 둔다.
from functools import partial
# 인자까지 묶은 호출 하나가 명령이 된다
queue = [
partial(send_mail, to="a@example.com"),
partial(resize_image, path="cover.png", width=800),
]
for command in queue:
command()상태(State): 상태마다 행동을 바꾼다
의도 — 객체의 상태마다 다른 행동을 상태 객체로 나눠, 상태가 바뀌면 객체가 마치 클래스를 바꾼 것처럼 다르게 움직이게 한다.
문제 상황
자판기는 동전이 없을 때, 있을 때, 품절일 때 같은 버튼에 다르게 반응한다. 이것을 필드 하나와 switch로 짜면, 동전 넣기, 반환, 선택, 채우기 메서드마다 같은 switch가 되풀이된다. 상태를 하나 더하면 모든 switch를 찾아 고쳐야 하고, 하나라도 빠뜨리면 이상한 전이가 생긴다. 주문(주문됨 → 결제됨 → 배송 중 → 배송 완료, 중간의 취소)도 같은 모양이다.
void select() {
switch (state) {
case IDLE -> show("먼저 동전을 넣으세요");
case HAS_COIN -> {
dispense();
state = stock > 0 ? State.IDLE : State.SOLD_OUT;
}
case SOLD_OUT -> show("품절");
}
}
// insertCoin(), refund(), restock()에도 같은 switch가 되풀이된다먼저 상태 그림을 그려 보면 무엇을 만들어야 하는지 분명해진다. 상태는 셋, 사건은 넷이고, 화살표마다 "사건 / 결과"를 적었다.
구조
예시 코드
interface State {
default void insertCoin(VendingMachine machine) {
machine.returnCoin();
}
default void refund(VendingMachine machine) {
}
default void select(VendingMachine machine) {
machine.show("먼저 동전을 넣으세요");
}
}
final class HasCoin implements State {
@Override
public void refund(VendingMachine machine) {
machine.returnCoin();
machine.setState(new Idle());
}
@Override
public void select(VendingMachine machine) {
machine.dispense();
State next = machine.stock() > 0 ? new Idle() : new SoldOut();
machine.setState(next);
}
}
final class VendingMachine {
private State state = new Idle();
private int stock;
void insertCoin() {
state.insertCoin(this);
}
void select() {
state.select(this);
}
void setState(State next) {
state = next;
}
// dispense(), returnCoin(), stock() 등은 생략
}인터페이스의 default 메서드에 "이 상태에서는 받지 않는 사건"의 기본 반응을 두면, 상태 클래스는 자기에게 의미 있는 사건만 덮어쓴다. 상태 그림의 화살표 하나가 메서드 하나와 짝을 이룬다.
상태가 적고 상태별 데이터가 없으면 클래스 여러 개 대신 enum 하나로 충분하다. 상태 패턴은 상태마다 행동이 크게 다르고 상태가 늘어날 것 같을 때 값어치가 있다.
// 상태가 적고 데이터가 없으면 enum 하나로도 충분하다
enum OrderStatus {
PLACED, PAID, SHIPPED, DELIVERED, CANCELLED;
boolean canCancel() {
return this == PLACED || this == PAID;
}
}실제 쓰임
- 주문, 결제, 배송처럼 업무 흐름이 상태 기계인 도메인 객체.
- 네트워크 연결(연결 중, 연결됨, 끊김), 문서 편집기의 도구(선택, 펜, 지우개), 게임 캐릭터(서기, 달리기, 점프)처럼 같은 입력에 모드마다 다르게 반응하는 곳.
장단점
- 장점: 상태 하나의 행동이 한 클래스에 모인다. 새 상태는 클래스 하나를 더하는 일이다. 거대한 조건문이 사라진다.
- 단점: 전체 전이가 여러 클래스에 흩어져 한눈에 보이지 않는다. 그래서 상태 그림이나 전이 표를 문서나 테스트로 함께 둔다.
주의
- 전략과의 차이: 구조 그림은 전략과 거의 같다. 다른 것은 누가 바꾸느냐다. 전략은 바깥이 골라 넣고 보통 그대로 쓰지만, 상태는 상태 객체가 사건을 처리하면서 스스로 다음 상태로 바꾼다. 상태 객체들은 서로를 알고, 전략 객체들은 서로를 모른다.
- 전이 규칙을 시험한다: "어느 상태에서 어느 사건이 오면 어디로 가는가" 표를 테스트로 만들어 두면 상태를 더할 때 빠진 화살표를 잡는다. 이 글의 상태 그림도 같은 방식으로 검사했다.
- 상태를 저장할 때: 데이터베이스에는 상태 객체가 아니라 상태 이름을 저장하고, 읽을 때 이름으로 객체를 다시 만든다.
다른 언어에서는
Go는 같은 방법을 인터페이스로 쓰고, 상태가 단순하면 상수와 switch를 그대로 둔다. Python은 상태가 적으면 match 문이나 "상태 → 처리 함수" 사전으로, 많으면 상태 클래스로 나눈다. JavaScript UI에서는 화면 상태(불러오는 중, 성공, 실패)를 하나의 상태 값으로 두고 그 값으로 화면을 고르는 식으로 같은 생각을 쓴다. 불리언 여러 개(isLoading, isError)로 상태를 나타내면 있을 수 없는 조합이 생기므로 하나로 모은다.
템플릿 메서드(Template Method): 골격은 부모, 단계는 자식
의도 — 일의 순서(골격)를 부모 클래스의 메서드 하나에 고정하고, 그 안의 몇 단계만 자식 클래스가 채우게 한다.
문제 상황
상품 목록을 CSV와 JSON으로 내보내는 두 클래스가 있다. 둘 다 "머리를 쓰고, 행마다 한 줄을 쓰고, 꼬리를 쓴다"는 같은 순서를 각자 복사해 들고 있다. 빈 목록 처리나 마지막 행의 쉼표 같은 규칙을 한쪽만 고치면 두 형식의 동작이 어긋난다. 순서는 같고 세부만 다른 코드가 여러 벌 복사된 것이 신호다.
구조
예시 코드
abstract class DataExporter {
/** 순서는 부모가 정하고, 자식이 바꾸지 못하게 final로 막는다 */
public final String export(List<Product> rows) {
var out = new StringBuilder(header());
for (int index = 0; index < rows.size(); index++) {
boolean last = index == rows.size() - 1;
out.append(row(rows.get(index), last));
}
return out.append(footer()).toString();
}
protected abstract String header();
protected abstract String row(Product product, boolean last);
/** 훅: 기본은 아무것도 붙이지 않는다 */
protected String footer() {
return "";
}
}
final class CsvExporter extends DataExporter {
@Override
protected String header() {
return "name,price\n";
}
@Override
protected String row(Product product, boolean last) {
return product.name() + "," + product.price() + "\n";
}
}
final class JsonExporter extends DataExporter {
@Override
protected String header() {
return "[\n";
}
@Override
protected String row(Product product, boolean last) {
String line = " {\"name\": \"%s\", \"price\": %d}".formatted(product.name(), product.price());
return line + (last ? "" : ",") + "\n";
}
@Override
protected String footer() {
return "]\n";
}
}단계는 두 종류다. 자식이 반드시 채워야 하는 추상 단계(header, row)와, 부모가 기본 동작(대개 아무것도 하지 않음)을 주고 필요한 자식만 덮어쓰는 훅 메서드(footer)다. 훅은 자식이 골격 중간에 끼어들 자리를 미리 열어 두는 장치다.
제어 흐름은 부모가 쥔다. 자식은 부모를 부르지 않고, 부모가 정한 때에 자식의 단계가 불린다. 이것을 흔히 할리우드 원칙("우리에게 연락하지 마세요, 우리가 부를게요")이라 부르며, 프레임워크가 내 코드를 부르는 제어의 역전(IoC)의 가장 단순한 모습이다.
실제 쓰임
java.util.AbstractList: 읽기 전용 목록은get(int)과size()만 구현하면 되고, 반복자 같은 나머지는 부모가 그 둘로 만들어 준다. 바꿀 수 있는 목록은set을, 크기가 변하는 목록은add(int, E)와remove(int)를 더 덮어쓴다. 덮어쓰지 않은set은UnsupportedOperationException을 던진다.- Spring
JdbcTemplate은 이름과 달리 콜백 방식이다: 연결을 얻고 닫기, 문장 실행, 결과 순회, 예외 변환이라는 골격을 템플릿이 쥐는 것은 같지만, 바뀌는 부분을 상속으로 받지 않고RowMapper,PreparedStatementCreator같은 콜백 인터페이스로 받는다. 구조로 보면 템플릿 메서드보다 "골격 + 전략(콜백)"에 가깝다.
/** start부터 size개의 정수를 담은 읽기 전용 목록 */
final class Range extends AbstractList<Integer> {
private final int start;
private final int size;
Range(int start, int size) {
this.start = start;
this.size = size;
}
@Override
public Integer get(int index) {
Objects.checkIndex(index, size);
return start + index;
}
@Override
public int size() {
return size;
}
}
// 반복자, indexOf, contains 같은 나머지는 부모가 get과 size로 만들어 준다// 상속하지 않는다. 바뀌는 부분(행 하나를 객체로)을 람다로 넘긴다
List<Product> products = jdbcTemplate.query(
"select name, price from product",
(resultSet, rowNumber) -> new Product(
resultSet.getString("name"),
resultSet.getLong("price")
)
);장단점
- 장점: 순서와 공통 처리가 한 곳에 있어 중복이 사라진다. 자식이 바꿀 수 있는 곳이 단계와 훅으로 정해져 있다.
- 단점: 상속에 묶인다. 자식은 부모 하나만 고를 수 있고, 부모를 고치면 모든 자식이 영향을 받는다. 단계가 많아지면 "지금 어느 메서드가 어디서 불리는지"를 부모와 자식을 오가며 읽어야 한다.
주의
- 골격 메서드는
final로: 자식이 순서 자체를 덮어쓰면 패턴이 무너진다. - 단계는
protected로: 단계는 골격이 부르는 부품이지 바깥에서 따로 부를 메서드가 아니다. - 합성으로 바꿀 수 있는지 먼저 본다: 단계가 한두 개라면 그 단계를 함수(전략)로 받는 편이 상속보다 가볍다.
JdbcTemplate이 그 길을 택했다.
다른 언어에서는
Python은 abc.ABC와 @abstractmethod로 같은 구조를 그대로 만든다. 하지만 단계가 적으면 함수를 인자로 받는 편이 더 흔하다. Go는 클래스 상속이 없어서 템플릿 메서드를 "골격 함수 + 단계를 담은 인터페이스"로 쓴다. 이는 구조상 전략과 같아, Go에서는 두 패턴의 경계가 거의 사라진다. JavaScript는 클래스 상속으로도, 단계 함수를 객체로 넘기는 방식으로도 쓴다.
from abc import ABC, abstractmethod
class DataExporter(ABC):
"""내보내기의 순서를 정하는 부모."""
def export(self, rows) -> str:
"""머리, 행마다, 꼬리 순서로 이어 붙인다."""
body = [self.row(row, index == len(rows) - 1) for index, row in enumerate(rows)]
return self.header() + "".join(body) + self.footer()
@abstractmethod
def header(self) -> str: ...
@abstractmethod
def row(self, row, last: bool) -> str: ...
def footer(self) -> str:
"""훅: 기본은 빈 문자열."""
return ""네 패턴 한눈에 비교: 전략, 상태, 템플릿 메서드, 커맨드
네 패턴 모두 "바뀌는 것을 떼어 낸다"지만, 무엇을 떼어 내고 누가 그것을 고르느냐가 다르다. 헷갈리면 "누가 바꾸는가"와 "상속인가 합성인가" 두 질문으로 가른다.
| 패턴 | 떼어 내는 것 | 누가 고르나 | 묶는 방식 | 대표 예 |
|---|---|---|---|---|
| 전략 | 알고리즘 하나(계산법, 비교 규칙) | 바깥(클라이언트)이 골라 넣는다 | 합성, 실행 중 교체 | Comparator, 할인 정책 |
| 상태 | 상태마다 다른 행동 묶음 | 상태 객체가 다음 상태를 스스로 고른다 | 합성, 사건마다 교체 | 자판기, 주문 상태 |
| 템플릿 메서드 | 정해진 순서 속 몇 단계 | 어느 자식 클래스를 만드느냐로 정해진다 | 상속, 컴파일 때 고정 | AbstractList, 내보내기 |
| 커맨드 | 요청 자체(무엇을 누구에게) | 만든 쪽과 실행하는 쪽이 따로다 | 요청을 객체로 저장 | Runnable, 실행 취소 |
- 전략 vs 상태: 구조는 같다. 바깥이 한 번 골라 넣으면 전략, 객체가 사건에 따라 스스로 갈아타면 상태다.
- 전략 vs 템플릿 메서드: 같은 문제(바뀌는 알고리즘)를 합성으로 풀면 전략, 상속으로 풀면 템플릿 메서드다. 전략은 알고리즘 전체를, 템플릿 메서드는 그 일부 단계만 바꾼다.
- 전략 vs 커맨드: 둘 다 람다 하나로 줄어들 때가 많다. 전략은 "어떻게 할지"를 받아 지금 쓰고, 커맨드는 "무엇을 할지"를 받아 나중에 실행하거나 되돌린다.
흔한 오해
- "람다가 생겼으니 전략 패턴은 필요 없다" — 람다가 곧 전략 객체다. 클래스를 덜 쓰게 되었을 뿐 "계산법을 바깥에서 넣는다"는 설계는 그대로다.
- "전략과 상태는 그림이 같으니 같은 패턴이다" — 누가 언제 바꾸는지가 다르다. 상태 객체는 다음 상태를 알고, 전략 객체는 다른 전략을 모른다.
- "옵저버를 쓰면 결합이 사라진다" — 줄어들 뿐이다. 주체는 여전히 구독자 목록을 들고 있고, 해제를 잊으면 메모리가 새며, 알림 순서나 예외는 여전히 신경 써야 한다.
- "Java에는
java.util.Observer가 있으니 그걸 쓰면 된다" — Java 9부터 deprecated다. 타입이 있는 리스너 인터페이스를 직접 만들거나,PropertyChangeSupport,Flow를 쓴다. - "
JdbcTemplate은 템플릿 메서드 패턴의 예다" — 골격을 쥔다는 생각은 같지만 상속이 아니라 콜백을 받는다. 이름만 보고 구조를 짐작하지 않는다. - "커맨드는 실행 취소용이다" — 실행 취소는 쓰임 하나다. 작업 큐, 예약 실행, 재시도, 매크로, 감사 기록 모두 "요청을 객체로 남긴다"에서 나온다.
- "패턴을 쓰면 코드가 좋아진다" — 분기가 둘이고 늘 일이 없으면
if가 낫다. 패턴은 바뀌는 방향이 보일 때 그 방향으로 문을 내는 일이다.
핵심 자료
요지는 직접 정리한 한 줄이다.
| 자료 | 한 줄 요지 |
|---|---|
| Oracle — Observable | Java 9에서 Observer와 함께 deprecated. 알림 순서가 정해지지 않았다는 점과 대안(java.beans, java.util.concurrent, Flow)을 적었다. |
| Oracle — Comparator | 함수형 인터페이스라 람다가 곧 전략이고, comparing, reversed, thenComparing으로 조합한다. |
| Oracle — UndoManager | 편집 목록과 다음 위치로 실행 취소를 관리하고, 새 편집이 오면 되돌린 편집을 버린다. 기본 한도 100개. |
| Spring — JdbcTemplate | 연결과 예외 처리는 템플릿이, SQL과 행 매핑은 콜백 인터페이스(RowMapper 등)가 맡는다. |
| MDN — addEventListener | 해제하려면 같은 함수 참조가 필요하고, signal이나 once로 해제를 묶을 수 있다. |
| Wikipedia — Observer pattern | 해제를 잊은 구독자가 남는 lapsed listener 문제와 발행-구독과의 차이. |
| Wikipedia — State pattern | 상태 객체가 문맥의 setState를 불러 전이한다. 전략과 구조는 같고 목적이 다르다. |
| Wikipedia — Inversion of control | 할리우드 원칙과 템플릿 메서드, 콜백, 이벤트 루프를 같은 원리로 묶는다. |
- Behavioral pattern — Wikipedia
- Strategy pattern — Wikipedia
- Observer pattern — Wikipedia
- Command pattern — Wikipedia
- State pattern — Wikipedia
- Template method pattern — Wikipedia
- Inversion of control — Wikipedia
- Comparator (Java SE 21) — Oracle
- Observer (Java SE 21) — Oracle
- Observable (Java SE 21) — Oracle
- PropertyChangeSupport (Java SE 21) — Oracle
- Flow (Java SE 21) — Oracle
- CopyOnWriteArrayList (Java SE 21) — Oracle
- Runnable (Java SE 21) — Oracle
- ExecutorService (Java SE 21) — Oracle
- UndoManager (Java SE 21) — Oracle
- AbstractList (Java SE 21) — Oracle
- JDBC Core Classes: JdbcTemplate — Spring Framework
- EventTarget: addEventListener() — MDN Web Docs
- ReactiveX Introduction — ReactiveX
- Sorting Techniques — Python 문서
- net/http HandlerFunc — Go 패키지 문서
- sort.Slice — Go 패키지 문서