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

행위 패턴 ① — 요청을 객체로 다룬다

행위 패턴 열한 개는 객체 사이의 책임 분배와 통신을 다룬다. 세 편으로 나눠 본다.

이 편의 셋은 공통점이 있다 — "요청" 을 그 자리에서 처리하지 않고 일단 객체로 만든다. 사슬로 넘기거나(CoR), 큐에 넣거나(Command), 트리로 해석한다(Interpreter).


Chain of Responsibility — 처리 사슬

요청을 처리할 수 있는 누군가를 만날 때까지 다음으로 넘긴다. 보내는 쪽은 누가 처리할지 모른다.

문제 — 분기가 한곳에 뭉친다

void handle(Request req) {
    if (!isAuthenticated(req))  { deny(401); return; }
    if (!hasPermission(req))    { deny(403); return; }
    if (isRateLimited(req))     { deny(429); return; }
    if (!validate(req))         { deny(400); return; }
    process(req);
}

검사를 하나 더하려면 이 메서드를 고쳐야 하고, 순서를 바꾸려면 줄을 옮겨야 하며, 일부만 골라 쓰는 것이 불가능하다. 관리자 API 에는 레이트리밋을 빼고 싶어도 방법이 없다.

적용 — 각 검사를 독립된 고리로

interface Handler {
    void handle(Request req, Chain chain);          // chain 으로 다음에게 넘긴다
}
interface Chain { void proceed(Request req); }

class AuthHandler implements Handler {
    public void handle(Request req, Chain chain) {
        if (!isAuthenticated(req)) throw new DenyException(401);
        chain.proceed(req);                          // 통과 → 다음으로
    }
}
class RateLimitHandler implements Handler { … }
// 사슬 조립 — 이 리스트가 곧 정책이다
class HandlerChain implements Chain {
    private final List<Handler> handlers;
    private int index = 0;

    public void proceed(Request req) {
        if (index < handlers.size()) handlers.get(index++).handle(req, this);
        else target.process(req);                    // 끝까지 통과하면 진짜 처리
    }
}

new HandlerChain(List.of(new AuthHandler(), new PermHandler(), new RateLimitHandler()));
new HandlerChain(List.of(new AuthHandler(), new PermHandler()));   // 관리자용 — 조합만 바꾼다

조합이 데이터가 되었다. 검사를 더하는 것은 클래스 하나 추가이고, 순서와 구성은 리스트로 정한다.

두 가지 변형 — 이것이 자주 헷갈린다

// ① 순수 CoR — 처리하는 순간 멈춘다 (GoF 원형)
for (var h : handlers) if (h.canHandle(req)) { h.handle(req); return; }

// ② 파이프라인 — 전부 거쳐 간다 (실무에서 훨씬 흔하다)
chain.proceed(req);   // 각 고리가 앞뒤에 무언가를 하고 다음으로 넘긴다

서블릿 필터·스프링 시큐리티·OkHttp 인터셉터는 전부 ②다. 모두가 거치면서 각자 한 겹씩 얹는다. ②는 Decorator 와 구조가 거의 같아지는데, 차이는 "고리가 흐름을 끊을 수 있는가" 다.

JDK·프레임워크에서는

// 서블릿 필터
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {
    log.info("before");
    chain.doFilter(req, res);        // 다음으로 — 안 부르면 요청이 여기서 끝난다
    log.info("after");
}

chain.doFilter()안 부르면 요청이 멈춘다. 인증 필터가 거부할 때 하는 일이 정확히 이것이다. 필터를 만들고 "다음이 안 불린다" 는 문제의 대부분이 이 한 줄을 빠뜨린 것이다.

java.util.logging.Logger 도 부모 로거로 위임하며 올라가는 사슬이고, 예외 전파 자체도 개념적으로는 같은 모양이다.

함정 — 사슬이 길어지면 어디서 끊겼는지 찾기가 어렵다. 스프링 시큐리티 필터 체인이 15개쯤 되는데, 401 이 났을 때 어느 필터가 낸 것인지 로그 없이는 알 수 없다. 각 고리가 진입·통과를 남기도록 해 두면 진단 시간이 크게 준다.

프레임워크 네 곳의 실제 소스로 이 구조를 읽는 것은 실무 적용 심화 편에서 다룬다.


Command — 요청의 객체화

동작과 그 인자를 객체로 포장한다. 그러면 요청을 저장하고, 큐에 넣고, 되돌릴 수 있다.

왜 객체로 만드나

