시리즈 안내
1편 — 생성 패턴: Abstract Factory, Builder, Prototype, Singleton
2편 — 구조 패턴 ①: Adapter, Bridge, Composite, Decorator (현재 글)
3편 — 구조 패턴 ②: Facade, Flyweight, Proxy
4편 — 행동 패턴: Chain of Responsibility, Command, Iterator, Mediator, Memento
들어가며
1편에서 다룬 생성 패턴이 "객체를 어떻게 만드는가"였다면, 구조 패턴은 "이미 만들어진 객체들을 어떻게 엮는가"를 다룹니다.
구조 패턴을 관통하는 도구는 하나입니다. 합성(composition). 네 패턴 모두 "다른 객체를 필드로 들고 있으면서, 그 객체에 일을 넘긴다"는 골격을 공유합니다. 차이는 오직 의도에 있습니다.
| 패턴 | 감싸는 이유 |
|---|---|
| Adapter | 인터페이스가 안 맞아서 맞추려고 |
| Bridge | 두 축이 각각 늘어나서 분리하려고 |
| Composite | 개별과 묶음을 똑같이 다루려고 |
| Decorator | 기능을 덧붙이려고 |
코드 모양이 비슷하니 이름을 헷갈리기 쉽습니다. "왜 감쌌는가"를 기준으로 읽어 주세요.
1. Adapter — 만능 어댑터
한 줄 요약: 인터페이스가 다른 것들을 이어 붙인다.
어떤 문제를 푸나
110V 기기를 220V 콘센트에 꽂을 때 쓰는 그 어댑터가 맞습니다. 코드에서는 이런 상황입니다.
- 외부 SDK를 붙였는데 메서드 이름과 반환 타입이 우리 코드 컨벤션과 전혀 다르다
- 레거시 모듈을 새 인터페이스에 끼워 넣어야 하는데, 레거시를 고칠 수 없다(또는 고치면 다른 곳이 깨진다)
핵심 전제는 "둘 중 최소 한쪽은 내가 고칠 수 없다"입니다. 양쪽 다 고칠 수 있으면 그냥 인터페이스를 맞추면 되지, 어댑터를 만들 이유가 없습니다.

구현
사내 표준 인터페이스가 이렇게 있다고 하죠.
interface MessageSender {
fun send(to: String, text: String): Boolean
}
그런데 새로 계약한 SMS 업체의 SDK는 이렇게 생겼습니다. 우리가 손댈 수 없는 코드입니다.
// 외부 SDK — 수정 불가
class LegacySmsClient {
fun dispatch(payload: SmsPayload): SmsResult
}
data class SmsPayload(val receiver: String, val content: String, val senderNo: String)
data class SmsResult(val code: String, val message: String)
사이에 어댑터를 하나 끼웁니다.
class SmsSenderAdapter(
private val client: LegacySmsClient,
private val senderNo: String,
) : MessageSender {
override fun send(to: String, text: String): Boolean {
val result = client.dispatch(
SmsPayload(receiver = to.replace("-", ""), content = text, senderNo = senderNo)
)
return result.code == SUCCESS_CODE
}
companion object {
private const val SUCCESS_CODE = "0000"
}
}
어댑터가 하는 일을 뜯어보면 세 가지입니다. 타입 변환(파라미터 → SmsPayload), 데이터 정규화(하이픈 제거), 결과 해석(코드 문자열 → Boolean). 이 지저분한 변환 로직이 서비스 코드 곳곳에 흩어지지 않고 한 클래스에 갇힙니다. 업체를 바꾸면 어댑터만 새로 씁니다.
코틀린스럽게
메서드가 하나뿐이라면 클래스를 만들 것도 없습니다. fun interface로 선언해 두면 람다로 어댑팅됩니다.
fun interface MessageSender {
fun send(to: String, text: String): Boolean
}
val sender = MessageSender { to, text ->
client.dispatch(SmsPayload(to.replace("-", ""), text, senderNo)).code == "0000"
}
확장 함수로 "변환기"를 붙이는 방식도 코틀린에서 흔합니다. 외부 클래스에 우리 쪽 인터페이스로 넘어오는 통로를 하나 뚫는 셈입니다.
fun LegacySmsClient.asMessageSender(senderNo: String): MessageSender =
MessageSender { to, text ->
dispatch(SmsPayload(to.replace("-", ""), text, senderNo)).code == "0000"
}
// 사용
val sender = legacyClient.asMessageSender(senderNo = "025553333")
주의점
- 어댑터는 번역만 해야 합니다. 재시도, 캐싱, 로깅이 들어가기 시작하면 그건 Decorator 또는 Facade의 일입니다.
- 어댑터 안에
if가 늘어난다면 두 인터페이스의 개념이 애초에 안 맞는다는 신호입니다. 억지로 맞추기 전에 도메인을 다시 보세요.
2. Bridge — 두 축의 분리
한 줄 요약: "무엇을 하는가"와 "어떻게 하는가"를 떼어내 각각 자유롭게 확장한다.
어떤 문제를 푸나
알림 시스템을 만든다고 해 봅시다. 알림 종류가 주문 접수 / 배송 시작 / 마케팅 세 가지, 전송 채널이 푸시 / 이메일 / 카카오 알림톡 세 가지입니다.
이걸 상속으로 풀면 이렇게 됩니다.
OrderPushNotification, OrderEmailNotification, OrderKakaoNotification,
ShippingPushNotification, ShippingEmailNotification, ShippingKakaoNotification,
MarketingPushNotification, ...
3 × 3 = 9개. 채널을 하나 추가하면 12개, 알림 종류를 추가하면 16개. 곱셈으로 늘어납니다. 이걸 클래스 폭발이라고 부릅니다.
Bridge는 두 축을 별개의 계층으로 쪼개서 곱셈을 덧셈으로 바꿉니다. 3 × 3 = 9가 3 + 3 = 6이 되고, 채널 하나 추가는 클래스 하나 추가로 끝납니다.

