테마 선택

📚 지식 노트

AI·하드웨어·주변기기·게임·컴퓨터 시스템·이론 컴퓨터 과학·수학·개발과 운영·보안·응용 분야에 대해 알아 두면 좋은 내용을 정리한 노트다. 위에서 분야를 고르고 아래에서 노트를 고른다.

행위 패턴 1: 전략·옵저버·커맨드·상태·템플릿 메서드

알고리즘을 갈아 끼우고, 변화를 알리고, 요청을 객체로 만들고, 상태마다 행동을 바꾸는 법이다.

목차 (9)
  1. 행위 패턴이 다루는 것: 책임과 대화
  2. 전략(Strategy): 알고리즘을 갈아 끼운다
  3. 옵저버(Observer): 바뀌면 알린다
  4. 커맨드(Command): 요청을 객체로 만든다
  5. 상태(State): 상태마다 행동을 바꾼다
  6. 템플릿 메서드(Template Method): 골격은 부모, 단계는 자식
  7. 네 패턴 한눈에 비교: 전략, 상태, 템플릿 메서드, 커맨드
  8. 흔한 오해
  9. 핵심 자료

행위 패턴이 다루는 것: 책임과 대화

생성 패턴이 객체를 만드는 일을, 구조 패턴이 객체를 엮는 일을 다룬다면, 행위 패턴은 객체들이 일을 나누고 말을 주고받는 방식을 다룬다. 누가 무엇을 책임지는지, 누가 누구를 부르는지, 그 관계를 언제 바꿀 수 있는지가 관심사다. 패턴이라는 말이 어디서 왔고 GoF 23가지가 어떻게 나뉘는지는 디자인 패턴이란 글에, 그 바탕이 되는 SOLID 같은 설계 원칙은 소프트웨어 공학 노트에 있다.

이 글의 다섯 패턴은 모두 "바뀌는 부분을 객체 하나로 떼어 낸다"는 같은 생각에서 출발한다. 무엇을 떼어 내느냐가 다르다. 전략은 계산법을, 옵저버는 "누가 듣는가"를, 커맨드는 요청 자체를, 상태는 상태마다의 행동을, 템플릿 메서드는 순서 속 몇 단계를 떼어 낸다.

패턴마다 같은 틀로 쓴다: 의도(한 문장) → 문제 상황(어떤 코드 냄새에서 시작하나) → 구조 그림 → 예시 코드(Java) → 실제 쓰임 → 장단점 → 주의 → 다른 언어에서는. 구조 그림은 UML 클래스 그림을 줄여 그렸고, 읽는 법은 아래 범례 하나로 갈음한다.

클래스 그림 읽는 법: 인터페이스, 추상 클래스, 구체 클래스 상자와 구현, 상속, 참조, 의존 화살표«interface»DiscountPolicy+ apply(amount)인터페이스약속(메서드 이름)만 있다«abstract»DataExporter# header()추상 클래스이름을 기울여 쓴다Checkout- policy+ pay(amount)구체 클래스+ 공개, - 비공개, # 자식용구현한다 (implements)상속한다 (extends)가진다 (필드로 참조)부른다, 만든다 (의존)
이 글의 클래스 그림 범례. 상자 맨 위 «interface»·«abstract»는 종류, 굵은 글자는 이름, 줄 아래는 필드와 메서드다. 빈 삼각형 화살표는 '~의 한 종류'(구현·상속), 뾰족한 화살표는 '안다'(참조·호출)를 뜻한다.

전략(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;
}

구조

