Skip to content
ioob.dev
Go back

디자인 패턴 7편 — Adapter, Facade, Composite: 구조를 정리하는 패턴들

· 13분 읽기
Design Pattern 시리즈 (7/8)
  1. 디자인 패턴 1편 — 패턴을 배우는 이유
  2. 디자인 패턴 2편 — Strategy와 State
  3. 디자인 패턴 3편 — Observer와 Command
  4. 디자인 패턴 4편 — Template Method와 Chain of Responsibility
  5. 디자인 패턴 5편 — Factory Method, Abstract Factory, Builder
  6. 디자인 패턴 6편 — Decorator와 Proxy
  7. 디자인 패턴 7편 — Adapter, Facade, Composite: 구조를 정리하는 패턴들
  8. 디자인 패턴 8편 — Singleton, Iterator, Prototype: 자주 쓰지만 오해도 많은 패턴들
Table of contents

Table of contents

구조를 정리한다는 것

지금까지 다룬 패턴들은 대부분 행동(Behavioral)이나 생성(Creational)에 초점이 있었다. 객체가 어떻게 행동할지, 어떻게 만들어질지를 고민하는 패턴들이었다. 이번 편에서 다루는 세 패턴은 결이 다르다. 구조(Structural) 패턴이다.

구조 패턴은 이미 존재하는 클래스나 객체를 어떻게 조합할지를 다룬다. 코드가 동작하는 방식은 바꾸지 않으면서, 클래스 간의 관계를 재배치해서 시스템을 더 쉽게 이해하고 변경할 수 있게 만드는 것이 목표다.

Adapter는 서로 맞지 않는 인터페이스 사이에 번역기를 끼워 넣고, Facade는 복잡한 서브시스템 앞에 간단한 창구를 하나 세운다. Composite는 개별 객체와 그 객체들의 집합을 동일한 방식으로 다루게 해준다. 세 패턴 모두 기존 코드를 건드리지 않으면서 구조를 정리한다는 공통점이 있다.

Adapter — 맞지 않는 퍼즐 조각 끼우기

문제 상황

프로젝트를 진행하다 보면 외부 라이브러리를 가져다 쓰는 상황이 반드시 온다. 문제는, 그 라이브러리의 인터페이스가 우리 시스템이 기대하는 형태와 다르다는 것이다.

예를 들어 우리 시스템은 Logger라는 인터페이스를 쓰고 있는데, 새로 도입한 모니터링 라이브러리는 MonitoringClient라는 전혀 다른 API를 노출한다. 모니터링 라이브러리의 코드를 수정할 수는 없고, 우리 시스템 전체를 고치자니 비용이 너무 크다. 이 간극을 메우는 것이 Adapter(어댑터) 패턴이다.

Adapter는 호환되지 않는 인터페이스를 연결하는 중간 계층이다. 전기 콘센트에 꽂는 여행용 어댑터를 생각하면 된다. 한국 플러그를 유럽 콘센트에 꽂으려면 중간에 변환기가 필요하듯, 코드에서도 인터페이스 변환기가 필요할 때가 있다.

객체 어댑터 vs 클래스 어댑터

Adapter를 구현하는 방법은 두 가지다.

객체 어댑터(Object Adapter): 변환 대상 객체를 필드로 들고 있으면서, 클라이언트가 기대하는 인터페이스를 구현한다. 내부에서 해당 객체에 위임하며 호출을 변환한다. 컴포지션(조합) 기반이다.

클래스 어댑터(Class Adapter): 변환 대상 클래스를 상속하면서 동시에 목표 인터페이스를 구현한다. 다중 상속이 필요하므로 Java/Kotlin에서는 인터페이스와 클래스를 조합해야만 가능하다.

두 방식의 구조를 다이어그램으로 비교하면 이렇다.

classDiagram
    class Target {
        <<interface>>
        +request()
    }
    class Adaptee {
        +specificRequest()
    }
    class ObjectAdapter {
        -adaptee: Adaptee
        +request()
    }
    class ClassAdapter {
        +request()
    }

    Target <|.. ObjectAdapter : 구현
    ObjectAdapter --> Adaptee : 위임
    Target <|.. ClassAdapter : 구현
    Adaptee <|-- ClassAdapter : 상속

