테마 선택

📚 지식 노트

AI·하드웨어·주변기기·게임·컴퓨터 시스템·이론 컴퓨터 과학·수학·개발과 운영·보안·응용 분야에 대해 알아 두면 좋은 내용을 정리한 노트다. 위에서 분야를 고르고 아래에서 노트를 고른다.

행위 패턴 2: 이터레이터·중재자·메멘토·책임 연쇄·방문자·인터프리터

순회를 감추고, 대화를 한곳에 모으고, 상태를 저장하고, 요청을 넘기고, 구조 위에 연산을 얹는 법이다.

목차 (8)
  1. 그림 읽는 법
  2. 이터레이터: 내부 구조를 감춘 순회
  3. 중재자: 대화를 한곳에 모으기
  4. 메멘토: 캡슐화를 깨지 않는 스냅숏
  5. 책임 연쇄: 처리할 때까지 넘기기
  6. 방문자: 구조는 그대로 두고 연산을 더하기
  7. 인터프리터: 작은 언어의 문법을 클래스로
  8. 행위 패턴 11가지 한눈에

그림 읽는 법

1994년에 나온 GoF 책 『Design Patterns』의 행위 패턴은 11가지다. 전략, 옵저버, 커맨드, 상태, 템플릿 메서드는 행위 패턴 1 노트에 있고, 이 글은 남은 여섯을 다룬다. 패턴마다 같은 순서로 쓴다. 의도 한 문장, 어떤 문제에서 나오나, 클래스 구조, 짧은 Java 예시, 실제 라이브러리에서의 쓰임, 장단점과 주의, 다른 언어에서의 모습이다.

클래스 그림은 UML을 줄여 그렸다. 아래 범례를 한 번만 보면 모든 그림에 그대로 통한다.

클래스 그림 범례: 점선과 속 빈 삼각형은 구현·상속, 실선 화살표는 참조, 점선 화살표는 만들기. 강조색 상자는 패턴의 핵심 역할구현·상속: 속 빈 삼각형 쪽이 인터페이스·부모참조: 화살표 쪽 객체를 알고 부른다만들기: 화살표 쪽 객체를 만든다«interface»기울인 이름패턴의 핵심 역할
이 글의 클래스 그림 범례. 점선과 속 빈 삼각형은 구현이나 상속, 실선 화살표는 참조, 점선 화살표는 만들기다. 강조색 상자가 패턴의 중심 역할이다.

이터레이터: 내부 구조를 감춘 순회

의도: 모음(aggregate)이 안에서 어떻게 생겼는지 드러내지 않고 원소를 하나씩 차례로 꺼내게 한다.

문제 상황

최근 로그 100줄을 담는 객체를 만든다고 하자. 메모리를 아끼려고 원형 버퍼로 짰다. 쓰는 쪽이 "배열 몇 번 칸부터 몇 칸, 끝에 닿으면 0번으로" 같은 규칙을 알아야 돌 수 있다면, 나중에 연결 리스트로 바꾸는 순간 모든 호출부가 깨진다. 순회하는 위치를 모음 안에 넣어 두면 두 군데서 동시에 돌 수도 없다.

구조

이터레이터 클래스 그림: RecentLog는 Iterable을, LogIterator는 Iterator를 구현하고, RecentLog가 LogIterator를 만든다. Client는 Iterator의 hasNext와 next만 부른다만든다쓴다«interface»Iterable<T>iterator(): Iterator<T>«interface»Iterator<T>hasNext(): booleannext(): TClientfor (T x : items)RecentLog- buffer: String[]+ add(line)+ iterator()LogIterator- visited: int+ hasNext()+ next()Client는 인터페이스두 개만 안다원형 버퍼라는 내부 구조는 RecentLog 안에 숨고, 순회하는 위치(visited)는 반복자가 따로 가진다반복자를 여러 개 만들면 같은 목록을 서로 다른 위치에서 동시에 돌 수 있다
이터레이터의 구조. 모음(RecentLog)은 반복자를 만들어 줄 뿐이고, 순회 위치는 반복자(LogIterator)가 가진다. Client는 두 인터페이스만 안다.

예시 코드

// 내부는 원형 버퍼인 "최근 로그 N줄". 바깥에는 순회만 내보인다
public final class RecentLog implements Iterable<String> {
    private final String[] buffer;
    private int start;
    private int size;

    public RecentLog(int capacity) {
        buffer = new String[capacity];
    }

    public void add(String line) {
        buffer[(start + size) % buffer.length] = line;
        if (size < buffer.length) {
            size++;
            return;
        }
        // 가득 찼으면 가장 오래된 줄을 덮어썼으므로 시작점을 민다
        start = (start + 1) % buffer.length;
    }

    @Override
    public Iterator<String> iterator() {
        return new Iterator<>() {
            private int visited;

            @Override
            public boolean hasNext() {
                return visited < size;
            }

            @Override
            public String next() {
                if (!hasNext()) {
                    throw new NoSuchElementException();
                }
                String line = buffer[(start + visited) % buffer.length];
                visited++;
                return line;
            }
        };
    }
}

// 쓰는 쪽은 원형 버퍼를 모른다. Iterable이면 for-each에 바로 넣을 수 있다
for (String line : recentLog) {
    System.out.println(line);
}

실제 쓰임

  • Java 컬렉션 전체: Iterable을 구현하면 그 객체를 for-each 문(enhanced for)에 넣을 수 있다. Iterator는 hasNext(), next(), 선택 연산인 remove(), 그리고 남은 원소를 한 번에 처리하는 forEachRemaining()을 가진다. 문서는 옛 Enumeration과 다른 점으로 순회 중 지우기의 의미가 분명하다는 것과 메서드 이름이 나아졌다는 것을 든다.
  • 외부 반복과 내부 반복: Iterator로 직접 next()를 부르는 것이 외부 반복이다. 언제 멈출지, 원소 둘을 번갈아 꺼낼지를 쓰는 쪽이 정한다. Java 8부터 있는 Iterable.forEach(action)은 반대로 모음이 돌면서 동작을 하나씩 불러 준다(내부 반복). 문서가 밝힌 기본 구현은 for-each로 돌며 action.accept를 부르는 것이다.

장단점

  • 장점: 모음의 자료구조를 바꿔도 쓰는 쪽은 그대로다. 같은 모음에 반복자를 여러 개 만들어 각자 다른 위치에서 돌 수 있다. 정방향, 역방향, 거르기 같은 순회 방법을 모음을 고치지 않고 더할 수 있다.
  • 단점: 배열처럼 아무 칸이나 바로 갈 수 있는 구조에서는 한 칸씩 가는 반복자가 오히려 불편하다. 반복자 객체 하나가 더 생긴다.

