자바 디자인 패턴 학습 노트 목차

행위 패턴 ③ — 알고리즘과 상태를 갈아 끼운다

남은 넷은 "무엇을 할지" 를 바깥으로 빼는 방법들이다. 알고리즘을 통째로 교체하고(Strategy), 상태에 따라 행동을 바꾸고(State), 골격만 고정하고 일부만 맡기고(Template Method), 구조는 두고 연산만 더한다(Visitor).

Strategy 와 State 는 코드가 거의 같다. 그래서 면접에서 가장 자주 묻는다.


Strategy — 알고리즘 교체

알고리즘 여러 개를 같은 인터페이스로 캡슐화해 런타임에 갈아 끼운다. OCP 의 교과서적 실현이다.

문제 — 분기가 계속 자란다

class ShippingCalculator {
    int fee(Order o, String method) {
        if (method.equals("STANDARD")) return o.weight() * 500;
        if (method.equals("EXPRESS"))  return o.weight() * 500 + 3000;
        if (method.equals("PICKUP"))   return 0;
        if (method.equals("OVERSEAS")) return o.weight() * 2000 + customs(o);
        throw new IllegalArgumentException(method);   // 배송 방식이 늘 때마다 여기를 고친다
    }
}

적용 — 각 알고리즘을 타입으로

interface ShippingPolicy { int fee(Order o); }

class StandardShipping implements ShippingPolicy {
    public int fee(Order o) { return o.weight() * 500; }
}
class ExpressShipping implements ShippingPolicy {
    public int fee(Order o) { return o.weight() * 500 + 3000; }
}

class ShippingCalculator {
    private final ShippingPolicy policy;                     // 전략을 주입받는다
    ShippingCalculator(ShippingPolicy policy) { this.policy = policy; }
    int fee(Order o) { return policy.fee(o); }               // 분기가 사라졌다
}

새 배송 방식은 클래스 추가로 끝난다. 기존 코드는 안 건드린다.

enum 전략 — 실무에서 가장 자주 쓰는 형태

public enum ShippingMethod implements ShippingPolicy {
    STANDARD { public int fee(Order o) { return o.weight() * 500; } },
    EXPRESS  { public int fee(Order o) { return o.weight() * 500 + 3000; } },
    PICKUP   { public int fee(Order o) { return 0; } };
}

ShippingMethod.valueOf(request.method()).fee(order);   // 문자열 → 전략

전략고정된 몇 개라면 이 형태가 가장 낫다. 클래스 파일이 안 늘고, switch 에서 컴파일러가 누락을 잡아 주며, DB 에 이름으로 저장하기도 쉽다.

스프링에서 전략 맵으로 받기

@Service
public class PaymentService {
    private final Map<PayType, PayStrategy> strategies;

    // 스프링이 같은 타입의 빈을 전부 모아 준다
    public PaymentService(List<PayStrategy> list) {
        this.strategies = list.stream()
                .collect(toMap(PayStrategy::type, Function.identity()));
    }

    void pay(PayType type, Order o) {
        var s = strategies.get(type);
        if (s == null) throw new UnsupportedPayTypeException(type);
        s.pay(o);
    }
}

새 결제 수단은 PayStrategy 를 구현한 @Component 를 하나 추가하는 것으로 끝난다. 어디에도 등록하지 않는다.

람다가 대체하는 경우

list.sort(Comparator.comparing(User::age));                    // 전략 = 람다
list.sort(Comparator.comparing(User::age).thenComparing(User::name).reversed());

메서드가 하나뿐인 전략함수형 인터페이스람다로 넘길 수 있다. Comparator 가 가장 익숙한 Strategy 이고, ThreadPoolExecutorRejectedExecutionHandler 도 마찬가지다.

함정 — 전략은 상태를 가지면 안 된다.

@Component
class DiscountStrategy implements PayStrategy {
    private int total;                       // ✗ 빈은 하나인데 요청은 여럿이다
    public void pay(Order o) { total += o.amount(); }   // 요청끼리 섞인다
}

전략을 싱글톤 빈으로 두는 순간 필드는 공유 상태가 된다. 필요한 값은 인자로 받고 결과는 반환한다.