두 계층이 인터페이스 하나로만 연결되어 있어, 어느 쪽이 늘어나도 반대쪽은 손대지 않습니다.
구현
// --- 구현 계층 (Implementor): "어떻게 보내는가" ---
interface NotificationChannel {
fun deliver(userId: String, title: String, body: String)
}
class PushChannel(private val fcm: FcmClient) : NotificationChannel {
override fun deliver(userId: String, title: String, body: String) {
fcm.push(userId, title, body)
}
}
class KakaoAlimtalkChannel(private val kakao: KakaoClient) : NotificationChannel {
override fun deliver(userId: String, title: String, body: String) {
kakao.sendAlimtalk(userId, templateCode = "ORDER_01", text = "$title\n$body")
}
}
// --- 추상화 계층 (Abstraction): "무엇을 보내는가" ---
abstract class Notification(private val channel: NotificationChannel) {
protected fun dispatch(userId: String, title: String, body: String) =
channel.deliver(userId, title, body)
abstract fun send(userId: String, params: Map<String, String>)
}
class OrderNotification(channel: NotificationChannel) : Notification(channel) {
override fun send(userId: String, params: Map<String, String>) {
dispatch(
userId = userId,
title = "주문이 접수되었습니다",
body = "주문번호 ${params["orderId"]} 건이 정상 접수되었습니다.",
)
}
}
class ShippingNotification(channel: NotificationChannel) : Notification(channel) {
override fun send(userId: String, params: Map<String, String>) {
dispatch(
userId = userId,
title = "상품이 발송되었습니다",
body = "송장번호 ${params["trackingNo"]}로 배송이 시작되었습니다.",
)
}
}
조합은 런타임에 자유롭게 만듭니다.
val notification = OrderNotification(KakaoAlimtalkChannel(kakaoClient))
notification.send(userId, mapOf("orderId" to "A-1001"))
// 사용자 설정에 따라 채널만 갈아끼운다
val channel = if (user.prefersKakao) KakaoAlimtalkChannel(kakaoClient) else PushChannel(fcmClient)
OrderNotification(channel).send(userId, params)
코틀린스럽게
구현 계층 인터페이스에 메서드가 하나뿐이라면 함수 타입으로 대체할 수 있습니다.
class OrderNotification(
private val deliver: (userId: String, title: String, body: String) -> Unit,
) {
fun send(userId: String, orderId: String) =
deliver(userId, "주문이 접수되었습니다", "주문번호 $orderId 건이 정상 접수되었습니다.")
}
OrderNotification(fcmClient::push).send(userId, "A-1001")
다만 메서드가 두 개 이상으로 늘어날 낌새가 보이면 인터페이스를 유지하는 편이 낫습니다.
Strategy와 뭐가 다른가요
코드 모양이 사실상 같아서 가장 많이 나오는 질문입니다. 차이는 의도와 수명입니다.
- Strategy(행동 패턴): 알고리즘을 교체하는 게 목적. 같은 객체가 실행 중에 전략을 바꾸기도 합니다. "정렬 방식을 빠른정렬에서 병합정렬로 바꾼다."
- Bridge(구조 패턴): 두 계층이 각각 독립적으로 성장하도록 설계 단계에서 축을 나눈 것. 보통 생성 시점에 한 번 묶이고 끝입니다.
한 문장으로: Strategy는 "무엇으로 바꿀까"의 문제, Bridge는 "어디에 선을 그을까"의 문제입니다.
3. Composite — 트리 조립기
한 줄 요약: 개별 객체와 묶음 객체를 클라이언트가 구분 없이 다루게 한다.
어떤 문제를 푸나
이커머스 장바구니를 생각해 봅시다. 담긴 것이 단일 상품일 수도 있고, 여러 상품을 묶은 세트 상품일 수도 있습니다. 세트 안에 또 다른 세트가 들어갈 수도 있고요.
이때 총액 계산 코드가 이렇게 되면 곤란합니다.
// 안 좋은 예
val total = items.sumOf { item ->
when (item) {
is Product -> item.price
is Bundle -> item.products.sumOf { it.price } * (1 - item.discountRate) // 중첩 세트는?
else -> 0
}
}
중첩 깊이가 늘어날 때마다 계산 코드가 깨집니다. Composite는 "단일도 묶음도 똑같은 인터페이스를 구현한다"로 이 문제를 재귀로 없앱니다.
구현
sealed interface CartItem {
val name: String
fun price(): Long
}
// Leaf — 더 이상 쪼개지지 않는 단말
data class Product(
override val name: String,
val unitPrice: Long,
val quantity: Int = 1,
) : CartItem {
override fun price(): Long = unitPrice * quantity
}
// Composite — 자식을 가지는 묶음
data class Bundle(
override val name: String,
val items: List<CartItem>,
val discountRate: Double = 0.0,
) : CartItem {
override fun price(): Long {
val sum = items.sumOf { it.price() } // 재귀 호출
return (sum * (1 - discountRate)).toLong()
}
}
Bundle.price()가 자식의 price()를 부르고, 그 자식이 또 Bundle이면 다시 내려갑니다. 깊이가 몇 단이든 코드는 그대로입니다.
val cart = Bundle(
name = "장바구니",
items = listOf(
Product("아메리카노 원두 1kg", 28_000),
Bundle(
name = "홈카페 입문 세트",
discountRate = 0.1,
items = listOf(
Product("드리퍼", 15_000),
Product("서버", 22_000),
Bundle(
name = "필터 묶음",
items = listOf(Product("필터 100매", 8_000, quantity = 2)),
),
),
),
),
)
println(cart.price()) // 클라이언트는 구조를 몰라도 된다

