Software Architecture/DDD & Patterns

DDD Patterns - 10. GoF 디자인 패턴

공구함의 도구는 자리가 있다 — GoF 23개 패턴이 빛나는 자리와 어둡게 되는 자리

작은 스타트업에 입사한 주니어를 하나 상상한다. GoF 디자인 패턴 책을 막 다 읽은 그는 첫 업무로 '게시글 좋아요' 기능을 맡았다. 그의 손은 LikeService를 만들기 전에 LikeServiceFactory부터 갔다. 인터페이스를 정의하고, 구현을 분리하고, 빌더로 조립하고, 프록시로 감쌌다. 리뷰 시간에 선배가 물었다 — "좋아요 기능 하나에 클래스가 아홉 개인 이유가 뭔가요?" 주니어는 자신 있게 답했다. "확장성이요." 선배는 한참을 보다가 조용히 PR을 닫았다. 6개월 뒤 그 코드는 좋아요 카운터에 버그가 생겼을 때 다섯 개의 파일을 함께 읽어야 하는 원인이 됐다.

이 이야기의 교훈은 "패턴이 나쁘다"가 아니다. 패턴은 문제를 푸는 도구다. 교훈은 도구에는 쓸 자리가 있다는 당연한 사실을 잊으면 도구가 문제가 된다는 것이다. 드라이버를 모든 못에 대면 못이 박히지 않을 뿐 아니라 드라이버 끝도 상한다. 좋은 목수는 공구함을 다 꺼내지 않는다. 필요한 도구만 꺼낸다. GoF(Gang of Four) 디자인 패턴은 Gamma, Helm, Johnson, Vlissides가 1994년에 정리한 23개의 반복적 설계 해법이다. 30년이 지났지만, 책에 담긴 구조 대부분은 여전히 현역이다. 변한 것은 두 가지다. 하나는 많은 언어가 패턴의 일부를 언어 기능으로 흡수했다는 점이고, 다른 하나는 객체지향이 곧 패턴이던 시대가 끝났다는 점이다. 그래서 오늘날 패턴을 배우는 진짜 가치는 이름 외우기가 아니라, 이 구조가 어떤 문제를 풀고 어떤 문제를 만드는가를 분별하는 감각에 있다.

이 글은 23개 패턴을 한자리에 정리하되, 각 패턴에 세 가지를 묻는다 — 이 패턴이 푸는 본질 문제는 무엇인가, 어떤 코드에서 빛나는가, 그리고 언제 과잉이 되는가.

패턴 언어에서 GoF까지 — 30년의 맥락

역사 박스 — 패턴이 '언어'였던 시초

1977년 건축가 Christopher Alexander는 에서 "패턴 언어"라는 개념을 세웠다. 그가 말한 패턴은 "특정 맥락에서 반복적으로 발생하는 문제에 대한 해법"이었다. 창문의 위치, 현관의 깊이, 길의 너비 같은 건축 결정이 패턴으로 정리됐다. GoF 4인은 이 개념을 소프트웨어로 가져왔다. 그들의 책 서문은 Alexander에게 바쳐져 있다.

이 맥락이 중요한 이유는, Alexander의 패턴이 규칙이 아니라 경험적으로 검증된 해법의 기록이었기 때문이다. GoF 역시 마찬가지다. 그들은 23개의 패턴을 발명한 게 아니라, 이미 Smalltalk과 C++ 커뮤니티에서 반복적으로 쓰이던 구조를 수집하고 명명한 것이다. 그래서 패턴을 법칙처럼 다루면 본질을 놓친다. 그것들은 '이런 상황에서 이런 구조가 도움이 됐다'는 기록이다.

세 축 — 만들고, 짜고, 시킨다

23개 패턴은 객체 설계의 세 질문으로 나뉜다. 객체를 어떻게 만들까(Creational), 객체를 어떻게 조립할까(Structural), 객체 간 책임을 어떻게 분배할까(Behavioral).

flowchart LR
    GoF["GoF 23개 패턴"] --> C["Creational 5<br/>객체 생성"]
    GoF --> S["Structural 7<br/>객체 조립"]
    GoF --> B["Behavioral 11<br/>책임 분배"]
    C --> C1["Abstract Factory<br/>Builder<br/>Factory Method<br/>Prototype<br/>Singleton"]
    S --> S1["Adapter<br/>Bridge<br/>Composite<br/>Decorator<br/>Facade<br/>Flyweight<br/>Proxy"]
    B --> B1["Chain of Responsibility<br/>Command<br/>Interpreter<br/>Iterator<br/>Mediator<br/>Memento<br/>Observer<br/>State<br/>Strategy<br/>Template Method<br/>Visitor"]

생성 패턴은 new를 직접 부르지 않고 유연하게 객체를 만드는 법을 다룬다. 왜 new를 피하나. new는 구상 클래스(concrete class)에 결합하기 때문이다. new Dog()가 코드 곳곳에 있으면, 나중에 DogWolf로 바꾸기 어렵다. 생성 패턴은 이 결합을 완화한다. 구조 패턴은 작은 객체를 더 큰 구조로 조립한다. 이미 있는 클래스를 고치지 않고 새 기능을 더하거나(Decorator), 호환되지 않는 인터페이스를 이어주거나(Adapter), 복잡한 서브시스템 앞에 단순한 출입구를 세운다(Facade). 행동 패턴은 "누가 무슨 일을 할까"를 분배한다. 상태가 바뀌면 알려주는(Observer), 알고리즘을 상황별로 바꾸는(Strategy), 요청을 객체로 감싸는(Command) 식이다. 행동 패턴은 대부분 "느슨한 결합"을 위해 존재한다 — 한 객체가 바뀌어도 다른 객체가 흔들리지 않도록.

생성 패턴 — new 너머의 유연함

Factory Method — 하위 클래스가 객체를 결정하게 한다

문제의 본질은 이렇다. 클라이언트 코드가 만들어야 할 객체의 구상 타입을 모르게 하려면 어떻게 해야 하는가. LogisticsApp이 운송 수단을 다룬다고 하자. 초기엔 트럭만 썼다. 그러다 해운이 추가됐다. new Truck()을 직접 부르던 코드는 전부 new Ship()으로 바뀌어야 한다. Factory Method는 이 결합을 끊는다.

abstract class Logistics {
    // 팩토리 메서드 — 하위 클래스가 무엇을 만들지 결정
    abstract Transport createTransport();

    void planDelivery() {
        Transport t = createTransport();   // 클라이언트는 어떤 타입인지 모름
        t.deliver();
    }
}

class RoadLogistics extends Logistics {
    Transport createTransport() { return new Truck(); }
}

class SeaLogistics extends Logistics {
    Transport createTransport() { return new Ship(); }
}

클라이언트는 Logistics 추상 클래스에만 의존한다. Truck이냐 Ship이냐는 하위 클래스가 결정한다. 새로운 운송 수단(예: AirLogistics, Plane)이 추가돼도 클라이언트 코드는 변하지 않는다. Gamma 등이 에서 정의한 이 구조의 핵심은 "객체 생성을 서브클래스에게 미루는 것"이다.

언제 빛나는가. 런타임에 또는 설정에 따라 만들 객체의 타입이 바뀌고, 그 결정을 클라이언트로부터 분리하고 싶을 때. Spring의 BeanFactory, FactoryBean이 이 패턴의 거대한 실례다. JDBC의 DriverManager.getConnection()도 같은 맥락이다 — URL에 따라 드라이버가 결정된다.

언제 과잉인가. 타입이 하나뿐이거나, new 한 줄로 충분한 코드에 Factory Method를 세우면 이득은 없고 클래스만 하나 늘어난다. Kerievsky가 에서 강조하듯, 패턴은 리팩터링의 도착점이지 출발점이 아니다. 중복이나 복잡도가 먼저 나타나야 한다.

Abstract Factory — 관련 객체 군을 함께 만든다

문제는 이렇다. 서로 관련된 여러 객체를 일관된 '가족(family)'으로 만들어야 하는데, 가족 구성원을 섞어 쓰면 안 될 때.

GUI 라이브러리를 생각해 본다. Windows에서는 WinButtonWinCheckbox를 쓰고, macOS에서는 MacButtonMacCheckbox를 쓴다. 버튼만 바꿔야 할 게 아니라 버튼, 체크박스, 스크롤바를 한꺼번에 한 가족으로 바꿔야 한다. WinButtonMacCheckbox가 섞이면 어색해진다.

interface GUIFactory {
    Button createButton();
    Checkbox createCheckbox();
}

class WinFactory implements GUIFactory {
    public Button createButton() { return new WinButton(); }
    public Checkbox createCheckbox() { return new WinCheckbox(); }
}