State — 상태 기계

객체의 상태마다 다른 행동을 클래스로 분리하고, 전이까지 캡슐화한다.

문제 — 상태 분기가 메서드마다 반복된다

class Order {
    private String status;   // "CREATED" | "PAID" | "SHIPPED" | "DONE"

    void pay() {
        if (status.equals("CREATED")) { … status = "PAID"; }
        else throw new IllegalStateException();
    }
    void ship() {
        if (status.equals("PAID")) { … status = "SHIPPED"; }
        else throw new IllegalStateException();
    }
    void cancel() {
        if (status.equals("CREATED") || status.equals("PAID")) { … }
        else throw new IllegalStateException();     // 같은 분기가 메서드마다 반복된다
    }
}

상태를 하나 추가하면 모든 메서드를 뒤져야 한다. 어떤 전이가 가능한지도 코드 전체를 읽어야 알 수 있다.

적용 — 상태를 타입으로

public enum OrderState {
    CREATED {
        public OrderState pay()    { return PAID; }
        public OrderState cancel() { return CANCELLED; }
    },
    PAID {
        public OrderState ship()   { return SHIPPED; }
        public OrderState cancel() { return CANCELLED; }   // 환불 필요
    },
    SHIPPED {
        public OrderState complete() { return DONE; }      // 취소 불가 — 아예 없다
    },
    DONE, CANCELLED;

    public OrderState pay()      { throw new IllegalTransition(this, "pay"); }
    public OrderState ship()     { throw new IllegalTransition(this, "ship"); }
    public OrderState cancel()   { throw new IllegalTransition(this, "cancel"); }
    public OrderState complete() { throw new IllegalTransition(this, "complete"); }
}

order.state = order.state.pay();     // 전이는 상태 자신이 결정한다

가능한 전이가 코드에 그대로 보인다. SHIPPEDcancel() 이 없으니 배송 후 취소는 구조적으로 막힌다. 상태를 추가하면 그 enum 상수 하나만 쓰면 된다.

Strategy 와 무엇이 다른가 — 코드는 같고 의도가 다르다

StrategyState
누가 고르나외부 클라이언트가 선택객체가 스스로 전이
서로 아는가전략끼리 서로 모른다상태가 다음 상태를 안다
언제 바뀌나보통 한 번 주입하고 유지실행 중 계속 바뀐다
바꾸는 이유정책 선택사건 발생

결정적인 차이는 "다음 상태를 누가 아는가" 다. CREATED.pay()PAID 를 반환하는 것 — 이 한 줄이 State 를 State 로 만든다. Strategy 에서 전략이 다른 전략을 반환하는 일은 없다.

실무에서는

// 상태 전이를 기록하면 감사 로그가 공짜로 나온다
void transition(Order o, OrderState next, String reason) {
    var prev = o.state();
    o.setState(next);
    history.save(new StateChange(o.no(), prev, next, reason, Instant.now()));
}

주문·결제·배송·워크플로 승인이 전부 상태 기계다. 상태를 명시적으로 다루면 "왜 이 주문이 이 상태가 됐나" 에 답할 수 있다.

함정 — 상태가 많아지면 enum 하나가 거대해진다. 이럴 때는 전이 규칙을 테이블(Map)로 빼는 방식이 낫다. 또 상태를 DB 에 저장할 때 ordinal() 을 쓰면 안 된다 — 순서를 바꾸거나 중간에 하나 넣으면 기존 데이터의 의미가 통째로 어긋난다. 이름으로 저장한다.


Template Method — 골격 고정

알고리즘의 뼈대는 상위가 정하고, 변하는 단계만 하위가 채운다. 상속 기반이다.

문제 — 흐름은 같은데 한두 단계만 다르다

class CsvExporter {
    void export(List<Row> rows, Path out) {
        var f = Files.newBufferedWriter(out);    // 열고
        writeHeader(f);                           // 헤더
        for (var r : rows) f.write(toCsv(r));     // ← 이 줄만 다르다
        f.flush(); f.close();                     // 닫는다
    }
}
class JsonExporter { /* 위와 거의 같은 코드 */ }

