구조 패턴 ① — 맞추고, 나누고, 쌓는다
구조 패턴은 객체를 조합해 더 큰 구조를 만들거나 인터페이스를 맞추는 일곱 개다. 이 편은 앞의 넷을 다룬다 — 안 맞는 걸 맞추고(Adapter), 두 축을 나누고(Bridge), 트리로 묶고(Composite), 한 겹씩 쌓는(Decorator) 것.
넷 다 감싸는(wrapping) 모양이라 헷갈리기 쉽다. 매번 "무엇을 위해 감쌌나" 를 붙잡고 읽는다.
Adapter — 인터페이스 변환기
이미 있는 클래스를 클라이언트가 기대하는 인터페이스로 바꿔 준다. 콘센트 어댑터와 같다 — 전기를 바꾸는 게 아니라 모양을 바꾼다.
문제 — 둘 다 멀쩡한데 안 맞는다
// 우리 코드가 기대하는 것
interface PayGateway { PayResult charge(long amount, String orderNo); }
// 새로 계약한 PG 사가 주는 SDK — 고칠 수 없다
class LegacyPgSdk {
public int doPayment(String merchantId, Map<String, Object> params) { … }
}
LegacyPgSdk 는 남의 코드라 못 고치고, PayGateway 는 우리 도메인 언어라 바꾸고 싶지 않다. 양쪽 다 옳은데 모양만 다르다.
적용 — 사이에 번역기를 둔다
class LegacyPgAdapter implements PayGateway { // 기대하는 인터페이스를 구현하고
private final LegacyPgSdk sdk; // 실제 대상을 품는다
private final String merchantId;
public PayResult charge(long amount, String orderNo) {
Map<String, Object> params = Map.of( // ① 우리 말 → 저쪽 말
"amt", amount, "ordNo", orderNo, "curr", "KRW");
int code = sdk.doPayment(merchantId, params);
return switch (code) { // ② 저쪽 답 → 우리 말
case 0 -> PayResult.success();
case 1001 -> PayResult.insufficientBalance();
default -> PayResult.fail("PG-" + code);
};
}
}
값을 옮기는 코드가 지저분해 보이지만, 그 지저분함이 한 클래스에 갇힌다는 것이 요점이다. PG 사를 또 바꾸면 어댑터만 하나 더 쓰면 되고, 도메인 코드는 PayGateway 만 계속 본다.
두 가지 방식
class ObjectAdapter implements Target { // ① 객체 어댑터 — 대상을 '가진다'
private final Adaptee adaptee;
public void request() { adaptee.specificRequest(); }
}
class ClassAdapter extends Adaptee implements Target { // ② 클래스 어댑터 — 대상을 '상속한다'
public void request() { specificRequest(); }
}
자바에서는 **거의 항상 객체 어댑터**를 쓴다. 클래스 어댑터는 단일 상속을 소진하고, 대상이 final 이면 불가능하며, 상속으로 인한 결합까지 떠안는다. 원칙 ②("상속보다 합성")가 여기서도 답을 준다.
JDK 에서는
List<String> list = Arrays.asList(array); // 배열 → List
Reader r = new InputStreamReader(inputStream, UTF_8); // 바이트 스트림 → 문자 스트림
List<String> l = Collections.list(enumeration); // Enumeration(레거시) → List
InputStreamReader 가 특히 좋은 예다. 바이트와 문자는 다른 세계인데, 그 사이에 인코딩 변환을 끼워 하나로 이어 준다.
함정 —
Arrays.asList()가 돌려주는 리스트는 배열을 감싼 뷰다. 크기를 못 바꾼다.List<String> l = Arrays.asList("a", "b"); l.set(0, "z"); // 된다 — 배열 원소를 바꾼다 l.add("c"); // UnsupportedOperationException — 배열은 크기가 고정이다어댑터는 대상의 성질을 바꾸지 못한다. 모양만 맞출 뿐이라는 점이 여기서 드러난다.
Bridge — 추상과 구현을 분리
독립적으로 변하는 두 축을 상속이 아니라 합성으로 잇는다. Adapter 가 사후 수습이라면 Bridge 는 사전 설계다.
문제 — 축이 둘인데 상속으로 풀면 곱해진다
알림을 보내는데 종류(주문완료·배송·프로모션)와 채널(SMS·이메일·푸시)이 각각 늘어난다고 하자.
class OrderSmsNotification extends Notification { }
class OrderEmailNotification extends Notification { }
class OrderPushNotification extends Notification { }
class ShipSmsNotification extends Notification { }
… // 3종 × 3채널 = 9개
채널을 하나 더하면 클래스가 3개 늘고, 종류를 하나 더하면 또 4개 는다. 곱집합 폭발이다.
적용 — 두 축을 갈라 참조로 잇는다
// 구현 쪽 축 — 어떻게 보내나
interface Channel { void send(String to, String title, String body); }
class SmsChannel implements Channel { … }
class EmailChannel implements Channel { … }
// 추상 쪽 축 — 무엇을 보내나
abstract class Notification {
protected final Channel channel; // ← 이 참조가 '다리(bridge)'
protected Notification(Channel channel) { this.channel = channel; }
abstract void notify(User u, Order o);
}
class OrderCompleted extends Notification {
OrderCompleted(Channel c) { super(c); }
void notify(User u, Order o) {
channel.send(u.contact(), "주문 완료", o.no() + " 결제가 완료됐습니다");
}
}
new OrderCompleted(new SmsChannel()).notify(user, order); // 조합은 런타임에
이제 3 + 3 = 6개다. 채널을 더하면 1개만 늘고, 조합은 실행 중에 만들어진다.
JDK 에서는
Connection c = DriverManager.getConnection(url); // JDBC API 는 추상 축
// 실제 구현은 MySQL·PostgreSQL 드라이버가 제공한다 — 구현 축
JDBC 가 교과서적 사례다. 애플리케이션이 쓰는 API 계층과 DB 별 드라이버 구현이 갈라져 있어, 우리 코드를 안 고치고 DB 를 바꿀 수 있다. AWT 의 Component ↔ ComponentPeer 도 같은 구조로, 플랫폼별 네이티브 위젯을 뒤에 숨긴다.
Adapter 와 무엇이 다른가
| Adapter | Bridge | |
|---|---|---|
| 언제 정하나 | 사후 — 이미 있는 것이 안 맞아서 | 사전 — 설계할 때부터 두 축을 나눔 |
| 목적 | 모양을 맞춘다 | 곱집합 폭발을 막는다 |
| 인터페이스 | 다른 것 → 기대하는 것 | 두 축이 각자 독립 |
Adapter 는 어쩔 수 없어서, Bridge 는 그러기로 해서 쓴다. 코드 모양은 비슷해도 의도가 반대다.
Composite — 부분-전체 트리
잎과 가지를 같은 인터페이스로 다뤄, 쓰는 쪽이 둘을 구분하지 않게 한다.
문제 — 쓰는 쪽이 매번 타입을 검사한다
long size(Object node) {
if (node instanceof File f) return f.length();
if (node instanceof Directory d) {
long sum = 0;
for (Object child : d.children()) sum += size(child); // 재귀 안에서 또 검사
return sum;
}
throw new IllegalArgumentException();
}
타입 검사가 호출부마다 반복되고, 노드 종류가 늘면 모든 호출부를 고쳐야 한다.
적용 — 같은 타입으로 만든다
interface Node {
String name();
long size();
}
record FileNode(String name, long size) implements Node { } // 잎
record DirNode(String name, List<Node> children) implements Node { // 가지
public long size() {
return children.stream().mapToLong(Node::size).sum(); // 재귀가 자연스럽다
}
}
Node tree = new DirNode("src", List.of(
new FileNode("Main.java", 1200),
new DirNode("util", List.of(new FileNode("Str.java", 800)))));
tree.size(); // 2000 — 쓰는 쪽은 잎인지 가지인지 모른다
재귀가 구조 안으로 들어갔다. 호출부는 size() 한 번만 부르면 된다.
JDK 에서는
Container panel = new JPanel();
panel.add(new JButton("확인")); // 잎
panel.add(new JPanel()); // 가지 — Container 도 Component 다
java.awt.Container 가 Component 를 상속하면서 Component 를 담는다. 자기 자신과 같은 타입을 담는 것이 Composite 의 서명이다. org.w3c.dom.Node 도 같다.
실무에서는
sealed interface MenuItem permits Leaf, SubMenu { boolean visibleTo(Role r); }
record Leaf(String label, String url, Set<Role> roles) implements MenuItem {
public boolean visibleTo(Role r) { return roles.contains(r); }
}
record SubMenu(String label, List<MenuItem> items) implements MenuItem {
public boolean visibleTo(Role r) {
return items.stream().anyMatch(i -> i.visibleTo(r)); // 하나라도 보이면 보인다
}
}
메뉴·조직도·권한 트리·댓글 스레드가 전부 이 모양이다. 규칙(여기서는 "보이는가")을 잎과 가지가 각자 답하게 하면 바깥에 분기가 안 생긴다.
함정 — 잎에는 의미 없는 메서드가 인터페이스에 끼어든다.
add(Node)를 공통 인터페이스에 두면FileNode.add()는 무엇을 해야 하나. GoF 는 투명성(공통 인터페이스에 다 넣기) 과 안전성(가지에만 두기) 의 트레이드오프로 설명한다. 자바에서는 대개 안전성 쪽을 택해 자식 조작은DirNode에만 둔다.그리고 트리가 깊으면 재귀가 스택을 소진할 수 있다. 깊이를 신뢰할 수 없는 구조(사용자가 만든 중첩)라면 반복 + 명시적 스택으로 바꾼다.
Decorator — 래핑으로 기능 추가
대상과 같은 인터페이스를 유지하면서 감싸서 책임을 덧붙인다. 상속이 아니라 런타임 조합이다.
문제 — 조합마다 클래스를 만들 수는 없다
class DataSource { void write(String s) { … } }
class EncryptedDataSource extends DataSource { … }
class CompressedDataSource extends DataSource { … }
class EncryptedCompressedDataSource extends DataSource { … } // 조합마다 하나씩?
기능이 3개면 조합은 8가지다. 그리고 순서까지 의미가 있으면(압축 후 암호화 vs 암호화 후 압축) 더 늘어난다.
적용 — 같은 타입으로 감싼다
interface DataSource { void write(String data); }
class FileDataSource implements DataSource { // 진짜 일을 하는 것
public void write(String data) { … }
}
abstract class DataSourceDecorator implements DataSource { // 감싸는 것들의 공통 뼈대
protected final DataSource wrappee;
protected DataSourceDecorator(DataSource w) { this.wrappee = w; }
}
class Compression extends DataSourceDecorator {
Compression(DataSource w) { super(w); }
public void write(String data) { wrappee.write(gzip(data)); } // 앞뒤에 끼운다
}
class Encryption extends DataSourceDecorator {
Encryption(DataSource w) { super(w); }
public void write(String data) { wrappee.write(encrypt(data)); }
}
DataSource ds = new Encryption(new Compression(new FileDataSource("a.txt")));
ds.write(json); // 암호화 → 압축 → 파일
클래스는 기능 수만큼만 있고, 조합과 순서는 실행 중에 정한다.
순서가 결과를 바꾼다
new Encryption(new Compression(src)) // 압축 → 암호화 : 압축이 잘 된다
new Compression(new Encryption(src)) // 암호화 → 압축 : 거의 안 줄어든다
암호화된 데이터는 무작위에 가까워 압축이 먹지 않는다. 같은 부품을 같은 개수로 썼는데 결과가 다르다 — Decorator 를 쓸 때 쌓는 순서가 곧 설계인 이유다. 재시도·타임아웃·서킷브레이커를 쌓을 때도 같은 문제가 생기는데, 그쪽은 실무 적용 심화 편에서 자세히 다룬다.
JDK 에서는
var in = new GZIPInputStream(new BufferedInputStream(new FileInputStream(f)));
List<String> ro = Collections.unmodifiableList(list); // 쓰기를 막는 겹
List<String> sync = Collections.synchronizedList(list); // 동기화하는 겹
java.io 가 교과서 예시로 유명하지만 실제로는 쓰기 불편해서 JDK 가 단축 메서드를 따로 뒀다.
BufferedReader r = Files.newBufferedReader(path); // 3중첩을 한 줄로
교과서 예시가 항상 좋은 API 인 것은 아니라는 점을 보여 준다.
Proxy 와 무엇이 다른가
둘 다 같은 인터페이스로 감싸는데 목적이 다르다.
new BufferedInputStream(...) 은 우리가 직접 감쌌으니 Decorator, @Transactional 이 붙은 빈은 스프링이 몰래 감쌌으니 Proxy 다.
함정 — 겹이 쌓이면 스택트레이스가 길어지고 디버깅이 어려워진다. 어느 겹에서 문제가 났는지 찾기 위해 각 데코레이터가 자기 이름을 로그에 남기도록 해 두면 도움이 된다. 그리고
equals/hashCode를 감싼 쪽에 위임하지 않으면 감싼 것과 원본이 다른 객체로 취급되어 컬렉션에서 사고가 난다.
한눈에 정리
- 넷 다 감싸는 모양이다. 구분은 "무엇을 위해 감쌌나" 로 한다
- Adapter — 안 맞는 것을 맞춘다(사후). 자바에서는 객체 어댑터를 쓴다. 대상의 성질까지 바꾸지는 못한다(
Arrays.asList) - Bridge — 두 축을 갈라 곱집합 폭발을 막는다(사전). 3×3 이 9개에서 6개가 된다. JDBC 가 대표
- Composite — 잎과 가지를 같은 타입으로. 재귀를 구조 안으로 넣어 호출부의 분기를 없앤다. 자식 조작은 가지에만 두는 편이 안전하다
- Decorator — 같은 인터페이스를 유지하며 겹을 쌓는다. 순서가 결과를 바꾼다.
equals/hashCode위임을 잊지 않는다 - Decorator vs Proxy — 기능 추가냐 접근 통제냐, 그리고 쓰는 쪽이 조립하느냐 모르느냐
출처 — GoF Design Patterns(1994) §4 Structural Patterns · Java SE 21 API Docs(
InputStreamReader·Arrays.asList·java.awt.Container·java.io) · Head First Design Patterns 3장(Decorator)