class MacFactory implements GUIFactory {
    public Button createButton() { return new MacButton(); }
    public Checkbox createCheckbox() { return new MacCheckbox(); }
}

class Application {
    private final GUIFactory factory;
    Application(GUIFactory f) { this.factory = f; }

    void render() {
        Button b = factory.createButton();
        Checkbox c = factory.createCheckbox();
        b.render(); c.render();
    }
}

Factory Method와의 차이는 "군"이다. Factory Method는 객체 하나를 만드는 법이고, Abstract Factory는 한 가족의 객체들을 함께 만드는 법이다. 가족 중 일부만 섞어 쓰면 안 될 때 이 패턴이 가치가 있다.

언제 빛나는가. 여러 제품군(플랫폼, 테마, 브랜드)을 지원하고, 같은 군 내의 객체끼리만 섞여 쓰여야 할 때. 데이터베이스 벤더별 커넥터(MySQL, PostgreSQL, Oracle 각각의 Connection, Statement, ResultSet)가 같은 구조다. 크로스 플랫폼 UI 툴킷도 마찬가지다.

언제 과잉인가. 제품군이 하나뿐이거나, 제품 간 호환 제약이 없다면 Abstract Factory는 그냥 복잡한 Factory Method일 뿐이다. "나중에 다른 플랫폼 추가할 수도 있으니"라는 전제는 YAGNI 위반의 온상이다.

Builder — 복잡한 객체를 단계별로 조립한다

문제는 이렇다. 객체 하나를 만드는 데 매개변수가 열 개가 넘고, 그중 일부는 선택적일 때.

Pizza 객체를 만든다고 하자. 필수는 사이즈와 도우이고, 토핑, 치즈, 소스는 선택이다. 생성자로 전부 받으면 new Pizza("L", "THIN", true, false, true, false, ...)처럼 읽을 수 없는 코드가 된다. GoF가 지적한 전형적인 코드 냄새, telescoping constructor(매개변수 개수별로 생성자가 늘어나는 현상)다.

class Pizza {
    private final String size;
    private final String dough;
    private final boolean cheese, pepperoni, mushroom, onion;

    private Pizza(Builder b) {
        this.size = b.size; this.dough = b.dough;
        this.cheese = b.cheese; this.pepperoni = b.pepperoni;
        this.mushroom = b.mushroom; this.onion = b.onion;
    }

    public static class Builder {
        private final String size;       // 필수
        private final String dough;
        private boolean cheese, pepperoni, mushroom, onion;

        Builder(String size, String dough) { this.size = size; this.dough = dough; }
        Builder cheese() { this.cheese = true; return this; }
        Builder pepperoni() { this.pepperoni = true; return this; }
        Builder mushroom() { this.mushroom = true; return this; }
        Builder onion() { this.onion = true; return this; }
        Pizza build() { return new Pizza(this); }
    }
}

Pizza p = new Pizza.Builder("L", "THIN").cheese().pepperoni().build();

마지막 줄을 보면 객체 생성이 거의 자연어 문장처럼 읽힌다. 이것이 Builder의 힘이다. Bloch는 3판에서 GoF의 Builder를 현대적으로 다듬어 "필수 매개변수는 생성자로, 선택 매개변수는 메서드 체인으로" 권장한다. 불변 객체를 만드는 데에도 유리하다 — Builder가 모든 상태를 채운 뒤 build()에서 한 번에 Pizza를 만들기 때문이다.

언제 빛나는가. 매개변수가 4~5개를 넘고, 그중 다수가 선택적이며, 불변 객체를 만들고 싶을 때. SQL 쿼리 빌더, HTTP 요청 빌더, 설정 객체 빌더가 대표적이다.

언제 과잉인가. 매개변수가 2~3개뿐인데 Builder를 만들면 보일러플레이트만 늘어난다. Lombok의 @Builder처럼 언어 차원에서 흡수된 곳에서는 그냥 애너테이션을 쓰는 게 맞다. Java 레코드(record)가 도입된 뒤로는 단순한 데이터 홀더에 Builder 대신 레코드를 쓰는 경우도 많아졌다.

Prototype — 복제로 만든다

문제는 이렇다. 기존 객체와 같은 상태를 가진 새 객체를 만들고 싶은데, 생성자로 그 상태를 재현하기 어려울 때.

게임 캐릭터를 생각해 본다. 몬스터 한 마리를 설정하는 데 20개의 속성(체력, 속도, 공격 패턴, AI 로직)을 맞춰야 한다. 매번 생성자로 세팅하면 비용이 크다. 이미 세팅된 몬스터를 "복제"하면 된다.

abstract class Monster implements Cloneable {
    int hp, speed;
    String name;

    public Monster clone() {
        try { return (Monster) super.clone(); }
        catch (CloneNotSupportedException e) { throw new AssertionError(); }
    }
}

Monster goblin = new Monster();
goblin.hp = 100; goblin.speed = 5; goblin.name = "고블린";

Monster anotherGoblin = goblin.clone();   // 같은 상태의 새 객체

얕은 복사(shallow copy)와 깊은 복사(deep copy)의 함정이 있다. super.clone()은 필드를 그대로 복사하므로, 참조 타입 필드는 같은 객체를 가리킨다. 의도하지 않은 공유가 생긴다. 깊은 복사가 필요하면 직접 구현해야 한다.

언제 빛나는가. 새 객체를 만드는 비용이 크고(heavyweight), 비슷한 상태의 객체를 여러 개 만들 때. 프로토타입 기반 언어인 JavaScript에서는 이 개념이 언어 철학의 중심에 있다 — Object.create(proto)가 같은 맥락이다.

언제 과잉인가. 단순히 new로 만들면 되는 객체에 Prototype을 도입하면 얕은 복사 함정만 얻는다. Java에서는 잘 쓰이지 않는다 — record와 불변 객체가 더 안전한 대안이기 때문이다. Python에서는 copy.deepcopy() 한 줄로 같은 효과를 얻는다.

Singleton — 인스턴스를 하나만

문제는 이렇다. 시스템 전체에 인스턴스가 하나만 존재해야 할 때.

데이터베이스 연결 풀, 설정 저장소, 로거. 이런 객체는 여러 개 만들면 일관성이 깨지거나 자원이 낭비된다.

class Database {
    private static final Database INSTANCE = new Database();
    private Database() {}   // 외부 생성 차단
    public static Database getInstance() { return INSTANCE; }
}

가장 단순한 형태다 — 클래스 로딩 시점에 인스턴스를 만든다(eager initialization). 스레드 안전하다. 그러나 Singleton은 GoF 23개 중 가장 오용이 많은 패턴이기도 하다.

왜 위험한가. 첫째, 전역 상태를 만든다. 어디서든 Database.getInstance()를 부를 수 있으므로, 시스템 전체가 이 객체에 암묵적으로 의존하게 된다. 의존성이 코드에 드러나지 않는다 — Database를 안 쓰는 척하는 클래스가 사실은 깊은 곳에서 getInstance()를 부른다. Ousterhout가 <소프트웨어 설계 철학>에서 경고한 "숨겨진 결합"의 전형이다.

둘째, 테스트가 어렵다. 목(mock)으로 교체할 수 없으므로, 단위 테스트마다 진짜 데이터베이스에 연결된다.

셋째, 멀티스레드에서 함정이 깊다. lazy initialization을 잘못 구현하면 두 스레드가 동시에 인스턴스를 만들어 싱글턴이 깨진다. 이를 막으려고 double-checked locking을 쓰면 Java 메모리 모델(JMM) 함정에 빠진다. volatile 없이는 안 된다.

// 잘못된 lazy initialization — volatile이 빠져 있음
class Database {
    private static Database instance;
    public static Database getInstance() {
        if (instance == null) {
            synchronized (Database.class) {
                if (instance == null) instance = new Database();
            }
        }
        return instance;
    }
}

오늘날 권장은 단순하다 — Singleton 패턴을 직접 구현하지 말고, DI 컨테이너(Spring의 @Component, @Service 등)에게 "인스턴스 하나를 관리"를 위임하라. 테스트에서 쉽게 교체할 수 있고, 생명 주기 관리도 컨테이너가 책임진다.

언제 빛나는가. 진짜로 인스턴스 하나여야 하는 하드웨어적, 외부 자원(장치 드라이버, 프로세스 단일 설정)을 다룰 때.

언제 과잉인가. 대부분의 경우. "하나만 있으면 된다"와 "하나여야만 한다"는 다르다. 전자는 DI가 관리하고, 후자만이 Singleton을 정당화한다.

구조 패턴 — 객체를 조립하는 일곱 가지 방식

Adapter — 호환되지 않는 인터페이스를 이어준다

문제는 이렇다. 이미 만들어진 클래스의 인터페이스가 클라이언트가 기대하는 형태와 다를 때.