주의: fail-fast와 ConcurrentModificationException

순회하는 동안 모음이 반복자 몰래 바뀌면 어디까지 돌았는지가 틀어진다. JDK의 범용 컬렉션 반복자는 이것을 알아채면 ConcurrentModificationException을 던진다. 나중에 엉뚱한 결과를 내느니 바로 실패한다는 뜻에서 fail-fast라 부른다. 이름에 "동시"가 있지만 스레드가 하나여도 난다. 문서의 예가 바로 for-each로 도는 중에 목록을 직접 바꾸는 경우다.

어떻게 알아채는지는 AbstractList의 modCount 문서에 있다. 목록은 크기를 바꾸는 구조 변경 횟수를 modCount에 센다. 반복자는 그 값이 예상과 다르게 바뀐 것을 보면 next(), remove() 등에서 예외를 낸다. 아래 표는 그 방식을 줄여 흉내 낸 계산이다.

목록 a, 빈 글, b, c를 for-each로 돌다가 빈 글을 목록에서 직접 지우면 modCount가 1로 바뀌고, 기대값 0과 달라 다음 next()가 ConcurrentModificationException을 낸다목록 ["a", "", "b", "c"]를 for-each로 돌며 빈 글을 tags.remove로 지운다호출목록 modCount반복자 기대값결과next()00"a"next()00""tags.remove("")10목록이 바뀜next()10예외(CME)CME는 ConcurrentModificationException. 고칠 때는 it.remove()나 removeIf를 쓴다
for-each 도중 목록에서 빈 글을 직접 지우면 목록의 modCount만 1이 되고 반복자의 기대값은 0에 머문다. 다음 next()가 이 차이를 보고 예외를 낸다. 표는 계산 함수로 만들었다.
List<String> tags = new ArrayList<>(List.of("a", "", "b", "c"));

// 잘못: for-each가 쓰는 반복자 몰래 목록을 바꾼다 → 다음 next()에서 ConcurrentModificationException
for (String tag : tags) {
    if (tag.isEmpty()) {
        tags.remove(tag);
    }
}

// 고친 것 1: 반복자에게 지우라고 한다
Iterator<String> iterator = tags.iterator();
while (iterator.hasNext()) {
    if (iterator.next().isEmpty()) {
        iterator.remove();
    }
}

// 고친 것 2: 순회와 삭제를 컬렉션에 맡긴다(내부 반복)
tags.removeIf(String::isEmpty);

문서는 이 검사가 최선의 노력(best-effort)일 뿐이라고 못 박는다. 동기화 없는 동시 수정에서는 아무것도 보장할 수 없으므로, 이 예외에 기대어 프로그램의 정확성을 만들면 안 되고 버그를 찾는 데만 써야 한다. 실제로 위 흉내 계산에서도 끝에서 두 번째 원소를 지우면 예외 없이 마지막 원소를 건너뛰고 끝난다. 여러 스레드가 함께 쓰는 모음이라면 동시성 컬렉션을 쓴다.

다른 언어에서는

파이썬의 for 문은 iter()로 반복자를 얻고 __next__()를 부르다가 StopIteration이 나면 멈춘다. 파이썬 튜토리얼에 따르면 yield를 쓰는 제너레이터는 이 두 메서드와 StopIteration을 자동으로 만들고 지역 변수도 호출 사이에 기억해 준다. 자바스크립트는 반복 프로토콜로 같은 일을 한다. [Symbol.iterator]()가 next()로 { value, done }을 내는 반복자를 돌려주면 그 객체는 for...of, 펼치기(...), 구조 분해 할당에 들어간다. 패턴이 언어 문법이 된 셈이다.

# Python: yield를 쓰면 __iter__와 __next__, StopIteration을 언어가 만들어 준다
class RecentLog:
    def __init__(self, capacity):
        self._buffer = [None] * capacity
        self._start = 0
        self._size = 0

    def __iter__(self):
        for offset in range(self._size):
            yield self._buffer[(self._start + offset) % len(self._buffer)]

// JavaScript: [Symbol.iterator]가 { value, done }을 내는 반복자를 돌려주면 for...of와 펼치기가 된다
class RecentLog {
  *[Symbol.iterator]() {
    for (let offset = 0; offset < this.size; offset++) {
      yield this.buffer[(this.start + offset) % this.buffer.length];
    }
  }
}
const lines = [...recentLog];

중재자: 대화를 한곳에 모으기

의도: 여러 객체가 서로 주고받는 방식을 한 객체에 담아, 객체끼리 직접 알지 않게 한다.

문제 상황

주문서 화면에 배송 방법, 주소 칸, 쿠폰, 결제 수단, 약관 동의, 주문 버튼이 있다. "매장 수령이면 주소 칸을 끈다", "약관에 동의하고 주소가 있어야 버튼을 켠다" 같은 규칙이 생길 때마다 위젯이 다른 위젯을 하나씩 알게 되면 연결이 그물처럼 늘어난다. 객체 n개가 서로 직접 알면 연결은 최대 n(n−1)/2개, 중재자 하나를 거치면 n개다. 위젯이 6개면 15개 대 6개, 20개면 190개 대 20개다.

위젯 여섯 개를 서로 직접 이으면 연결이 15개, 중재자 하나를 거치면 6개다. 위젯이 20개면 190개 대 20개다서로 직접: 15줄중재자를 거쳐: 6줄중재자배송주소쿠폰결제약관버튼배송주소쿠폰결제약관버튼위젯 수 n4개6개10개20개직접 연결 n(n−1)/261545190중재자 n461020
위젯 여섯 개를 서로 잇는 경우(왼쪽)와 중재자 하나로 모으는 경우(오른쪽). 아래 표는 위젯 수에 따른 연결 수다. 모두 계산 함수로 구했다.

공항 관제탑이 좋은 비유다. 비행기끼리 서로 무선으로 "제가 먼저 내릴게요"를 협의하지 않는다. 모두 관제탑과만 교신하고, 순서를 정하는 규칙은 관제탑에 있다. 비행기를 한 대 더 받아도 다른 비행기는 바뀌지 않는다.

구조