적용 — 변하는 단계만 추상으로

abstract class Exporter {
    public final void export(List<Row> rows, Path out) {   // final — 흐름은 못 바꾼다
        try (var w = Files.newBufferedWriter(out)) {
            writeHeader(w);
            for (var r : rows) writeRow(w, r);
            writeFooter(w);
        }
    }
    protected void writeHeader(Writer w) { }          // 훅 — 기본은 아무것도 안 함
    protected abstract void writeRow(Writer w, Row r); // 반드시 구현
    protected void writeFooter(Writer w) { }          // 훅
}

class CsvExporter extends Exporter {
    protected void writeRow(Writer w, Row r) { w.write(String.join(",", r.values())); }
}

export()final 로 둔 것이 중요하다. 하위가 흐름 자체를 바꾸지 못하게 막는다. 훅 메서드(기본 구현이 있는 것)와 추상 메서드(반드시 구현)를 구분해 두면, 필요한 것만 오버라이드하면 된다.

콜백형 — 실무에서 더 많이 보는 형태

// 상속 대신 함수를 받는다
class Exporter {
    public void export(List<Row> rows, Path out, BiConsumer<Writer, Row> writeRow) {
        try (var w = Files.newBufferedWriter(out)) {
            for (var r : rows) writeRow.accept(w, r);
        }
    }
}
exporter.export(rows, path, (w, r) -> w.write(String.join(",", r.values())));

상속이 필요 없어 클래스가 안 늘고, 여러 변형을 한 자리에서 쓸 수 있다. JdbcTemplate 이 정확히 이 방식이다.

jdbcTemplate.execute((StatementCallback<Integer>) stmt -> {
    // 연결 열기·닫기·예외 변환은 템플릿이, 이 안은 우리가
    return stmt.executeUpdate("...");
});

"열고 닫고 예외 처리하는 골격" 은 고정하고 가운데만 받는다 — 자원 관리가 필요한 곳에서 반복적으로 나타나는 모양이다.

JDK 에서는

abstract class AbstractList<E> implements List<E> {
    public abstract E get(int index);      // 이 둘만 구현하면
    public abstract int size();
    public boolean contains(Object o) { … } // 나머지는 이미 만들어져 있다
    public Iterator<E> iterator() { … }
}

AbstractList · AbstractMap · InputStream.read() · HttpServlet.service()doGet/doPost 가 전부 이 구조다.

Strategy 와의 차이

  • Template Method상속, 컴파일 시점 고정, 골격의 일부만 교체
  • Strategy합성, 런타임 교체, 알고리즘 통째로 교체

같은 문제를 상속으로 푸느냐 합성으로 푸느냐의 차이이고, 원칙 ②("상속보다 합성")에 따라 선택지가 있으면 Strategy 쪽이 유연하다. Template Method 는 단계가 많고 순서가 정말 고정일 때 쓴다.

함정 — 훅이 많아지면 하위 클래스가 상위의 호출 순서를 알아야 한다. writeHeader 가 언제 불리는지 모르면 제대로 구현할 수 없어, 캡슐화가 사실상 깨진다. 훅은 적게 두고 문서로 순서를 남긴다.


Visitor — 연산의 외부화

객체 구조는 그대로 두고 새 연산만 밖에서 추가한다.

문제 — 구조에 연산이 계속 붙는다

sealed interface Node permits FileNode, DirNode { }
// 여기에 size(), print(), search(), export(), validate() … 가 계속 추가된다

노드 종류는 안 변하는데 하고 싶은 일이 계속 는다. 그때마다 모든 노드 클래스를 고쳐야 한다.

적용 — 연산을 방문자로 뺀다

interface Visitor<R> {
    R visit(FileNode f);
    R visit(DirNode d);
}

interface Node { <R> R accept(Visitor<R> v); }

record FileNode(String name, long size) implements Node {
    public <R> R accept(Visitor<R> v) { return v.visit(this); }   // ← double dispatch
}
record DirNode(String name, List<Node> children) implements Node {
    public <R> R accept(Visitor<R> v) { return v.visit(this); }
}