도메인이 PaymentGateway.charge(orderId, amount)를 원하는데, 외부 PG사 SDK는 pay(merchantUid, amountKrw)를 제공한다. 둘 다 "결제"를 하지만 인터페이스가 다르다. SDK를 직접 쓰면 도메인이 PG사에 묶인다. Adapter가 그 사이를 잇는다.

public interface PaymentGateway {
    PaymentResult charge(OrderId orderId, Money amount);
}

public class TossPaymentAdapter implements PaymentGateway {
    private final TossClient toss;
    public TossPaymentAdapter(TossClient toss) { this.toss = toss; }

    public PaymentResult charge(OrderId orderId, Money amount) {
        TossResponse raw = toss.pay(orderId.value(), amount.amount());
        return new PaymentResult("DONE".equals(raw.status()), raw.paymentKey());
    }
}

헥사고날 아키텍처의 어댑터가 바로 이 패턴의 시스템 단위 확장이다. 포트(도메인이 정의한 인터페이스)와 어댑터(외부 SDK 구현)의 분리가 GoF Adapter와 같은 원리다.

언제 빛나는가. 외부 라이브러리나 레거시 시스템을 도메인에 맞출 때. 한쪽을 고칠 수 없거나 고치면 안 될 때. java.util.Arrays.asList()가 배열을 List 인터페이스로 맞추는 어댑터 역할을 하는 것도 같은 맥락이다.

언제 과잉인가. 같은 코드베이스의 두 클래스 사이에 어댑터를 두면, 인터페이스를 그냥 맞추면 될 일을 굳이 간접층으로 감싸는 꼴이다. 인터페이스가 단순히 다르면 어댑터보다 그냥 리팩터링이 낫다.

Bridge — 추상과 구현을 분리한다

문제는 이렇다. 두 가지 변화 축이 직교할 때, 둘 다 독립적으로 확장되게 하려면.

리모컨과 기기를 생각해 본다. 리모컨은 기본, 고급 두 종류고, 기기는 TV, 라디오 두 종류다. 이걸 클래스 상속으로 풀면 BasicRemoteTV, BasicRemoteRadio, AdvancedRemoteTV, AdvancedRemoteRadio 네 개가 필요하고, 한 축이 늘 때마다 클래스가 두 배씩 늘어난다. 이를 class explosion이라 부른다.

Bridge는 상속 대신 조합(composition)을 쓴다. 하나의 축이 다른 축을 참조로 갖는다.

interface Device {
    void on();
    void off();
    void setChannel(int n);
}

class TV implements Device { /* 구현 */ }
class Radio implements Device { /* 구현 */ }

abstract class Remote {
    protected Device device;          // 다른 축
    Remote(Device d) { this.device = d; }
    void togglePower() { /* device.on/off 호출 */ }
}

class BasicRemote extends Remote {
    BasicRemote(Device d) { super(d); }
}
class AdvancedRemote extends Remote {
    AdvancedRemote(Device d) { super(d); }
    void mute() { /* 구현 */ }
}

이제 리모컨 종류와 기기 종류가 독립적으로 늘어난다. 2×2가 아니라 2+2로 줄었다.

언제 빛나는가. 두 개 이상의 독립적 변화 축이 직교할 때. JDBC가 좋은 사례다 — DriverManager(추상)와 Driver(구현, 벤더별)이 분리돼 있어 새 벤더 드라이버를 추가해도 클라이언트 코드가 변하지 않는다.

언제 과잉인가. 변화 축이 하나뿐이거나, 한 축이 사실상 고정일 때. Bridge는 복잡한 구조이므로 진짜 두 축이 모두 변할 때만 가치가 있다.

Composite — 트리 구조를 단일 객체처럼 다룬다

문제는 이렇다. 부분-전체 계층(part-whole hierarchy)에서, 개별 객체와 복합 객체를 클라이언트가 동일하게 다루려면.

파일 시스템을 생각해 본다. 파일과 폴더가 있다. 폴더는 파일과 하위 폴더를 담을 수 있다. 클라이언트는 "이게 파일인지 폴더인지 모른 채" 크기를 구하거나 삭제하고 싶다.

interface FileSystemNode {
    long size();
    void delete();
}

class File implements FileSystemNode {
    private final long size;
    public File(long size) { this.size = size; }
    public long size() { return size; }
    public void delete() { /* 파일 삭제 */ }
}

class Directory implements FileSystemNode {
    private final List<FileSystemNode> children = new ArrayList<>();
    public void add(FileSystemNode n) { children.add(n); }
    public long size() {
        return children.stream().mapToLong(FileSystemNode::size).sum();
    }
    public void delete() { children.forEach(FileSystemNode::delete); }
}

Directory 안에 File도 들어가고 Directory도 들어간다. 클라이언트는 node.size()를 부르기만 하면 된다 — 그게 파일이든 폴더든. 이 "동일한 인터페이스로 개별과 복합을 함께 다룬다"는 Composite의 본질이다.

언제 빛나는가. 트리 구조(UI 컴포넌트, 조직도, 카테고리, 메뉴)에서 개별 원소와 복합 원소를 같은 인터페이스로 다루고 싶을 때. AWS CDK나 Terraform 같은 '리소스 트리'도 같은 구조다. React 컴포넌트 트리도 본질적으로 Composite이다.

언제 과잉인가. 트리가 아니거나 깊이가 1로 고정일 때. "자식이 여럿일 수도 있는 리스트" 정도면 Composite 없이 List<Item>이면 충분하다.

Decorator — 상속 없이 기능을 더한다

문제는 이렇다. 클래스를 수정하지 않고 런타임에 기능을 추가하려면.

커피 주문을 생각해 본다. 기본은 에스프레소, 여기에 우유, 시럽, 휘핑크림을 조합할 수 있다. 이걸 클래스 상속으로 풀면 EspressoWithMilk, EspressoWithMilkAndSyrup... 조합 수만큼 클래스가 필요하다(class explosion의 다른 사례).

Decorator는 감싸는 방식으로 푼다. 원본 객체를 다른 객체로 감싸서 기능을 더한다.

interface Coffee { int cost(); String desc(); }

class Espresso implements Coffee {
    public int cost() { return 2000; }
    public String desc() { return "에스프레소"; }
}

abstract class CoffeeDecorator implements Coffee {
    protected final Coffee inner;
    CoffeeDecorator(Coffee c) { this.inner = c; }
}

class Milk extends CoffeeDecorator {
    Milk(Coffee c) { super(c); }
    public int cost() { return inner.cost() + 500; }
    public String desc() { return inner.desc() + " + 우유"; }
}

class Syrup extends CoffeeDecorator {
    Syrup(Coffee c) { super(c); }
    public int cost() { return inner.cost() + 300; }
    public String desc() { return inner.desc() + " + 시럽"; }
}

Coffee c = new Syrup(new Milk(new Espresso()));   // 2800원

마지막 줄의 겹겹의 괄호가 이 패턴의 서명이다. Java의 InputStream 계열이 이 패턴의 결정체 — BufferedInputStream(new FileInputStream(file))처럼 쓴다.

언제 빛나는가. 기능을 조합해서 붙일 수 있고, 그 조합 수가 많을 때. I/O 스트림, 로깅 데코레이터(메트릭, 재시도, 캐시), 인증 체인이 같은 맥락이다.

언제 과잉인가. 데코레이터가 한두 개뿐이거나, 단순한 설정값 하나를 더하는 정도면 그냥 필드 추가나 상속이 낫다. 데코레이터는 각 층이 객체이므로, 층이 깊어지면 디버깅이 어렵다 — 어느 층에서 값이 변했는지 따라가야 한다. Python의 @decorator 문법과는 다르다. Python 데코레이터는 함수 변환의 언어 기능이고, GoF Decorator는 객체 합성의 설계 패턴이다. 우연히 같은 이름이 붙었을 뿐 본질이 다르다.

Facade — 복잡한 서브시스템에 단순한 출입구를 세운다

문제는 이렇다. 여러 클래스가 얽힌 서브시스템을 클라이언트가 단순하게 쓰게 하려면.

영화관 시스템을 생각해 본다. 영화를 틀려면 프로젝터 켜기, 스크린 내리기, 소등, 앰프 켜기, 플레이어 재생 — 다섯 가지 객체를 순서대로 조작해야 한다. 클라이언트가 이걸 매번 직접 하면 서브시스템이 클라이언트에 다 드러난다.

class HomeTheaterFacade {
    private final Projector projector;
    private final Screen screen;
    private final Amplifier amp;
    private final Player player;

    HomeTheaterFacade(Projector p, Screen s, Amplifier a, Player pl) {
        this.projector = p; this.screen = s; this.amp = a; this.player = pl;
    }