중재자 클래스 그림: CheckoutForm이 FormMediator를 구현하고 세 위젯을 조정한다. DeliveryChoice, AddressField, SubmitButton은 Widget을 상속하고 Widget은 FormMediator에게 알린다알린다조정«interface»FormMediatorchanged(source)«abstract»Widget# mediator: FormMediator# notifyChanged()CheckoutFormchanged(source)DeliveryChoiceisPickup()AddressFieldtext()SubmitButtonsetEnabled(b)CheckoutForm은 세 위젯을 모두 알고 조정한다. 위젯끼리는 서로를 모르고 중재자에게만 알린다"배송 방법이 바뀌면 주소 칸을 끄고 버튼을 켠다" 같은 규칙이 한 클래스에 모인다
중재자의 구조. 위젯은 바뀌면 중재자에게 알리기만 하고, 위젯 사이의 규칙은 CheckoutForm 한 곳에 있다.

예시 코드

public interface FormMediator {
    void changed(Widget source);
}

public abstract class Widget {
    protected final FormMediator mediator;

    protected Widget(FormMediator mediator) {
        this.mediator = mediator;
    }

    protected void notifyChanged() {
        mediator.changed(this);
    }
}

public final class AddressField extends Widget {
    private String text = "";
    private boolean enabled = true;

    public AddressField(FormMediator mediator) {
        super(mediator);
    }

    public void type(String value) {
        text = value;
        notifyChanged();
    }

    public String text() {
        return text;
    }

    public void setEnabled(boolean enabled) {
        this.enabled = enabled;
    }
}
// DeliveryChoice(isPickup), SubmitButton(setEnabled)도 같은 모양이다

// 위젯 사이의 규칙은 전부 여기 한 곳에 있다
public final class CheckoutForm implements FormMediator {
    private final DeliveryChoice delivery = new DeliveryChoice(this);
    private final AddressField address = new AddressField(this);
    private final SubmitButton submit = new SubmitButton(this);

    @Override
    public void changed(Widget source) {
        boolean pickup = delivery.isPickup();
        address.setEnabled(!pickup);
        submit.setEnabled(pickup || !address.text().isBlank());
    }
}

실제 쓰임

  • Swing의 ButtonGroup: ButtonGroup에 넣은 버튼들은 하나를 켜면 나머지가 모두 꺼진다. 라디오 버튼끼리는 서로를 모르고, "하나만 켜진다"는 규칙은 그룹 객체가 맡는다.
  • .NET의 MediatR: MediatR은 스스로를 단순한 중재자 구현이라 소개한다. 요청과 응답, 명령, 질의, 알림, 이벤트를 중재자에게 보내면 맞는 처리기를 찾아 넘겨 주므로, 컨트롤러가 처리기 클래스를 직접 알 필요가 없다.

장단점

  • 장점: 위젯은 다른 화면에도 그대로 가져다 쓸 수 있다. 상호작용 규칙을 한 파일에서 읽고 고친다. 연결 수가 n(n−1)/2에서 n으로 준다.
  • 단점: 복잡도가 사라지는 것이 아니라 중재자로 옮겨진다. 규칙이 쌓이면 중재자가 모든 것을 아는 거대한 객체가 된다.

주의: 갓 오브젝트가 되지 않게

중재자가 위젯 상태를 다 읽고, 서버도 부르고, 화면 이동도 하기 시작하면 흔히 갓 오브젝트(god object)라 부르는 안티패턴이 된다. 화면 단위나 기능 단위로 중재자를 나누고, 중재자는 "누가 바뀌면 누구를 어떻게"만 담게 한다. 단순히 "바뀌었다"를 여럿에게 알리기만 하면 되는 경우는 옵저버로 충분하다. 옵저버는 알릴 뿐 규칙을 담지 않고, 중재자는 규칙을 담는다는 점이 다르다.

다른 언어에서는

채팅방은 중재자의 교과서적 예다. 사용자는 방에 말하고, 방이 나머지에게 전한다. 자바스크립트로 쓰면 아래와 같고, 사용자를 더해도 다른 사용자 코드는 바뀌지 않는다. 프런트엔드에서는 컴포넌트끼리 직접 부르지 않고 부모 컴포넌트나 상태 저장소를 거치게 하는 구조가 같은 생각이다.

// JavaScript: 채팅방이 중재자다. 사용자는 방만 알고 다른 사용자를 모른다
class ChatRoom {
  #members = new Map();

  join(user) {
    this.#members.set(user.name, user);
    user.room = this;
  }

  send(from, text) {
    for (const [name, user] of this.#members) {
      if (name !== from) user.receive(from, text);
    }
  }
}

class User {
  constructor(name) {
    this.name = name;
  }

  say(text) {
    this.room.send(this.name, text);
  }

  receive(from, text) {
    console.log(`[${this.name}] ${from}: ${text}`);
  }
}

메멘토: 캡슐화를 깨지 않는 스냅숏

의도: 객체의 내부 상태를 밖에 저장해 두었다가 그대로 되돌리되, 그 상태의 속은 객체 자신만 보게 한다.

문제 상황

편집기에 되돌리기를 넣으려면 과거 상태를 어딘가에 저장해야 한다. 가장 쉬운 길은 편집기의 글, 커서, 선택 범위를 모두 getter로 열어 History가 꺼내 가는 것이다. 그러면 편집기 내부 표현을 바꿀 때마다 History도 고쳐야 하고, 아무 코드나 편집기 속을 만질 수 있게 된다.

구조

역할은 셋이다. 상태를 가진 원본(Originator)이 자기 상태를 찍은 메멘토(Memento)를 만들어 주고, 그것으로 자신을 되살린다. 관리자(Caretaker)는 메멘토를 받아 두었다가 돌려줄 뿐이다. 메멘토는 바뀌지 않는 값이다.

메멘토 클래스 그림: Editor가 Editor.Snapshot을 만들고, History가 Snapshot을 보관하며 Editor의 save와 restore를 부른다만든다보관save, restore를 부른다Editor- text, cursor+ type(words)+ save(): Snapshot+ restore(s)Editor.Snapshot- text- cursor(바깥엔 공개 메서드 없음)History- undo: Deque- redo: Deque+ undo()+ redo()원본(Originator)메멘토(Memento)관리자(Caretaker)스냅숏의 필드는 Editor만 읽는다. History는 봉투를 쌓고 돌려줄 뿐 안을 열지 못한다그래서 Editor의 내부 표현을 바꿔도 History는 고칠 필요가 없다
메멘토의 구조. 스냅숏은 Editor 안의 중첩 클래스라 필드를 Editor만 읽는다. History는 스냅숏을 쌓아 두고 돌려주지만 안을 볼 수 없다.

예시 코드