실무에서는 거의 대부분 객체 어댑터를 쓴다. 클래스 어댑터는 상속을 하나 소비해야 하고, 변환 대상이 바뀌면 어댑터 클래스 자체를 다시 만들어야 하기 때문이다. 객체 어댑터는 필드만 교체하면 되니 유연성이 훨씬 높다.

코드로 보기 — 외부 라이브러리 통합

상황을 구체적으로 만들어보자. 우리 시스템은 NotificationSender라는 인터페이스를 쓰고 있다.

interface NotificationSender {
    fun send(userId: String, message: String)
}

그런데 새로 도입한 외부 라이브러리는 전혀 다른 형태의 API를 제공한다.

// 외부 라이브러리 — 우리가 수정할 수 없다
public class SlackWebhookClient {
    public void postMessage(String channel, String text, String iconEmoji) {
        // Slack API 호출
    }
}

파라미터 이름도 다르고, 메서드 이름도 다르고, 인자 개수도 다르다. 이 간극을 Adapter로 연결한다.

class SlackNotificationAdapter(
    private val slackClient: SlackWebhookClient,
    private val channelResolver: (String) -> String
) : NotificationSender {

    override fun send(userId: String, message: String) {
        val channel = channelResolver(userId)
        slackClient.postMessage(channel, message, ":bell:")
    }
}

SlackNotificationAdapterNotificationSender 인터페이스를 구현하면서 내부적으로 SlackWebhookClient에 위임한다. userId를 Slack 채널로 변환하는 로직은 channelResolver 함수로 외부에서 주입받았다. 이렇게 하면 채널 매핑 로직이 바뀌어도 어댑터를 수정할 필요가 없다.

사용하는 쪽에서는 어댑터의 존재를 알 필요 없이 NotificationSender만 의존한다.

class OrderService(private val notifier: NotificationSender) {

    fun completeOrder(orderId: String, userId: String) {
        // 주문 처리 로직...
        notifier.send(userId, "주문 ${orderId}이 완료되었습니다.")
    }
}

OrderService는 Slack인지, 이메일인지, SMS인지 모른다. 인터페이스만 알 뿐이다.

어댑터가 필요한 순간들

Adapter 패턴이 빛을 발하는 대표적인 상황을 정리하면 이렇다.

주의할 점도 있다. 어댑터가 너무 많아지면 “이 호출이 실제로 어디로 가는 거지?”를 추적하기 어려워진다. 단순한 이름 변경 정도라면 어댑터 없이 인터페이스를 통일하는 편이 낫다. 진짜 인터페이스가 호환되지 않을 때만 쓰는 것이 원칙이다.

Facade — 복잡한 것 앞에 간단한 문 하나

서브시스템이 복잡해지면

시스템이 커지면 하나의 기능을 수행하기 위해 여러 클래스를 조합해야 하는 상황이 생긴다. 예를 들어 주문을 처리하려면 재고를 확인하고, 결제를 요청하고, 배송을 예약하고, 알림을 보내야 한다. 각각의 서비스는 잘 분리되어 있지만, 이걸 조합하는 클라이언트 코드가 복잡해진다.

Facade(퍼사드) 패턴은 복잡한 서브시스템 앞에 단순화된 인터페이스를 제공하는 패턴이다. 건물의 정면(facade)이 내부 구조를 감추듯, 클라이언트는 Facade 하나만 바라보면 된다.

flowchart LR
    C["클라이언트"] --> F["Facade"]
    F --> S1["재고 서비스"]
    F --> S2["결제 서비스"]
    F --> S3["배송 서비스"]
    F --> S4["알림 서비스"]

    style F fill:#f9f,stroke:#333,stroke-width:2px

코드로 보기 — 주문 처리 Facade

서브시스템을 구성하는 개별 서비스들이 있다. 각각은 자기 역할만 한다.

class InventoryService {
    fun reserve(productId: String, quantity: Int): Boolean {
        // 재고 확인 및 예약
        return true
    }
    fun release(productId: String, quantity: Int) {
        // 예약 취소
    }
}

class PaymentService {
    fun charge(userId: String, amount: Long): String {
        // 결제 처리, 트랜잭션 ID 반환
        return "txn-12345"
    }
    fun refund(transactionId: String) {
        // 환불 처리
    }
}

class ShippingService {
    fun schedule(orderId: String, address: String): String {
        // 배송 예약, 추적 번호 반환
        return "track-67890"
    }
}