    void watchMovie() {
        screen.down();
        projector.on();
        amp.on();
        player.play();
    }
}

클라이언트는 facade.watchMovie() 한 줄로 다 한다. Facade는 서브시스템을 감추는 것이지 변경하지 않는다. 서브시스템 클래스들은 여전히 직접 접근 가능하다 — Facade는 "편의 출입구"지 "유일한 출입구"가 아니다.

언제 빛나는가. 레거시 시스템 통합, 복잡한 라이브러리 진입점 단순화, 마이크로서비스 호출 체인 정리. Spring의 JdbcTemplate이 JDBC의 복잡함을 감추는 Facade의 예다.

언제 과잉인가. 서브시스템이 원래 단순한데 Facade를 또 만들면, 같은 일을 하는 출입구가 두 개가 된다. Facade는 "클라이언트가 매번 똑같은 다단계 작업을 반복한다"는 중복이 보일 때 도입한다.

Flyweight — 공유로 메모리를 절약한다

문제는 이렇다. 비슷한 객체가 아주 많을 때, 공통 부분을 공유해 메모리를 아끼려면.

텍스트 편집기를 생각해 본다. 글자 하나하나를 객체로 만들면, 'a'가 1만 번 등장할 때 'a' 객체가 1만 개 생긴다. 글자 'a'의 폰트, 크기 등은 모두 같다. 다른 부분은 위치뿐이다. Flyweight는 공통 부분(intrinsic state)을 공유하고, 다른 부분(extrinsic state)은 외부에서 넘겨받는다.

class Glyph {
    private final char ch;        // intrinsic — 공유
    private final String font;    // intrinsic — 공유
    Glyph(char ch, String font) { this.ch = ch; this.font = font; }
    void render(int row, int col) { /* 위치만 받아 렌더 */ }
}

class GlyphFactory {
    private final Map<String, Glyph> pool = new HashMap<>();
    Glyph get(char ch, String font) {
        String key = ch + font;
        return pool.computeIfAbsent(key, k -> new Glyph(ch, font));
    }
}

언제 빛나는가. 객체 수가 수만에서 수백만 단위이고, 대부분의 상태가 공유 가능할 때. 게임의 파티클, 지도의 타일, 텍스트의 글리프가 전형적이다.

언제 과잉인가. 객체 수가 수십에서 수백 단위이면 공유의 이득보다 복잡도가 크다. modern Java에서 Integer 캐싱(-128에서 127)이나 String 풀처럼 언어 차원에서 흡수된 경우가 많아, 직접 구현할 일은 드물다. Integer.parseInt("127") == Integer.parseInt("127")이 true인 것도 이 패턴 덕분이다.

Proxy — 접근을 제어한다

문제는 이렇다. 객체에 접근할 때 어떤 제어(지연, 권한, 로깅, 원격)를 끼워넣으려면.

Proxy는 원본 객체와 같은 인터페이스를 구현하면서, 내부에 원본을 품고 클라이언트 요청을 가로채는 객체다. 목적에 따라 여러 형태가 있다.

가상 프록시(virtual proxy) — 무거운 객체를 실제 쓰일 때까지 미룬다.

class ImageProxy implements Image {
    private RealImage real;
    private final String path;
    ImageProxy(String path) { this.path = path; }

    public void render() {
        if (real == null) real = new RealImage(path);   // 처음 쓰일 때 로드
        real.render();
    }
}

보호 프록시(protection proxy)는 권한을 검사한다. 원격 프록시(remote proxy)는 네트워크 너머의 객체처럼 보이게 한다(RMI, gRPC stub). 이 경우 "프록시"라는 이름이 "스텁"으로도 쓰인다.

언제 빛나는가. 지연 초기화가 큰 효과를 낼 때(이미지, 문서, 연결), 접근 제어가 필요할 때, 원격 객체를 로컬처럼 다루고 싶을 때. Hibernate의 지연 로딩(lazy loading), Spring AOP의 @Transactional이 프록시 기반으로 동작한다.

언제 과잉인가. 원본 객체가 가볍고 접근 제어도 필요 없는 곳에 프록시를 두면 한 번의 메서드 호출이 두 번의 메서드 호출이 된다. 특히 분산 시스템에서 원격 프록시를 잘못 쓰면 "로컬 호출인 줄 알았는데 네트워크 호출"이라는 성능 함정이 된다 — 분산 시스템의 여덟 가지 오해 중 첫 번째, "네트워크는 신뢰할 수 있다"를 위반하는 지점이다.

행동 패턴 — 누가 무슨 일을 할까

Observer — 상태가 바뀌면 알려준다

문제는 이렇다. 어떤 객체의 상태 변화를 여러 객체가 알아야 할 때, 그들 사이의 결합을 느슨하게 유지하려면.

주문이 확정되면 배송팀, 마일리지팀, 알림팀이 각자 일을 해야 한다. OrderServiceshipping.handle(), mileage.add(), notification.send()를 직접 부르면, 새 팀이 생길 때마다 OrderService를 고쳐야 한다. OCP 위반이다.

Observer는 "구독"으로 푼다. 발행자(subject)는 구독자 목록만 갖고, 상태가 바뀌면 "알려"만 한다. 구독자는 각자 자기 일을 한다.

interface OrderEvent { }
interface OrderEventHandler { void on(OrderEvent e); }

class OrderService {
    private final List<OrderEventHandler> handlers = new ArrayList<>();
    public void subscribe(OrderEventHandler h) { handlers.add(h); }

    public void confirm(OrderId id) {
        Order order = repo.findById(id);
        OrderPlaced event = order.confirm();
        repo.save(order);
        handlers.forEach(h -> h.on(event));   // Observer — 알림만
    }
}

// 각 핸들러는 자기 일만
new ShippingHandler();
new MileageHandler();
new NotificationHandler();

이것이 도메인 이벤트(domain event)의 뼈대이기도 하다. 발행-구독 모델의 시스템 단위 확장이 Kafka 같은 메시지 브로커다.

주의점이 있다. 동기식 Observer에서 한 핸들러가 느리면 전체가 느려진다. 핸들러가 예외를 던지면 뒤 핸들러가 안 돌아갈 수 있다. 또한 무한 루프 함정이 있다 — A가 B를 구독하고 B가 A를 구독하면 영원히 돈다.

언제 빛나는가. 한 상태 변화가 여러 곳에 영향을 미치고, 그 "여러 곳"이 자주 변할 때. MVC의 Model-View, React의 상태-렌더, 이벤트 기반 아키텍처의 근간이다.

언제 과잉인가. 구독자가 하나뿐이면 직접 호출이 더 명확하다. 또한 Observer를 과도하게 쓰면 "이 이벤트가 어디서 발생했고, 누가 받는지" 추적이 어려워진다 — 디버깅이 어려워진다. 이것이 일부 현대 프레임워크(Flux, Redux)가 "단방향 데이터 흐름"을 강조하는 이유다.

Strategy — 알고리즘을 상황별로 바꾼다

문제는 이렇다. 같은 목적의 여러 알고리즘이 상황에 따라 선택돼야 할 때.

할인 정책이 고객 등급별로 다르다. VIP는 10%, 일반은 없음, 직원은 30%. 이걸 if-else로 짜면 등급이 추가될 때마다 메서드가 길어진다.

interface DiscountPolicy { Money apply(Money original); }

class NoDiscount implements DiscountPolicy {
    public Money apply(Money m) { return m; }
}
class VIPDiscount implements DiscountPolicy {
    public Money apply(Money m) { return m.multiply(0.9); }
}
class EmployeeDiscount implements DiscountPolicy {
    public Money apply(Money m) { return m.multiply(0.7); }
}

class OrderService {
    private final Map<CustomerGrade, DiscountPolicy> policies;

    public Money calculateTotal(Order order, CustomerGrade grade) {
        return policies.get(grade).apply(order.subtotal());
    }
}

새 등급이 추가되면 Map에 새 정책을 넣기만 하면 된다. OrderService는 변하지 않는다 — OCP(개방-폐쇄 원칙)의 전형적 실천이다.

언제 빛나는가. 알고리즘이 런타임에 교체되고, 알고리즘 종류가 셋 이상이며, 각각의 조건이 복잡할 때. 결제 수단 선택, 정렬 기준 선택, 세금 계산이 대표적이다.

언제 과잉인가. 전략이 하나뿐이거나, 둘인데 단순 조건문으로 끝날 때. 람다식이 있는 언어(Java 8+, Python, JavaScript)에서는 굳이 클래스를 안 만들고 람다로 전략을 넘기는 경우가 많다.

// Strategy 인터페이스 없이 람다로
Map<CustomerGrade, Function<Money, Money>> policies = Map.of(
    VIP, m -> m.multiply(0.9),
    EMPLOYEE, m -> m.multiply(0.7),
    REGULAR, Function.identity()
);