Java에서는 중첩 클래스의 private 필드를 바깥 클래스가 읽을 수 있다. 이것으로 "Editor만 여는 봉투"를 만든다. 생성자도 private이라 바깥에서 가짜 스냅숏을 만들 수 없다.

public final class Editor {
    private StringBuilder text = new StringBuilder();
    private int cursor;

    public void type(String words) {
        text.insert(cursor, words);
        cursor += words.length();
    }

    public Snapshot save() {
        return new Snapshot(text.toString(), cursor);
    }

    public void restore(Snapshot snapshot) {
        text = new StringBuilder(snapshot.text);
        cursor = snapshot.cursor;
    }

    // 바깥에서는 만들 수도, 열어 볼 수도 없는 봉투. 필드는 바깥 클래스 Editor만 읽는다
    public static final class Snapshot {
        private final String text;
        private final int cursor;

        private Snapshot(String text, int cursor) {
            this.text = text;
            this.cursor = cursor;
        }
    }
}

// 관리자: 언제 찍고 언제 되돌릴지만 정하고 봉투 안은 모른다
public final class History {
    private final Deque<Editor.Snapshot> undoStack = new ArrayDeque<>();
    private final Deque<Editor.Snapshot> redoStack = new ArrayDeque<>();
    private final Editor editor;

    public History(Editor editor) {
        this.editor = editor;
    }

    public void type(String words) {
        undoStack.push(editor.save());
        redoStack.clear();
        editor.type(words);
    }

    public void undo() {
        if (undoStack.isEmpty()) {
            return;
        }
        redoStack.push(editor.save());
        editor.restore(undoStack.pop());
    }

    public void redo() {
        if (redoStack.isEmpty()) {
            return;
        }
        undoStack.push(editor.save());
        editor.restore(redoStack.pop());
    }
}
메멘토 단계 그림: 안녕, 하세요, 느낌표를 입력하고 두 번 되돌린 뒤 다시 하기, 그리고 물음표를 입력하면 다시 하기 스택이 비워진다Editor(원본)처음|비어 있음비어 있음입력 "안녕"안녕|(빈 글)맨 위비어 있음입력 "하세요"안녕하세요|(빈 글)안녕맨 위비어 있음입력 "!"안녕하세요!|(빈 글)안녕안녕하세요맨 위비어 있음되돌리기안녕하세요|(빈 글)안녕맨 위안녕하세요!맨 위되돌리기안녕|(빈 글)맨 위안녕하세요!안녕하세요맨 위다시 하기안녕하세요|(빈 글)안녕맨 위안녕하세요!맨 위입력 "?"안녕하세요?|(빈 글)안녕안녕하세요맨 위비어 있음되돌리기 스택다시 하기 스택스택 칸 하나가 Editor.Snapshot 하나다. 보이게 하려고 글을 적었지만 History는 이 글을 읽을 수 없다
1 / 8

1. 처음 — 편집기는 비어 있고 두 스택도 비었다.

편집기 되돌리기를 메멘토로 만든 모습. History는 스냅숏을 두 스택에 옮겨 담기만 하고 안은 열지 않는다. 스택 내용은 계산 함수로 구했다.

실제 쓰임

  • 되돌리기, 버전 관리, 직렬화: 위키백과는 이 셋을 대표 쓰임으로 든다. 저장한 상태로 돌아가는 일은 모두 메멘토 꼴이다.
  • 난수 생성기의 시드: 같은 시드(메멘토)를 넣으면 같은 수열이 다시 나온다. 생성기 내부 계산은 몰라도 된다. 게임의 리플레이나 테스트 재현에 이 성질을 쓴다.

장단점

  • 장점: 원본의 내부를 열지 않고 저장과 복원을 한다. 되돌리는 코드가 단순하다. 스냅숏으로 덮어쓰면 끝이라 연산마다 역연산을 짤 필요가 없다.
  • 단점: 상태 전체를 복사하므로 상태가 크거나 자주 찍으면 메모리를 많이 쓴다. 언어에 따라 "원본만 연다"를 강제하기 어렵다.

주의: 커맨드 기반 되돌리기와 비교

되돌리기는 커맨드로도 만든다. 커맨드 방식은 한 일을 객체로 쌓아 두고, 되돌릴 때 맨 위 커맨드의 undo()를 부른다. Swing의 UndoManager가 이 방식이다. 되돌릴 수 있는 편집(UndoableEdit) 목록을 관리하고, 새 편집이 들어오면 되돌려 둔 편집들을 목록에서 지우며, 기본 상한은 100개다.

메멘토커맨드
저장하는 것그 순간의 상태 전체한 일(과 되돌리는 법)
되돌리는 법스냅숏으로 덮어쓰기역연산 실행
비용상태가 크면 메모리연산마다 역연산 코드
맞는 곳상태가 작거나, 역연산을 만들기 어려울 때상태는 크고 한 번의 변경은 작을 때

둘을 섞기도 한다. 역연산을 짜기 어려운 커맨드는 실행 직전에 메멘토를 하나 찍어 들고 있다가, undo()에서 그것으로 복원한다. 어느 쪽이든 기록 개수에 상한을 두고, 새로 입력하면 다시 하기 기록을 버린다는 규칙은 같다. 메멘토는 객체 하나의 상태만 담으므로, 원본이 파일이나 다른 객체를 함께 바꿨다면 그것까지 되돌아오지는 않는다는 점도 위키백과가 짚는다.

다른 언어에서는

파이썬에는 바깥에서 아예 못 읽는 private 변수가 없고, 튜토리얼도 밑줄로 시작하는 이름을 공개 API가 아닌 것으로 대하는 관례를 소개한다. 그래서 메멘토는 밑줄 붙은 불변 객체로 "열지 말라"는 뜻만 표시한다. 자바스크립트도 상태를 담은 평범한 객체를 배열에 쌓는 식으로 자주 쓴다.

# Python: private이 없으므로 밑줄 관례와 불변 객체로 "열지 말라"를 표시한다
from dataclasses import dataclass

@dataclass(frozen=True)
class _Snapshot:
    text: str
    cursor: int

class Editor:
    def __init__(self):
        self._text = ""
        self._cursor = 0

    def save(self):
        return _Snapshot(self._text, self._cursor)

    def restore(self, snapshot):
        self._text = snapshot.text
        self._cursor = snapshot.cursor

책임 연쇄: 처리할 때까지 넘기기

의도: 요청을 처리할 수 있는 객체들을 사슬로 엮고, 누군가 처리할 때까지 요청을 넘겨, 보내는 쪽과 받는 쪽을 떼어 놓는다.