class NotificationService {
    fun sendOrderConfirmation(userId: String, orderId: String) {
        // 주문 확인 알림 발송
    }
}

이 서비스들을 직접 조합하면 클라이언트 코드가 네 개의 서비스를 전부 알아야 한다. 호출 순서도 맞춰야 하고, 실패 시 롤백도 직접 처리해야 한다.

Facade는 이 복잡성을 한 곳에 모아서 감춘다.

class OrderFacade(
    private val inventory: InventoryService,
    private val payment: PaymentService,
    private val shipping: ShippingService,
    private val notification: NotificationService
) {
    fun placeOrder(userId: String, productId: String, quantity: Int,
                   amount: Long, address: String): OrderResult {

        // 1. 재고 확인
        if (!inventory.reserve(productId, quantity)) {
            return OrderResult.OutOfStock
        }

        // 2. 결제
        val txnId = try {
            payment.charge(userId, amount)
        } catch (e: Exception) {
            inventory.release(productId, quantity)
            return OrderResult.PaymentFailed
        }

        // 3. 배송 예약
        val orderId = "order-${System.currentTimeMillis()}"
        val trackingNumber = shipping.schedule(orderId, address)

        // 4. 알림
        notification.sendOrderConfirmation(userId, orderId)

        return OrderResult.Success(orderId, trackingNumber, txnId)
    }
}

sealed class OrderResult {
    data class Success(val orderId: String, val trackingNumber: String,
                       val transactionId: String) : OrderResult()
    data object OutOfStock : OrderResult()
    data object PaymentFailed : OrderResult()
}

클라이언트는 OrderFacade.placeOrder()만 호출하면 된다. 재고 확인, 결제, 배송, 알림의 순서나 실패 처리를 신경 쓸 필요가 없어진다.

val facade = OrderFacade(inventory, payment, shipping, notification)
val result = facade.placeOrder("user-1", "prod-100", 2, 39800L, "서울시 강남구...")

when (result) {
    is OrderResult.Success -> println("주문 완료: ${result.orderId}")
    is OrderResult.OutOfStock -> println("재고 부족")
    is OrderResult.PaymentFailed -> println("결제 실패")
}

Facade의 장점과 남용 경고

Facade의 가장 큰 장점은 결합도 감소다. 클라이언트가 서브시스템 내부 클래스를 직접 의존하지 않으니, 서브시스템 내부가 바뀌어도 Facade의 인터페이스만 유지하면 클라이언트에는 영향이 없다.

또 하나의 장점은 진입 장벽 완화다. 복잡한 서브시스템에 새로 합류한 개발자도 Facade를 먼저 보면 전체 흐름을 빠르게 파악할 수 있다.

하지만 남용하면 문제가 된다. Facade가 너무 많은 것을 감싸기 시작하면 God Object(모든 것을 아는 객체)가 된다. 주문도 하고, 회원 가입도 하고, 통계도 내는 Facade가 만들어지면 그 자체가 복잡성의 원인이 되어버린다.

Facade를 쓸 때 지켜야 할 원칙은 명확하다.

Adapter와 Facade의 차이

둘 다 감싸는 패턴이라 혼동하기 쉽다. 핵심 차이는 의도에 있다.

구분AdapterFacade
의도인터페이스 변환단순화
대상하나의 클래스/인터페이스여러 클래스로 구성된 서브시스템
새 인터페이스?기존 인터페이스에 맞춤새로운 단순 인터페이스 정의
클라이언트가 모르는 것실제 구현체의 API 형태서브시스템의 내부 구조

Adapter는 이미 정해진 인터페이스에 맞추기 위해 변환하고, Facade는 아직 정해진 인터페이스가 없으니 단순한 걸 새로 만들자는 방향이다.

Composite — 부분과 전체를 같은 방식으로

트리 구조의 딜레마

파일 시스템을 생각해보자. 디렉토리 안에는 파일이 있을 수도 있고, 또 다른 디렉토리가 있을 수도 있다. 디렉토리의 크기를 계산하려면 그 안에 있는 모든 파일의 크기를 합산해야 하고, 하위 디렉토리가 있으면 재귀적으로 들어가야 한다.

문제는 이걸 코드로 표현할 때다. 파일과 디렉토리를 완전히 다른 타입으로 만들면, 이 둘을 다루는 코드마다 if (file) ... else if (directory) ... 분기가 필요하다. 타입이 추가될 때마다 분기도 늘어난다.