전략 패턴 구조: Checkout이 DiscountPolicy 인터페이스를 가지고, NoDiscount, RateDiscount, FixedDiscount가 그 인터페이스를 구현한다. 2만 원 주문은 각각 2만 원, 1만 8천 원, 1만 7천 원이 된다문맥(Context)전략(Strategy)Checkout- policy: DiscountPolicy+ pay(amount)«interface»DiscountPolicy+ apply(amount): long가진다NoDiscount금액 그대로20,000원 → 20,000원RateDiscount금액 × (100 − p)%20,000원 → 18,000원FixedDiscount금액 − off, 0 이상20,000원 → 17,000원Checkout은 할인 계산법을모른다. apply()만 부른다.새 정책은 새 클래스 하나(Checkout은 고치지 않는다)주문 금액에 끼운 결과
계산대(Checkout)는 할인 정책 인터페이스 하나만 안다. 정책 셋은 서로를 모르고, 새 정책은 클래스 하나를 더하면 된다. 맨 아래는 2만 원 주문에 각 정책을 끼운 결과로, 계산 함수로 구했다.

예시 코드

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 이벤트도 같은 문제다.

구조

옵저버 패턴 구조: StockTicker가 PriceListener 목록을 가지고, PriceChart, PriceAlert, TradeLog가 PriceListener를 구현한다주체(Subject, 발행자)관찰자(Observer, 구독자)StockTicker- listeners: List<PriceListener>+ subscribe(l): Runnable+ publish(symbol, price)«interface»PriceListener+ onPrice(symbol, price)0..* 가진다PriceChart점 추가PriceAlert기준가 넘으면 알림TradeLog한 줄 기록publish()는 목록을 돌며onPrice()를 차례로 부른다.구독자가 무엇을 하는지는주체가 모른다
주체(StockTicker)는 PriceListener 목록만 들고 있다. 구독자 셋은 각자 알아서 반응하고, 새 구독자가 생겨도 주체는 그대로다.

예시 코드

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이 나지 않는다. 대신 바꿀 때마다 배열 전체를 복사하므로, 순회가 변경보다 훨씬 잦은 리스너 목록 같은 곳에 맞다.

옵저버 단계 그림: 차트와 알림이 구독하고, 가격 알림을 받고, 로그가 뒤늦게 구독하고, 차트가 해제된 뒤에는 알림과 로그만 받는다StockTicker현재가알림 기준가 10,000원처음 상태—차트구독 안 함알림구독 안 함로그구독 안 함구독 목록: []subscribe(차트)—차트등록 순서 1알림구독 안 함로그구독 안 함구독 목록: [차트]subscribe(알림)—차트등록 순서 1알림등록 순서 2로그구독 안 함구독 목록: [차트, 알림]publish(10,200원)10,200원차트등록 순서 1알림등록 순서 2로그구독 안 함1번째점 하나 추가2번째기준가 넘음: 알림구독 목록: [차트, 알림]subscribe(로그)10,200원차트등록 순서 1알림등록 순서 2로그등록 순서 3구독 목록: [차트, 알림, 로그]publish(10,500원)10,500원차트등록 순서 1알림등록 순서 2로그등록 순서 31번째점 하나 추가2번째기준가 넘음: 알림3번째한 줄 기록구독 목록: [차트, 알림, 로그]unsubscribe(차트)10,500원차트해제됨알림등록 순서 1로그등록 순서 2구독 목록: [알림, 로그]publish(9,900원)9,900원차트해제됨알림등록 순서 1로그등록 순서 21번째기준가 아래: 조용2번째한 줄 기록구독 목록: [알림, 로그]
1 / 8

처음: 시세 티커만 있고 구독자는 없다. 가격이 바뀌어도 아무도 모른다.

시세 티커와 세 구독자. 구독자는 목록에 들어오고 나가며, 알림은 그때의 목록을 등록 순서대로 돈다. 장면은 계산 함수로 구했다.