문제 상황

고객 지원 요청이 들어온다. 단순 문의는 챗봇이, 10만 원까지의 환불은 상담원이, 100만 원까지는 팀장이 맡는다. 요청을 받는 코드에 if로 이 규칙을 다 넣으면 등급이 하나 늘 때마다 그 코드를 고쳐야 한다. 보내는 쪽은 "누가 처리하는지"를 몰라도 되어야 한다.

구조

책임 연쇄 클래스 그림: Client는 SupportHandler의 첫 담당만 알고, SupportHandler는 next로 자기와 같은 타입을 가리킨다. RefundLimitHandler가 SupportHandler를 상속한다첫 담당nextClientchain.handle(t)«abstract»SupportHandler- next: SupportHandler+ handle(t) final# canHandle(t)# resolve(t)RefundLimitHandler- name, limit# canHandle(t)# resolve(t)자기를 가리키는참조가 사슬을 만든다챗봇, 상담원, 팀장은상한만 다른 객체 셋handle은 할 수 있으면 resolve, 못 하면 next.handle로 넘긴다. 끝까지 없으면 "처리 못 함"
책임 연쇄의 구조. 담당은 다음 담당 하나만 가리킨다. Client는 사슬의 첫 담당만 안다.

예시 코드

public record Ticket(String title, long refundAmount) {
}

public abstract class SupportHandler {
    private SupportHandler next;

    public SupportHandler then(SupportHandler next) {
        this.next = next;
        return next;
    }

    // 처리할 수 있으면 처리하고 멈추고, 아니면 다음 담당에게 넘긴다
    public final String handle(Ticket ticket) {
        if (canHandle(ticket)) {
            return resolve(ticket);
        }
        if (next == null) {
            return "처리할 곳 없음: " + ticket.title();
        }
        return next.handle(ticket);
    }

    protected abstract boolean canHandle(Ticket ticket);

    protected abstract String resolve(Ticket ticket);
}

public final class RefundLimitHandler extends SupportHandler {
    private final String name;
    private final long limit;

    public RefundLimitHandler(String name, long limit) {
        this.name = name;
        this.limit = limit;
    }

    @Override
    protected boolean canHandle(Ticket ticket) {
        return ticket.refundAmount() <= limit;
    }

    @Override
    protected String resolve(Ticket ticket) {
        return name + " 처리: " + ticket.title();
    }
}

SupportHandler chain = new RefundLimitHandler("챗봇", 0);
chain.then(new RefundLimitHandler("상담원", 100_000))
    .then(new RefundLimitHandler("팀장", 1_000_000));

// "상담원 처리: 배송 지연 환불"
chain.handle(new Ticket("배송 지연 환불", 50_000));
책임 연쇄 단계 그림: 배송 문의는 챗봇이, 5만 원 환불은 상담원이 처리하고, 300만 원 환불은 팀장까지 넘어가도 처리되지 않는다배송 문의 (0원)챗봇상한 0원✓ 처리하고 멈춤상담원상한 10만 원팀장상한 100만 원사슬 끝▶배송 지연 환불 (5만 원)챗봇상한 0원못 함, 넘김 →상담원상한 10만 원✓ 처리하고 멈춤팀장상한 100만 원사슬 끝▶대량 주문 환불 (300만 원)챗봇상한 0원못 함, 넘김 →상담원상한 10만 원못 함, 넘김 →팀장상한 100만 원못 함, 넘김 →사슬 끝처리할 곳 없음▶담당은 자기가 못 하는 일을 다음에 넘길 뿐, 사슬 전체가 어떻게 생겼는지 모른다
1 / 5

요청 3개가 사슬 맨 앞(챗봇)으로 들어온다. 보내는 쪽은 누가 처리할지 모르고 첫 담당만 안다.

고객 지원 요청이 책임 연쇄를 따라가는 모습. 담당마다 처리할 수 있는 환불 금액 상한이 다르다. 결과는 계산 함수로 구했다.

실제 쓰임

  • 서블릿 필터: 서블릿 컨테이너가 주는 FilterChain의 doFilter는 다음 필터를, 마지막 필터라면 사슬 끝의 자원(서블릿)을 부른다. 필터는 이것을 부르지 않는 것으로 사슬을 끊는다.
  • Spring Security: 공식 문서에 따르면 컨테이너에 등록된 DelegatingFilterProxy가 FilterChainProxy에 맡기고, FilterChainProxy는 여러 SecurityFilterChain 중 요청에 처음 맞는 하나만 실행한다. 보안 필터는 응답을 직접 쓰고 그 뒤의 필터와 서블릿을 부르지 않을 수 있다. 사슬 안에 또 사슬이 있는 꼴이다.
  • Express 미들웨어: Express 안내서는 미들웨어가 요청-응답 주기를 끝내지 않으면 반드시 next()를 불러야 하고, 그러지 않으면 요청이 매달린 채 남는다고 적는다.
  • UI 이벤트: 위키백과는 macOS와 iOS의 responder chain을 든다. 뷰가 처리하지 않은 이벤트는 상위 뷰, 뷰 컨트롤러나 창을 거쳐 앱 객체까지 올라간다.
// 서블릿 필터: 조건이 맞지 않으면 응답을 쓰고 사슬을 끊는다. 맞으면 chain.doFilter로 다음에 넘긴다
public final class ApiKeyFilter implements Filter {
    @Override
    public void doFilter(
        ServletRequest request,
        ServletResponse response,
        FilterChain chain
    ) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        if (httpRequest.getHeader("X-Api-Key") == null) {
            ((HttpServletResponse) response).sendError(401);
            return;
        }
        chain.doFilter(request, response);
    }
}

장단점

  • 장점: 보내는 쪽은 첫 담당만 안다. 담당을 더하거나 순서를 바꾸는 일이 사슬 조립 코드 한 곳에서 끝난다. 담당 하나하나는 자기 조건만 본다.
  • 단점: 요청이 어디서 처리됐는지 코드만 보고는 알기 어렵다. 사슬이 길면 매번 앞에서부터 지나가야 한다.

주의

  • 사슬 끝을 정한다: 아무도 처리하지 않는 요청이 생긴다. 끝에서 오류를 낼지, 기본 처리를 할지 정하지 않으면 요청이 조용히 사라지거나 Express처럼 매달린다.
  • 순서가 의미다: 인증 필터보다 로깅 필터가 먼저인지 나중인지에 따라 기록되는 내용이 달라진다. 순서는 한곳에서 명시적으로 조립한다.
  • 엄격한 꼴과 느슨한 꼴: GoF의 정의는 담당 하나만 처리하는 것이다. 서블릿 필터나 미들웨어처럼 여러 담당이 조금씩 일하고 넘기는 구현도 흔하다. 모두가 처리하고 넘긴다면 구조가 거의 같은 데코레이터에 가깝다.