// 새 연산 = 새 방문자 하나. 노드는 안 건드린다
class SizeVisitor implements Visitor<Long> {
    public Long visit(FileNode f) { return f.size(); }
    public Long visit(DirNode d)  {
        return d.children().stream().mapToLong(c -> c.accept(this)).sum();
    }
}

double dispatch 가 무엇인가

자바의 메서드 선택은 수신 객체의 실제 타입으로만 이뤄진다(single dispatch). 인자 타입은 컴파일 시점 타입으로 정해진다.

Node n = new FileNode(…);
visitor.visit(n);       // ✗ 컴파일 에러 — 정적 타입이 Node 라 어느 visit 인지 못 고른다
n.accept(visitor);      // ✓ n 의 실제 타입으로 accept 가 정해지고,
                        //   그 안에서 this 의 타입이 확정돼 visit 이 정해진다

두 번 나눠서 디스패치하기 때문에 double dispatch 다. accept 라는 우회로가 필요한 이유가 이것이다.

이 패턴의 비대칭

무엇을 추가하나비용
연산(방문자)쉽다 — 클래스 하나 추가
노드 타입비싸다 — 모든 방문자를 고쳐야 한다

그래서 구조는 안정적이고 연산이 자주 느는 경우에만 맞다. 컴파일러 AST, 파일 트리 순회, 문서 변환기가 전형이다.

모던 자바에서는 대체된다

sealed interface Node permits FileNode, DirNode { }

long size(Node n) {
    return switch (n) {                               // 망라성을 컴파일러가 검사한다
        case FileNode f -> f.size();
        case DirNode d  -> d.children().stream().mapToLong(this::size).sum();
    };
}

sealed + 패턴 매칭이면 accept/visit 없이 같은 일을 한다. 하위 타입이 봉인돼 있으므로 빠뜨린 case 를 컴파일러가 잡아 준다Visitor 가 주던 안전성을 언어가 대신 준 것이다.

Visitor 를 여전히 쓰는 경우는 타입 계층을 봉인할 수 없을 때(플러그인이 노드를 추가하는 구조)나 이미 그렇게 만들어진 API 를 쓸 때다.

JDK 에서는

Files.walkFileTree(root, new SimpleFileVisitor<Path>() {
    public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
        total.addAndGet(a.size());
        return FileVisitResult.CONTINUE;      // 순회 제어까지 반환값으로 한다
    }
});

FileVisitor 는 반환값으로 계속·건너뛰기·중단을 지시할 수 있어, 순회 전략까지 방문자가 정한다. 애너테이션 처리의 ElementVisitor 도 같은 구조다.


한눈에 정리

  • Strategy — 알고리즘을 타입으로 바꿔 분기를 없앤다. 고정된 몇 개면 enum 전략이, 스프링이면 List<전략> 주입 후 맵으로 가 가장 실용적이다
  • 전략은 상태를 가지면 안 된다싱글톤 빈의 필드는 공유 상태다
  • State — 상태를 타입으로. 가능한 전이가 코드에 보이는 것이 값어치다. DB 에는 ordinal() 이 아니라 이름으로 저장한다
  • Strategy vs State — 코드는 같다. 갈림길은 "다음 상태를 누가 아는가"
  • Template Method — 골격은 final, 변하는 단계만 추상·훅. 실무에서는 상속형보다 콜백형(JdbcTemplate)이 흔하다
  • Template Method vs Strategy — 상속이냐 합성이냐, 일부 교체냐 통째 교체냐
  • Visitor — 연산 추가는 쉽고 노드 타입 추가는 비싸다. accept 우회로는 자바가 single dispatch 이기 때문이다
  • 자바 21 에서는 **sealed + 패턴 매칭**이 Visitor 를 대체한다. 컴파일러가 망라성을 검사해 준다

출처 — GoF Design Patterns(1994) §5 Behavioral Patterns · JEP 441(Pattern Matching for switch, Java 21) · JLS §8.1.1.2(sealed) · Java SE 21 API Docs(Comparator · AbstractList · FileVisitor · Files.walkFileTree)

행위 패턴 ② — 돌고, 알리고, 되돌린다동시성 패턴 — 여럿이 동시에 움직일 때