
Decorator 패턴은 기능을 덧붙이고 싶을 때 상속 계층을 더 만드는 대신 래퍼를 겹쳐 붙이는 방식입니다. Kotlin에서 이 감각을 잡아두면 로깅, 압축, 암호화, 캐싱 같은 부가 기능을 훨씬 덜 답답하게 붙일 수 있습니다.
이번 글에서는 먼저 왜 상속이 답답해지는지부터 보고, 그다음 Kotlin 예제로 Decorator 구조를 구현해보겠습니다. 그리고 Kotlin delegation이 어디까지 도와주는지, Proxy와는 무엇이 다른지도 함께 정리하겠습니다.
Decorator 패턴을 먼저 감으로 이해하기
상속은 비슷한 종류를 나눌 때 익숙합니다. 하지만 기능을 하나씩 더 붙여야 할 때는 금방 불편해집니다.
예를 들어 기본 알림 발송기 하나에 로깅을 붙이고 싶고, 압축도 붙이고 싶고, 민감한 메시지에는 암호화도 붙이고 싶다고 해보겠습니다.
- LoggingNotifier
- CompressingNotifier
- EncryptingNotifier
- LoggingCompressingNotifier
- LoggingEncryptingNotifier
- CompressingEncryptingNotifier
- LoggingCompressingEncryptingNotifier
처음에는 몇 개 안 되어 보여도, 기능 축이 늘어날수록 조합 수가 빠르게 불어납니다. Decorator 패턴은 바로 이 지점에서 방향을 바꿉니다.
핵심은 단순합니다. 새 종류를 계속 만드는 대신, 같은 인터페이스를 가진 래퍼 객체를 하나씩 둘러서 기능을 쌓는 것입니다.
Decorator의 핵심은 상속으로 종류를 늘리는 것이 아니라 composition으로 기능을 겹쳐 붙이는 데 있습니다.
상속이 답답해지는 이유
상속이 항상 나쁜 것은 아닙니다. 다만 부가 기능 조합에는 자주 불리합니다.
- 기능 조합마다 하위 클래스가 늘어나기 쉽다
- 어떤 기능만 빼거나 순서를 바꾸기가 어렵다
- 런타임에 조립하기보다 설계 시점에 계층을 고정하게 된다
이 문제는 상속이 종류 분류에는 강하지만, 기능 합성에는 자주 무겁기 때문입니다.
Decorator 패턴 Kotlin 예제
예시는 단순한 메시지 발송기입니다. 기본 발송기 하나에 로깅, 압축, 암호화를 순서대로 붙여보겠습니다.
먼저 공통 인터페이스와 기본 구현을 둡니다.
interface MessageSender {
fun send(message: String)
}
class BasicMessageSender : MessageSender {
override fun send(message: String) {
println("send -> $message")
}
}이제 래퍼의 공통 뼈대를 만듭니다. 이 클래스가 Decorator의 핵심입니다.
open class MessageSenderDecorator(
protected val delegate: MessageSender,
) : MessageSender {
override fun send(message: String) {
delegate.send(message)
}
}중요한 점은 래퍼도 MessageSender이고, 안쪽 객체도 MessageSender라는 점입니다. 그래서 클라이언트는 바깥이 기본 객체인지 래퍼인지 굳이 구분하지 않아도 됩니다.
로깅 기능 붙이기
class LoggingSender(
delegate: MessageSender,
) : MessageSenderDecorator(delegate) {
override fun send(message: String) {
println("[log] before send: length=${message.length}")
delegate.send(message)
println("[log] after send")
}
}압축 기능 붙이기
예제에서는 실제 압축 라이브러리 대신 공백을 줄이는 단순한 형태로 감각만 보여주겠습니다.
class CompressingSender(
delegate: MessageSender,
) : MessageSenderDecorator(delegate) {
override fun send(message: String) {
val compressed = message.replace(" ", "")
delegate.send(compressed)
}
}암호화 기능 붙이기
여기서도 실제 암호화 대신 예시를 위해 문자열을 뒤집는 단순 변환만 사용하겠습니다.
class EncryptingSender(
delegate: MessageSender,
) : MessageSenderDecorator(delegate) {
override fun send(message: String) {
val encrypted = message.reversed()
delegate.send(encrypted)
}
}조합해서 사용하기
fun main() {
val sender: MessageSender = LoggingSender(
CompressingSender(
EncryptingSender(
BasicMessageSender(),
),
),
)
sender.send("Kotlin Decorator Pattern")
}클라이언트는 MessageSender만 바라봅니다. 로깅을 뺄지, 압축을 먼저 할지, 암호화를 뒤로 미룰지는 객체 조합으로 바꿀 수 있습니다.
바로 이 유연성이 Decorator의 핵심입니다.
이 예제에서 꼭 봐야 할 포인트
같은 인터페이스를 공유한다
래퍼와 실제 객체가 모두 같은 인터페이스를 구현합니다. 그래서 감싼 뒤에도 클라이언트 코드는 그대로 유지됩니다.
기능을 층처럼 쌓을 수 있다
로깅 하나만 붙여도 되고, 로깅 뒤에 압축을 붙여도 되고, 암호화를 가장 안쪽에 둘 수도 있습니다. 기능 조합이 클래스 이름이 아니라 객체 연결 순서로 표현됩니다.
순서도 설계의 일부다
압축 후 암호화와 암호화 후 압축은 결과가 다를 수 있습니다. Decorator는 단순히 기능을 많이 붙이는 패턴이 아니라, 어떤 순서로 감쌀지까지 설계에 포함되는 패턴입니다.
상속과 Decorator를 비교하면 무엇이 달라질까
상속으로 접근하면 보통 종류가 늘어납니다. Decorator로 접근하면 역할 조합이 늘어납니다.
- 상속: EmailSenderWithLogging 같은 새 하위 클래스를 만든다
- Decorator: 기존 sender를 LoggingSender로 감싼다
상속은 구조가 빨리 고정됩니다. 반면 Decorator는 필요한 기능을 런타임에 조립하기 쉽습니다.
또 하나 중요한 차이는 변경 범위입니다. 로깅 정책만 바꾸고 싶다면 LoggingSender만 손보면 됩니다. 기본 발송기나 압축 래퍼까지 같이 흔들 필요가 없습니다.
상속이 종류를 늘리는 방식이라면, Decorator는 역할을 겹쳐 붙이는 방식에 가깝습니다.
Kotlin delegation은 어디서 도움이 될까
Kotlin 공식 문서가 설명하듯 delegation은 구현 상속의 대안으로 쓸 수 있고, by 문법으로 전달 코드를 줄여줍니다.
Decorator에서도 이 감각은 유용합니다. 래퍼가 원본 객체의 동작을 대부분 전달하면서 일부만 덧붙일 때, delegation은 보일러플레이트를 줄이는 데 도움이 됩니다.
interface Reader {
fun read(): String
}
class FileReader : Reader {
override fun read(): String = "raw data"
}
class LoggingReader(
private val inner: Reader,
) : Reader by inner {
override fun read(): String {
println("[log] read start")
return inner.read().also { println("[log] read end") }
}
}다만 여기서 조심할 점이 있습니다. by 문법을 썼다고 자동으로 Decorator가 되는 것은 아닙니다. 중요한 것은 전달 문법이 아니라 왜 감쌌는지라는 의도입니다.
Decorator와 Proxy는 어디서 갈릴까
둘 다 같은 인터페이스를 감싸기 때문에 처음 보면 비슷합니다. 하지만 중심 질문이 다릅니다.
- Decorator: 기능을 더 붙이려는가
- Proxy: 접근을 대신하거나 제어하려는가
예를 들어 캐시 추가, 로깅 추가, 압축 추가는 Decorator 설명에 잘 맞습니다.
반면 지연 로딩, 원격 호출 대리, 접근 권한 검사처럼 원본 접근 자체를 조정하는 쪽은 Proxy 설명에 더 가깝습니다.
실무 코드에서는 두 의도가 섞일 수 있습니다. 그래서 모양보다 목적을 먼저 보는 편이 안전합니다.
언제 특히 잘 맞을까
- 기본 객체는 유지하고 부가 기능만 여러 조합으로 붙이고 싶을 때
- 기능을 켜고 끄는 조합이 자주 바뀔 때
- 상속 계층보다 역할 체인이 더 자연스러울 때
- 클라이언트는 같은 인터페이스만 보게 하고 싶을 때
반대로 기능 수가 적고 조합도 거의 없으며, 계층이 아주 안정적이라면 상속이 더 단순할 수 있습니다.
실무에서 주의할 점
Decorator는 유연하지만, 래퍼가 너무 많아지면 읽기가 어려워질 수 있습니다. 특히 순서에 따라 결과가 달라지는 기능은 생성 코드가 복잡해질 수 있습니다.
- 래퍼 순서가 의미를 바꾸는가
- 디버깅할 때 호출 흐름을 추적하기 쉬운가
- 너무 많은 부가 기능을 한 체인에 몰아넣고 있지는 않은가
즉, Decorator의 목표는 패턴 자체가 아니라 변경을 작게 만들고 확장을 조립 가능하게 만드는 데 있습니다.
정리
Decorator 패턴을 한 문장으로 줄이면 이렇습니다. 기존 객체를 같은 인터페이스의 래퍼로 감싸서 기능을 층처럼 덧붙이는 패턴입니다.
상속이 종류를 늘리는 방식이라면, Decorator는 역할을 겹쳐 붙이는 방식에 가깝습니다. 그래서 로깅, 압축, 암호화처럼 독립적인 부가 기능을 조합해야 할 때 특히 힘을 씁니다.
같이 보면 좋은 글은 코틀린 디자인 패턴 (8) – Composite 패턴으로 트리 구조 다루기, 코틀린 디자인 패턴 (7) – Bridge 패턴은 상속 폭발을 어떻게 줄일까, 상속보다 조합이 더 나은 순간: 객체지향 설계에서 조합을 고르는 기준입니다. 외부 참고 자료로는 Refactoring.Guru의 Decorator 설명, Kotlin Delegation 문서, Refactoring.Guru의 Proxy 설명을 연결해서 보면 이해가 더 또렷해집니다.