문서 읽는 데 70분 · C3

C-3: 행위 패턴 — 객체가 어떻게 협력하나, 전략·옵저버·템플릿 메서드·상태·커맨드

목차 31
전체 11강 중 9강 · 클린코드
난이도 · 중급

ℹ️읽기 좋고 고치기 쉬운 코드를 쓰는 법 — 리팩토링·설계 원칙. 코드를 충분히 써본 뒤에 빛을 발하는 주제예요.

안녕하세요, 홍순구 튜터입니다. 지난 두 시간 동안 우리는 카테고리 C를 절반 넘게 걸어왔어요. 생성 패턴으로 "객체를 어떻게 만드나"를 보고, 구조 패턴으로 "이미 만들어진 객체를 어떻게 조립하나"를 봤죠. 팩토리·빌더·싱글톤으로 객체를 태어나게 했고, 어댑터·데코레이터·퍼사드·프록시로 그 객체들을 감싸고 묶었습니다.

오늘은 디자인 패턴의 마지막 갈래, 행위 패턴(behavioral pattern)으로 갑니다. 객체를 만들었고(C-1) 조립도 했으니(C-2), 이제 그 엮인 객체들이 어떻게 일을 주고받는지를 볼 차례예요. 지난 시간이 "객체를 어떻게 엮나"였다면, 오늘은 "엮은 객체들이 어떻게 협력하나"입니다.

텍스트
 카테고리 C — 디자인 패턴, 검증된 해법의 카탈로그

 C-1  생성 패턴   "객체를 어떻게 만드나"     (지난 시간)
        팩토리 · 빌더 · 싱글톤(+안티패턴)
 C-2  구조 패턴   "객체를 어떻게 조립하나"    (지난 시간)
        어댑터 · 데코레이터 · 퍼사드 · 프록시
 C-3  행위 패턴   "객체가 어떻게 협력하나"     오늘 여기
        전략 · 옵저버 · 템플릿 메서드 · 상태 · 커맨드

행위 패턴은 코드 리뷰에서 자주 듣는 말들과 짝이 맞아요. "if-else 분기가 스무 개예요", "상태마다 같은 조건문이 중복돼요" 같은 지적이 바로 행위 패턴이 풀어 주는 문제입니다. 그리고 오늘 우리가 B-1에서 본 OCP("확장에는 열고 수정에는 닫는다")가 전략이라는 이름을 달고 돌아오고, C-2에서 예고했던 "거대한 조건문을 다형성으로"가 상태 패턴으로 실현됩니다.

다만 오늘은 마지막에 한 가지를 정면으로 짚어요. 패턴 다섯 개를 손에 쥐면 어디에나 쓰고 싶어지는데, "이거 그냥 함수 하나면 될 걸 클래스 다섯 개로 만들었네요"라는 말이 가장 무서운 코드 리뷰 지적입니다. 패턴은 무기지, 모든 못을 박는 망치가 아니에요. C 카테고리를 닫으며 그 경계를 분명히 긋겠습니다.


💡 오늘 수업의 핵심 — "알고리즘은 전략으로 갈아 끼우고, 변화는 옵저버로 알리고, 절차의 뼈대는 템플릿으로 고정하고, 상태는 객체로 나누고, 요청은 커맨드로 포장한다 — 그리고 다섯 패턴 모두 '단순함이 먼저'라는 경계 위에 선다"

🎯 학습 목표

  1. 행위 패턴이 "객체가 어떻게 협력하나"를 푸는 갈래임을 이해하고, 전략·옵저버·템플릿 메서드·상태·커맨드 다섯을 지도로 잡는다.
  2. 전략으로 거대한 if/switch를 갈아 끼울 수 있는 알고리즘으로 바꾸고(B-1 OCP 회수), 그 전략이 Java 람다·Python 함수 하나로도 표현됨을 본다. 상태로 흩어진 조건문을 상태별 객체로 모은다(C-2 회수).
  3. 옵저버로 느슨하게 알리고, 템플릿 메서드로 절차의 뼈대를 고정하며, 커맨드로 요청을 객체로 포장한다. 그리고 패턴마다 "언제 안 쓰나"의 경계를 쥐고, 마지막에 "패턴을 위한 패턴"의 함정을 정면으로 본다.

Step 1: "객체가 어떻게 협력하나" — 행위 패턴의 지도

생성 패턴이 "객체를 어떻게 태어나게 하나", 구조 패턴이 "객체를 어떻게 조립하나"였다면, 행위 패턴은 그렇게 만들어 조립한 객체들이 실행 중에 어떻게 일을 나눠 맡고 주고받나를 다룹니다. 한 객체가 모든 일을 떠안지 않고, 책임을 잘게 나눠 협력하게 만드는 거예요.

오늘 걸을 다섯 패턴을 먼저 지도에 펼쳐 봅시다.

텍스트
 행위 패턴 — "객체가 어떻게 협력하나"

 전략         알고리즘을 통째로 갈아 끼운다              (거대한 if/switch 제거)
 옵저버       상태 변화를 구독자들에게 알린다             (느슨한 결합)
 템플릿 메서드  절차의 뼈대는 고정, 한 칸만 자식이 채운다     (상속 기반)
 상태         상태에 따라 행동이 바뀐다                 (if 지옥을 다형성으로)
 커맨드       요청 자체를 객체로 포장한다                (실행 취소·작업 큐)

다섯 패턴을 관통하는 통찰이 하나 있어요. 행위 패턴 상당수는 "바뀌는 부분"을 인터페이스 뒤로 빼낸다는 점에서 닮았습니다. 배송비 계산 방식이 바뀌면 전략으로, 알려야 할 대상이 바뀌면 옵저버로, 절차 중 한 칸이 바뀌면 템플릿으로, 상태마다 행동이 바뀌면 상태 패턴으로 빼냅니다. 변하는 것과 변하지 않는 것을 가르고, 변하는 쪽을 갈아 끼울 수 있게 만드는 게 행위 패턴의 공통된 정신이에요.

특히 전략과 상태는 코드 구조가 거의 똑같습니다. 둘 다 인터페이스 하나에 여러 구현, 그리고 그걸 들고 쓰는 컨텍스트로 이뤄져요. 그런데 의도가 다릅니다. 전략은 외부에서 알고리즘을 골라 끼우고, 상태는 객체가 스스로 다음 상태로 넘어갑니다. 이 차이는 Step 5에서 나란히 두고 보겠습니다. 지금은 "구조가 비슷해도 의도로 갈린다"만 담고 첫 패턴으로 들어가요.

💡 한 줄 정리

행위 패턴은 객체들이 실행 중에 어떻게 협력하나를 푼다. 공통 정신은 "바뀌는 부분을 인터페이스 뒤로 빼내 갈아 끼울 수 있게" 만드는 것이다.

🙋 학생 질문 — "튜터님, 생성·구조·행위 이 셋을 실무에서 칼같이 구분해서 쓰나요?"

머릿속에서 칼같이 나누진 않아요. 실무에선 "이 문제를 어떻게 풀까"를 고민하다 자연스럽게 어떤 패턴에 닿습니다. 분류는 그 패턴들을 정리해 기억하기 위한 서랍이에요.

다만 이 서랍이 대화에선 큰 값을 합니다. "여기 전략 패턴으로 빼죠"라고 하면 상대는 "아, 알고리즘을 갈아 끼울 수 있게 인터페이스로 빼자는 거구나"를 한 번에 알아들어요. 패턴 이름이 곧 공통 어휘라서, 긴 설명 없이 설계 의도가 전달됩니다. 그러니 분류 자체를 외우기보다, 각 패턴이 "무슨 문제를 푸는가"를 손에 익히는 게 먼저예요.


Step 2: "if-else 분기가 스무 개예요" — 전략

첫 패턴은 전략(strategy)입니다. 한마디로 알고리즘을 통째로 갈아 끼울 수 있게 만드는 패턴이에요. 같은 일을 하는 방법이 여러 가지일 때, 그 방법들을 각각 객체로 빼서 필요한 걸 골라 끼웁니다.

배송비를 계산하는 코드를 봅시다. 배송 방법마다 계산식이 달라요. 처음엔 if로 갈래를 나누게 됩니다.

Java
// 배송 방법마다 분기가 늘어나는 거대한 if/switch (behavioral.strategy.ShippingFeeCalculatorBefore)
public class ShippingFeeCalculatorBefore {

    public int fee(String method, int weightGram) {
        if (method.equals("STANDARD")) {
            return 3000;
        } else if (method.equals("EXPRESS")) {
            return 5000;
        } else if (method.equals("OVERNIGHT")) {
            return 9000 + (weightGram / 1000) * 1000;
        } else {
            throw new IllegalArgumentException("알 수 없는 배송 방법: " + method);
        }
    }
}

배송 방법이 하나 늘 때마다 이 메서드를 열어 else if를 한 가지 더 끼워야 합니다. "드론 배송"이 생기면? 또 엽니다. 계산식 하나만 고치려 해도 이 큰 메서드 전체를 다시 읽어야 하고요. 코드 리뷰에서 "분기가 스무 개예요"라고 듣는 모습이 이거예요.