깊이가 몇 단이든 price() 호출 한 번으로 전체가 계산됩니다. 호출하는 쪽이 트리 구조를 전혀 신경 쓰지 않는다는 점이 핵심입니다.
코틀린스럽게
sealed interface를 쓰면 when이 exhaustive해집니다. 나중에 CartItem 구현체를 추가했을 때, 분기 처리가 빠진 곳을 컴파일러가 전부 잡아 줍니다.
fun describe(item: CartItem): String = when (item) {
is Product -> "${item.name} x${item.quantity}"
is Bundle -> "${item.name} (${item.items.size}개 구성)"
// else 불필요 — 새 타입 추가 시 컴파일 에러로 알려 준다
}
트리 순회는 sequence로 평탄화해 두면 재사용이 편합니다.
fun CartItem.flatten(): Sequence<Product> = sequence {
when (this@flatten) {
is Product -> yield(this@flatten)
is Bundle -> items.forEach { yieldAll(it.flatten()) }
}
}
val productCount = cart.flatten().count()
주의점
- 순환 참조가 생기면
price()가 무한 재귀에 빠집니다. 트리를 만드는 지점에서 막으세요. 위 예제처럼 불변List로 생성 시점에 조립하면 구조적으로 순환이 불가능합니다. - 깊이가 수천 단이 될 수 있는 구조라면 재귀 대신 명시적 스택 순회를 고려하세요(
StackOverflowError). - Leaf에 자식 관리 메서드(
add,remove)를 억지로 넣고 예외를 던지는 구현이 GoF 원서에 나오는데, 코틀린에서는 그냥sealed로 타입을 나누는 편이 훨씬 안전합니다.
4. Decorator — 기능 덧붙이기
한 줄 요약: 원본을 고치지 않고 기능을 겹겹이 입힌다.
어떤 문제를 푸나
ProductRepository에 캐싱을 넣고 싶습니다. 로깅도 필요합니다. 나중에 재시도도 붙일 것 같고요.
원본 클래스에 다 때려넣으면 조회 로직 하나에 세 가지 관심사가 뒤엉킵니다. 상속으로 풀면 CachingLoggingProductRepository 같은 조합 폭발이 다시 시작되고요.
Decorator는 같은 인터페이스를 구현하면서 원본을 감싸는 방식으로 기능을 층층이 쌓습니다.
코틀린의 킬러 기능: by 위임
자바에서 Decorator가 귀찮았던 이유는, 메서드가 10개인 인터페이스를 감쌀 때 관심 없는 9개까지 전부 위임 코드를 손으로 써야 했기 때문입니다. 코틀린은 by 키워드로 이걸 없앴습니다.
interface ProductRepository {
fun findById(id: Long): Product?
fun findAll(ids: List<Long>): List<Product>
fun save(product: Product)
fun delete(id: Long)
}
class DbProductRepository(private val jdbc: JdbcTemplate) : ProductRepository {
// ... 실제 DB 구현
}
로깅 데코레이터입니다. by delegate 한 줄로 나머지 메서드는 전부 자동 위임됩니다.
class LoggingProductRepository(
private val delegate: ProductRepository,
) : ProductRepository by delegate {
private val log = LoggerFactory.getLogger(javaClass)
override fun findById(id: Long): Product? {
val started = System.nanoTime()
return delegate.findById(id).also {
val tookMs = (System.nanoTime() - started) / 1_000_000
log.info("findById(id={}) took {}ms, hit={}", id, tookMs, it != null)
}
}
// findAll, save, delete는 한 줄도 안 써도 delegate로 넘어간다
}
캐싱 데코레이터도 같은 방식입니다.
class CachingProductRepository(
private val delegate: ProductRepository,
private val cache: Cache<Long, Product>,
) : ProductRepository by delegate {
override fun findById(id: Long): Product? =
cache.getIfPresent(id) ?: delegate.findById(id)?.also { cache.put(id, it) }
override fun save(product: Product) {
delegate.save(product)
cache.invalidate(product.id) // 쓰기 시 캐시 무효화
}
}
이제 조립합니다.
val repository: ProductRepository =
LoggingProductRepository(
CachingProductRepository(
DbProductRepository(jdbc),
cache,
)
)