Strategy의 본질(알고리즘의 캡슐화와 교체)은 유지되면서 보일러플레이트가 줄었다. 언어가 패턴을 흡수한 사례다.

Command — 요청을 객체로 만든다

문제는 이렇다. 요청(또는 동작)을 매개변수로 넘기거나, 큐에 넣거나, 로그로 남기거나, 취소(undo) 가능하게 만들려면.

GUI 버튼을 생각해 본다. "저장" 버튼과 "인쇄" 버튼은 같은 Button 클래스지만 눌렸을 때 다른 일을 한다. 버튼이 직접 저장, 인쇄 로직을 가지면 안 된다 — 버튼은 "어떤 동작"만 알아야 한다.

interface Command { void execute(); }

class SaveCommand implements Command {
    private final Document doc;
    SaveCommand(Document d) { this.doc = d; }
    public void execute() { doc.save(); }
}

class PrintCommand implements Command {
    private final Document doc;
    PrintCommand(Document d) { this.doc = d; }
    public void execute() { doc.print(); }
}

class Button {
    private Command command;
    Button(Command c) { this.command = c; }
    void click() { command.execute(); }
}

버튼은 자기가 무슨 일을 하는지 모른다. 그저 들고 있는 Command를 실행할 뿐이다. 저장이냐 인쇄냐는 버튼을 만들 때 결정된다.

Command의 진짜 힘은 부가 기능에 있다. undo(UndoableCommandundo() 추가), 큐잉(명령을 리스트에 넣고 지연 실행), 로깅(실행 전 직렬화해 저장), 트랜잭션(명령 단위로 커밋, 롤백)이 모두 가능하다.

언제 빛나는가. 작업 큐(스케줄러, 작업자 패턴), undo/redo 편집기, 매크로 기록, 트랜잭션 스크립트. CQRS의 "명령(command)"이 바로 이 패턴에서 따온 이름이다.

언제 과잉인가. 동작 하나를 감싸는 데 객체 하나를 만드는 비용이 드는 단순한 경우. 람다가 있는 언어에선 Runnable이나 함수형 인터페이스로 충분하다.

Template Method — 알고리즘 골격을 고정하고 단계를 열어둔다

문제는 이렇다. 알고리즘의 전체 흐름은 고정되어 있지만, 일부 단계만 하위 클래스가 결정해야 할 때.

데이터 처리 파이프라인을 생각해 본다. "데이터 읽기 → 변환 → 쓰기"의 흐름은 고정이지만, 읽기, 변환, 쓰기의 구현은 CSV, JSON, XML마다 다르다.

abstract class DataPipeline {
    // 템플릿 — 흐름 고정
    public final void run() {
        var data = read();
        var transformed = transform(data);
        write(transformed);
    }

    protected abstract List<Record> read();
    protected abstract List<Record> transform(List<Record> data);
    protected abstract void write(List<Record> data);
}

class CsvPipeline extends DataPipeline {
    protected List<Record> read() { /* CSV에서 읽기 */ return List.of(); }
    protected List<Record> transform(List<Record> d) { /* 변환 */ return d; }
    protected void write(List<Record> d) { /* 쓰기 */ }
}

run()의 흐름은 상위 클래스가 고정한다(final로 재정의까지 막을 수 있다). 하위 클래스는 세 단계만 구현한다. Spring의 JdbcTemplate, RestTemplate이 이 패턴의 실례 — 템플릿이 "흐름"을 잡고, 콜백으로 "단계"를 열어둔다.

언제 빛나는가. 알고리즘의 뼈대가 안정적이고 일부 단계만 변할 때. 프레임워크에서 흔하다 — 프레임워크가 흐름을 잡고, 사용자가 "빈 칸"을 채운다(Hollywood Principle: "Don't call us, we'll call you").

언제 과잉인가. 단계가 하나뿐이거나, 흐름이 자주 변하면. 흐름이 변하면 상위 클래스를 계속 고쳐야 하므로, 이 패턴의 이점이 사라진다. 최근엔 람다, 함수형 인터페이스로 같은 효과를 더 유연하게 얻는 경우가 많다.

State — 상태마다 행동을 바꾼다

문제는 이렇다. 객체의 행동이 내부 상태에 따라 바뀔 때, if (state == X) ... else if (state == Y) ...를 피하려면.

주문의 상태 전이를 생각해 본다 — Created → Paid → Shipped → Delivered. 각 상태에서 허용되는 행동이 다르다. Created에서는 "결제"만 되고, Shipped에서는 "배송 취소"가 안 된다. 이걸 큰 switch로 짜면 상태가 늘 때마다 분기가 늘어난다.

interface OrderState {
    OrderState pay();
    OrderState ship();
    OrderState cancel();
}

class Created implements OrderState {
    public OrderState pay() { return new Paid(); }
    public OrderState ship() { throw new IllegalStateException("미결제 상태"); }
    public OrderState cancel() { return new Cancelled(); }
}

class Paid implements OrderState {
    public OrderState pay() { throw new IllegalStateException("이미 결제됨"); }
    public OrderState ship() { return new Shipped(); }
    public OrderState cancel() { return new Cancelled(); }   // 환불 처리
}

class Order {
    private OrderState state = new Created();
    public void pay() { state = state.pay(); }
    public void ship() { state = state.ship(); }
    public void cancel() { state = state.cancel(); }
}

Order는 자기 상태가 뭔지 모른다. 그저 현재 상태 객체에 위임할 뿐이다. 새 상태가 추가되면 OrderState 구현체를 하나 더 만들면 된다.

Strategy와의 차이는 미묘하지만 중요하다. Strategy는 클라이언트가 상황에 따라 전략을 선택하는 것이고, State은 객체가 스스로 상태를 전이하는 것이다. State은 자신의 다음 상태를 알고, 전이를 일으킨다.

언제 빛나는가. 상태 머신, 워크플로우, 게임 캐릭터 AI 상태, UI의 화면 전이. 상태 수가 다섯 개를 넘고, 상태별 행동이 복잡할 때 특히 가치가 있다.

언제 과잉인가. 상태가 둘 또는 셋뿐이고 단순 불리언으로 표현되는 정도. 또한 상태 전이 규칙이 자주 바뀌면 상태 클래스를 계속 고쳐야 하므로, 가벼운 상태 머신 라이브러리(Spring StateMachine 등)가 더 낫다.

Chain of Responsibility — 요청을 처리할 핸들러 체인으로

문제는 이렇다. 요청을 여러 핸들러가 순차적으로 검사하게 하고, 각 핸들러가 처리하거나 다음으로 넘기게 하려면.

HTTP 요청 인증 파이프라인을 생각해 본다. 순서대로 로깅, 인증, 권한, 본문 처리. 각 단계는 처리할 수 있으면 하고, 못 하면 다음으로 넘긴다. 이걸 한 큰 메서드로 짜면 한 함수가 너무 많은 일을 한다.

abstract class Handler {
    protected Handler next;
    Handler setNext(Handler n) { this.next = n; return n; }
    abstract void handle(Request req);
}

class AuthHandler extends Handler {
    void handle(Request req) {
        if (req.hasValidToken()) next.handle(req);
        else throw new UnauthorizedException();
    }
}

class LogHandler extends Handler {
    void handle(Request req) {
        log(req);
        if (next != null) next.handle(req);
    }
}

// 체인 구성
Handler chain = new LogHandler();
chain.setNext(new AuthHandler()).setNext(new BusinessHandler());

Servlet의 Filter, Spring의 HandlerInterceptor, Python의 미들웨어가 같은 구조다. 각 필터가 체인으로 연결되고 요청이 흐른다.

언제 빛나는가. 요청 처리 단계가 파이프라인 형태이고, 단계 순서가 바뀔 수 있으며, 각 단계가 독립적일 때. 인증, 로깅, 검증, 변환 파이프라인이 대표적이다.

언제 과잉인가. 단계가 둘 또는 셋이고 고정적일 때. 체인은 런타임에 단계를 넣고 뺄 수 있다는 점이 이득인데, 단계가 고정이면 그냥 메서드 세 개가 낫다.

Iterator — 컬렉션 내부를 드러내지 않고 순회한다

문제는 이렇다. 컬렉션이 내부 구조(리스트? 트리? 해시?)를 드러내지 않으면서, 클라이언트가 원소를 하나씩 순회하게 하려면.

interface Iterator<T> { boolean hasNext(); T next(); }

class ArrayIterator<T> implements Iterator<T> {
    private final T[] array;
    private int i = 0;
    ArrayIterator(T[] a) { this.array = a; }
    public boolean hasNext() { return i < array.length; }
    public T next() { return array[i++]; }
}