메서드 호출은 그 순간에만 존재한다. 나중에 실행하거나, 로그로 남기거나, 취소하려면 호출 자체를 값으로 만들어야 한다.

주문 생성처럼 여러 외부 시스템을 순서대로 부르는 흐름을 생각해 보자. 중간에 실패하면 앞의 것들을 되돌려야 한다.

interface Step {
    void execute(SagaContext ctx);
    void compensate(SagaContext ctx);      // 되돌리기
}

record ReserveStock(InventoryClient client) implements Step {
    public void execute(SagaContext ctx)    { ctx.put("stockId", client.reserve(ctx.items())); }
    public void compensate(SagaContext ctx) { client.release(ctx.get("stockId")); }
}
record ChargePayment(PayGateway gateway) implements Step {
    public void execute(SagaContext ctx)    { ctx.put("payId", gateway.charge(ctx.amount())); }
    public void compensate(SagaContext ctx) { gateway.cancel(ctx.get("payId")); }
}

class SagaRunner {
    void run(List<Step> steps, SagaContext ctx) {
        var done = new ArrayDeque<Step>();
        try {
            for (var s : steps) { s.execute(ctx); done.push(s); }
        } catch (Exception e) {
            while (!done.isEmpty()) done.pop().compensate(ctx);   // 역순으로 되돌린다
            throw e;
        }
    }
}

되돌리기가 자연스럽게 나온다. 실행한 단계를 스택에 쌓아 두었다가 역순으로 꺼내 보상하면 그만이다. 단계를 메서드 호출로 짰다면 이 되돌리기 로직을 매번 손으로 써야 한다.

분산 트랜잭션에서 2PC 를 못 쓸 때 쓰는 보상 트랜잭션(Saga)이 정확히 이 구조다 — 각 단계가 자기 되돌리기 방법을 아는 커맨드다.

실행을 미루고 옮긴다

// 커맨드를 큐에 넣으면 '누가 언제 실행할지' 가 분리된다
BlockingQueue<Command> queue = new LinkedBlockingQueue<>();
queue.put(new SendMailCommand(to, subject, body));      // 넣는 쪽

// 워커
while (running) queue.take().execute();                  // 꺼내 실행하는 쪽

메시지 큐로 넘기는 작업(Job), 스케줄러 태스크, 이벤트 소싱의 커맨드가 전부 이 형태다. 요청이 값이 되었으므로 직렬화해서 네트워크로 보낼 수도 있다.

JDK 에서는

Runnable  task   = () -> log.info("run");                 // 반환 없음
Callable<Integer> job = () -> compute();                  // 값·예외 반환

ExecutorService pool = Executors.newFixedThreadPool(4);
Future<Integer> f = pool.submit(job);                     // 제출과 실행이 분리된다

Runnable 이 가장 익숙한 Command 다. "무엇을 할지" 를 객체로 만들어 넘기니 스레드 풀이 그것을 언제 어느 스레드에서 실행할지 정할 수 있다.

람다가 대체하는 부분과 못 하는 부분

// 메서드가 하나뿐이면 람다로 충분하다
Command c = () -> doc.insert(pos, text);

하지만 undo() 까지 있으면 람다로 안 된다. Command 가 여전히 클래스로 남는 경우는 부가 정보가 필요할 때다 — 되돌리기, 직렬화, 로깅용 이름, 재시도 횟수.

sealed interface Command permits AddText, DeleteText {
    void execute(); void undo();
    default String describe() { return getClass().getSimpleName(); }   // 로그·감사용
}

실무에서는

// 이벤트 소싱 — 상태가 아니라 '일어난 일' 을 저장한다
record OrderPlaced(String orderNo, long amount, Instant at) { }
record OrderCancelled(String orderNo, String reason, Instant at) { }

Order replay(List<Object> events) {                 // 커맨드를 재생해 현재 상태를 만든다
    Order o = Order.empty();
    for (var e : events) o = o.apply(e);
    return o;
}

함정되돌리기를 정확히 만드는 것이 생각보다 어렵다. "재고 예약" 의 반대는 "해제" 로 명확하지만, 이미 보낸 알림 메일의 반대는 무엇인가? 되돌릴 수 없는 동작은 가장 뒤로 미루는 것이 원칙이다 — 되돌릴 수 있는 것들을 먼저 다 성공시킨 뒤에 실행한다.

반대 동작을 정의할 수 없는 경우에는 대신 이전 상태 스냅샷을 저장한다. 그것이 다음 편의 Memento 다.


Interpreter — 문법 해석