실제 쓰임

  • UI 이벤트: 브라우저의 addEventListener처럼 "이벤트가 나면 등록해 둔 함수를 부른다"는 구조는 옵저버다. 버튼은 누가 듣는지 모른 채 이벤트만 알린다.
  • java.beans.PropertyChangeSupport: 속성이 바뀔 때 리스너에게 알리는 도우미다. 이전 값과 새 값이 같고 null이 아니면 알리지 않는다.
  • java.util.Observer는 쓰지 않는다: Java 9에서 Observable과 함께 deprecated 되었다. 문서는 이벤트 모델이 빈약하고, 알림 순서가 정해져 있지 않으며, 상태 변화와 알림이 일대일로 맞지 않는다는 점을 이유로 들고, 대신 java.beans, java.util.concurrent의 자료구조, Flow API를 권한다.

장단점

  • 장점: 주체를 고치지 않고 구독자를 더하고 뺀다. 주체와 구독자를 따로 시험할 수 있다.
  • 단점: 흐름이 코드에 드러나지 않는다. "가격을 바꾸면 무슨 일이 일어나나"를 알려면 실행 중에 누가 구독했는지 봐야 한다. 구독자가 다시 주체를 바꾸면 알림이 연쇄되거나 무한히 돈다.

주의

  • 메모리 누수(리스너 해제): 주체는 구독자를 강한 참조로 들고 있다. 닫힌 화면이 구독을 해제하지 않으면 주체가 살아 있는 한 그 화면도 수거되지 않는다(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(…)를 직접 부르고 있어 "방금 무엇을 했는지"가 어디에도 남지 않는다. 같은 동작을 여러 곳에서 부르는 중복, 나중에 실행하거나 다시 실행할 방법이 없는 것도 같은 뿌리다. 메서드 호출은 일어나는 순간 사라지기 때문이다.

구조

커맨드 패턴 구조: History가 Command 목록을 쌓아 두고, InsertText와 DeleteText가 Command를 구현하며 둘 다 Document를 가진다호출자(Invoker)명령(Command)수신자(Receiver)History- done: Deque<Command>- undone: Deque<Command>+ run(command)+ undo()+ redo()«interface»Command+ execute()+ undo()Document+ insert()+ delete()쌓아 둔다InsertTextposition, textDeleteTextposition, removed두 명령 모두 Document를 가진다History는 명령이 무엇을하는지 모른다.execute(), undo()만 부른다
호출자(History)는 Command 인터페이스만 알고 명령을 쌓아 둔다. 실제 일은 명령이 가진 수신자(Document)가 한다. 명령을 만들어 History에 넘기는 쪽(메뉴, 단축키)이 클라이언트다.

예시 코드

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 인터랙션과 상태 노트의 실행 취소 절에 있다.

실행 취소 단계 그림: 안녕, 하세요, 느낌표를 입력하고 두 번 되돌린 뒤 한 번 다시 실행하고, 물음표를 입력하면 다시 실행 스택이 비워진다Document (수신자)실행 취소 스택 (done)다시 실행 스택 (undone)맨 위 칸이 다음에 꺼낼 명령이다빈 문서(빈 문서)명령을 실행하면 done에쌓인다비어 있음비어 있음run(입력 "안녕")안녕execute() 후 done에넣는다입력 "안녕"비어 있음run(입력 "하세요")안녕하세요execute() 후 done에넣는다입력 "안녕"입력 "하세요"비어 있음run(입력 "!")안녕하세요!execute() 후 done에넣는다입력 "안녕"입력 "하세요"입력 "!"비어 있음undo() · Ctrl+Z안녕하세요done 맨 위를 꺼내undo()하고 undone에 넣는다입력 "안녕"입력 "하세요"입력 "!"undo() · Ctrl+Z안녕done 맨 위를 꺼내undo()하고 undone에 넣는다입력 "안녕"입력 "!"입력 "하세요"redo() · Ctrl+Y안녕하세요undone 맨 위를 꺼내execute()하고 done에 넣는다입력 "안녕"입력 "하세요"입력 "!"run(입력 "?")안녕하세요?execute() 후 done에 넣고새 갈래라 undone을 비운다입력 "안녕"입력 "하세요"입력 "?"비어 있음
1 / 8

빈 문서에서 시작한다. 두 스택도 비어 있다.

편집기의 실행 취소와 다시 실행. 명령 객체가 '무엇을 했고 어떻게 되돌리는지'를 들고 있어서, 호출자는 스택 두 개만 관리하면 된다. 장면은 계산 함수로 구했다.

명령은 다른 명령을 담을 수도 있다. 여러 명령을 하나로 묶으면 매크로가 되고, 되돌릴 때는 거꾸로 되돌린다. 되돌리기가 필요 없다면 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가 되풀이된다

먼저 상태 그림을 그려 보면 무엇을 만들어야 하는지 분명해진다. 상태는 셋, 사건은 넷이고, 화살표마다 "사건 / 결과"를 적었다.

자판기 상태 그림: 대기에서 동전을 넣으면 동전 있음, 반환하면 대기, 선택하면 음료를 내고 재고가 남으면 대기, 마지막이면 품절, 품절은 채우면 대기로 돌아간다대기동전 있음품절동전반환 / 동전선택 / 음료 (재고 2개 이상)선택 / 음료(마지막 1개)동전 / 반환동전 / 반환채우기화살표는 "사건 / 결과". 그림에 없는 사건(대기 중 선택 등)은 상태를 바꾸지 않고 안내만 한다
자판기의 상태 그림. 동전 있음에서 선택하면 재고에 따라 대기나 품절로 간다. 화살표 목록은 계산 함수의 전이 규칙과 같은지 테스트로 확인했다.

구조

상태 패턴 구조: VendingMachine이 State 하나를 가지고, Idle, HasCoin, SoldOut이 State를 구현하며, 상태 객체가 VendingMachine의 setState를 불러 다음 상태로 바꾼다문맥(Context)상태(State)VendingMachine- state: State- stock: int+ insertCoin()+ select()+ setState(next)«interface»State+ insertCoin(machine)+ refund(machine)+ select(machine)지금 상태 하나Idle동전 받으면 HasCoinHasCoin선택하면 음료SoldOut동전은 돌려준다상태 객체가 직접setState() 호출다음 상태는 문맥이 아니라 상태 객체가 정한다
자판기(문맥)는 지금 상태 객체 하나를 들고 모든 요청을 그대로 넘긴다. 상태 객체가 일을 처리하고, 필요하면 setState로 다음 상태를 넣는다.

예시 코드

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으로 내보내는 두 클래스가 있다. 둘 다 "머리를 쓰고, 행마다 한 줄을 쓰고, 꼬리를 쓴다"는 같은 순서를 각자 복사해 들고 있다. 빈 목록 처리나 마지막 행의 쉼표 같은 규칙을 한쪽만 고치면 두 형식의 동작이 어긋난다. 순서는 같고 세부만 다른 코드가 여러 벌 복사된 것이 신호다.

구조

템플릿 메서드 구조: 추상 클래스 DataExporter의 export가 header, row, footer 순서를 정하고, CsvExporter와 JsonExporter가 단계를 채워 CSV 세 줄과 JSON 네 줄을 만든다골격은 부모, 단계는 자식export()가 정한 순서«abstract»DataExporter+ export(rows) «final»# header() 추상# row(product, last) 추상# footer() 훅, 기본은 빈 값CsvExporterheader(), row()JsonExporterheader(), row(),footer()header()row() × nfooter()점선 = 훅(덮어써도 되고 안 해도 된다)CsvExporter 결과 (꼬리 없음)name,price키보드,39000마우스,21000JsonExporter 결과[ {"name": "키보드", "price": 39000}, {"name": "마우스", "price": 21000}]
부모의 export()가 머리 → 행마다 → 꼬리 순서를 정하고 final로 막는다. 자식은 단계만 채운다. footer()는 훅이라 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 — ObservableJava 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할리우드 원칙과 템플릿 메서드, 콜백, 이벤트 루프를 같은 원리로 묶는다.