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

구조 패턴 ① — 맞추고, 나누고, 쌓는다

구조 패턴은 객체를 조합해 더 큰 구조를 만들거나 인터페이스를 맞추는 일곱 개다. 이 편은 앞의 넷을 다룬다 — 안 맞는 걸 맞추고(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 의 ComponentComponentPeer 도 같은 구조로, 플랫폼별 네이티브 위젯을 뒤에 숨긴다.

Adapter 와 무엇이 다른가

AdapterBridge
언제 정하나사후 — 이미 있는 것이 안 맞아서사전 — 설계할 때부터 두 축을 나눔
목적모양을 맞춘다곱집합 폭발을 막는다
인터페이스다른 것 → 기대하는 것두 축이 각자 독립

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.ContainerComponent 를 상속하면서 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 와 무엇이 다른가

둘 다 같은 인터페이스로 감싸는데 목적이 다르다.

  • Decorator기능을 더한다. 여러 겹 쌓는 것이 정상이고, 쓰는 쪽이 조립한다
  • 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)

생성 패턴 — 객체를 만드는 일을 떼어낸다구조 패턴 ② — 감추고, 공유하고, 대신한다