순서가 의미를 바꾼다
데코레이터는 감싸는 순서에 따라 동작이 달라집니다.
Logging(Caching(Db))— 캐시 히트도 로그에 남습니다. 전체 응답 시간을 측정하고 싶을 때.Caching(Logging(Db))— 캐시 히트 시 로깅이 아예 실행되지 않습니다. DB 접근만 측정하고 싶을 때.
둘 다 맞는 구성입니다. 무엇을 측정하려는지에 따라 고르면 됩니다. 다만 이 순서가 조립 코드에만 드러나고 클래스 이름에는 안 드러난다는 점은 유지보수 시 함정이 될 수 있으니, 조립부에 주석을 남기는 걸 권합니다.
함정: by 위임은 상속이 아니다
이건 코틀린 데코레이터에서 가장 많이 걸리는 지점입니다.
class DbProductRepository : ProductRepository {
override fun findById(id: Long): Product? { /* DB 조회 */ }
override fun findAll(ids: List<Long>): List<Product> =
ids.mapNotNull { findById(it) } // ← 이 findById는 "자기 자신"의 것이다
}
CachingProductRepository(DbProductRepository())를 만들고 findAll()을 호출하면, findAll은 위임되어 DbProductRepository.findAll로 갑니다. 그런데 그 안의 findById는 데코레이터를 거치지 않고 DbProductRepository 자신의 것을 부릅니다. 캐시가 전혀 동작하지 않습니다.
상속이었다면 오버라이드된 메서드가 불렸겠지만, 위임은 그냥 다른 객체에 호출을 넘기는 것뿐입니다. 대책은 두 가지입니다.
- 감싸질 클래스는 내부에서 자기 public 메서드를 호출하지 않게 작성한다(권장).
- 데코레이터에서 해당 메서드도 명시적으로 오버라이드한다.
override fun findAll(ids: List<Long>): List<Product> = ids.mapNotNull { findById(it) }
// 데코레이터의 findById가 불리도록 직접 구현
Proxy와 뭐가 다른가요
3편에서 자세히 다루겠지만 미리 요약하면 이렇습니다. Decorator는 기능을 더하고, Proxy는 접근을 통제합니다. 구조는 똑같고 의도가 다릅니다. 캐싱은 사실 관점에 따라 둘 다로 볼 수 있는 경계 사례입니다.
정리
| 패턴 | 언제 쓰나 | 코틀린에서는 |
|---|---|---|
| Adapter | 못 고치는 인터페이스를 맞춰야 할 때 | fun interface + 람다, 확장 함수 |
| Bridge | 두 축이 곱셈으로 늘어날 때 | 생성자 주입, 단일 메서드면 함수 타입 |
| Composite | 개별과 묶음을 똑같이 다뤄야 할 때 | sealed interface + 재귀, sequence 순회 |
| Decorator | 원본을 안 건드리고 기능을 쌓을 때 | by 위임 — 보일러플레이트 제거 |
네 패턴 모두 "다른 객체를 품는다"는 같은 골격입니다. 코드 리뷰에서 이름을 정할 땐 왜 감쌌는지를 물어보세요. 그 대답이 곧 패턴 이름입니다.
다음 편에서는 나머지 구조 패턴 — Facade, Flyweight, Proxy를 다룹니다. 특히 Proxy는 이번 편의 Decorator와 나란히 놓고 보면 이해가 훨씬 빠릅니다.
Thanks
Hans