B-1에서 배운 OCP를 떠올려 보세요. "확장에는 열려 있고, 수정에는 닫혀 있어야 한다"고 했죠. 지금 이 코드는 정반대입니다. 확장(새 배송 방법)을 하려면 기존 코드를 수정해야 해요. 이걸 패턴으로 뒤집어 봅시다.

먼저, 갈아 끼울 알고리즘 하나의 약속을 인터페이스로 정합니다.

Java
// 갈아 끼울 수 있는 알고리즘 한 가지의 약속 (behavioral.strategy.ShippingFeePolicy)
public interface ShippingFeePolicy {

    int fee(int weightGram);
}

그리고 배송 방법마다 이 약속을 구현한 전략 객체를 따로 둡니다.

Java
// behavioral.strategy — 방법마다 전략 하나씩
public class StandardShipping implements ShippingFeePolicy {
    @Override
    public int fee(int weightGram) {
        return 3000;
    }
}

public class OvernightShipping implements ShippingFeePolicy {
    @Override
    public int fee(int weightGram) {
        return 9000 + (weightGram / 1000) * 1000;
    }
}

이제 전략을 받아 쓰는 자리입니다. 여기엔 if가 한 줄도 없어요.

Java
// 전략을 주입받아 계산을 위임한다 (behavioral.strategy.ShippingService)
public class ShippingService {

    private final ShippingFeePolicy policy;

    public ShippingService(ShippingFeePolicy policy) {
        this.policy = policy;
    }

    public int checkout(int weightGram) {
        return policy.fee(weightGram);
    }
}

ShippingService는 어떤 배송 방법인지 따지지 않습니다. 생성될 때 전략 하나를 받아 두고, 계산은 그 전략에게 맡길 뿐이에요. 새 배송 방법이 생기면 ShippingFeePolicy를 구현한 클래스 하나만 추가하면 됩니다. ShippingService도, 기존 전략들도 한 줄도 바뀌지 않아요. 확장에는 열려 있고 수정에는 닫혀 있는 — B-1의 OCP가 패턴으로 돌아온 모습입니다.

전략은 사실 함수 하나일 때가 많다 — Java 람다와 Python 함수

여기서 오늘의 가장 중요한 현대적 시각을 하나 심어 둘게요. 방금 ShippingFeePolicy를 보면 메서드가 fee 하나뿐이죠? 메서드가 하나뿐인 인터페이스를 Java에서는 함수형 인터페이스라 부르고, 이런 건 클래스를 따로 만들지 않고 람다 한 줄로 표현할 수 있습니다.

Java
// 전략을 클래스 대신 람다로 — 메서드가 하나뿐이라 가능하다 (behavioral.strategy.ShippingFeeRegistry)
private static final Map<String, ShippingFeePolicy> POLICIES = Map.of(
    "STANDARD", weightGram -> 3000,
    "EXPRESS", weightGram -> 5000,
    "OVERNIGHT", weightGram -> 9000 + (weightGram / 1000) * 1000);

StandardShipping·ExpressShipping·OvernightShipping 세 클래스가 람다 세 줄로 녹았습니다. 새 배송 방법은 맵에 한 줄을 더하면 끝이에요. 클래스도, if 분기도 늘지 않습니다. 이것도 똑같이 OCP고요.

Python에서는 이게 더 자연스럽습니다. 함수가 일급(first-class)이라, "방법 → 함수"를 담은 딕셔너리 하나면 끝나요.