다른 언어에서는

함수를 값으로 다루는 언어에서는 담당 클래스 대신 "다음"을 인자로 받는 함수의 목록으로 쓴다. Express의 (req, res, next)가 그 모양이고, 사슬은 app.use를 부른 순서다.

// Express: 미들웨어는 응답을 끝내거나 next()를 불러야 한다. 둘 다 안 하면 요청이 매달린다
app.use((req, res, next) => {
  if (!req.get('X-Api-Key')) {
    res.status(401).send('API 키가 없습니다');
    return;
  }
  next();
});

app.use((req, res, next) => {
  console.log(req.method, req.path);
  next();
});

방문자: 구조는 그대로 두고 연산을 더하기

의도: 객체 구조의 원소 클래스를 고치지 않고 그 위에서 도는 새 연산을 정의한다.

문제 상황

계산기의 식 트리(AST)에 숫자 마디와 더하기 마디가 있다. 처음에는 값 계산만 필요했는데, 곧 글자로 찍기, 상수 접기, 타입 검사가 차례로 붙는다. 연산마다 모든 마디 클래스에 메서드를 하나씩 더하면, 마디 클래스는 서로 상관없는 일로 비대해지고 연산 하나의 코드는 여러 파일에 흩어진다.

구조

방문자 클래스 그림: Num과 Add가 Expr를 구현하고, EvalVisitor와 PrintVisitor가 ExprVisitor를 구현한다. Expr의 accept가 방문자를 받는다방문자를 받는다«interface»Expraccept(v): RNumaccept(v)Addaccept(v)«interface»ExprVisitor<R>visitNum(n): RvisitAdd(a): REvalVisitorvisitNum(n)visitAdd(a)PrintVisitorvisitNum(n)visitAdd(a)구조(잘 안 바뀜)연산(자주 늘어남)Add.accept(v)는 v.visitAdd(this)를 부른다. 새 연산은 방문자 클래스 하나를 더하면 된다새 노드(Mul)를 더하면 ExprVisitor와 모든 방문자에 visitMul을 더해야 한다
방문자의 구조. 왼쪽 원소 쪽은 accept 하나만 가지고, 연산은 오른쪽 방문자 클래스로 모인다.

핵심은 이중 디스패치(double dispatch)다. Java의 보통 메서드 호출은 받는 객체 하나의 실제 타입으로만 메서드를 고른다. 방문자는 호출을 두 번 해서 두 타입을 모두 쓴다. node.accept(v)에서 노드의 실제 타입으로 한 번, 그 안의 v.visitAdd(this)에서 방문자의 실제 타입으로 한 번 고른다. 결국 실행되는 메서드는 (노드 타입, 방문자 타입)의 조합으로 정해진다.

이중 디스패치: 호출자가 node.accept(v)를 부르면 노드의 실제 타입(Add)으로 accept가 골라지고, 그 안에서 v.visitAdd(this)를 부르면 방문자의 실제 타입(EvalVisitor)으로 visitAdd가 골라진다. 실행되는 메서드는 노드 타입과 방문자 타입의 조합으로 정해진다호출자node: Expr실제는 Addv: ExprVisitor실제는 EvalVisitornode.accept(v)① 노드 타입으로 Add.acceptv.visitAdd(this)② 방문자 타입으로 고름실행되는 메서드 = 노드 타입 × 방문자 타입EvalVisitorPrintVisitorNumvisitNum: 값 그대로visitNum: "1" 같은 글자AddvisitAdd: 왼쪽 + 오른쪽visitAdd: "(왼 + 오)"
이중 디스패치. 첫 호출은 노드 타입으로, 둘째 호출은 방문자 타입으로 갈린다. 아래 표의 네 칸이 두 타입의 조합마다 실행되는 메서드다.

예시 코드

public interface ExprVisitor<R> {
    R visitNum(Num num);

    R visitAdd(Add add);
}

public interface Expr {
    <R> R accept(ExprVisitor<R> visitor);
}

public record Num(double value) implements Expr {
    @Override
    public <R> R accept(ExprVisitor<R> visitor) {
        return visitor.visitNum(this);
    }
}

public record Add(Expr left, Expr right) implements Expr {
    @Override
    public <R> R accept(ExprVisitor<R> visitor) {
        return visitor.visitAdd(this);
    }
}

public final class EvalVisitor implements ExprVisitor<Double> {
    @Override
    public Double visitNum(Num num) {
        return num.value();
    }

    @Override
    public Double visitAdd(Add add) {
        return add.left().accept(this) + add.right().accept(this);
    }
}

public final class PrintVisitor implements ExprVisitor<String> {
    @Override
    public String visitNum(Num num) {
        return String.valueOf(num.value());
    }

    @Override
    public String visitAdd(Add add) {
        return "(" + add.left().accept(this) + " + " + add.right().accept(this) + ")";
    }
}

Expr expr = new Add(new Num(1), new Num(2));
// 3.0
double value = expr.accept(new EvalVisitor());
// "(1.0 + 2.0)"
String text = expr.accept(new PrintVisitor());

실제 쓰임

  • 컴파일러와 애너테이션 처리: Java의 ElementVisitor는 스스로를 방문자 패턴 꼴의 프로그램 요소 방문자라 소개한다. 반환 타입 R과 추가 인자 P를 타입 매개변수로 받는다. 구문 트리 위에서 검사, 최적화, 코드 생성을 차례로 하는 컴파일러 구조는 컴파일러 노트에 있다.
  • 파일 트리 돌기: FileVisitor를 Files.walkFileTree에 넘기면 디렉터리에 들어가기 전, 파일마다, 실패했을 때, 디렉터리를 다 돈 뒤에 맞는 메서드가 불린다. 반환값 FileVisitResult의 CONTINUE, SKIP_SUBTREE 등으로 순회를 조절한다.
  • 파이썬 ast: ast.NodeVisitor는 아래 다른 언어 절에서 본다. 트리를 고쳐 쓰는 NodeTransformer는 방문 메서드의 반환값으로 원래 마디를 바꾸거나 지운다.