Composite(컴포지트) 패턴은 이 문제를 해결한다. 개별 객체(Leaf)와 복합 객체(Composite)가 동일한 인터페이스를 구현하게 해서, 클라이언트가 둘을 구분하지 않고 동일하게 다룰 수 있도록 만드는 패턴이다.

트리 구조 다이어그램

Composite 패턴의 구조를 클래스 다이어그램으로 보면 이렇다.

classDiagram
    class Component {
        <<interface>>
        +getSize() Long
        +getName() String
    }
    class File {
        -name: String
        -size: Long
        +getSize() Long
        +getName() String
    }
    class Directory {
        -name: String
        -children: List~Component~
        +getSize() Long
        +getName() String
        +add(component: Component)
        +remove(component: Component)
    }

    Component <|.. File
    Component <|.. Directory
    Directory o-- Component : children

핵심은 DirectoryComponent의 목록을 들고 있으면서, 자기 자신도 Component라는 것이다. 이 자기 참조 구조가 트리를 만든다.

실제 트리 형태를 시각화하면 다음과 같다.

flowchart TB
    ROOT["Directory: project"]
    SRC["Directory: src"]
    TEST["Directory: test"]
    MAIN["File: Main.kt\n(2KB)"]
    UTIL["File: Utils.kt\n(1KB)"]
    T1["File: MainTest.kt\n(3KB)"]

    ROOT --> SRC
    ROOT --> TEST
    SRC --> MAIN
    SRC --> UTIL
    TEST --> T1

project의 크기를 구하면, 재귀적으로 srctest를 타고 내려가서 모든 파일 크기를 합산한다. 6KB가 된다.

코드로 보기 — 파일 시스템

공통 인터페이스를 정의한다. 파일이든 디렉토리든 이름과 크기를 가진다.

interface FileSystemComponent {
    val name: String
    fun getSize(): Long
    fun display(indent: String = "")
}

개별 파일은 Leaf(잎) 노드다. 자식을 가질 수 없고, 크기는 자기 자신의 크기다.

class File(
    override val name: String,
    private val size: Long
) : FileSystemComponent {

    override fun getSize(): Long = size

    override fun display(indent: String) {
        println("${indent}📄 $name (${size}KB)")
    }
}

디렉토리는 Composite 노드다. 자식 목록을 들고 있고, 크기는 자식들의 크기 합이다.

class Directory(
    override val name: String
) : FileSystemComponent {

    private val children = mutableListOf<FileSystemComponent>()

    fun add(component: FileSystemComponent): Directory {
        children.add(component)
        return this
    }

    fun remove(component: FileSystemComponent) {
        children.remove(component)
    }

    override fun getSize(): Long = children.sumOf { it.getSize() }

    override fun display(indent: String) {
        println("${indent}📁 $name (${getSize()}KB)")
        children.forEach { it.display("$indent  ") }
    }
}

이 둘을 조합해서 트리를 만들면 이렇게 된다.

fun main() {
    val project = Directory("project").apply {
        add(Directory("src").apply {
            add(File("Main.kt", 2))
            add(File("Utils.kt", 1))
        })
        add(Directory("test").apply {
            add(File("MainTest.kt", 3))
        })
    }

    project.display()
    println("전체 크기: ${project.getSize()}KB")
}

실행 결과는 다음과 같다.

📁 project (6KB)
  📁 src (3KB)
    📄 Main.kt (2KB)
    📄 Utils.kt (1KB)
  📁 test (3KB)
    📄 MainTest.kt (3KB)
전체 크기: 6KB

project.getSize()를 호출하면, Directory는 자식들에게 getSize()를 위임하고, 그 자식이 또 Directory라면 다시 아래로 위임한다. 재귀가 자연스럽게 발생한다. 클라이언트는 자기가 다루는 것이 파일인지 디렉토리인지 신경 쓸 필요가 없다.

UI 컴포넌트에서의 Composite

Composite 패턴은 UI 프레임워크에서도 자주 볼 수 있다. 버튼, 텍스트 같은 개별 위젯과, 패널이나 레이아웃 같은 컨테이너가 동일한 Component 인터페이스를 구현하는 구조가 전형적인 Composite다.

간단한 UI 컴포넌트 예시를 만들어보자.