class TreeIterator<T> implements Iterator<T> {
    // 내부는 완전히 다르지만 인터페이스는 같다
}

오늘날 이 패턴은 거의 모든 언어에 흡수됐다. Java의 향상된 for 루프, Python의 for x in iterable, JavaScript의 for...of 모두 Iterator 패턴의 언어 흡수다. foreach가 가능한 컬렉션이면 내부가 뭐든 같은 코드로 순회된다.

언제 빛나는가. 커스텀 컬렉션을 만들고 표준 순회 인터페이스를 제공할 때. 대부분은 언어 기본 컬렉션과 그 순회 기능을 쓰면 되므로, 직접 구현할 일은 드물다.

언제 과잉인가. 단순한 리스트 순회에 직접 Iterator를 만들면 보일러플레이트만 늘어난다.

Mediator — 객체 간 통신을 중재한다

문제는 이렇다. 여러 객체가 서로 직접 통신하면 결합이 거미줄처럼 얽힌다. 한 곳에서 중재하게 하면 어떨까.

UI 컴포넌트를 생각해 본다. 체크박스를 끄면 텍스트 입력이 비활성화되고, 버튼이 사라진다. 각 컴포넌트가 다른 컴포넌트를 직접 참조하면 N×N 결합이 생긴다.

class DialogMediator {
    private CheckBox cb;
    private TextBox tb;
    private Button btn;

    void onCheckBoxChanged(boolean checked) {
        tb.setEnabled(checked);
        btn.setVisible(checked);
    }
}

// 컴포넌트들은 서로 모르고 mediator만 앎
class CheckBox {
    private DialogMediator mediator;
    void onChange(boolean checked) { mediator.onCheckBoxChanged(checked); }
}

컴포넌트들은 서로를 모른다. 중재자만 안다. N×N이 N+M이 됐다. Observer와 비슷해 보이지만, Observer는 발행자가 여러 구독자에게 "알림"만 하는 반면, Mediator는 양방향 통신을 조율한다. Spring MVC의 DispatcherServlet이 HTTP 요청을 컨트롤러, 뷰, 인터셉터 사이에서 중재하는 Mediator다.

언제 빛나는가. 여러 객체가 서로 복잡하게 상호작용할 때, 특히 UI 컴포넌트, 워크플로우 엔진, 채팅 시스템.

언제 과잉인가. 컴포넌트 수가 적고 상호작용이 단순하면 Mediator는 God Object가 되기 쉽다. 모든 통신이 한 곳으로 모이므로, 중재자가 비대해지는 함정을 조심해야 한다.

Memento — 상태를 저장하고 복원한다

문제는 이렇다. 객체의 캡슐화를 깨뜨리지 않고 상태를 저장했다가 복원하려면.

편집기의 undo를 생각해 본다. 문서의 상태를 스냅샷으로 저장해 뒀다가 되돌려야 한다. 그런데 문서가 내부 상태를 private로 숨기면, 외부에서 스냅샷을 만들 수 없다. 그렇다고 내부를 다 드러내면 캡슐화가 깨진다.

class Document {
    private String content;
    private int cursor;

    Memento save() { return new Memento(content, cursor); }
    void restore(Memento m) { this.content = m.content; this.cursor = m.cursor; }

    static class Memento {
        private final String content;
        private final int cursor;
        Memento(String c, int p) { content = c; cursor = p; }
    }
}

Memento는 오직 Document만 만들고 읽을 수 있다. 외부에서는 불투명한 객체로만 보관한다.

언제 빛나는가. undo/redo, 트랜잭션 롤백, 체크포인트. 직렬화, 역직렬화가 허용되는 모든 곳.

언제 과잉인가. 상태가 단순한 값 몇 개면 Memento 없이 그냥 복사본을 만드는 게 낫다. Memento는 상태가 크고 복잡할 때 가치가 있는데, 상태가 크면 스냅샷도 무거워져 메모리 압박이 생긴다 — undo 스택 깊이를 제한해야 한다.

Visitor — 구조를 바꾸지 않고 연산을 추가한다

문제는 이렇다. 안정적인 객체 구조(트리, AST)에 새 연산을 자주 추가해야 할 때.

컴파일러의 추상 구문 트리(AST)를 생각해 본다. 노드는 NumberNode, BinOpNode, FunctionCallNode 등이다. 이 트리에 "타입 검사", "코드 생성", "pretty print" 같은 연산을 자주 추가해야 한다. 각 노드 클래스에 이 연산들을 전부 넣으면 클래스가 비대해진다.

Visitor는 연산을 밖으로 뺀다.

interface Visitor {
    void visit(NumberNode n);
    void visit(BinOpNode n);
    void visit(FunctionCallNode n);
}

abstract class AstNode {
    abstract void accept(Visitor v);
}

class NumberNode extends AstNode {
    void accept(Visitor v) { v.visit(this); }
}

class TypeChecker implements Visitor {
    public void visit(NumberNode n) { /* 타입 = int */ }
    public void visit(BinOpNode n) { /* 양쪽 타입 비교 */ }
    public void visit(FunctionCallNode n) { /* 함수 시그니처 검사 */ }
}

// 사용
astRoot.accept(new TypeChecker());

노드 클래스는 accept 하나만 갖는다. 새 연산은 Visitor 구현체 하나로 추가된다. 노드 클래스를 안 고쳐도 된다 — OCP의 실천.

단, 노드 종류가 추가되면 모든 Visitor를 고쳐야 한다. 그래서 Visitor는 "구조는 안정적이고 연산이 자주 추가되는" 시스템에 맞다. 반대 상황(구조가 자주 변하고 연산이 안정적)이면 Visitor는 독이 된다.

언제 빛나는가. 컴파일러 AST, 문서 객체 모델(DOM), 설정 트리. 구조가 거의 안 변하는 영역에서.

언제 과잉인가. 객체 구조가 단순하거나 자주 변할 때. Java에서는 패턴 매칭(switch on type)이 발전하면서 Visitor의 필요성이 줄었다 — 최신 Java 21의 sealed class와 패턴 매칭이 더 간결한 대안이다.

Interpreter — 언어를 해석한다

문제는 이렇다. 단순한 언어(규칙, 수식, 쿼리)를 해석해야 할 때.

정규표현식, SQL, 수식 평가기, 룰 엔진. 이런 "작은 언어"를 해석할 때, 각 문법 규칙을 클래스로 표현한다.

interface Expression { boolean interpret(Context ctx); }

class TerminalExpression implements Expression {
    private final String value;
    TerminalExpression(String v) { this.value = v; }
    public boolean interpret(Context ctx) { return ctx.has(value); }
}

class AndExpression implements Expression {
    private final Expression a, b;
    AndExpression(Expression a, Expression b) { this.a = a; this.b = b; }
    public boolean interpret(Context ctx) { return a.interpret(ctx) && b.interpret(ctx); }
}

이 패턴은 GoF 23개 중 가장 안 쓰인다. 단순한 언어라면 파서 생성기(ANTLR, yacc)나 내장 스크립팅(JavaScript 엔진, Groovy)이 훨씬 낫기 때문이다. 언어 해석이 필요하면 Interpreter 패턴보다 기존 도구를 쓰는 게 일반적이다.

그래도 이 패턴의 정신은 살아 있다 — AST를 순회하며 의미를 부여하는 구조 자체가 컴파일러와 인터프리터의 근간이다. Visitor와 짝을 이뤄 쓰는 경우가 많다.

언제 빛나는가. 단순한 규칙 언어를 만들 때(룰 엔진, 검색 쿼리 파서). 복잡한 언어는 도구를 쓴다.

언제 과잉인가. 대부분. 일반적인 비즈니스 로직에 이 패턴을 끼워넣으면, 단순한 if-else로 될 일에 AST와 트리 순회가 들어간다.

언제 패턴이 오버엔지니어링이 되는가

여기까지 23개 패턴을 보았다. 공통된 질문이 있다 — "이걸 써야 할까?" 답은 항상 "글쎄"로 시작된다. 패턴 자체가 좋고 나쁨이 아니라, 그 패턴이 풀고자 하는 문제가 지금 있는가에 달려 있다.

Kerievsky는 에서 이를 명확히 한다. 패턴은 리팩터링의 도착점이지 출발점이 아니다. 코드를 짜기 전에 "어떤 패턴을 쓸까"부터 생각하면 과잉이 된다. 먼저 코드를 짜고, 중복, 경직, 복잡도가 나타나면 그때 패턴이 답이 되는지 본다.

과잉의 전형적인 신호를 정리한다.