장단점

  • 장점: 새 연산은 방문자 클래스 하나로 끝난다. 한 연산의 코드가 한 클래스에 모인다. 방문하면서 결과를 모으는 상태(개수, 기호표)를 방문자 안에 둘 수 있다.
  • 단점: 새 마디 타입을 더하기가 어렵다. 방문자 인터페이스와 모든 구현에 메서드를 더해야 한다. 마디의 내부를 방문자가 읽어야 하므로 원소 쪽 캡슐화가 약해진다.

주의: 무엇이 더 자주 바뀌나

방문자는 "구조는 거의 안 바뀌고 연산이 자주 늘어날 때"를 위한 거래다. 마디 타입이 자주 늘어나는 곳이라면 연산을 마디 클래스에 두는 보통 다형성이 낫다. ElementVisitor 문서는 이 문제를 실제로 겪는 예다. 언어에 새 구성 요소가 생기면 인터페이스에 기본 메서드를 더하고, 그 기본 구현은 visitUnknown을 불러 옛 방문자가 새 요소를 만나면 UnknownElementException을 내게 한다. 그래서 문서는 인터페이스를 직접 구현하지 말고 SimpleElementVisitor14 같은 추상 클래스를 상속하라고 권한다.

다른 언어에서는

합 타입과 패턴 매칭이 있는 언어에서는 방문자가 하는 일을 switch 하나로 쓴다. Java도 JDK 21에서 JEP 441로 switch의 패턴 매칭을 정식 기능으로 넣었다. 봉인된(sealed) 타입이면 컴파일러가 permits 목록으로 모든 경우를 다뤘는지 검사하므로 default가 필요 없을 수 있다. JEP는 앞으로의 일 절에서 이런 계산을 지금은 "번거로운 방문자 패턴"으로 해야 한다고 적는다. 새 타입을 더했을 때 빠진 곳을 컴파일러가 찾아 주는 장점은 같고, accept와 방문자 인터페이스라는 의식만 사라진다.

// Java 21: 봉인된 타입 + 패턴 매칭 switch. accept도 방문자 인터페이스도 없다
sealed interface Expr permits Num, Add, Mul {
}

record Num(double value) implements Expr {
}

record Add(Expr left, Expr right) implements Expr {
}

record Mul(Expr left, Expr right) implements Expr {
}

static double eval(Expr expr) {
    // permits 목록을 다 다뤘으므로 default가 필요 없다. 새 타입을 permits에 더하면 여기서 컴파일 오류가 난다
    return switch (expr) {
        case Num num -> num.value();
        case Add add -> eval(add.left()) + eval(add.right());
        case Mul mul -> eval(mul.left()) * eval(mul.right());
    };
}

파이썬의 ast.NodeVisitor는 이중 디스패치 대신 이름으로 고른다. visit(node)가 visit_클래스이름 메서드를 찾아 부르고, 없으면 자식을 도는 generic_visit로 간다. 직접 만든 방문 메서드는 자식을 알아서 돌지 않는다는 점을 문서가 경고한다.

# Python: ast.NodeVisitor는 visit_<클래스 이름> 메서드를 찾아 부른다
import ast

class CallCounter(ast.NodeVisitor):
    def __init__(self):
        self.count = 0

    def visit_Call(self, node):
        self.count += 1
        # 직접 만든 visit_ 메서드는 자식을 알아서 돌지 않으므로 이어서 내려간다
        self.generic_visit(node)

counter = CallCounter()
counter.visit(ast.parse("print(len(items))"))
# counter.count == 2

인터프리터: 작은 언어의 문법을 클래스로

의도: 간단한 언어의 문법 규칙마다 클래스를 하나씩 두고, 그 클래스들로 만든 구문 트리로 문장을 해석한다.

문제 상황

쇼핑몰 관리자가 할인 규칙을 (price + tax) * quantity 같은 식으로 직접 적게 하고 싶다. 식의 모양은 몇 가지뿐(숫자, 변수, 더하기, 곱하기)이지만 서로 얼마든지 겹친다. 문자열을 매번 잘라 if로 해석하면 괄호와 우선순위에서 금방 무너진다.

구조

구성은 공통 인터페이스(AbstractExpression), 자식 없이 바로 값을 내는 끝 기호(TerminalExpression), 자식을 가지고 해석을 자식에게 넘겨 합치는 비끝 기호(NonterminalExpression)다. 트리 모양 자체는 컴포지트이고, 그 위에 "해석"이라는 연산 하나를 얹은 것이다.

인터프리터 클래스 그림: Constant, Variable, Plus, Times가 Expression을 구현하고, Expression의 interpret는 Context를 읽는다읽는다«interface»Expressioninterpret(ctx): doubleContextlookup(name)Constantvalueinterpret(ctx)Variablenameinterpret(ctx)Plusleft, rightinterpret(ctx)Timesleft, rightinterpret(ctx)끝 기호: 바로 값을 낸다비끝 기호: 자식 Expression을 계산해 합친다문법 규칙 하나가 클래스 하나다. 문장을 이 트리로 만드는 일(파싱)은 패턴 밖이다
인터프리터의 구조. 문법 기호마다 클래스가 하나다. 변수 값 같은 해석 환경은 Context로 넘긴다.

예시 코드

public interface Expression {
    double interpret(Map<String, Double> context);
}

// 끝 기호: 바로 값을 낸다
public record Constant(double value) implements Expression {
    @Override
    public double interpret(Map<String, Double> context) {
        return value;
    }
}

public record Variable(String name) implements Expression {
    @Override
    public double interpret(Map<String, Double> context) {
        Double value = context.get(name);
        if (value == null) {
            throw new IllegalArgumentException("정의되지 않은 변수: " + name);
        }
        return value;
    }
}

// 비끝 기호: 자식을 먼저 계산하고 합친다
public record Plus(Expression left, Expression right) implements Expression {
    @Override
    public double interpret(Map<String, Double> context) {
        return left.interpret(context) + right.interpret(context);
    }
}

public record Times(Expression left, Expression right) implements Expression {
    @Override
    public double interpret(Map<String, Double> context) {
        return left.interpret(context) * right.interpret(context);
    }
}

// (price + tax) * quantity — 트리는 파서가 만들거나 손으로 만든다
Expression total = new Times(
    new Plus(new Variable("price"), new Variable("tax")),
    new Variable("quantity")
);
// 3300.0
double amount = total.interpret(Map.of("price", 1000.0, "tax", 100.0, "quantity", 3.0));

