행위 패턴 2: 이터레이터·중재자·메멘토·책임 연쇄·방문자·인터프리터
순회를 감추고, 대화를 한곳에 모으고, 상태를 저장하고, 요청을 넘기고, 구조 위에 연산을 얹는 법이다.
목차 (8)
그림 읽는 법
1994년에 나온 GoF 책 『Design Patterns』의 행위 패턴은 11가지다. 전략, 옵저버, 커맨드, 상태, 템플릿 메서드는 행위 패턴 1 노트에 있고, 이 글은 남은 여섯을 다룬다. 패턴마다 같은 순서로 쓴다. 의도 한 문장, 어떤 문제에서 나오나, 클래스 구조, 짧은 Java 예시, 실제 라이브러리에서의 쓰임, 장단점과 주의, 다른 언어에서의 모습이다.
클래스 그림은 UML을 줄여 그렸다. 아래 범례를 한 번만 보면 모든 그림에 그대로 통한다.
이터레이터: 내부 구조를 감춘 순회
의도: 모음(aggregate)이 안에서 어떻게 생겼는지 드러내지 않고 원소를 하나씩 차례로 꺼내게 한다.
문제 상황
최근 로그 100줄을 담는 객체를 만든다고 하자. 메모리를 아끼려고 원형 버퍼로 짰다. 쓰는 쪽이 "배열 몇 번 칸부터 몇 칸, 끝에 닿으면 0번으로" 같은 규칙을 알아야 돌 수 있다면, 나중에 연결 리스트로 바꾸는 순간 모든 호출부가 깨진다. 순회하는 위치를 모음 안에 넣어 두면 두 군데서 동시에 돌 수도 없다.
구조
예시 코드
// 내부는 원형 버퍼인 "최근 로그 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() 등에서 예외를 낸다. 아래 표는 그 방식을 줄여 흉내 낸 계산이다.
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개다.
공항 관제탑이 좋은 비유다. 비행기끼리 서로 무선으로 "제가 먼저 내릴게요"를 협의하지 않는다. 모두 관제탑과만 교신하고, 순서를 정하는 규칙은 관제탑에 있다. 비행기를 한 대 더 받아도 다른 비행기는 바뀌지 않는다.
구조
예시 코드
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)는 메멘토를 받아 두었다가 돌려줄 뿐이다. 메멘토는 바뀌지 않는 값이다.
예시 코드
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());
}
}1. 처음 — 편집기는 비어 있고 두 스택도 비었다.
실제 쓰임
- 되돌리기, 버전 관리, 직렬화: 위키백과는 이 셋을 대표 쓰임으로 든다. 저장한 상태로 돌아가는 일은 모두 메멘토 꼴이다.
- 난수 생성기의 시드: 같은 시드(메멘토)를 넣으면 같은 수열이 다시 나온다. 생성기 내부 계산은 몰라도 된다. 게임의 리플레이나 테스트 재현에 이 성질을 쓴다.
장단점
- 장점: 원본의 내부를 열지 않고 저장과 복원을 한다. 되돌리는 코드가 단순하다. 스냅숏으로 덮어쓰면 끝이라 연산마다 역연산을 짤 필요가 없다.
- 단점: 상태 전체를 복사하므로 상태가 크거나 자주 찍으면 메모리를 많이 쓴다. 언어에 따라 "원본만 연다"를 강제하기 어렵다.
주의: 커맨드 기반 되돌리기와 비교
되돌리기는 커맨드로도 만든다. 커맨드 방식은 한 일을 객체로 쌓아 두고, 되돌릴 때 맨 위 커맨드의 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로 이 규칙을 다 넣으면 등급이 하나 늘 때마다 그 코드를 고쳐야 한다. 보내는 쪽은 "누가 처리하는지"를 몰라도 되어야 한다.
구조
예시 코드
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));요청 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)에 숫자 마디와 더하기 마디가 있다. 처음에는 값 계산만 필요했는데, 곧 글자로 찍기, 상수 접기, 타입 검사가 차례로 붙는다. 연산마다 모든 마디 클래스에 메서드를 하나씩 더하면, 마디 클래스는 서로 상관없는 일로 비대해지고 연산 하나의 코드는 여러 파일에 흩어진다.
구조
핵심은 이중 디스패치(double dispatch)다. Java의 보통 메서드 호출은 받는 객체 하나의 실제 타입으로만 메서드를 고른다. 방문자는 호출을 두 번 해서 두 타입을 모두 쓴다. node.accept(v)에서 노드의 실제 타입으로 한 번, 그 안의 v.visitAdd(this)에서 방문자의 실제 타입으로 한 번 고른다. 결국 실행되는 메서드는 (노드 타입, 방문자 타입)의 조합으로 정해진다.
예시 코드
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)다. 트리 모양 자체는 컴포지트이고, 그 위에 "해석"이라는 연산 하나를 얹은 것이다.
예시 코드
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을 트리로 만든 뒤 같은 방식으로 계산했다. 문법의 층(식, 항, 인수)이 연산자 우선순위가 된다.
실제 쓰임
- 작은 언어(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 예외가 동시 수정을 막아 준다. 최선의 노력일 뿐 버그 탐지용이다 | 위키백과 |
| 중재자 | 객체끼리의 대화 규칙을 한 객체로 모은다 | 복잡도가 사라진다. 한곳으로 옮겨질 뿐이라, 커지면 중재자가 갓 오브젝트가 된다 | 위키백과 |
| 메멘토 | 상태를 저장하되 그 안은 원본만 보게 한다 | 스냅숏은 공짜다. 상태 전체를 복사하므로 개수 상한과 크기 관리가 필요하다 | 위키백과 |
| 책임 연쇄 | 누가 처리할지를 보내는 쪽에게서 감춘다 | 누군가는 반드시 처리한다. 끝까지 아무도 안 할 수 있으니 사슬 끝을 정해 둔다 | 위키백과 |
| 방문자 | 구조와 그 위의 연산을 나눈다 | 트리를 돌면 방문자다. 핵심은 순회가 아니라 이중 디스패치로 타입별 연산을 밖에 두는 것이다 | 위키백과 |
| 인터프리터 | 작은 언어의 문법 규칙을 클래스로 나눈다 | 파서까지 들어 있다. 패턴은 만들어진 트리를 계산하는 쪽만 말한다 | 위키백과 |
- Software design pattern — Wikipedia
- Design Patterns (book) — Wikipedia
- Iterator pattern — Wikipedia
- Iterator (Java SE 21) — Oracle
- Iterable (Java SE 21) — Oracle
- ConcurrentModificationException (Java SE 21) — Oracle
- AbstractList (Java SE 21) — Oracle
- Classes: Iterators, Generators — Python Tutorial
- Iteration protocols — MDN Web Docs
- Mediator pattern — Wikipedia
- ButtonGroup (Java SE 21) — Oracle
- MediatR — GitHub
- Memento pattern — Wikipedia
- UndoManager (Java SE 21) — Oracle
- Command pattern — Wikipedia
- Chain-of-responsibility pattern — Wikipedia
- FilterChain (Java EE 7) — Oracle
- Spring Security Architecture — Spring
- Using middleware — Express
- Visitor pattern — Wikipedia
- ElementVisitor (Java SE 21) — Oracle
- FileVisitor (Java SE 21) — Oracle
- ast — Abstract Syntax Trees — Python
- JEP 441: Pattern Matching for switch — OpenJDK
- Interpreter pattern — Wikipedia
- ANTLR
- Strategy pattern — Wikipedia
- Observer pattern — Wikipedia
- State pattern — Wikipedia
- Template method pattern — Wikipedia