신호 설명 대처
패턴이 문제를 만든다 "이 패턴을 쓰고 싶다"가 먼저고, 코드에 그 문제가 없다 패턴을 빼고 단순하게 짠다
전략/팩토리가 하나뿐 Strategy에 구현체가 하나, Factory가 하나의 타입만 만든다 if-else나 직접 new가 낫다
"나중에 확장할 수도 있어서" YAGNI 위반. 아직 나지 않은 요구를 위한 구조 요구가 생기면 그때 도입한다
익숙해서 쓴다 "아는 패턴이라 썼다" — 문제 분석 없이 문제 먼저, 도구는 나중에
패턴 이름이 설명을 대신 클래스 이름에 패턴명을 붙이면 다 설명된다고 믿음 문맥과 의도를 코드로 드러낸다
패턴 여러 개를 겹침 Decorator 3겹, Proxy 2겹, Observer 얹기 한 겹만 쓸 수 없는지 본다

Ousterhout가 <소프트웨어 설계 철학>에서 다른 각도로 지적한다. 그는 "deep module"을 권한다 — 인터페이스는 좁고 구현은 깊게. 패턴을 과도하게 쓰면 반대가 된다. 인터페이스가 많아지고 각각이 얕아진다. 한 객체가 다음 객체를 부르고 그 다음을 부르는 꼴이 된다.

flowchart LR
    Client --> Proxy --> Decorator1 --> Decorator2 --> RealObject
    RealObject --> Observer --> Mediator --> TargetService

이 그림처럼, 각 패턴은 단독으로는 합리적일 수 있지만 겹치면 이해를 방해한다. 디버깅할 때 어디서 값이 변했는지 일곱 단계를 추적해야 한다. 이것은 우발적 복잡성(accidental complexity)이다 — 문제 자체가 아니라 도구 사용에서 오는 복잡성. Brooks가 "No Silver Bullet"에서 구분한 이 개념은 패턴 과잉에도 그대로 적용된다.

원칙이 먼저다 — SOLID와 Ousterhout의 시선

패턴을 외우기 전에 원칙을 이해하는 게 효율적이다. 패턴은 원칙의 구체적 표현이기 때문이다.

Martin이 에서 정립한 SOLID 다섯 원칙이 그 중심에 있다. SRP(단일 책임), OCP(개방-폐쇄), LSP(리스코프 치환), ISP(인터페이스 분리), DIP(의존성 역전). 이 원칙들이 패턴과 어떻게 연결되는지 보면, 패턴이 왜 그 구조인지 이해된다.

  • OCP의 실천이 Strategy, Observer, Template Method — 확장엔 열려 있고 변경엔 닫혀 있게 만든다.
  • DIP의 실천이 Factory Method, Abstract Factory — 클라이언트가 구상 클래스에 의존하지 않게 한다.
  • SRP의 실천이 Facade, Command, Mediator — 책임을 한 곳에 모으고 다른 곳과 분리한다.

반대로 패턴이 원칙을 위반하기도 한다. Singleton은 DIP, SRP 모두에 위배될 수 있다. Ousterhout의 시선에서 보면, 많은 패턴은 "deep module"을 위반한다 — 너무 많은 작은 인터페이스로 쪼개는 것. 그래서 그는 "패턴이 답이라기보다 원칙을 따르다 보면 자연히 패턴 모양이 되는 것"이라고 본다.