위 코드는 트리를 손으로 만들었다. 패턴 설명도 구문 트리를 만드는 법은 다루지 않는다고 분명히 한다. 이 글의 그림은 작은 재귀 하강 파서로 (1 + 2) * 3을 트리로 만든 뒤 같은 방식으로 계산했다. 문법의 층(식, 항, 인수)이 연산자 우선순위가 된다.

식 (1 + 2) * 3의 트리. 곱하기가 뿌리이고 왼쪽 자식이 더하기(1과 2), 오른쪽 자식이 3이다. 1, 2, 더하기(3), 3, 곱하기(9) 순서로 계산이 끝난다입력: (1 + 2) * 3 = 91122+= 3334×= 95검은 번호: 계산이 끝나는 순서. 자식을 먼저 계산하고 부모가 합친다(후위 순회)문법(우선순위가 층이 된다)expr := term {(+|-) term}term := factor {(*|/) factor}factor := 수 | (expr)기호 → 클래스수 → Constant (끝 기호)+ - → Plus, Minus* / → Times, Divide
(1 + 2) * 3의 식 트리. 마디 옆 값은 그 마디가 낸 결과이고, 번호는 계산이 끝나는 순서다. 트리, 값, 순서 모두 계산 함수로 구했다.

실제 쓰임

  • 작은 언어(DSL): 위키백과는 SQL 같은 특화된 데이터베이스 질의 언어와 통신 프로토콜을 기술하는 언어를 쓰임으로 든다. 정규식, 템플릿 문법, 설정 파일의 조건식, 규칙 엔진의 규칙처럼 문법이 작고 고정된 언어가 모두 후보다.
  • 언어 처리기의 뼈대: 구문 트리를 직접 걸으며 실행하는 트리 걷기 인터프리터가 이 패턴을 크게 키운 것이다. 바이트코드와 JIT로 가는 다음 단계는 컴파일러 노트에 있다.

장단점

  • 장점: 문법 규칙 하나가 클래스 하나라 문법을 읽듯 코드를 읽는다. 규칙을 더하거나 바꾸기 쉽다. 같은 트리에 출력, 검사 같은 연산을 방문자로 더할 수 있다.
  • 단점: 규칙이 수십 개를 넘으면 클래스가 감당하기 어렵게 늘어난다. 트리를 매번 걸으므로 빠르지 않다.

주의: 언제 파서 생성기를 쓰나

문법이 커지거나 사용자가 직접 쓰는 언어라 오류 메시지가 중요해지면, 파서를 손으로 짜지 말고 파서 생성기를 쓴다. ANTLR는 문법 파일에서 구문 트리를 만들고 걷는 파서를 만들어 준다. 어떤 문법이 스택 하나로 판정되는 문맥 자유 문법인지, 정규식으로 충분한지는 계산 이론 노트의 문법 절에 있다. 정규식으로 충분한 언어라면 파서도 인터프리터 클래스도 필요 없다. 그리고 사용자가 적은 식을 해석할 때는 반복 깊이, 실행 시간, 접근할 수 있는 변수를 제한해야 한다.

다른 언어에서는

합 타입이 있는 언어에서는 문법 규칙을 클래스 대신 타입의 경우로 두고, interpret를 함수 하나의 분기로 쓴다. 방문자 절의 Java 21 switch와 같은 생각이다. 이 글의 그림을 그린 계산 코드도 이렇게 생겼다.

// TypeScript: 클래스 대신 태그 붙은 합 타입과 함수 하나. 이 글의 식 트리 그림도 이렇게 계산했다
type Expr =
  | { kind: 'num'; value: number }
  | { kind: 'bin'; op: '+' | '-' | '*' | '/'; left: Expr; right: Expr };

function evaluate(expr: Expr): number {
  if (expr.kind === 'num') return expr.value;
  const left = evaluate(expr.left);
  const right = evaluate(expr.right);
  if (expr.op === '+') return left + right;
  if (expr.op === '-') return left - right;
  if (expr.op === '*') return left * right;
  if (right === 0) throw new Error('0으로 나눌 수 없다');
  return left / right;
}

행위 패턴 11가지 한눈에

행위 패턴은 모두 "누가 무엇을 알고, 누구에게 무엇을 시키나"를 나누는 방법이다. 위 다섯은 행위 패턴 1 노트, 아래 여섯은 이 글에 있다.

패턴무엇을 감추나, 나누나흔한 오해핵심 자료
전략알고리즘을 갈아 끼울 수 있게 나눈다분기마다 전략 클래스를 만들어야 한다. 단계 하나면 함수 하나를 넘기는 것으로 충분하다위키백과
옵저버바뀐 쪽과 알아야 하는 쪽을 떼어 놓는다구독만 하면 끝이다. 해지하지 않은 관찰자는 대상이 붙잡고 있어 메모리에 남는다위키백과
커맨드요청을 객체로 감싸 부르는 쪽과 하는 쪽을 나눈다실행 취소 전용이다. 요청을 객체로 만들면 미루고, 쌓고, 기록할 수도 있다위키백과
상태상태마다 다른 행동을 상태 객체로 나눈다전략과 같다. 구조는 닮았지만 상태는 객체가 스스로 다음 상태로 옮겨 간다위키백과
템플릿 메서드알고리즘 뼈대는 부모에, 단계는 자식에 둔다상속이 늘 맞다. 바꿀 단계가 하나뿐이면 전략처럼 함수를 넘기는 편이 가볍다위키백과
이터레이터모음의 내부 구조를 감추고 순회 위치를 따로 둔다fail-fast 예외가 동시 수정을 막아 준다. 최선의 노력일 뿐 버그 탐지용이다위키백과
중재자객체끼리의 대화 규칙을 한 객체로 모은다복잡도가 사라진다. 한곳으로 옮겨질 뿐이라, 커지면 중재자가 갓 오브젝트가 된다위키백과
메멘토상태를 저장하되 그 안은 원본만 보게 한다스냅숏은 공짜다. 상태 전체를 복사하므로 개수 상한과 크기 관리가 필요하다위키백과
책임 연쇄누가 처리할지를 보내는 쪽에게서 감춘다누군가는 반드시 처리한다. 끝까지 아무도 안 할 수 있으니 사슬 끝을 정해 둔다위키백과
방문자구조와 그 위의 연산을 나눈다트리를 돌면 방문자다. 핵심은 순회가 아니라 이중 디스패치로 타입별 연산을 밖에 두는 것이다위키백과
인터프리터작은 언어의 문법 규칙을 클래스로 나눈다파서까지 들어 있다. 패턴은 만들어진 트리를 계산하는 쪽만 말한다위키백과