간단한 언어나 규칙을 클래스 트리로 표현하고 해석한다. 23개 중 직접 구현할 일이 가장 드물지만, 쓰고 있는 도구가 전부 이것이다.

문법을 트리로 만든다

sealed interface Expr permits Num, Add, Mul { int eval(); }

record Num(int value)            implements Expr { public int eval() { return value; } }
record Add(Expr left, Expr right) implements Expr { public int eval() { return left.eval() + right.eval(); } }
record Mul(Expr left, Expr right) implements Expr { public int eval() { return left.eval() * right.eval(); } }

// (2 + 3) * 4
Expr e = new Mul(new Add(new Num(2), new Num(3)), new Num(4));
e.eval();   // 20

문법 규칙 하나가 클래스 하나가 되고, 해석은 재귀 호출로 자연스럽게 이뤄진다. 구조가 Composite 와 같다 — 실제로 Interpreter 의 AST 는 Composite 트리다.

조건 규칙으로 확장하면 실무 모양이 된다

sealed interface Rule permits Gt, And, Or {
    boolean test(Map<String, Integer> ctx);
}
record Gt(String field, int value) implements Rule {
    public boolean test(Map<String, Integer> ctx) { return ctx.get(field) > value; }
}
record And(Rule a, Rule b) implements Rule {
    public boolean test(Map<String, Integer> ctx) { return a.test(ctx) && b.test(ctx); }
}

// "구매액 > 100000 이고 등급점수 > 5" 를 데이터로 표현했다
Rule r = new And(new Gt("amount", 100_000), new Gt("gradeScore", 5));
r.test(Map.of("amount", 150_000, "gradeScore", 7));   // true

규칙이 코드가 아니라 데이터가 되었다. DB 에서 읽어 와 조립하면 배포 없이 정책을 바꿀 수 있다 — 쿠폰 조건, 할인 정책, 알림 규칙이 이렇게 만들어진다.

JDK·실무에서는

Pattern p = Pattern.compile("^\\d{3}-\\d{4}$");   // 정규식을 내부 트리로 컴파일한다
new MessageFormat("{0}님 {1}건").format(args);
new DecimalFormat("#,###.##").format(1234.5);

Pattern.compile() 이 하는 일이 정확히 Interpreter 다 — 문자열 문법을 받아 해석 가능한 구조로 바꾼다. 스프링의 SpEL(@Value("#{...}")), 규칙 엔진, 검색 쿼리 파서도 같다.

함정 — GoF 도 이 패턴을 문법이 단순할 때만 권한다. 문법이 복잡해지면 클래스 수가 폭발하고 성능도 나빠진다. 진짜 언어를 파싱해야 한다면 패턴을 손으로 구현하는 대신 파서 생성기를 쓴다.

그리고 규칙을 외부 데이터로 받는 순간 보안 문제가 생긴다. 사용자 입력을 규칙으로 해석하게 두면 임의 실행 통로가 된다. 허용할 연산자를 화이트리스트로 제한한다.


한눈에 정리

  • 셋 다 요청을 그 자리에서 처리하지 않고 객체로 만든다. 그래서 미루고, 옮기고, 되돌릴 수 있다
  • CoR — 검사·처리를 독립 고리로 쪼개 조합을 데이터로 만든다. 순수형(처리하면 멈춤)과 파이프라인형(전부 거침)이 있고 실무는 후자가 훨씬 흔하다
  • 필터에서 chain.doFilter() 를 안 부르면 거기서 끝난다 — 의도한 거부이자 흔한 실수
  • Command — 동작+인자를 값으로 만들어 큐잉·로깅·undo 를 가능하게 한다. Runnable 이 가장 익숙한 사례
  • 람다로 대체되는 것은 메서드가 하나일 때뿐. undo·직렬화·이름이 필요하면 클래스로 남는다
  • Interpreter — 문법을 클래스 트리로. 구조는 Composite 다. 직접 구현은 드물지만 규칙을 데이터로 만들어 배포 없이 바꾸는 용도로는 실무에서 쓴다
  • 문법이 복잡하면 쓰지 않는다. 외부 입력을 해석할 때는 화이트리스트로 제한한다

출처 — GoF Design Patterns(1994) §5 Behavioral Patterns · Jakarta Servlet 6.0 §6(Filter·FilterChain) · Java SE 21 API Docs(Runnable · Callable · ExecutorService · java.util.regex.Pattern)

구조 패턴 ② — 감추고, 공유하고, 대신한다행위 패턴 ② — 돌고, 알리고, 되돌린다