패턴을 하나씩 뜯어보기 전에, 패턴들이 실제로 어떻게 함께 사는지를 먼저 본다. GoF가 카탈로그 앞머리에 Lexi라는 가상의 문서 편집기를 세운 이유가 그것이다. Lexi는 워드프로세서 같은 WYSIWYG 편집기다 — 화면에 보이는 그대로가 인쇄물이 되고, 글자와 그림과 표가 섞이며, 스크롤바가 달리고, 맥과 윈도우에서 똑같이 돌고, 실행 취소가 되고, 철자를 검사한다. 이 평범한 요구 목록이 곧 일곱 개의 설계 문제이고, 각 문제가 서로 다른 패턴을 불러낸다.
여기서 중요한 것은 개별 패턴의 정의가 아니라 한 편집기 안에서 여덟 패턴이 서로 맞물린다는 사실이다. 컴포지트가 만든 문서 트리 위를 반복자가 훑고, 그 트리에 방문자가 철자 검사를 얹고, 트리의 각 줄은 전략이 배치하고, 화면 가장자리는 데코레이터가 감싸고, 그 모든 위젯은 추상 팩토리가 룩앤필에 맞춰 찍어 내고, 창 시스템의 차이는 브리지가 흡수하며, 사용자의 편집 하나하나는 커맨드가 객체로 기록해 되돌린다. 패턴은 레고 블록이 아니라 서로의 전제가 되어 준다.
문제 1 — 문서의 구조: 컴포지트
가장 먼저 물어야 할 것은 “문서를 무엇으로 표현하나”다. 사용자 눈에 문서는 글자와 그림과 줄과 열과 표의 배열이지만, 그 안을 들여다보면 재귀적이다. 글자들이 모여 줄이 되고, 줄들이 모여 열이 되고, 표의 각 칸 안에 또 글자와 그림이 들어간다. 개별 원소(글자 하나)와 원소들의 묶음(한 줄, 표 하나)을 코드가 다르게 다뤄야 한다면, 화면을 그리고 크기를 재는 코드마다 “이게 낱개냐 묶음이냐”를 매번 따져야 한다.
[컴포지트]는 이 물음에 “낱개와 묶음을 같은 얼굴로 다뤄라”라고 답한다. 글자든 그림이든 줄이든 표든, 전부 Glyph라는 한 인터페이스를 구현한다. 자신을 그리고(draw), 자기 영역을 알려 주고(bounds), 자식을 품을 수 있다(insert, child). 글자는 자식이 없는 잎이고, 줄이나 표는 자식을 담는 가지일 뿐 — 클라이언트는 둘을 구별하지 않는다.
interface Glyph {
void draw(Window w);
Rect bounds();
void insert(Glyph g, int at); // 잎에서는 무의미하지만 인터페이스는 공유
}
// Character(잎)와 Row·Column·Table(가지)이 모두 Glyph를 구현한다.
// Row.draw()는 자식 글리프들의 draw()를 재귀 호출할 뿐이다.이 트리가 이 사례 연구의 뼈대다. 앞으로 나올 패턴 대부분이 이 Glyph 트리 위에서 논다. 반복자가 훑는 것도, 방문자가 방문하는 것도, 데코레이터가 감싸는 것도 전부 Glyph다. 첫 설계 결정 하나가 뒤따르는 여섯 결정의 지반을 놓는다.
판단 기준: 개별 원소와 원소들의 묶음을 클라이언트가 똑같이 취급해야 하고, 그 구조가 재귀적이라면 컴포지트다. 함정: 잎에도 insert·child 같은 자식 관리 연산이 인터페이스로 새어 나온다 — 통일성을 얻는 대신 잎에서 호출하면 안 되는 메서드를 인터페이스가 광고하게 된다. 이 안전성과 투명성의 맞바꿈이 컴포지트의 상수다.
문제 2 — 줄바꿈: 전략
문서 트리는 정해졌다. 이제 한 줄에 글리프들을 어떻게 배치할지가 문제다. 단어가 오른쪽 경계를 넘으면 그 앞에서 끊는 단순한 방식도 있고, 줄들의 여백 편차가 최소가 되도록 문단 전체를 놓고 계산하는 TeX식도 있고, 표에서는 아예 안 끊는다. 배치는 하나의 일이지만 방법이 여럿이고, 앞으로 더 는다.
이 로직을 Column 안에 if (mode == SIMPLE) ... else if (mode == TEX)로 박아 넣으면, 배치 방법이 문서 구조 클래스에 눌어붙는다. [전략]은 배치 알고리즘을 Compositor라는 별도 객체로 뽑아, Composition(줄바꿈을 적용받는 특수한 Column)이 이를 밖에서 갈아 끼우게 한다.
interface Compositor { // 배치 전략
int[] compose(Glyph[] children, int lineWidth);
}
class SimpleCompositor implements Compositor { /* 탐욕적 줄바꿈 */ }
class TeXCompositor implements Compositor { /* 전역 최적 줄바꿈 */ }
class Composition extends Column {
private Compositor compositor; // 어떤 방식인지 모른 채 참조만 쥔다
void setCompositor(Compositor c) { this.compositor = c; } // 런타임 교체
}여기서 협력이 드러난다. 전략(Compositor)은 홀로 존재하지 못하고 컴포지트가 만든 글리프 트리를 입력으로 받는다. compose가 다루는 것은 Composition의 자식 Glyph 배열이다. 문제 1의 산출물이 문제 2의 재료가 되는 것이다.
판단 기준: 같은 자리에서 하는 일은 하나인데 방법이 여럿이고 그 방법이 늘어난다면 전략으로 뽑는다. 함정: 배치 방법이 앞으로도 하나라면 Composition 안에 그냥 두는 편이 낫다 — 쓰지 않을 유연성을 위해 인터페이스 하나와 클래스 배선을 미리 지불하는 것은 과용이다.
문제 3 — 테두리와 스크롤바: 데코레이터
편집 영역 주위에 테두리를 두르고 스크롤바를 붙이고 싶다. 소박한 발상은 Composition을 상속해 BorderedComposition, ScrolledComposition, BorderedScrolledComposition을 만드는 것이다. 그러나 꾸밈이 하나 늘 때마다 조합이 곱으로 폭발하고, “스크롤되는 테두리 있는 열”과 “테두리 있는 스크롤되는 표”를 각각 클래스로 찍어야 한다.
[데코레이터]는 꾸밈을 상속이 아니라 감싸기로 붙인다. Border와 Scroller도 Glyph를 구현하되, 내부에 다른 Glyph 하나를 품고 자기 일(테두리 그리기)을 한 뒤 품은 것에게 위임한다.
abstract class MonoGlyph implements Glyph { // 하나를 감싸는 글리프
protected Glyph component;
public void draw(Window w) { component.draw(w); } // 기본은 위임
}
class Border extends MonoGlyph {
public void draw(Window w) { super.draw(w); drawBorder(w); } // 위임 + 덧칠
}
// new Border(new Scroller(composition)) — 감싼 순서대로 기능이 쌓인다.데코레이터가 Glyph를 구현한다는 점이 핵심 협력이다. Border로 감싼 결과도 여전히 Glyph이므로, 컴포지트 트리 어디에나 그대로 꽂힌다. 꾸밈은 트리의 특별한 노드일 뿐이고, 나머지 코드는 감싸였는지조차 모른다. 컴포지트가 정의한 공통 얼굴이 데코레이터를 성립시킨다.
판단 기준: 책임을 객체 단위로, 동적으로, 조합 가능하게 덧붙이고 싶고 상속 조합이 폭발한다면 데코레이터다. 함정: 여러 겹으로 감싸면 디버깅 때 Border → Scroller → Border → Composition처럼 위임 사슬을 한 겹씩 벗겨 내려가야 원본에 닿는다 — 얇은 껍질이 많아질수록 스택은 깊어지고 정체는 흐려진다.
문제 4 — 룩앤필 표준: 추상팩토리
Lexi는 맥의 룩앤필과 윈도우의 룩앤필에서 각각 그 OS의 위젯처럼 보여야 한다. 스크롤바 하나를 만들더라도 맥이냐 윈도우냐에 따라 다른 클래스를 써야 하는데, 코드 곳곳에서 new MacScrollBar()를 직접 부르면 룩앤필을 바꾸는 순간 그 new들을 전부 찾아 고쳐야 한다. 게다가 실수로 맥 스크롤바와 윈도우 버튼을 섞어 만들 위험도 있다.
추상팩토리(Abstract Factory)는 한 룩앤필에 속한 위젯 군(群)을 통째로 찍어 내는 공장을 세운다. GUIFactory가 createScrollBar, createButton, createMenu를 선언하고, MacFactory와 WindowsFactory가 각자 자기 룩앤필의 위젯을 반환한다. 클라이언트는 공장 하나만 주입받고 구체 위젯 클래스는 모른다.
interface GUIFactory {
ScrollBar createScrollBar();
Button createButton();
}
class MacFactory implements GUIFactory {
public ScrollBar createScrollBar() { return new MacScrollBar(); }
public Button createButton() { return new MacButton(); }
}
// 앞의 Scroller 데코레이터는 factory.createScrollBar()로 스크롤바를 얻는다.
// 룩앤필 교체 = 주입하는 factory 하나만 교체.문제 3에서 만든 Scroller 데코레이터가 스크롤바를 어디서 얻느냐 — 바로 이 공장에서다. 데코레이터는 “무엇을 감싸는지”를 알지만 “그것이 맥용인지 윈도우용인지”는 몰라야 하고, 추상 팩토리가 정확히 그 앎을 대신 짊어진다. 팩토리가 위젯 군의 일관성을 보장하니 맥과 윈도우가 한 화면에 섞이지 않는다.
판단 기준: 서로 어울려야 하는 객체들의 군을 통째로 갈아 끼워야 하고 구체 클래스를 클라이언트에서 숨기고 싶다면 추상 팩토리다. 함정: 제품군에 새 종류(예: createTooltip)를 추가하려면 모든 공장 클래스를 다 열어 고쳐야 한다 — 군의 교체는 쉽게, 군에 새 멤버 추가는 어렵게 만드는 것이 이 패턴의 비대칭이다.
문제 5 — 창 시스템 이식: 브리지
룩앤필과는 다른 축의 문제가 하나 더 있다. Lexi는 맥, 윈도우, X 윈도우 같은 서로 다른 창 시스템 위에서 돌아야 한다. Window라는 추상(윈도우가 사용자에게 제공하는 것 — 그리기, 아이콘화, 크기 조절)과, 그것을 실제 픽셀로 실현하는 플랫폼별 구현(MacWindowImp, XWindowImp)은 각각 독립적으로 확장된다. 윈도우 종류(응용 윈도우·대화상자·아이콘 윈도우)도 늘고, 플랫폼도 는다. 이 둘을 상속 한 축으로 묶으면 종류 × 플랫폼의 클래스 폭발이 온다.
[브리지]는 이 두 축을 갈라 놓는다. Window 계층(추상)과 WindowImp 계층(구현)을 분리하고, 윈도우가 자기 일을 픽셀로 옮길 때마다 자신이 쥔 WindowImp에게 위임한다.
abstract class Window {
private WindowImp imp; // 구현으로 이어지는 다리
void drawRect(Rect r) { imp.deviceRect(r); } // 추상 → 구현 위임
}
interface WindowImp { void deviceRect(Rect r); } // 플랫폼별 구현
// Window의 종류(ApplicationWindow, IconWindow)와
// WindowImp의 플랫폼(Mac, X)이 서로 독립적으로 늘어난다.앞의 Glyph.draw(Window w)가 받던 그 Window가 여기서 두 겹으로 갈라진다. 글리프는 Window라는 추상만 알고, 그것이 맥 화면에 그려지는지 X 서버로 나가는지는 브리지 너머의 일이다. 추상 팩토리가 “어떤 룩앤필의 위젯이냐”를 흡수했다면, 브리지는 “어떤 창 시스템이냐”를 흡수한다 — 둘은 다른 변화 축을 각자 맡는 이웃이다.
판단 기준: 추상과 구현이 각각 독립적으로 확장되어 상속 한 축으로 묶으면 클래스가 곱으로 터진다면 브리지다. 함정: 변화 축이 실은 하나뿐인데 미리 두 계층으로 갈라 놓으면, 다리를 건너는 위임 비용만 추가되고 분리의 이득은 오지 않는다 — 두 축이 정말 따로 노는지부터 확인해야 한다.
문제 6 — 사용자 조작과 실행 취소: 커맨드
메뉴·버튼·단축키가 전부 같은 동작(예: “붙여넣기”)을 부를 수 있어야 하고, 그 동작들은 되돌릴 수 있어야 한다. 버튼 클릭 핸들러 안에 붙여넣기 로직을 직접 박으면, 같은 로직을 단축키 핸들러에도 복사해야 하고, 실행 취소를 붙이려면 모든 핸들러가 방금 무엇을 했는지 일일이 기억해야 한다.
[커맨드]는 각 조작을 객체로 캡슐화한다. Command가 execute와 undo 하나씩만 선언하고, PasteCommand·FontCommand가 각자 자기 조작과 그 역연산을 안다. 위젯은 어떤 조작인지 모른 채 Command 하나를 쥐고 있다가 눌리면 execute를 부른다.
interface Command { void execute(); void undo(); }
class CommandHistory {
private Deque<Command> done = new ArrayDeque<>();
void run(Command c) { c.execute(); done.push(c); }
void undo() { if (!done.isEmpty()) done.pop().undo(); } // 역순 되돌리기
}
// 버튼·메뉴·단축키가 같은 PasteCommand를 공유한다. 실행된 커맨드는
// 스택에 쌓이고, undo는 그 스택을 거꾸로 감는다.여기서 커맨드가 조작하는 대상이 무엇인가 — 대개 컴포지트가 만든 문서 트리다. PasteCommand.execute는 트리에 글리프를 insert하고, undo는 그 글리프를 도로 뺀다. 실행 취소는 [메멘토]와 짝을 이루기도 한다 — 커맨드가 되돌리기에 필요한 상태를 메멘토로 저장해 두는 식이다. 조작을 객체로 만든 덕에 로그·큐·매크로도 공짜로 따라온다. 커맨드 여럿을 묶으면 그대로 컴포지트 구조의 매크로 커맨드가 된다 — 패턴이 또 서로를 부른다.
판단 기준: 조작을 발동하는 쪽(위젯)과 수행하는 쪽(수신자)을 떼고, 실행 취소·큐·로그·매크로가 필요하면 커맨드다. 함정: 되돌릴 일이 전혀 없고 조작을 값처럼 다룰 필요도 없는 단순 호출까지 커맨드로 감싸면, 함수 한 줄이면 될 것을 클래스 하나로 부풀린 셈이다.
문제 7 — 순회와 철자 검사: 반복자와 방문자
마지막 문제는 두 패턴이 한 자리에서 손잡는 장면이다. 철자 검사와 하이픈 넣기를 하려면 문서 트리의 모든 글자를 순서대로 훑어야 한다. 그런데 트리는 컴포지트라 내부 구조가 종류마다 다르다(열은 세로로, 행은 가로로, 표는 격자로 자식을 쥔다). 검사 코드가 이 내부 구조를 직접 파고들면, 글리프 종류가 늘 때마다 검사 코드가 깨진다.
[반복자]가 먼저 나선다 — 트리를 어떻게 훑는지를 별도 객체로 뽑아, 검사 코드가 내부 구조를 모른 채 next()로 다음 글리프만 받게 한다. 전위 순회, 후위 순회 같은 순회 방식을 반복자 종류로 갈아 끼울 수 있고, 트리 구조가 바뀌어도 검사 코드는 그대로다.
interface Iterator<T> { boolean hasNext(); T next(); }
// PreorderIterator가 Glyph 트리를 재귀적으로 훑는다.
// 검사 코드는 "무엇을 훑는지"만 알고 "어떻게 훑는지"는 모른다.그러나 순회만으로는 부족하다. 훑으면서 글리프 종류마다 다른 일을 해야 한다 — 글자는 철자 버퍼에 모으고, 공백은 단어 경계로 삼고, 그림은 건너뛴다. 이 분기를 if (g instanceof Character) ... else if (g instanceof Space)로 짜면, 철자 검사·하이픈·문자 수 세기 같은 새 연산을 추가할 때마다 이 분기를 곳곳에 복제하게 된다.
[방문자]가 이 연산을 트리 밖으로 꺼낸다. SpellingCheckerVisitor가 visitCharacter, visitSpace, visitImage를 갖고, 각 글리프는 방문자를 받으면 자기 타입에 맞는 메서드를 되불러 준다(accept(v) → v.visitCharacter(this), 더블 디스패치).
interface Visitor {
void visitCharacter(Character c);
void visitImage(Image i);
}
interface Glyph { void accept(Visitor v); /* ... */ }
class Character implements Glyph {
public void accept(Visitor v) { v.visitCharacter(this); } // 자기 타입을 되알림
}
class SpellingChecker implements Visitor {
public void visitCharacter(Character c) { buffer.append(c.getChar()); }
public void visitImage(Image i) { /* 건너뜀 */ }
}협력이 여기서 정점에 이른다. 반복자가 트리를 훑으며 각 글리프에게 accept(visitor)를 부르고, 방문자가 글리프 종류별로 갈라 처리한다. 컴포지트가 구조를 세우고, 반복자가 그 위를 걷고, 방문자가 걸으며 일을 한다 — 세 패턴이 하나의 철자 검사 기능 안에서 각자 다른 관심사를 나눠 맡는다. 하이픈 넣기라는 새 연산이 필요하면? 트리는 한 줄도 안 건드리고 HyphenationVisitor만 새로 추가한다.
판단 기준: 집합체 내부를 노출하지 않고 훑고 싶으면 반복자, 그 원소들에 새 연산을 자주 추가하되 원소 종류는 안정적이라면 방문자다. 함정: 방문자는 정확히 그 반대 방향에 약하다 — 글리프 종류가 하나 늘면 모든 방문자에 visitNewGlyph를 추가해야 한다. 연산 추가는 쉽게, 원소 추가는 어렵게 만드는 맞바꿈이라, 원소 계층이 자주 바뀌는 곳에 방문자를 놓으면 매번 모든 방문자를 뜯어고치게 된다.
여덟 패턴이 한 화면 위에서
Lexi 하나를 지으며 우리는 여덟 패턴을 만났다. 정리하면 이렇게 맞물린다. 컴포지트가 문서 트리라는 지반을 놓고, 그 트리의 줄바꿈은 전략이 갈아 끼우며, 트리의 노드로 데코레이터가 테두리와 스크롤바를 얹는다. 그 위젯들은 추상팩토리가 룩앤필별로 찍어 내고, 화면에 픽셀로 나갈 때 창 시스템의 차이는 브리지가 흡수한다. 사용자의 편집은 커맨드가 객체로 기록해 되돌리고, 철자 검사는 반복자가 트리를 훑으며 방문자에게 종류별 처리를 맡겨 완성한다.
이 사례가 주는 교훈은 개별 패턴의 쓸모가 아니라 패턴들의 결합 방식이다. 어느 패턴도 홀로 등장하지 않았다. 데코레이터는 컴포지트의 공통 인터페이스가 있어야 트리에 꽂히고, 방문자는 컴포지트 구조와 반복자의 순회 위에서만 일하며, 커맨드의 매크로는 그 자체가 컴포지트다. 좋은 설계는 패턴을 많이 쓰는 것이 아니라, 각 변화 축에 맞는 패턴을 고르고 그것들이 서로의 전제가 되도록 엮는 일이다.
판단 기준: 새 요구가 왔을 때 “이건 어느 변화 축인가”(구조냐·알고리즘이냐·꾸밈이냐·제품군이냐·연산 추가냐)를 먼저 묻고, 그 축에 맞는 패턴을 고른다. 축을 잘못 짚으면 아무리 이름난 패턴도 헛돈다. 함정: 반대로 “이 문제에 패턴을 몇 개 넣을까”부터 세는 것은 패턴을 위한 패턴이다 — Lexi가 여덟 패턴을 쓴 것은 서로 다른 일곱 문제가 실제로 있었기 때문이지, 패턴을 채우려던 것이 아니다. 문제가 없으면 패턴도 없다.
이제 이 사례에서 맛본 패턴들을 하나씩 깊이 들여다볼 차례다. 위젯 군을 통째로 찍어 내던 그 공장 — 생성 패턴의 첫 자리에서 시작한다.
다음으로 추상팩토리