KISS(Keep It Simple, Stupid)와 YAGNI(You Aren't Gonna Need It)가 GoF보다 먼저다. 패턴 없이 단순하게 풀 수 있으면 그렇게 한다. 패턴이 필요해지는 순간에 패턴을 도입한다 — 그 순간은 "중복이 보이고", "변경이 두 번 이상 일어났고", "코드가 경직됐다"고 느낄 때다.

언어가 패턴을 흡수했다

1994년 GoF 책이 나올 때 Java는 갓 등장했고, C++가 주류였다. 30년이 지난 지금, 많은 언어가 패턴의 일부를 언어 기능으로 흡수했다. Peter Norvig이 1998년 "Design Patterns in Dynamic Languages"에서 지적한 이 통찰은 더 강해졌다.

패턴 언어 흡수
Iterator Java for-each, Python for x in, C# foreach, JavaScript for...of
Strategy 람다, 함수형 인터페이스(Java), first-class function(Python, JS, Go)
Command 람다, Runnable, Promise
Observer C# event, JavaScript addEventListener, React hooks
Decorator Python @decorator, Java 어노테이션(AOP)
Singleton 모듈, 정적 클래스(JavaScript 모듈, Kotlin object, Python 모듈)
Builder named arguments(Python, Kotlin), 레코드 with(Java), 빌더 매크로(Lombok)

이 흐름은 "패턴이 필요 없어졌다"가 아니라, "패턴의 일부가 언어 차원에서 더 간결해졌다"는 뜻이다. Strategy를 위해 인터페이스와 구현체 세 개를 만들던 시대는 끝났다. 람다로 같은 구조를 세 줄로 표현한다. 하지만 "상황에 따라 알고리즘을 교체한다"는 전략의 본질은 그대로다 — 표현만 달라졌다.

아키텍처에서 패턴이 서는 자리

GoF 패턴은 객체 수준(component level)의 도구다. 아키텍처는 시스템 수준의 결정이다. 둘은 다른 추상 단계지만, 같은 원리가 반복된다.

  • 헥사고날 아키텍처의 어댑터는 Adapter 패턴의 시스템 단위 확장이다.
  • CQRS의 읽기 모델 갱신은 Observer 패턴의 변형이다.
  • Repository(도메인 주도 설계의)가 내부적으로 Factory Method를 쓴다.
  • 마이크로서비스의 API Gateway는 Facade 패턴이다.
  • 서비스 메시(service mesh)의 사이드카는 Proxy 패턴이다.
  • 이벤트 소싱(event sourcing)의 상태 복원은 Memento 패턴의 확장이다.

아키텍처 패턴과 디자인 패턴은 다른 단계에서 같은 원리를 반복한다. 작은 쪽에서 큰 쪽으로, "누가 무슨 일을 할까"와 "어떻게 조립할까"라는 질문은 반복된다. 그래서 GoF 패턴의 감각을 가진 아키텍트는 더 큰 구조에서도 더 빠르게 판단한다.

설계 사례 — 결제 시스템 한 모퉁이에서 패턴 여럿이 만나는 자리

추상을 넘어 실제 코드에 패턴이 어떻게, 왜, 몇 개가 같이 쓰이는지 본다. 간단한 결제 시스템의 한 모퉁이다. 주문이 들어오면, 등급별로 할인을 적용하고, 외부 PG사로 결제를 시도하고, 결과에 따라 마일리지, 알림, 배송 예약을 돌린다.

flowchart TD
    Order["OrderService.placeOrder"] --> Discount["DiscountPolicy<br/>(Strategy)"]
    Order --> Gateway["PaymentGateway<br/>(Adapter)"]
    Gateway --> Retry["RetryProxy<br/>(Proxy)"]
    Retry --> Toss["TossClient"]
    Order --> Events["DomainEventPublisher<br/>(Observer)"]
    Events --> MileageHandler
    Events --> NotifyHandler
    Events --> ShippingHandler

다섯 개의 패턴이 한 흐름에 겹쳐 있다. 각각의 자리와 이유를 본다.

Strategy — 등급별 할인 정책

public interface DiscountPolicy {
    Money apply(Order order);
}

public class NoDiscount implements DiscountPolicy {
    public Money apply(Order o) { return o.subtotal(); }
}
public class VIPDiscount implements DiscountPolicy {
    public Money apply(Order o) { return o.subtotal().multiply(0.9); }
}
public class EmployeeDiscount implements DiscountPolicy {
    public Money apply(Order o) { return o.subtotal().multiply(0.7); }
}

// 사용처 — Map으로 등급별 전략 주입
public class OrderService {
    private final Map<CustomerGrade, DiscountPolicy> discounts;

    public OrderService(Map<CustomerGrade, DiscountPolicy> discounts /* , ... */) {
        this.discounts = discounts;
    }
}

왜 Strategy인가. 등급이 추가될 때마다 if-else가 늘어나는 걸 막기 위해. 새 등급(GOLD)이 생기면 DiscountPolicy 구현체 하나와 맵 항목 하나만 추가하면 된다.

Adapter — 외부 PG사 인터페이스 맞춤

public interface PaymentGateway {
    PaymentResult charge(OrderId orderId, Money amount);
}

public class TossPaymentAdapter implements PaymentGateway {
    private final TossClient toss;   // 외부 SDK

    public PaymentResult charge(OrderId orderId, Money amount) {
        TossResponse raw = toss.pay(orderId.value(), amount.amount());
        return new PaymentResult("DONE".equals(raw.status()), raw.paymentKey());
    }
}

public class IamportPaymentAdapter implements PaymentGateway {
    private final IamportClient iamport;

    public PaymentResult charge(OrderId orderId, Money amount) {
        IamportResult raw = iamport.payAsync(orderId.value(), amount.amount());
        return new PaymentResult(raw.paid(), raw.id());
    }
}

왜 Adapter인가. 도메인이 PG사마다 다른 응답 형식에 오염되지 않게 하려고. PG사를 바꿔도 도메인 코드는 한 줄도 안 바뀐다. 다른 PG사 추가는 또 다른 어댑터 하나로 끝난다.

Proxy — 결제 재시도

public class RetryProxy implements PaymentGateway {
    private final PaymentGateway inner;
    private final int maxRetry;

    public RetryProxy(PaymentGateway inner, int maxRetry) {
        this.inner = inner;
        this.maxRetry = maxRetry;
    }

    public PaymentResult charge(OrderId orderId, Money amount) {
        RuntimeException last = null;
        for (int i = 0; i <= maxRetry; i++) {
            try {
                return inner.charge(orderId, amount);
            } catch (RuntimeException e) {
                last = e;
                // 지수 백오프 삽입 가능
            }
        }
        throw last;
    }
}

// 조립 — 어댑터를 프록시로 감쌈
PaymentGateway gateway = new RetryProxy(new TossPaymentAdapter(tossClient), 3);

왜 Proxy인가. 재시도 로직을 결제 어댑터 안에 넣으면 결제 로직과 내결함성 로직이 섞인다. 프록시로 분리하면 재시도 정책이 바뀌어도 결제 코드는 그대로다. 이 패턴이 Spring AOP의 @Retryable이 하는 일이다.

Observer — 결제 완료 이벤트 전파

public interface OrderEventHandler {
    void on(OrderPlaced event);
}

public class OrderService {
    private final List<OrderEventHandler> handlers;

    public void placeOrder(Order order, CustomerGrade grade) {
        Money finalAmount = discounts.get(grade).apply(order);
        PaymentResult result = gateway.charge(order.id(), finalAmount);
        if (!result.success()) throw new PaymentFailedException();

        OrderPlaced event = new OrderPlaced(order.id(), finalAmount, result.paymentKey(), Instant.now());
        handlers.forEach(h -> h.on(event));
    }
}

// 각 핸들러는 자기 일만
public class MileageHandler implements OrderEventHandler {
    public void on(OrderPlaced e) { /* 마일리지 적립 */ }
}
public class NotifyHandler implements OrderEventHandler {
    public void on(OrderPlaced e) { /* 알림 발송 */ }
}
public class ShippingHandler implements OrderEventHandler {
    public void on(OrderPlaced e) { /* 배송 예약 */ }
}

왜 Observer인가. 결제 완료 후 작업이 세 개이고, 네 번째가 될 수 있다. OrderService를 고치지 않고 핸들러만 추가하게 하려고. 마케팅 팀이 "결제한 고객에게 쿠폰을"이라고 하면 CouponHandler만 추가하면 된다.

Builder — 복잡한 주문 객체 생성

public class Order {
    private final OrderId id;
    private final CustomerId customer;
    private final List<OrderLine> lines;
    private final Address shippingAddress;
    private final Money subtotal;
    private final Instant placedAt;

    private Order(Builder b) { /* 필드 할당 */ }

    public static class Builder {
        // 필수
        private final OrderId id;
        private final CustomerId customer;
        // 선택
        private List<OrderLine> lines = List.of();
        private Address shippingAddress;
        // ...

        public Builder(OrderId id, CustomerId customer) {
            this.id = id; this.customer = customer;
        }
        public Builder lines(List<OrderLine> l) { this.lines = l; return this; }
        public Builder shipTo(Address a) { this.shippingAddress = a; return this; }
        public Order build() { return new Order(this); }
    }
}

Order o = new Order.Builder(id, customer)
    .lines(List.of(line1, line2))
    .shipTo(address)
    .build();

왜 Builder인가. 주문은 필수 2개와 선택 여러 개로 이뤄져 있다. 생성자 하나로 처리하면 매개변수가 일곱 개가 넘고, 순서를 헷갈린다. Builder가 읽기 쉬운 생성 코드를 만든다.

이 설계에서 패턴이 아닌 것들

위 코드에서 안 보이는 것도 중요하다. 도입하지 않은 패턴들.

  • SingletonOrderService는 DI 컨테이너가 인스턴스를 관리한다. 직접 Singleton을 구현하지 않는다.
  • Abstract Factory — PG사는 하나의 인터페이스(PaymentGateway)로 충분하다. PaymentGatewayFactory 추상층은 또 하나의 간접층만 늘릴 뿐이다.
  • State — 주문의 상태 전이는 따로 분리하지 않았다. 이 설계에서는 상태가 단순(결제 전, 후)해서 State 패턴의 가치가 없다. 상태가 여섯 개쯤 늘면 그때 도입한다.
  • Visitor — 핸들러는 이벤트 타입별로 if로 분기한다. 이벤트 종류가 2개라 Visitor는 과하다. 이벤트가 열 종류가 넘으면 그때 고려한다.

이런 "안 한 선택"이 패턴 감각의 진짜 표시다. "다 쓸 수 있으니 다 쓴다"가 아니라 "지금 문제에 맞는 것만 쓴다".

위반 감지 — 코드에서 과잉을 알아내는 신호

코드 냄새 의심할 패턴 과잉 대처
클래스 1개당 인터페이스 1개, 구현체 1개 Factory / Strategy가 하나뿐 직접 생성 또는 if-else로 단순화
같은 클래스를 4겹 이상 감쌈 Decorator, Proxy 겹침 한 겹으로 합치거나 재설계
getInstance()가 코드 곳곳에 Singleton 남발 DI로 교체
패턴 이름이 붙은 클래스가 기능 없음 "패턴을 쓰고 싶어서" 만든 클래스 도입 근거 재점검
if (strategy != null) strategy.execute() Strategy가 실제론 한 개 그냥 메서드 호출
메서드 하나가 온통 mediator.notify(...) Mediator가 God Object 책임 분할 또는 컴포넌트 분리

이 코드를 단순화한다면

팀이 스타트업이라 주문량이 하루 100건이고, 등급은 둘(일반, VIP)뿐이고, PG사는 하나만 쓴다고 치자. 위 설계는 대부분 과잉이다.

// 단순 버전 — 패턴 없이
public class OrderService {
    private final TossClient toss;

    public void placeOrder(Order order, boolean isVIP) {
        Money amount = isVIP ? order.subtotal().multiply(0.9) : order.subtotal();
        TossResponse r = toss.pay(order.id().value(), amount.amount());
        if (!"DONE".equals(r.status())) throw new PaymentFailedException();

        // 후속 작업 직접 호출 — 핸들러가 1~2개면 이게 더 읽기 쉽다
        mileage.add(order.customerId(), amount);
        notify.send(order.customerId(), "결제 완료");
        shipping.schedule(order);
    }
}

이 코드가 나쁘지 않다. 등급이 셋 이상, PG사가 둘 이상, 후속 작업이 다섯 개 이상이 될 때 비로소 앞의 패턴 기반 설계가 빛을 발한다. 시스템의 규모와 변화 가능성에 맞춰 도구를 고른다. 공구함에서 드라이버만 꺼내면 될 일에 공구함 전체를 들고 나가지 않는다.

패턴이 된다는 것

GoF 23개 패턴을 한 번에 훑었다. 각 패턴은 특정 문제에 대한 검증된 해법이다. 그러나 패턴의 가치는 외움에 있지 않다. 세 가지를 분별하는 감각에 있다.

하나, 이 패턴이 푸는 문제가 지금 있는가. 문제가 없는데 패턴을 쓰면, 패턴이 새 문제를 만든다. 가장 흔한 함정이다.

둘, 다른 패턴이나 더 단순한 코드로 풀 수 없는가. 람다 하나로 끝날 일에 Strategy 인터페이스를 만들지 않는다. if-else 두 개로 끝날 일에 State 패턴을 만들지 않는다.

셋, 이 패턴을 도입했을 때 생기는 복잡성을 팀이 감당할 수 있는가. 패턴은 간접층을 만든다. 간접층은 코드를 읽기 어렵게 한다. 그 복잡성이 가져다주는 유연성의 가치가 더 클 때만 도입한다.

Alexander가 건축에서 말한 패턴 언어의 본래 정신으로 돌아간다. 패턴은 규칙이 아니라 경험의 기록이다. "이런 맥락에서 이런 구조가 도움이 됐다"는 선배들의 기록. 그 기록을 읽고, 자기 맥락에 맞춰 빌려 쓰고, 안 맞으면 버린다. 공구함의 도구는 자리가 있다 — 그 자리를 분별하는 감각이, 패턴을 진짜로 아는 것이다.


참고