Python
# 전략 = 함수 하나. Python 은 함수가 일급이라 dict 에 담으면 끝이다 (strategy_as_function.py)
SHIPPING_POLICIES = {
    "STANDARD": lambda weight_gram: 3000,
    "EXPRESS": lambda weight_gram: 5000,
    "OVERNIGHT": lambda weight_gram: 9000 + (weight_gram // 1000) * 1000,
}

"GoF 전략 패턴이 사실은 함수가 일급이 아닌 언어에서, 함수를 흉내 내려고 만든 우회로"라는 시각이 여기서 나옵니다. 함수를 변수처럼 다룰 수 있으면, 전략은 그냥 함수 하나면 돼요. 그래서 패턴을 신성한 정답으로 외우는 게 아니라, "왜 이런 모양이 됐나"를 알아야 합니다.

⚠️ 언제 안 쓰나. 갈아 끼울 알고리즘이 지금 단 하나뿐이라면, 전략 인터페이스를 미리 만드는 건 과합니다. "나중에 종류가 늘 것 같아서"만으로 인터페이스를 깔면, 구현이 하나뿐인 빈 추상화만 남아요(YAGNI). 그리고 전략이 짧은 계산 하나라면, 클래스 다섯 개로 늘리지 말고 람다나 함수 하나로 두는 게 더 깔끔합니다. 전략 패턴의 정신은 "알고리즘을 갈아 끼운다"이지 "무조건 인터페이스와 클래스를 만든다"가 아니에요.

💡 한 줄 정리

전략은 갈아 끼울 알고리즘을 인터페이스 뒤로 빼, 거대한 if/switch를 없애고 OCP를 지킨다. 다만 알고리즘이 짧거나 하나뿐이면 클래스 대신 람다·함수 하나로 두는 게 낫다.

🙋 학생 질문 — "그럼 전략은 언제 클래스로 만들고, 언제 람다로 둬요?"

기준은 "그 알고리즘이 얼마나 무거운가"예요. 계산이 한두 줄로 끝나고 상태도 없다면, 람다나 함수 하나가 답입니다. 굳이 클래스를 만들면 파일만 늘어나요.

반대로 전략 하나가 여러 필드를 들고, 메서드도 여러 개고, 그 자체로 테스트할 가치가 있을 만큼 묵직하다면 클래스가 낫습니다. 이름을 붙여 두면 "이게 무슨 전략인지"가 코드에서 또렷해지고, 단위 테스트도 따로 붙이기 좋고요. 한 줄이면 람다, 한 덩어리면 클래스 — 이렇게 기억해 두세요.


Step 3: "상태가 바뀔 때마다 여기저기 알려 줘야 해요" — 옵저버

두 번째는 옵저버(observer)입니다. 상태가 변하면 그걸 구독한 쪽들에게 알려 주는 패턴이에요. 신문 구독을 떠올리면 됩니다. 신문사는 누가 구독하는지만 알고, 구독자들에게 신문을 돌릴 뿐이에요. 구독자가 그 신문으로 뭘 하는지는 신문사가 몰라도 됩니다.

상품 재입고 상황을 봅시다. 품절됐던 상품이 다시 들어오면, 여러 곳에 알려야 해요. 알림 메일도 보내고, "NEW" 배지도 갱신하고요. 처음엔 재입고 처리 안에서 이걸 직접 다 부르게 됩니다.

Java
// 발행자가 통지 대상을 직접 다 안다 — 강결합 (behavioral.observer.ProductBefore)
public class ProductBefore {

    private final StringBuilder emailLog = new StringBuilder();
    private int stock;
    private int badgeCount;

    public void restock(String name, int quantity) {
        this.stock += quantity;
        // 알려야 할 곳을 발행자가 직접 다 호출한다
        emailLog.append(name).append(" 재입고 알림: ").append(quantity).append("개");
        this.badgeCount++;
    }
    // ... getter 생략 ...
}

문제가 보이시나요? "재고를 채운다"는 일과 "여기저기 알린다"는 일이 한 덩어리로 엉켰어요. 알릴 곳이 하나 늘 때마다 이 restock을 열어 코드를 더해야 하고, 통지 대상이 늘수록 이 클래스가 점점 무거워집니다. 재입고 로직을 고치러 들어왔다가 알림 코드까지 마주하게 되죠.

옵저버로 풀어 봅시다. 먼저 "통지를 받는다"는 약속(구독자의 인터페이스)을 정합니다.

Java
// 통지를 받고 싶은 쪽이 구현하는 약속 (behavioral.observer.StockObserver)
public interface StockObserver {

    void onRestock(String product, int quantity);
}

발행자는 구독자 목록을 들고, 재입고가 일어나면 그 목록에 대고 알리기만 합니다.

Java
// 발행자(Subject). 구독자에게 "알린다"는 사실만 안다 (behavioral.observer.Product)
public class Product {

    private final String name;
    private final List<StockObserver> observers = new ArrayList<>();
    private int stock;

    public Product(String name) {
        this.name = name;
    }

    public void subscribe(StockObserver observer) {
        observers.add(observer);
    }

    public void restock(int quantity) {
        this.stock += quantity;
        for (StockObserver observer : observers) {
            observer.onRestock(name, quantity);
        }
    }
    // ... getStock 생략 ...
}

Product는 누가 구독하는지, 그들이 통지를 받아 무엇을 하는지 전혀 모릅니다. 그냥 onRestock을 부를 뿐이에요. 구독자는 각자 자기 일을 합니다.

Java
// 구독자 둘 — 같은 통지를 전혀 다르게 처리한다 (behavioral.observer)
public class EmailObserver implements StockObserver {
    private String lastMessage = "";
    @Override
    public void onRestock(String product, int quantity) {
        this.lastMessage = product + " 재입고 알림: " + quantity + "개";
    }
    // ... getLastMessage 생략 ...
}

public class BadgeObserver implements StockObserver {
    private int restockCount;
    @Override
    public void onRestock(String product, int quantity) {
        this.restockCount++;
    }
    // ... getRestockCount 생략 ...
}

이제 새 구독자가 생겨도 Product는 한 줄도 바뀌지 않아요. subscribe로 끼워 넣으면 그만입니다. 발행자와 구독자가 서로의 속을 모른 채 약속(인터페이스)으로만 연결된 이 상태를 느슨한 결합(loose coupling)이라고 해요. B-1에서 "결합도를 낮추자"고 했던 그 결합도가, 여기선 통지 구조로 낮아진 겁니다.

💡 이 발행-구독의 원리는 현대 백엔드 곳곳에 녹아 있어요. "이벤트가 발생하면 그걸 구독한 여러 핸들러가 반응한다"는 스프링의 이벤트 메커니즘, 데이터의 흐름을 구독해 반응하는 리액티브 스트림이 모두 옵저버의 후예입니다. 다만 그 본격적인 적용은 후속 spring-boot 과정의 몫이에요. 여기서는 "이런 원리 위에 서 있구나"까지만 잡아 둡시다.

⚠️ 언제 안 쓰나. 알릴 대상이 지금 하나뿐이고 앞으로도 늘 일이 없다면, 옵저버 구조는 과합니다. 그냥 직접 호출하는 게 읽기 더 쉬워요. 그리고 옵저버는 "누가 언제 반응하는지"가 코드에 직접 드러나지 않는다는 비용이 있습니다. restock 한 줄을 보고는 무슨 일이 연쇄로 일어나는지 알기 어려워, 문제가 생겼을 때 추적이 까다로울 수 있어요. 통지 순서나 통지 중 예외 처리도 직접 챙겨야 하고요. 느슨한 결합은 공짜가 아닙니다.

💡 한 줄 정리

옵저버는 발행자가 구독자 목록에 대고 알리기만 하고, 구독자는 각자 반응한다. 발행자와 구독자가 인터페이스로만 연결돼 느슨하게 결합되지만, "흐름이 코드에 안 보이는" 비용이 따른다.

🙋 학생 질문 — "구독자가 통지를 받다가 예외를 던지면 어떻게 돼요?"

좋은 질문이에요. 우리 예제처럼 단순히 반복문을 돌며 onRestock을 부르면, 한 구독자가 예외를 던지는 순간 그 뒤 구독자들은 통지를 못 받습니다. 메일 알림에서 터지면 배지 갱신이 안 되는 식이죠.

그래서 실무 옵저버에서는 통지 하나하나를 감싸 예외를 가두거나, 통지를 비동기로 떼어 내 서로 영향을 안 주게 만드는 식으로 보완합니다. 이건 옵저버 패턴 자체의 약점이 아니라, "여러 곳에 알린다"는 구조가 본래 안고 있는 숙제예요. 그래서 위 경고에 "통지 중 예외 처리도 직접 챙겨야 한다"고 적어 둔 겁니다. 패턴을 쓰면 끝이 아니라, 그 패턴이 데려오는 새 고민까지 보는 게 중요해요.


Step 4: "공통 절차는 같은데 한 부분만 달라요" — 템플릿 메서드

세 번째는 템플릿 메서드(template method)입니다. 절차의 뼈대는 부모가 고정하고, 그중 달라지는 한 칸만 자식이 채우게 하는 패턴이에요.

리포트를 만든다고 합시다. 어떤 형식이든 "머리말을 쓰고 → 본문을 채우고 → 꼬리말을 쓴다"는 절차는 똑같아요. 오직 본문 형식만 CSV냐 HTML이냐로 갈립니다. 이 "공통 절차 + 한 칸만 다름"을 템플릿 메서드로 표현합니다.

Java
// 절차의 뼈대는 부모가 고정, 본문 한 칸만 자식이 채운다 (behavioral.templatemethod.ReportGenerator)
public abstract class ReportGenerator {

    // 템플릿 메서드 — 절차의 뼈대를 고정한다(자식이 순서를 못 바꾸게 final)
    public final String generate() {
        return header() + body() + footer();
    }

    private String header() {
        return "[리포트 시작]\n";
    }

    // 자식이 채우는 한 칸 — 본문 형식만 다르다
    protected abstract String body();

    private String footer() {
        return "\n[리포트 끝]";
    }
}

generate()가 헤더 → 본문 → 푸터 순서를 못 박습니다. 이게 "템플릿"이에요. 순서를 자식이 함부로 못 바꾸게 final로 잠갔고, 가운데 body()만 추상 메서드로 비워 뒀습니다. 자식은 그 한 칸만 채워요.

Java
// 본문 한 칸만 형식이 다르다 (behavioral.templatemethod)
public class CsvReport extends ReportGenerator {
    @Override
    protected String body() {
        return "이름,점수\n홍길동,90";
    }
}

public class HtmlReport extends ReportGenerator {
    @Override
    protected String body() {
        return "<table><tr><td>홍길동</td><td>90</td></tr></table>";
    }
}

헤더·푸터를 쓰는 절차는 두 자식이 똑같이 물려받고, 본문 한 칸만 다르게 채웁니다. 나중에 "모든 리포트 머리말에 날짜를 넣어 주세요"라는 요구가 오면, 부모의 header() 한 곳만 고치면 모든 자식에 한꺼번에 반영돼요. 절차가 한 곳에 모여 있기 때문입니다.

템플릿 메서드(상속) vs 전략(위임)

여기서 Step 2의 전략과 비교해 봐야 합니다. 둘은 묘하게 닮았어요. "달라지는 한 부분을 빼낸다"는 목표가 같거든요. 그런데 빼내는 방법이 다릅니다.

텍스트
 같은 목표 — "달라지는 한 부분만 갈아 끼운다", 다른 방법

 템플릿 메서드 (상속)         전략 (위임/컴포지션)
   부모가 뼈대를 들고            컨텍스트가 뼈대를 들고
   자식이 빈 칸을 채운다          전략 객체를 주입받아 맡긴다
    컴파일 시점에 고정           런타임에 갈아 끼울 수 있다

B-2에서 "상속보다 컴포지션"을 배웠죠? 템플릿 메서드는 상속으로 빈 칸을 채우고, 전략은 위임(컴포지션)으로 채웁니다. 그래서 변하는 게 본문 하나뿐이고 컴파일 시점에 정해져도 된다면 템플릿 메서드가 간결하고, 변하는 방법이 여럿이라 실행 중에 갈아 끼우고 싶다면 전략이 어울려요. 같은 문제를 상속으로 풀 수도, 위임으로 풀 수도 있다는 걸 두 패턴이 나란히 보여 줍니다.

⚠️ 언제 안 쓰나. 템플릿 메서드는 상속에 기댑니다. 상속은 자식을 부모의 뼈대에 단단히 묶어요. 부모의 절차가 바뀌면 모든 자식이 영향을 받고, 자식은 부모 없이 존재할 수 없습니다. 그래서 빼낼 부분이 하나가 아니라 여럿이고, 런타임에 조합을 바꾸고 싶다면 상속의 경직성보다 전략의 유연함이 낫습니다. "공통 절차 + 딱 한 칸"이 또렷할 때만 템플릿 메서드를 쓰세요.

💡 한 줄 정리

템플릿 메서드는 절차의 뼈대를 부모가 final로 고정하고, 달라지는 한 칸만 자식이 상속으로 채운다. 같은 목표를 위임으로 풀면 전략이 되고, 런타임 교체가 필요하면 전략이 더 유연하다.

🙋 학생 질문 — "그럼 템플릿 메서드는 전략으로 다 대체할 수 있는 거 아니에요?"

기능만 보면 상당 부분 대체할 수 있어요. 본문 채우는 일을 함수로 주입받게 만들면 전략이 되니까요. 실제로 Java 8 이후엔 "추상 메서드 하나짜리 템플릿"을 그냥 함수를 받는 방식으로 바꾸는 경우가 많습니다.

다만 템플릿 메서드가 더 어울리는 자리도 있어요. 빈 칸이 여러 개고 그 칸들이 부모의 흐름과 긴밀하게 엮여 있을 때입니다. 자식이 채워야 할 "훅"이 절차 중간중간 여럿 박혀 있으면, 그걸 다 함수로 주입받는 것보다 상속으로 한 클래스에 묶는 게 읽기 편하거든요. 결국 "빈 칸이 하나고 단순하면 전략(위임), 빈 칸이 여럿이고 절차와 얽혀 있으면 템플릿(상속)" 정도로 감을 잡으면 됩니다.


Step 5: "상태마다 같은 조건문이 중복돼요" — 상태

네 번째는 상태(state) 패턴입니다. C-2 마무리에서 "거대한 조건문을 다형성으로 푸는 상태 패턴"을 예고했었죠. 그걸 오늘 실현합니다. 객체의 상태에 따라 행동이 달라질 때, 그 상태들을 각각 객체로 빼내 조건문을 없애는 패턴이에요.

주문 상태를 봅시다. 결제 완료(PAID) → 배송 중(SHIPPED) → 배송 완료(DELIVERED)로 흐르고, 중간에 취소(CANCELLED)될 수도 있어요. 그런데 "지금 어떤 상태냐"에 따라 할 수 있는 일이 다릅니다. 결제 완료 상태에선 취소가 되지만, 배송 중엔 취소가 안 되죠. 처음엔 이걸 상태 문자열과 if로 풉니다.

Java
// 메서드마다 같은 상태 분기를 되풀이한다 (behavioral.state.OrderBefore)
public class OrderBefore {

    private String status = "PAID";

    public void ship() {
        if (status.equals("PAID")) {
            status = "SHIPPED";
        } else if (status.equals("SHIPPED")) {
            throw new IllegalStateException("이미 배송 중입니다");
        } else if (status.equals("DELIVERED")) {
            throw new IllegalStateException("이미 배송 완료된 주문입니다");
        } else {
            throw new IllegalStateException("취소된 주문은 배송할 수 없습니다");
        }
    }

    public void cancel() {
        if (status.equals("PAID")) {
            status = "CANCELLED";
        } else if (status.equals("SHIPPED")) {
            throw new IllegalStateException("배송 중인 주문은 취소할 수 없습니다");
        } else if (status.equals("DELIVERED")) {
            throw new IllegalStateException("배송 완료된 주문은 취소할 수 없습니다");
        } else {
            throw new IllegalStateException("이미 취소된 주문입니다");
        }
    }
    // deliver() 도 똑같은 상태 분기를 또 한 번 반복한다 ...
}

문제가 보이시죠? ship·cancel·deliver 세 메서드가 저마다 똑같은 상태 분기를 되풀이합니다. 상태가 하나 늘면 세 메서드를 전부 열어 분기를 더해야 하고, "이 상태에서 뭘 할 수 있나"가 코드 곳곳에 흩어져요. 코드 리뷰에서 "상태마다 같은 조건문이 중복돼요"라고 듣는 바로 그 모습입니다.

상태 패턴으로 풀어 봅시다. "이 상태에서 무엇을 할 수 있는가"를 약속으로 정합니다. 각 동작은 다음 상태를 돌려주고, 할 수 없는 동작이면 예외를 던져요.

Java
// "이 상태에서 무엇을 할 수 있는가"의 약속 (behavioral.state.OrderState)
public interface OrderState {

    OrderState ship();

    OrderState deliver();

    OrderState cancel();

    String name();
}

그리고 상태마다 객체를 하나씩 둡니다. 각 상태는 자기 상태에서 가능한 일과 넘어갈 다음 상태만 알아요.

Java
// 결제 완료 상태 — 배송 시작과 취소만 가능하다 (behavioral.state.PaidState)
public class PaidState implements OrderState {

    @Override
    public OrderState ship() {
        return new ShippedState();
    }

    @Override
    public OrderState deliver() {
        throw new IllegalStateException("아직 배송이 시작되지 않았습니다");
    }

    @Override
    public OrderState cancel() {
        return new CancelledState();
    }

    @Override
    public String name() {
        return "PAID";
    }
}
Java
// 배송 중 상태 — 배송 완료로만 넘어간다 (behavioral.state.ShippedState)
public class ShippedState implements OrderState {

    @Override
    public OrderState ship() {
        throw new IllegalStateException("이미 배송 중입니다");
    }

    @Override
    public OrderState deliver() {
        return new DeliveredState();
    }

    @Override
    public OrderState cancel() {
        throw new IllegalStateException("배송 중인 주문은 취소할 수 없습니다");
    }

    @Override
    public String name() {
        return "SHIPPED";
    }
}

이제 주문은 현재 상태 객체 하나만 들고, 동작을 그 객체에게 넘깁니다.

Java
// 현재 상태 객체에게 동작을 위임한다 — if 가 없다 (behavioral.state.Order)
public class Order {

    private OrderState state = new PaidState();

    public void ship() {
        state = state.ship();
    }

    public void deliver() {
        state = state.deliver();
    }

    public void cancel() {
        state = state.cancel();
    }

    public String status() {
        return state.name();
    }
}

Order의 어느 메서드에도 if 분기가 없어요. "지금 어떤 상태 객체인가"가 행동을 정하고, 각 동작은 다음 상태 객체를 돌려받아 갈아 끼웁니다. 흩어졌던 조건문이 상태별 객체로 모인 거예요. 상태가 하나 늘어도 새 상태 클래스 하나만 추가하면 됩니다.

전략과 상태 — 구조는 쌍둥이, 의도는 다르다

Step 1에서 예고했듯, 전략과 상태는 코드 구조가 거의 똑같습니다. 둘 다 인터페이스 + 여러 구현 + 그걸 들고 쓰는 컨텍스트예요. 그런데 의도가 갈립니다.

텍스트
 전략                          상태
   외부가 알고리즘을 골라 끼운다      객체가 스스로 다음 상태로 넘어간다
   ShippingService(전략을 받음)     Order(state = state.ship())
   "어떻게 계산할까"를 갈아 끼움      "지금 무엇을 할 수 있나"가 바뀜
   전략끼리는 서로를 모른다          상태가 다음 상태를 안다(전이)

전략은 바깥에서 어떤 알고리즘을 쓸지 정해 주입합니다. 상태는 객체 안에서 스스로 다음 상태로 전이해요. 그래서 상태 패턴의 각 상태는 "다음에 어떤 상태가 오는지"를 알지만, 전략들은 서로의 존재를 모릅니다. 구조가 닮았다고 같은 패턴이 아니에요. 의도가 패턴의 정체를 정합니다.

⚠️ 언제 안 쓰나. 상태와 전이가 두세 개로 적고 앞으로도 단순하다면, 상태 클래스를 여러 개 만드는 건 과합니다. 작은 상태 머신은 enumswitch 하나로도 충분히 읽혀요. 상태 패턴은 상태가 많고, 상태마다 행동이 복잡하며, 전이 규칙이 자주 바뀔 때 값을 합니다. 상태 셋에 동작 하나뿐인데 클래스 셋을 만들면, 오히려 코드가 흩어져 읽기 어려워져요.

💡 한 줄 정리

상태 패턴은 상태마다 객체를 두어, 메서드마다 중복되던 조건문을 없앤다. 전략과 구조는 같지만, 전략은 외부가 알고리즘을 고르고 상태는 객체가 스스로 다음 상태로 전이한다.

🙋 학생 질문 — "상태를 객체로 나누면, 전이 규칙이 여러 클래스에 흩어지지 않나요?"

날카로운 지적이에요. 맞습니다. OrderBefore에서는 모든 전이가 한 클래스 안에 있었는데, 상태 패턴으로 바꾸면 "PAID에서 어디로 가는지"는 PaidState에, "SHIPPED에서 어디로 가는지"는 ShippedState에 흩어져요.

그래서 이건 트레이드오프입니다. 전이의 전체 그림을 한눈에 보고 싶다면 흩어진 게 불리하고, 각 상태에서 가능한 일을 또렷하게 모으고 싶다면 유리해요. 상태가 적고 전이표를 한눈에 보는 게 중요하면 enum+switch가 낫고, 상태마다 행동이 복잡해 각 상태를 독립적으로 다루고 싶으면 상태 패턴이 낫습니다. 위 경고가 "상태가 많고 복잡할 때 값을 한다"고 말한 게 이 뜻이에요. 패턴은 공짜가 아니라 무언가를 내주고 무언가를 얻는 거래입니다.


Step 6: "방금 한 작업을 되돌리고 싶어요" — 커맨드

마지막 패턴은 커맨드(command)입니다. "요청 하나"를 객체로 포장하는 패턴이에요. 무엇을 할지를 객체에 담아 두면, 그 요청을 나중에 실행하거나, 쌓아 뒀다가 되돌리거나, 큐에 넣어 두는 일이 가능해집니다.

텍스트 편집기를 봅시다. 글자를 입력하는 기능을 가장 단순하게 만들면 이렇게 됩니다.

Java
// 동작을 그 자리에서 바로 수행한다 — 되돌릴 수 없다 (behavioral.command.EditorBefore)
public class EditorBefore {

    private final StringBuilder text = new StringBuilder();

    public void append(String s) {
        text.append(s);
    }

    public String content() {
        return text.toString();
    }
}

동작은 합니다. 그런데 "방금 한 일"을 어디에도 남기지 않아요. 그래서 되돌릴(undo) 방법이 없습니다. 작업을 모아 뒀다 나중에 실행하거나, 로그로 남기는 것도 불가능하고요. 무엇을 했는지가 흔적도 없이 사라지기 때문이에요.

커맨드로 풀어 봅시다. "요청 하나"를 객체로 만듭니다. 무엇을 할지(execute)와 그걸 어떻게 되돌릴지(undo)를 한 객체가 함께 알아요.

Java
// "요청 하나"를 객체로 포장한 약속 (behavioral.command.Command)
public interface Command {

    void execute();

    void undo();
}

"글자를 붙여라"라는 요청을 이 약속으로 구현합니다. 무엇을 붙였는지 기억해 두기에, 되돌릴 때 붙인 만큼 정확히 지울 수 있어요.

Java
// "이 문서에 이 글자를 붙여라"를 객체로 (behavioral.command.AppendCommand)
public class AppendCommand implements Command {

    private final Document document;
    private final String text;

    public AppendCommand(Document document, String text) {
        this.document = document;
        this.text = text;
    }

    @Override
    public void execute() {
        document.append(text);
    }

    @Override
    public void undo() {
        document.deleteLast(text.length());
    }
}

이제 편집기는 커맨드를 실행하고 히스토리에 쌓아 둡니다. 무슨 동작인지 직접 알 필요 없이, 들어온 커맨드를 실행하고 스택에 push할 뿐이에요.

Java
// 커맨드를 실행하고 히스토리에 쌓는다 (behavioral.command.Editor)
public class Editor {

    private final Deque<Command> history = new ArrayDeque<>();

    public void run(Command command) {
        command.execute();
        history.push(command);
    }

    public void undo() {
        if (!history.isEmpty()) {
            history.pop().undo();
        }
    }
}

undo()는 가장 최근 커맨드를 꺼내, 그 커맨드 자신에게 "되돌리라"고 시킵니다. 요청이 객체가 됐기에 실행 취소가 자연스럽게 따라와요. Before에선 불가능했던 일이죠. 같은 구조로 커맨드를 큐에 쌓으면 작업 예약이 되고, 커맨드 목록을 저장하면 매크로가 됩니다. 요청을 객체로 만든다는 한 수가 이 모든 걸 열어 줘요.

💡 커맨드도 현대 언어에선 모습이 가벼워집니다. Java의 Runnable이 사실 "인자도 결과도 없는 커맨드"예요. 메서드 하나짜리라 람다로도 표현되고요. 전략과 마찬가지로, 일급 함수가 있는 곳에선 "요청을 객체로 포장한다"가 "함수를 변수에 담는다"로 녹습니다.

⚠️ 언제 안 쓰나. 실행 취소도, 작업 큐도, 로그도, 매크로도 필요 없다면 커맨드는 과합니다. 그냥 메서드를 직접 부르면 될 일을, 굳이 객체로 포장하면 클래스만 늘어나요. 커맨드는 "요청을 나중에 다루고 싶다"는 분명한 필요가 있을 때 — 되돌리기, 예약 실행, 작업 기록 — 그때 값을 합니다.

💡 한 줄 정리

커맨드는 요청을 execute/undo를 가진 객체로 포장해, 실행 취소·작업 큐·기록을 가능하게 한다. 다만 그런 필요가 없으면 메서드 직접 호출이 낫고, 일급 함수가 있는 언어에선 람다로 가벼워진다.

🙋 학생 질문 — "되돌리기를 꼭 커맨드로 만들어야 해요? 그냥 이전 상태를 통째로 저장하면 안 되나요?"

둘 다 쓰는 방법이에요. "이전 상태 전체를 찍어 두고 되돌릴 때 그걸로 덮어쓰는" 방식을 메멘토(memento) 패턴이라고 부릅니다. 상태가 작으면 이게 단순하고 확실해요.

반면 커맨드 방식은 "무엇을 했는지"만 기억하고 그 반대 동작으로 되돌립니다. 상태가 크고 변경이 작을 때 유리해요. 문서 전체를 매번 복사하는 대신 "붙인 다섯 글자만 지우면" 되니까요. 그래서 되돌릴 단위가 작고 또렷하면 커맨드, 상태 스냅샷이 간단하면 메멘토 쪽으로 기웁니다. 정답이 하나가 아니라, 무엇을 되돌리느냐에 따라 고르는 거예요.


Step 7: "이거 그냥 함수 하나면 될 걸 클래스 다섯 개로 만들었네요" — 패턴을 위한 패턴을 경계하라

다섯 패턴을 다 봤습니다. 먼저 한 표로 의도를 정리하고, C 카테고리 전체를 닫는 가장 중요한 이야기를 하겠습니다.

패턴 무엇을 빼내나 한 줄 의도 코드 리뷰 후크
전략 알고리즘 갈아 끼울 수 있게 "if 분기가 스무 개예요"
옵저버 통지 대상 느슨하게 알리려고 "상태 바뀔 때마다 여기저기 호출해요"
템플릿 메서드 절차 중 한 칸 뼈대는 고정, 일부만 자식이 "흐름은 같은데 한 부분만 달라요"
상태 상태별 행동 조건문을 다형성으로 "상태마다 같은 조건문이 중복돼요"
커맨드 요청 자체 객체로 포장해 나중에 다루려고 "실행 취소를 못 만들겠어요"

다섯 패턴 모두 "바뀌는 부분을 인터페이스 뒤로 빼낸다"는 한 정신을 공유합니다. 빼내는 게 알고리즘이냐(전략), 통지냐(옵저버), 절차의 한 칸이냐(템플릿), 상태냐(상태), 요청이냐(커맨드)로 갈릴 뿐이에요.

패턴은 무기지, 목적이 아니다

이제 C 카테고리 전체를 닫는 경계입니다. 패턴 열 몇 개를 손에 쥐면, 모든 코드에서 패턴을 쓰고 싶어집니다. 그런데 패턴을 많이 쓴 코드가 좋은 코드가 아니에요. 오히려 정반대인 경우가 더 흔합니다.

텍스트
 ❌ 패턴 과용 — 단순한 일을 복잡하게

   "두 수를 더한다"
      AdderStrategy 인터페이스
      PlusStrategy 구현 클래스
      CalculatorContext 컨텍스트
      StrategyFactory 팩토리
   다섯 파일을 열어야 a + b 가 보인다


 ✅ 단순함이 먼저

   "두 수를 더한다"
      return a + b;

"이거 그냥 함수 하나면 될 걸 클래스 다섯 개로 만들었네요" — 이게 패턴을 갓 배운 사람이 가장 자주 듣는 코드 리뷰 지적입니다. 패턴은 문제가 있을 때 꺼내는 해법이지, 미리 깔아 두는 장식이 아니에요. 지금 갈아 끼울 알고리즘이 하나뿐인데 전략을, 알릴 대상이 하나뿐인데 옵저버를, 되돌릴 일이 없는데 커맨드를 두는 건 전부 과잉입니다.

이걸 YAGNI(You Aren't Gonna Need It, "그거 필요 없을걸")라고 불러요. "나중에 필요할 것 같아서" 미리 만든 추상화의 대부분은 끝내 쓰이지 않고, 코드만 복잡하게 만듭니다. 우리가 이번 모듈 내내 패턴마다 ⚠️ "언제 안 쓰나"를 단 이유가 이거예요. 패턴을 아는 것보다 언제 안 쓸지 아는 게 더 어렵고 중요합니다.

그리고 오늘 거듭 본 것처럼, 전략·커맨드는 람다 하나로, 옵저버는 리액티브로 모습을 바꿉니다. 패턴은 "언어를 초월한 영원한 정답"이라기보다, 그 시대 언어가 부족한 부분을 메우려고 사람들이 찾아낸 검증된 우회로에 가까워요. 그러니 패턴을 신성시하지 말고, 도구로 보세요. 손에 든 게 망치라고 모든 게 못으로 보이면 안 됩니다.

텍스트
 디자인 패턴 카탈로그 (GoF 23) — C 카테고리 완주

 C-1  생성   객체를 어떻게 만드나     팩토리 · 빌더 · 싱글톤 …
 C-2  구조   객체를 어떻게 조립하나    어댑터 · 데코레이터 · 퍼사드 · 프록시 …
 C-3  행위   객체가 어떻게 협력하나    전략 · 옵저버 · 템플릿 · 상태 · 커맨드 …

  이제 패턴은 "검증된 해법"이자 "남용하면 독"이라는 양면을 함께 안다

💡 한 줄 정리

다섯 행위 패턴은 "바뀌는 부분을 빼낸다"는 정신을 공유한다. 그러나 패턴을 많이 쓴 코드가 좋은 코드는 아니다. 패턴은 문제가 있을 때 꺼내는 도구지, 미리 까는 장식이 아니다 — 단순함이 먼저다.

🙋 학생 질문 — "그럼 패턴을 언제 도입할지, 실무에서 판단하는 기준이 있을까요?"

가장 쓸 만한 기준은 "같은 종류의 변경이 세 번째로 찾아왔을 때"예요. 처음 if 하나를 추가할 땐 그냥 추가합니다. 두 번째도 어쩌면 그냥 둬요. 그런데 같은 자리에 세 번째 분기가 또 들어오면, 그제야 "여기는 계속 늘어나는 자리구나"가 분명해집니다. 그때 전략이나 상태로 빼내는 거예요.

이걸 "세 번 규칙"이라고도 부릅니다. 미리 추상화하지 말고, 변화가 실제로 반복되는 걸 본 뒤에 패턴을 꺼내라는 거죠. 그래야 YAGNI에 걸리지 않아요. 한 번은 우연, 두 번은 패턴의 조짐, 세 번은 확신 — 이 리듬을 몸에 익히면 "패턴을 위한 패턴"의 함정을 피할 수 있습니다. 다음 시간에 배울 리팩토링이 바로 이 "변화를 본 뒤에 구조를 바꾸는" 작업이에요.


마무리

오늘은 행위 패턴 다섯으로 "객체가 어떻게 협력하나"를 익혔습니다. 그리고 C 카테고리 전체를 닫으며, 패턴이 검증된 해법인 동시에 남용하면 독이라는 양면을 함께 봤어요.

오늘 배운 핵심 세 가지

💡 하나 — 행위 패턴은 "바뀌는 부분을 인터페이스 뒤로 빼낸다". 전략은 알고리즘을, 옵저버는 통지 대상을, 템플릿 메서드는 절차의 한 칸을, 상태는 상태별 행동을, 커맨드는 요청 자체를 빼낸다. 거대한 if/switch와 흩어진 조건문이 이 빼내기로 정리된다.

💡 둘 — 구조가 닮아도 의도로 갈린다. 전략과 상태는 코드 구조가 쌍둥이지만, 전략은 외부가 알고리즘을 고르고 상태는 객체가 스스로 전이한다. 템플릿 메서드(상속)와 전략(위임)도 같은 목표를 다른 방법으로 푼다. 패턴의 정체는 코드 모양이 아니라 의도가 정한다.

💡 셋 — 패턴은 무기지 목적이 아니다. 다섯 패턴 모두 람다·함수로 가벼워지고, 필요 없는 자리에 깔면 독이 된다. "함수 하나면 될 걸 클래스 다섯 개로" 만드는 게 가장 흔한 함정이다. 변화가 세 번째로 반복될 때 꺼내라 — 단순함이 먼저다.

다음 시간 예고

다음 시간(D-1)부터는 카테고리 D, 리팩토링으로 갑니다. 지금까지 A·B·C에서 "좋은 코드란 무엇인가"의 목표 지점을 봤다면, 이제는 이미 나빠진 코드를 그 지점으로 안전하게 옮기는 실전이에요.

여기서 오늘 배운 패턴들이 다시 돌아옵니다. 리팩토링의 핵심 기법 중에 "조건문을 다형성으로 바꾸기"가 있는데, 그게 바로 오늘 우리가 거대한 if를 전략으로, 흩어진 조건문을 상태로 바꾼 그 작업이에요. 패턴을 알았기에 리팩토링의 "이 if 지옥을 어디로 끌고 갈지"가 막히지 않습니다. 그리고 그렇게 코드를 바꾸면서도 행동이 바뀌지 않았음을 테스트로 증명하는 안전망을 다음 시간에 처음 정면으로 다룹니다. 돌아가는 코드를 멈추지 않고 고치는 법, D-1에서 만나요.


과제

오늘 배운 패턴을 직접 손으로 적용해 봅니다. 각 Before 코드를 더 나은 구조로 고쳐 보세요. 정답은 예시답안에서 확인할 수 있어요.

[기초] 거대한 환율 변환 if를 전략으로

아래는 외화를 원화로 바꾸는 코드입니다. 통화마다 환율이 달라서 if로 갈래를 나눴어요.

Java
// Before — 통화가 늘 때마다 else if 를 더해야 한다
public class CurrencyConverterBefore {

    public long toKrw(String currency, long amount) {
        if (currency.equals("USD")) {
            return amount * 1300;
        } else if (currency.equals("EUR")) {
            return amount * 1400;
        } else if (currency.equals("JPY")) {
            return amount * 9;
        } else {
            throw new IllegalArgumentException("지원하지 않는 통화: " + currency);
        }
    }
}

ExchangePolicy라는 약속(메서드 long toKrw(long amount))을 두고, 통화마다 전략을 구현해 보세요. 그래서 새 통화(예: GBP)가 생겨도 기존 코드는 수정하지 않고 클래스(또는 람다) 하나만 추가하면 되게 고쳐 보세요. 여유가 있다면, 세 전략 클래스를 만드는 대신 "통화 → 람다"를 담은 맵 하나로도 같은 일을 해 보고, 둘 중 어느 쪽이 이 상황에 더 어울리는지 판단해 보세요.

[응용] 주문 완료의 여러 후처리를 옵저버로

아래는 주문이 완료되면 여러 후처리를 직접 다 부르는 코드입니다.

Java
// Before — 주문 완료 처리가 후처리 대상을 직접 다 안다 (강결합)
public class OrderServiceBefore {

    public void complete(String orderId) {
        // 주문을 완료 처리하고...
        // 알려야 할 곳을 직접 다 호출한다
        System.out.println(orderId + " 재고 차감");
        System.out.println(orderId + " 포인트 적립");
        System.out.println(orderId + " 완료 메일 발송");
    }
}

OrderObserver라는 약속(메서드 void onCompleted(String orderId))을 두고, 재고 차감·포인트 적립·메일 발송을 각각 구독자로 빼내 보세요. 주문 완료 처리(발행자)는 구독자 목록에 대고 알리기만 하고, 누가 무엇을 하는지 모르게 만드는 게 목표입니다. 그런 다음 "쿠폰 발급"이라는 후처리를 하나 더 추가할 때, 발행자 코드를 한 줄도 고치지 않고 끼워 넣을 수 있는지 확인해 보세요.

[심화] 글 발행 상태 머신을 상태 패턴으로

아래는 블로그 글의 상태를 문자열과 if로 관리하는 코드입니다. 초안(DRAFT) → 검토(REVIEW) → 발행(PUBLISHED)으로 흐르고, 검토 중엔 반려(DRAFT로 복귀)할 수 있어요.

Java
// Before — 메서드마다 같은 상태 분기가 중복된다
public class ArticleBefore {

    private String status = "DRAFT";

    public void submit() {   // 검토 요청
        if (status.equals("DRAFT")) {
            status = "REVIEW";
        } else {
            throw new IllegalStateException("초안만 검토 요청할 수 있습니다");
        }
    }

    public void approve() {  // 승인 → 발행
        if (status.equals("REVIEW")) {
            status = "PUBLISHED";
        } else {
            throw new IllegalStateException("검토 중인 글만 발행할 수 있습니다");
        }
    }

    public void reject() {   // 반려 → 초안
        if (status.equals("REVIEW")) {
            status = "DRAFT";
        } else {
            throw new IllegalStateException("검토 중인 글만 반려할 수 있습니다");
        }
    }

    public String status() {
        return status;
    }
}

ArticleState 인터페이스와 상태별 객체(DraftState·ReviewState·PublishedState)로 바꿔, 각 상태가 자기 전이만 알게 만들어 보세요. Article은 현재 상태 객체에게 동작을 위임하고 if 분기가 사라지게 고치면 됩니다. 추가로, 만약 "발행을 예약했다가 취소하는" 기능(되돌리기)을 붙인다면 어떤 행위 패턴이 어울릴지, 코드까지 짜지 않아도 좋으니 한 문단으로 설명해 보세요.


생각해볼 주제

1. 전략 패턴 vs 함수 하나 — 언제 클래스로 만들고 언제 람다로 둘까?

오늘 우리는 전략 패턴을 세 개의 클래스로도, 람다 세 줄로도, Python 함수 딕셔너리로도 표현했다. 기능은 똑같았다. 그렇다면 "전략 = 클래스"라는 공식은 더 이상 맞지 않는 걸까? 어떤 자리에서 굳이 전략을 인터페이스와 클래스로 만들고, 어떤 자리에서 람다 한 줄로 두는 게 옳을까? "패턴의 이름값(공통 어휘)"과 "코드의 간결함"이 부딪힐 때, 무엇을 기준으로 고를지 생각해 보자.

2. 옵저버의 느슨한 결합은 공짜인가?

옵저버는 발행자와 구독자를 느슨하게 떼어 놓는다. 새 구독자를 자유롭게 끼울 수 있어 유연하다. 그런데 그 대가로, 코드만 봐서는 "이 동작이 일어나면 무엇이 연쇄로 반응하는지"가 보이지 않는다. restock 한 줄 뒤에 어떤 일들이 벌어지는지 추적하려면 구독자 목록을 일일이 따라가야 한다. 느슨한 결합이 주는 유연함과, 그것이 앗아 가는 "흐름의 가시성" 사이에서 우리는 무엇을 얻고 무엇을 잃는 걸까? 결합을 느슨하게 할수록 항상 좋은 걸까?

3. 행위 패턴 다수가 함수 하나로 녹는다면, 패턴을 공부하는 의미는?

전략은 람다로, 커맨드는 Runnable로, 옵저버는 리액티브 스트림으로 — 오늘 본 행위 패턴 상당수가 현대 언어에서는 일급 함수나 라이브러리로 모습을 바꾼다. 그렇다면 "GoF 패턴은 함수가 일급이 아니던 시절의 우회로"라는 시각이 맞는 걸까? 만약 그렇다면, 우리가 이 패턴들을 굳이 공부한 의미는 무엇일까? 패턴의 코드 모양이 사라져도 남는 것이 있다면, 그건 무엇일까? C 카테고리 전체를 닫으며, "패턴을 배운다는 것"이 결국 무엇을 손에 남기는 일이었는지 정리해 보자.

✅ 예시 답안정답 보기

과제와 생각해볼 주제의 예시답안입니다. 정답이 하나뿐인 건 아니에요. 전략을 클래스로 두든 람다로 두든, 상태를 객체로 나누든 enum으로 두든, 그 상황에 맞으면 다른 모습도 좋습니다. 중요한 건 "바뀌는 부분을 어떻게 빼냈는가", 그리고 "이 패턴을 정말 써야 하는 자리인가"를 판단하는 눈입니다.


과제 예시답안

🎯 [과제 1 예시답안] 거대한 환율 변환 if를 전략으로

채점 포인트

항목 보는 것 배점
약속 도입 갈아 끼울 알고리즘의 인터페이스(ExchangePolicy)를 두었는가 ★★★
전략 분리 통화마다 계산을 전략으로 빼내 if를 없앴는가 ★★★
OCP 새 통화가 기존 코드 수정 없이 추가되는가 ★★★
람다 판단 짧은 계산이라 람다로도 됨을 알고, 상황에 맞게 골랐는가 ★★☆

풀이 예시

❌ Before — 통화가 늘 때마다 else if를 더해야 한다.

Java
public class CurrencyConverterBefore {

    public long toKrw(String currency, long amount) {
        if (currency.equals("USD")) {
            return amount * 1300;
        } else if (currency.equals("EUR")) {
            return amount * 1400;
        } else if (currency.equals("JPY")) {
            return amount * 9;
        } else {
            throw new IllegalArgumentException("지원하지 않는 통화: " + currency);
        }
    }
}

✅ After — 먼저 갈아 끼울 알고리즘의 약속을 정한다.

Java
public interface ExchangePolicy {

    long toKrw(long amount);
}

통화마다 전략을 구현한다(달러 전략 하나만 예로 든다).

Java
public class UsdExchange implements ExchangePolicy {

    @Override
    public long toKrw(long amount) {
        return amount * 1300;
    }
}

그리고 "통화 → 전략" 맵을 받아 위임하는 변환기를 둔다. if가 사라졌다.

Java
public class CurrencyConverter {

    private final Map<String, ExchangePolicy> policies;

    public CurrencyConverter(Map<String, ExchangePolicy> policies) {
        this.policies = policies;
    }

    public long toKrw(String currency, long amount) {
        ExchangePolicy policy = policies.get(currency);
        if (policy == null) {
            throw new IllegalArgumentException("지원하지 않는 통화: " + currency);
        }
        return policy.toKrw(amount);
    }
}

이제 새 통화 GBP는 맵에 한 줄을 더하면 끝입니다. CurrencyConverter도, 기존 전략들도 바뀌지 않아요. 그리고 환율 계산은 한 줄짜리라, 사실 전략 클래스를 만들지 않고 람다로 넣어도 됩니다.

Java
Map<String, ExchangePolicy> policies = new HashMap<>();
policies.put("USD", amount -> amount * 1300);
policies.put("GBP", amount -> amount * 1600);   // 새 통화도 한 줄

💡 튜터의 한마디 — 이 과제의 핵심은 "전략 = 무조건 클래스"가 아니라는 걸 체감하는 거예요. 환율처럼 계산이 한 줄이면 람다 맵이 더 깔끔합니다. 반대로 통화별로 수수료·반올림 규칙이 복잡하게 붙는다면 그땐 클래스로 빼서 이름과 테스트를 붙이는 게 낫고요. 같은 전략 패턴이라도 알고리즘의 무게에 따라 모습을 고르세요.

🎯 [과제 2 예시답안] 주문 완료의 여러 후처리를 옵저버로

채점 포인트

항목 보는 것 배점
약속 도입 통지를 받는 구독자 인터페이스(OrderObserver)를 두었는가 ★★★
발행자 분리 발행자가 구독자 목록에 알리기만 하는가(직접 호출 제거) ★★★
느슨한 결합 발행자가 구독자가 무엇을 하는지 모르는가 ★★☆
확장 새 후처리를 발행자 수정 없이 끼우는가 ★★★

풀이 예시

❌ Before — 주문 완료 처리가 후처리 대상을 직접 다 안다.

Java
public class OrderServiceBefore {

    public void complete(String orderId) {
        System.out.println(orderId + " 재고 차감");
        System.out.println(orderId + " 포인트 적립");
        System.out.println(orderId + " 완료 메일 발송");
    }
}

✅ After — 통지를 받는 약속을 정하고, 발행자는 구독자 목록에 알리기만 한다.

Java
public interface OrderObserver {

    void onCompleted(String orderId);
}
Java
public class OrderService {

    private final List<OrderObserver> observers = new ArrayList<>();

    public void subscribe(OrderObserver observer) {
        observers.add(observer);
    }

    public void complete(String orderId) {
        // 주문을 완료 처리하고, 구독자들에게 알리기만 한다
        for (OrderObserver observer : observers) {
            observer.onCompleted(orderId);
        }
    }
}

후처리는 각각 구독자로 빼냅니다(재고 차감 하나만 예로 든다).

Java
public class InventoryObserver implements OrderObserver {

    private String lastHandled = "";

    @Override
    public void onCompleted(String orderId) {
        this.lastHandled = orderId + " 재고 차감";
    }
    // ... getLastHandled 생략 ...
}

이제 "쿠폰 발급"을 더해도 OrderService는 한 줄도 바뀌지 않아요. OrderObserver도 메서드 하나뿐이라 람다로 즉석에서 끼울 수 있습니다.

Java
service.subscribe(orderId -> couponLog.append(orderId).append(" 쿠폰 발급"));

💡 튜터의 한마디 — 발행자(OrderService)가 더 이상 "무엇을 후처리하는지" 목록을 들고 있지 않다는 게 핵심입니다. 후처리가 늘고 줄어도 발행자는 그대로예요. 다만 이 유연함의 대가로, complete 한 줄만 봐서는 어떤 후처리가 연쇄로 일어나는지 안 보입니다. 그래서 구독을 어디서 거는지(조립 지점)를 한곳에 모아 두는 습관이 중요해요.

🎯 [과제 3 예시답안] 글 발행 상태 머신을 상태 패턴으로

채점 포인트

항목 보는 것 배점
약속 도입 "이 상태에서 뭘 할 수 있나"의 인터페이스(ArticleState)를 두었는가 ★★★
상태별 객체 상태마다 객체로 빼내 메서드 중복 분기를 없앴는가 ★★★
전이 각 동작이 다음 상태를 돌려주고, 불가능하면 예외인가 ★★☆
되돌리기 설명 예약 취소(되돌리기)에 커맨드가 어울림을 짚었는가 ★★☆

풀이 예시

❌ Beforesubmit·approve·reject가 저마다 같은 상태 분기를 되풀이한다.

Java
public class ArticleBefore {

    private String status = "DRAFT";

    public void submit() {
        if (status.equals("DRAFT")) {
            status = "REVIEW";
        } else {
            throw new IllegalStateException("초안만 검토 요청할 수 있습니다");
        }
    }
    // approve(), reject() 도 똑같은 상태 분기를 또 한 번 반복한다 ...
}

✅ After — "이 상태에서 무엇을 할 수 있는가"를 약속으로 정한다.

Java
public interface ArticleState {

    ArticleState submit();

    ArticleState approve();

    ArticleState reject();

    String name();
}

상태마다 객체로 빼낸다(초안·검토 둘만 예로 든다).

Java
public class DraftState implements ArticleState {

    @Override
    public ArticleState submit() {
        return new ReviewState();
    }

    @Override
    public ArticleState approve() {
        throw new IllegalStateException("검토 중인 글만 발행할 수 있습니다");
    }

    @Override
    public ArticleState reject() {
        throw new IllegalStateException("검토 중인 글만 반려할 수 있습니다");
    }

    @Override
    public String name() {
        return "DRAFT";
    }
}
Java
public class ReviewState implements ArticleState {

    @Override
    public ArticleState submit() {
        throw new IllegalStateException("초안만 검토 요청할 수 있습니다");
    }

    @Override
    public ArticleState approve() {
        return new PublishedState();
    }

    @Override
    public ArticleState reject() {
        return new DraftState();
    }

    @Override
    public String name() {
        return "REVIEW";
    }
}

글은 현재 상태 객체에게 동작을 위임한다. if가 사라졌다.

Java
public class Article {

    private ArticleState state = new DraftState();

    public void submit() {
        state = state.submit();
    }

    public void approve() {
        state = state.approve();
    }

    public void reject() {
        state = state.reject();
    }

    public String status() {
        return state.name();
    }
}

되돌리기(예약 취소) 기능은 어떤 패턴? — "발행을 예약했다가 취소한다"는 건 상태 전이가 아니라 방금 한 동작을 되돌리는 일입니다. 이건 상태 패턴이 아니라 커맨드가 어울려요. 발행·예약 같은 동작을 execute/undo를 가진 커맨드 객체로 포장해 히스토리에 쌓으면, 가장 최근 동작을 꺼내 undo로 되돌릴 수 있습니다. 상태 패턴은 "지금 어떤 상태에서 무엇이 가능한가"를, 커맨드는 "방금 한 요청을 되돌리거나 미뤄 둘 수 있는가"를 풉니다. 둘은 함께 쓰여요.

💡 튜터의 한마디 — 상태 패턴의 값은 "상태마다 가능한 일이 한 객체에 모인다"는 데 있어요. ReviewState만 보면 검토 상태에서 뭐가 되고 안 되는지가 한눈에 들어옵니다. 다만 상태가 셋뿐이라면 사실 enum+switch로도 충분히 읽혀요. 이 과제는 "상태 패턴을 쓸 줄 안다"와 동시에 "이 정도면 굳이 안 써도 된다"는 판단까지 함께 가져가는 게 목표였습니다.


생각해볼 주제 예시답안

🤔 [생각해볼 주제 1] 전략 패턴 vs 함수 하나 — 언제 클래스로, 언제 람다로?

문제 상황 요약

전략 패턴을 클래스로도, 람다로도, Python 함수 딕셔너리로도 표현했고 기능은 같았다. 그렇다면 "전략 = 클래스"라는 공식은 깨진 걸까? 무엇을 기준으로 둘을 고를까?

튜터의 가이드 및 해설

기준은 두 가지가 부딪히는 지점에서 정해집니다. 알고리즘의 무게이름값이에요.

알고리즘이 한두 줄로 끝나고 상태도 없다면, 람다나 함수 하나가 답입니다. 환율 계산 amount * 1300 같은 걸 위해 클래스를 만들면 파일만 늘고 읽는 사람만 피곤해져요. 이때 인터페이스와 클래스는 빈 껍데기에 가깝습니다.

반대로 전략 하나가 여러 필드를 들고, 메서드도 여럿이고, 그 자체로 단위 테스트를 붙일 가치가 있을 만큼 묵직하다면 클래스가 낫습니다. 이름을 붙이면 "이게 무슨 전략인지"가 코드에 또렷해지고, 협업할 때 "여기 WeightedShippingPolicy로 빼죠"라는 한마디가 통하거든요. 이게 이름값, 곧 공통 어휘로서의 값입니다.

그래서 "공식이 깨졌다"기보다, 전략의 본질은 '알고리즘을 갈아 끼운다'이지 '클래스를 만든다'가 아니었다는 게 드러난 거예요. 클래스는 그 본질을 담는 한 가지 그릇일 뿐입니다.

🎯 면접에선 이렇게 나온다

"전략 패턴을 람다로 대체할 수 있는데, 그럼 패턴을 클래스로 만드는 게 의미가 있나요?"

이렇게 답하면 좋습니다. "알고리즘이 가벼우면 람다가 낫습니다. 하지만 전략이 상태와 여러 메서드를 갖거나, 이름을 붙여 공통 어휘로 삼고 독립적으로 테스트하고 싶을 땐 클래스가 낫습니다. 전략 패턴의 핵심은 '알고리즘을 갈아 끼울 수 있게 분리한다'이고, 클래스냐 람다냐는 그걸 담는 그릇의 선택입니다. 한 줄이면 람다, 한 덩어리면 클래스로 고릅니다."

💡 실무에선

함수형 인터페이스(Function·Comparator 등)로 받게 설계해 두면, 호출하는 쪽이 클래스 전략이든 람다든 자유롭게 넣을 수 있어 가장 유연합니다.

🤔 [생각해볼 주제 2] 옵저버의 느슨한 결합은 공짜인가?

문제 상황 요약

옵저버는 발행자와 구독자를 느슨하게 떼어 새 구독자를 자유롭게 끼우게 해 준다. 그 대가로, 코드만 봐서는 "이 동작 뒤에 무엇이 연쇄로 반응하는지"가 보이지 않는다. 결합을 느슨하게 할수록 항상 좋은가?

튜터의 가이드 및 해설

느슨한 결합은 공짜가 아닙니다. 유연함을 얻는 대신 흐름의 가시성을 내줘요.

강결합 코드(ProductBefore)에선 restock 한 메서드만 읽으면 무슨 일이 일어나는지 다 보입니다. 메일도 보내고 배지도 갱신한다는 게 그 자리에 적혀 있으니까요. 대신 새 동작을 더하려면 그 메서드를 열어 고쳐야 하죠.

옵저버로 바꾸면 반대가 됩니다. 새 구독자를 자유롭게 끼울 수 있지만, restock 한 줄을 봐선 무엇이 연쇄로 일어나는지 알 수 없어요. 추적하려면 구독자 목록을 따라가야 하고, 통지 순서나 통지 중 예외도 직접 챙겨야 합니다.

그래서 답은 "느슨할수록 좋다"가 아니라 "변화의 빈도에 맞춰 고른다"입니다. 알릴 대상이 자주 늘고 줄면 옵저버의 유연함이 값을 하고, 대상이 고정이고 흐름을 한눈에 보는 게 중요하면 직접 호출이 낫습니다. 결합도는 무조건 낮추는 게 아니라, 바뀌는 곳은 느슨하게, 안 바뀌는 곳은 단순하게 두는 거예요.

🎯 면접에선 이렇게 나온다

"이벤트 기반(옵저버)으로 느슨하게 짰는데, 오히려 디버깅이 어려워졌습니다. 왜일까요?"

이렇게 정리하면 좋습니다. "옵저버는 발행자와 구독자를 떼어 유연함을 얻는 대신, 실행 흐름이 코드에 직접 드러나지 않는 비용을 치릅니다. 한 이벤트가 어떤 핸들러를 연쇄로 깨우는지 정적으로 보이지 않아 추적이 어렵죠. 그래서 구독 등록 지점을 한곳에 모으고, 통지 순서·예외 격리를 명시적으로 관리하는 게 중요합니다. 느슨한 결합은 공짜가 아니라 트레이드오프입니다."

💡 실무에선

알림이 여러 갈래로 퍼지고 자주 바뀌면 이벤트(옵저버)로, 흐름이 고정이고 한눈에 봐야 하면 직접 호출로 둡니다. 둘을 섞어 쓰는 게 보통입니다.

🤔 [생각해볼 주제 3] 행위 패턴 다수가 함수로 녹는다면, 패턴 공부의 의미는?

문제 상황 요약

전략은 람다로, 커맨드는 Runnable로, 옵저버는 리액티브로 — 현대 언어에서 행위 패턴 상당수가 일급 함수나 라이브러리로 모습을 바꾼다. 그렇다면 "GoF 패턴은 함수가 일급이 아니던 시절의 우회로"라는 시각이 맞는가? 패턴을 배운 의미는 무엇인가?

튜터의 가이드 및 해설

"우회로"라는 시각에는 진실이 담겨 있습니다. 함수를 변수처럼 다룰 수 있으면 전략은 함수 하나로, 커맨드는 람다로 녹아요. 패턴의 코드 모양은 언어가 발전하면 분명히 가벼워지거나 사라집니다.

그런데 코드 모양이 사라져도 남는 게 있어요. "무엇을 왜 분리하는가"라는 설계 의도입니다. 전략이 람다가 돼도 "갈아 끼울 알고리즘을 분리한다"는 생각은 그대로고, 옵저버가 리액티브가 돼도 "변화를 구독자에게 알린다"는 구조는 그대로예요. 패턴이 가르치는 건 특정 클래스 배치가 아니라, 변하는 것과 변하지 않는 것을 가르는 눈입니다.

그래서 패턴 공부의 진짜 소득은 두 가지예요. 하나는 공통 어휘 — "여기 전략으로 빼죠"라는 한마디로 설계 의도가 전달됩니다. 다른 하나는 문제를 알아보는 눈 — if 지옥을 보면 "다형성으로 풀 자리다"가 보이고, 남의 코드에서 "이건 옵저버구나"가 읽힙니다. 코드 모양은 언어 따라 바뀌어도, 이 눈은 언어를 넘어 남아요.

그러니 패턴을 "외워야 할 23개의 정답"으로 보면 우회로에 그치지만, "설계 문제와 그 해법의 카탈로그"로 보면 언어가 바뀌어도 살아남는 안목이 됩니다. 그게 우리가 C 카테고리에서 패턴마다 "언제 안 쓰나"를 함께 단 이유예요.

🎯 면접에선 이렇게 나온다

"요즘 언어에선 디자인 패턴이 다 함수로 대체되는데, 굳이 배울 필요가 있나요?"

이렇게 답하면 깊이가 드러납니다. "코드 모양은 언어에 따라 가벼워지는 게 맞습니다. 전략은 람다로, 커맨드는 함수 객체로 녹죠. 하지만 패턴이 가르치는 건 클래스 배치가 아니라 '변하는 부분을 분리한다'는 설계 의도와, 그걸 부르는 공통 어휘입니다. 그건 언어가 바뀌어도 남습니다. 그래서 저는 패턴을 정답으로 외우기보다, 문제와 해법의 카탈로그이자 팀의 공통 언어로 씁니다."

💡 실무에선

패턴 이름을 알면 코드 리뷰와 설계 회의의 속도가 빨라집니다. "여기 상태 패턴이죠"가 긴 설명을 대신하니까요. 모양보다 어휘로 익혀 두세요.

전체 목록 클린코드