interface UIComponent {
    fun render(depth: Int = 0)
}

class Button(private val label: String) : UIComponent {
    override fun render(depth: Int) {
        println("${"  ".repeat(depth)}[Button: $label]")
    }
}

class TextField(private val placeholder: String) : UIComponent {
    override fun render(depth: Int) {
        println("${"  ".repeat(depth)}[TextField: $placeholder]")
    }
}

class Panel(private val title: String) : UIComponent {
    private val children = mutableListOf<UIComponent>()

    fun add(component: UIComponent): Panel {
        children.add(component)
        return this
    }

    override fun render(depth: Int) {
        println("${"  ".repeat(depth)}[Panel: $title]")
        children.forEach { it.render(depth + 1) }
    }
}

패널 안에 버튼과 텍스트 필드가 들어가고, 패널 안에 또 다른 패널이 들어갈 수 있다.

val loginForm = Panel("로그인 폼").apply {
    add(TextField("이메일"))
    add(TextField("비밀번호"))
    add(Panel("버튼 영역").apply {
        add(Button("로그인"))
        add(Button("회원가입"))
    })
}

loginForm.render()

render()를 호출하면 트리를 재귀적으로 순회하면서 전체 UI를 그린다. React의 컴포넌트 트리, Android의 View 계층 구조, Swing의 JComponent 모두 이 원리 위에 서 있다.

Composite를 쓸 때 주의할 점

Composite 패턴이 빛나는 건 트리 구조가 자연스러운 도메인일 때다. 파일 시스템, UI 레이아웃, 조직도, 메뉴 구조 같은 곳에서는 이 패턴이 아주 잘 맞는다.

반면 주의해야 할 지점도 있다.

세 패턴의 공통점

Adapter, Facade, Composite는 모두 GoF(Gang of Four)의 구조 패턴으로 분류된다. 세 패턴에는 흥미로운 공통점이 있다.

기존 코드를 건드리지 않는다. Adapter는 외부 라이브러리를 수정하지 않고 우리 인터페이스에 맞춘다. Facade는 서브시스템 내부를 바꾸지 않고 앞에 간단한 창구를 세운다. Composite는 개별 객체의 구현을 바꾸지 않고 트리 구조로 조합한다. 모두 기존의 것을 감싸거나 조합해서 새로운 구조를 만들어내는 방식이다.

클라이언트의 복잡성을 줄인다. Adapter를 쓰면 클라이언트가 외부 API의 형태를 몰라도 된다. Facade를 쓰면 서브시스템의 내부 클래스를 몰라도 된다. Composite를 쓰면 개별 객체인지 복합 객체인지 몰라도 된다. 세 패턴 모두 클라이언트가 알아야 할 것을 줄이는 방향으로 작동한다.

남용하면 역효과가 난다. 어댑터가 10개씩 쌓이면 호출 경로를 추적하기 어렵고, Facade가 비대해지면 God Object가 되며, Composite가 깊어지면 디버깅이 고통스러워진다. 패턴은 도구이지 목적이 아니라는 원칙을 다시 한 번 떠올릴 필요가 있다.

실무에서의 선택 기준

세 패턴 중 어떤 걸 써야 할지 고민될 때, 질문 하나로 갈림길을 찾을 수 있다.

이 세 패턴은 서로 배타적이지 않다. 실제 프로젝트에서는 Facade 안에서 Adapter를 사용하기도 하고, Composite 노드가 Facade를 통해 외부 시스템과 통신하기도 한다. 패턴을 단독으로 외우는 것보다, 어떤 문제를 풀기 위한 도구인지를 이해하는 것이 더 중요하다.


여기까지 구조를 정리하는 세 가지 패턴을 살펴봤다. Adapter는 맞지 않는 인터페이스 사이의 번역기이고, Facade는 복잡한 서브시스템 앞의 단순한 문이며, Composite는 개별과 전체를 동일하게 다루는 트리 구조다. 다음 편에서는 자주 쓰이지만 오해도 많은 패턴들 — Singleton, Iterator, Prototype — 을 다루면서 시리즈를 마무리한다.

8편: Singleton, Iterator, Prototype


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
디자인 패턴 6편 — Decorator와 Proxy
Next Post
디자인 패턴 8편 — Singleton, Iterator, Prototype: 자주 쓰지만 오해도 많은 패턴들