행위 패턴 ③ — 알고리즘과 상태를 갈아 끼운다
남은 넷은 "무엇을 할지" 를 바깥으로 빼는 방법들이다. 알고리즘을 통째로 교체하고(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 이고, ThreadPoolExecutor 의 RejectedExecutionHandler 도 마찬가지다.
함정 — 전략은 상태를 가지면 안 된다.
@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(); // 전이는 상태 자신이 결정한다
가능한 전이가 코드에 그대로 보인다. SHIPPED 에 cancel() 이 없으니 배송 후 취소는 구조적으로 막힌다. 상태를 추가하면 그 enum 상수 하나만 쓰면 된다.
Strategy 와 무엇이 다른가 — 코드는 같고 의도가 다르다
| Strategy | State | |
|---|---|---|
| 누가 고르나 | 외부 클라이언트가 선택 | 객체가 스스로 전이 |
| 서로 아는가 | 전략끼리 서로 모른다 | 상태가 다음 상태를 안다 |
| 언제 바뀌나 | 보통 한 번 주입하고 유지 | 실행 중 계속 바뀐다 |
| 바꾸는 이유 | 정책 선택 | 사건 발생 |
결정적인 차이는 "다음 상태를 누가 아는가" 다. 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)