
Bridge 패턴은 클래스 수가 많아서 쓰는 패턴이 아닙니다. 서로 다른 두 축을 상속 하나로 같이 풀려다 보니 구조가 갑자기 답답해질 때 꺼내는 패턴에 더 가깝습니다.
예를 들어 알림 종류는 일반 알림, 긴급 알림으로 늘어나고, 발송 채널은 이메일, 슬랙, 푸시로 늘어난다고 해보겠습니다. 처음에는 하위 클래스를 몇 개 더 만들면 끝날 것 같지만, 조합이 붙는 순간 클래스 수가 빠르게 불어납니다. 이번 글에서는 이 문제를 Bridge가 어떻게 푸는지, 또 언제는 오히려 과한지까지 정리해보겠습니다.
왜 상속이 답답해질까
Bridge를 처음 볼 때 abstraction, implementation 같은 용어부터 붙잡으면 괜히 어렵게 느껴집니다. 더 단순하게 보면 됩니다. 독립적으로 바뀌는 축이 둘 이상인데 그걸 한 계층에 같이 넣으면 상속이 버거워진다. 이 감각이 출발점입니다.
알림 시스템을 떠올려보겠습니다. 알림의 성격은 일반 알림일 수도 있고 긴급 알림일 수도 있습니다. 그런데 보내는 방식은 이메일일 수도 있고 슬랙일 수도 있고 푸시일 수도 있습니다. 이 두 축은 서로 다른 이유로 바뀝니다. 하나는 비즈니스 성격이고, 다른 하나는 전달 수단입니다.
abstract class Notification {
abstract fun send(title: String, body: String)
}
class GeneralEmailNotification : Notification() {
override fun send(title: String, body: String) {
println("[EMAIL][GENERAL] $title - $body")
}
}
class GeneralSlackNotification : Notification() {
override fun send(title: String, body: String) {
println("[SLACK][GENERAL] $title - $body")
}
}
class UrgentEmailNotification : Notification() {
override fun send(title: String, body: String) {
println("[EMAIL][URGENT] [즉시 확인] $title - $body")
}
}
class UrgentSlackNotification : Notification() {
override fun send(title: String, body: String) {
println("[SLACK][URGENT] @channel [즉시 확인] $title - $body")
}
}처음에는 이 정도도 괜찮아 보입니다. 그런데 푸시 채널이 추가되고, 조용한 시간대용 알림이 추가되고, 운영용 알림까지 생기면 어떻게 될까요. GeneralPushNotification, UrgentPushNotification, OpsSlackNotification 같은 식으로 클래스가 계속 늘어납니다.
독립적으로 바뀌는 두 축의 조합 하나하나를 하위 클래스로 표현하려다 보니 계층이 폭발하는 것이 핵심입니다.
Bridge는 무엇을 나눌까
Bridge는 여기서 한쪽을 포기하자는 패턴이 아닙니다. 알림 종류도 필요하고 발송 채널도 필요합니다. 대신 둘을 한 클래스 계층에 욱여넣지 말고, 각자 따로 확장되게 하자는 쪽에 가깝습니다.
보통 설명에서는 알림 종류 쪽을 abstraction, 발송 채널 쪽을 implementation이라고 부릅니다. 하지만 이름보다 중요한 건 역할입니다. 한쪽은 ‘무슨 알림인가’를 담당하고, 다른 한쪽은 ‘어디로 어떻게 보낼까’를 담당합니다. 둘이 참조로 연결되면, 각 축을 따로 늘릴 수 있습니다.
interface MessageSender {
fun send(title: String, body: String)
}
class EmailSender : MessageSender {
override fun send(title: String, body: String) {
println("[EMAIL] $title - $body")
}
}
class SlackSender : MessageSender {
override fun send(title: String, body: String) {
println("[SLACK] $title - $body")
}
}
class PushSender : MessageSender {
override fun send(title: String, body: String) {
println("[PUSH] $title - $body")
}
}이제 알림 종류는 채널 구현을 상속으로 직접 들고 있지 않고, MessageSender를 받아서 사용합니다. 연결점은 하위 클래스가 아니라 필드 하나입니다. 그래서 이름이 Bridge입니다.
Kotlin 예제로 보기
이번에는 조금 더 실전적으로 써보겠습니다. 알림 종류는 일반 알림과 긴급 알림 두 가지입니다. 채널은 이메일, 슬랙, 푸시 세 가지입니다. 알림 종류는 메시지 포맷을 조정하고, 채널은 실제 전송만 담당합니다.
interface MessageSender {
fun send(title: String, body: String)
}
class EmailSender : MessageSender {
override fun send(title: String, body: String) {
println("EMAIL -> title=$title, body=$body")
}
}
class SlackSender : MessageSender {
override fun send(title: String, body: String) {
println("SLACK -> title=$title, body=$body")
}
}
class PushSender : MessageSender {
override fun send(title: String, body: String) {
println("PUSH -> title=$title, body=$body")
}
}
abstract class Notification(
protected val sender: MessageSender,
) {
abstract fun notify(title: String, body: String)
}
class GeneralNotification(sender: MessageSender) : Notification(sender) {
override fun notify(title: String, body: String) {
sender.send(title = title, body = body)
}
}
class UrgentNotification(sender: MessageSender) : Notification(sender) {
override fun notify(title: String, body: String) {
sender.send(
title = "[긴급] $title",
body = "$body
즉시 확인이 필요합니다.",
)
}
}
fun main() {
val urgentSlack = UrgentNotification(SlackSender())
val generalEmail = GeneralNotification(EmailSender())
val urgentPush = UrgentNotification(PushSender())
urgentSlack.notify("배포 실패", "프로덕션 배포가 실패했습니다.")
generalEmail.notify("주간 리포트", "이번 주 요약을 전달합니다.")
urgentPush.notify("장애 감지", "응답 지연이 임계치를 넘었습니다.")
}읽는 포인트는 단순합니다. UrgentNotification은 슬랙 전용 클래스가 아닙니다. 이메일에도 붙을 수 있고 푸시에도 붙을 수 있습니다. 반대로 SlackSender도 긴급 알림 전용이 아닙니다. 일반 알림과도 조합될 수 있습니다.
이 구조에서는 알림 종류 하나를 추가해도 채널 계층은 그대로 두면 됩니다. 채널 하나를 더 추가해도 알림 종류 계층은 그대로 둡니다. 바로 이 분리가 Bridge의 핵심입니다.
왜 클래스 폭발이 줄어들까
핵심은 조합 수를 표현하는 방식이 바뀐다는 데 있습니다. 상속만으로 풀면 조합 하나가 클래스 하나가 되기 쉽습니다. Bridge로 풀면 조합은 런타임에 객체를 연결하는 선택이 됩니다.
- 일반 알림, 긴급 알림처럼 비즈니스 축은 알림 계층에서 확장한다
- 이메일, 슬랙, 푸시처럼 전달 수단 축은 sender 계층에서 확장한다
- 실제 조합은 생성 시점에 객체를 연결해서 만든다
예를 들어 알림 종류가 4개이고 채널이 5개라고 해보겠습니다. 상속 조합으로 풀면 최악의 경우 20개 하위 클래스를 고민하게 됩니다. Bridge에서는 알림 종류 4개와 채널 5개를 따로 두고 필요할 때 연결하면 됩니다. 물론 클래스 수가 완전히 사라지는 것은 아니지만, 축마다 책임이 분명해지고 증가 방식이 훨씬 덜 거칠어집니다.
Bridge는 클래스 수를 마법처럼 줄이는 패턴이 아니라, 클래스가 늘어나는 이유를 축별로 나눠서 관리 가능한 구조로 바꾸는 패턴이라고 이해하는 편이 더 정확합니다.
Kotlin에서는 뭐가 편할까
Kotlin에서는 인터페이스와 생성자 주입이 간결해서 Bridge 코드가 비교적 담백하게 보입니다. 자바처럼 장황한 보일러플레이트를 많이 쓰지 않아도 되고, 조합 구조를 드러내기 쉽습니다.
또 공식 문서에서 설명하듯 Kotlin은 by 위임 문법을 지원합니다. 이 문법은 구현 상속의 대안으로 자주 쓰이며, Bridge 주변의 전달 코드를 줄이는 데 도움을 줄 수 있습니다. 다만 이번 패턴의 본질은 by 자체가 아니라, 독립 축을 분리하는 설계 판단입니다.
interface MessageSender {
fun send(title: String, body: String)
}
class LoggingSender(
private val delegate: MessageSender,
) : MessageSender by delegate {
override fun send(title: String, body: String) {
println("before send: $title")
delegate.send(title, body)
}
}이 예시는 Bridge 그 자체라기보다, Bridge에서 쓰는 구현 객체를 Kotlin답게 다듬는 보조 도구에 가깝습니다. 패턴의 중심을 문법으로 착각하지 않는 편이 좋습니다.
언제 잘 맞을까
- 서로 독립적으로 변하는 축이 둘 이상 있을 때
- 축 조합마다 하위 클래스를 만들기 시작했을 때
- 한 축의 변경이 다른 축 계층을 자꾸 건드릴 때
- 기능 분류와 구현 수단을 별도로 확장하고 싶을 때
알림 종류와 채널 말고도, 보고서 종류와 출력 포맷, 도형과 렌더링 방식, 원격 제어 기능과 실제 디바이스 구현처럼 두 축이 따로 자라는 문제에서 자주 어울립니다. 중요한 건 두 축이 정말 독립적인지입니다. 억지로 둘로 나누면 오히려 이해하기만 어려워질 수 있습니다.
언제 과할까
축이 하나일 때
실제로는 발송 채널만 다르고 알림 종류는 늘어날 계획이 거의 없다면, 단순한 전략 분리나 인터페이스 하나로도 충분할 수 있습니다. 굳이 Bridge라는 이름에 맞춰 두 계층을 만들 필요는 없습니다.
조합이 거의 안 늘 때
일반 알림 하나와 이메일 채널 하나만 있고 앞으로도 크게 늘 가능성이 없다면, 추상화 두 겹은 부담이 됩니다. 읽는 사람 입장에서는 구조만 복잡해 보일 수 있습니다.
분리 기준이 흐릴 때
비즈니스 규칙과 전달 수단이 정말로 독립적인지 먼저 봐야 합니다. 한쪽 규칙이 항상 다른 한쪽에 강하게 묶여 있다면, 분리해도 결국 다시 얽히게 됩니다. 그런 경우 Bridge보다 다른 구조가 더 자연스러울 수 있습니다.
즉 Bridge는 멋있어 보여서 붙이는 패턴이 아닙니다. 독립 축이 실제로 있을 때만 힘을 씁니다.
Adapter와 차이
Bridge와 Adapter는 둘 다 중간 계층이 있어 보여서 자주 헷갈립니다. 하지만 질문이 다릅니다. Adapter는 이미 있는 코드를 다른 인터페이스에 맞추는 문제를 다루고, Bridge는 처음부터 두 축을 분리해 각각 확장 가능하게 만드는 문제를 다룹니다.
즉 Adapter가 ‘안 맞는 것을 맞추는 번역’에 가깝다면, Bridge는 ‘엉키기 쉬운 구조를 미리 갈라두는 설계’에 가깝습니다. 지난 글의 Adapter와 연결해서 보면 차이가 더 또렷해집니다.
정리
Bridge 패턴을 한 문장으로 정리하면 이렇습니다. 독립적으로 변하는 두 축을 상속 계층 하나에 몰아넣지 말고, 따로 두고 연결하자.
상속 폭발은 클래스 수가 많아서 생기는 문제가 아니라, 다른 이유로 변하는 것들을 한 계층에 같이 묶어서 생기는 문제인 경우가 많습니다. Bridge는 그 얽힘을 풀어주는 방향을 제시합니다. 다만 축이 하나뿐이거나 조합이 거의 늘지 않는 구조라면 과한 추상화가 될 수 있으니, 언제나 문제 크기와 변화 방향부터 먼저 보는 편이 좋습니다.
이전 글인 코틀린 디자인 패턴 (6) – Adapter 패턴으로 기존 코드를 새 인터페이스에 맞추기를 함께 보면, 구조 패턴 안에서도 “맞추는 문제”와 “분리하는 문제”가 어떻게 다른지 더 자연스럽게 비교할 수 있습니다. 공식 참고 자료로는 Kotlin Delegation 문서와 Refactoring.Guru의 Bridge 설명을 함께 보면 좋습니다.