19. Bridge — 카드·현금·포인트 × 홀·포장·배달 = 조합 폭발
작성일:2026.05.21|수정일:2026.08.04|조회수:0

에이든 피자의 서비스가 다양해지면서, 행복하지만 머리 아픈 고민이 시작됐다. 결제 수단이 '카드'와 '현금'에서 '포인트'까지 늘어났고, 주문 방식도 '홀'과 '포장'에 이어 '배달'이 추가됐다. 처음에는 단순하게 클래스를 만들었다. 카드로 결제하는 배달 주문은 CardDeliveryOrder, 현금으로 결제하는 홀 주문은 CashDineInOrder라고 이름 붙이면서 말이다.
그런데 결제 수단 3가지와 주문 방식 3가지를 조합하려니, 벌써 클래스가 9개가 됐다. 만약 여기서 '카카오페이' 결제가 추가되거나 '예약 주문' 방식이 추가된다면 어떻게 될까? 결제 수단 M개와 주문 방식 N개가 만날 때마다 우리는 M×N개의 클래스를 만들어야 하는 '조합의 늪'에 빠지게 될 것이다.
// 상속으로 해결하려다 마주한 참사class CardDineInOrder extends Order {}class CardTakeoutOrder extends Order {}class CardDeliveryOrder extends Order {}class CashDineInOrder extends Order {}// ... (이하 생략, 이미 쓰기 싫어졌다)무엇이 불편한가
지금의 가장 큰 문제는 '확장의 축'이 두 개인데, 이를 하나의 상속 구조에 억지로 구겨 넣으려 한다는 점이다. 결제 로직이 바뀌면 관련된 모든 조합의 클래스를 열어봐야 하고, 주문 처리 방식이 바뀌어도 마찬가지다. 두 개념이 한 클래스 안에 끈적하게 달라붙어 있어, 어느 하나를 독립적으로 고치기가 어려워졌다.
클래스 이름은 길어지고, 중복 코드는 넘쳐난다. CardDineInOrder와 CardDeliveryOrder 안에는 '카드 결제'라는 똑같은 로직이 복사되어 들어있을 것이다. 이건 상속의 오용이다. 상속은 '기능을 확장'할 때 쓰는 것이지, '조합'을 만들기 위해 쓰는 도구가 아니기 때문이다.
우리는 두 개의 축, 즉 '주문 방식'이라는 추상적인 개념과 '결제 수단'이라는 구체적인 구현을 완전히 분리하고 싶었다. 그리고 그 둘 사이를 필요할 때만 연결해 주는 '다리(Bridge)'를 놓고 싶었다.
Bridge 패턴
Bridge 패턴은 추상화(Abstraction)와 구현(Implementation)을 분리하여, 이 둘이 독립적으로 변화할 수 있게 하는 패턴이다. GoF의 원래 의도는 다음과 같다.
추상화와 구현을 분리하여 둘을 독립적으로 변화시킬 수 있게 한다.
여기서 “추상화”는 상위 수준의 제어 로직(주문 방식)을 의미하고, “구현”은 그 로직에서 사용하는 하부 도구(결제 수단)를 의미한다.
에이든 피자의 상황에 대입해 보자. OrderType(홀, 포장, 배달)은 추상화 계층이다. 이들은 주문을 어떻게 처리할지 큰 틀을 잡는다. 반면 PaymentMethod(카드, 현금, 포인트)는 구현 계층이다. 실제로 돈을 어떻게 받을지 담당한다.
우리는 OrderType이 PaymentMethod를 상속받게 하지 않는다. 대신 OrderType이 PaymentMethod를 자신의 멤버 변수로 '가지게(Has-a)' 만든다. 이것이 바로 두 세계를 잇는 다리다. 이제 DineInOrder 객체를 만들 때 원하는 결제 수단 객체를 생성자로 슥 끼워주기만 하면 된다.
Adapter vs. Bridge: 시점의 차이
16편에서 배운 Adapter와 Bridge는 둘 다 한 객체가 다른 객체를 감싸는 구조라 비슷해 보일 수 있다. 하지만 결정적인 차이는 '언제 쓰느냐'에 있다.
Adapter는 사후 구제용이다. 이미 만들어진 서로 다른 인터페이스들을 어떻게든 연결해야 할 때 쓴다. 배민 API가 우리와 다르게 생겼으니 어쩔 수 없이 맞추는 느낌이다. 반면 Bridge는 사전 설계용이다. 처음부터 "주문 방식과 결제 수단은 각자 독립적으로 확장될 거야"라고 예상하고, 두 계층이 서로를 방해하지 않게 미리 다리를 놓아두는 것이다.
어댑터가 '맞지 않는 조각'을 깎아서 맞추는 것이라면, 브릿지는 처음부터 레고 블록처럼 조립할 수 있게 설계하는 것에 가깝다.
TypeScript 포인트: 제네릭과 타입 안전성
TypeScript에서 Bridge 패턴을 쓸 때 얻을 수 있는 묘미는 '제네릭'이다. 주문 방식 클래스가 어떤 결제 수단을 쓰는지 타입을 고정하고 싶다면 제네릭을 활용할 수 있다.
abstract class OrderType<P extends PaymentMethod> { constructor(protected payment: P) {} // ...}이렇게 하면 DineInOrder가 특정 결제 수단의 고유한 기능(예: 포인트 잔액 조회)을 안전하게 호출할 수 있게 된다. 하지만 대부분의 경우엔 인터페이스인 PaymentMethod에만 의존하게 하여 유연한 구성을 택한다. 이미 만든 주문에서 결제 수단을 바꿔야 한다면 그 동작을 changePaymentMethod()처럼 명시적으로 구현해야 한다. 생성자 합성만으로는 멤버가 저절로 교체되지 않으므로, 코드가 없는 런타임 교체를 주장해서는 안 된다.
트레이드오프
Bridge 패턴의 축복은 'M+N'의 승리다. 클래스 9개가 필요했던 상황이 결제 수단 3개, 주문 방식 3개, 총 6개의 클래스로 해결된다. 새로운 결제 수단이 추가되어도 클래스 하나만 더 만들면 모든 주문 방식과 즉시 조합할 수 있다. 두 축이 서로의 영역을 침범하지 않으니 코드도 훨씬 깔끔해진다.
다만 구조가 복잡해진다는 단점은 있다. 클래스가 하나였을 때보다 객체 생성과 관리가 번거로울 수 있다. 만약 확장의 축이 하나뿐이거나 앞으로 새로운 조합이 생길 가능성이 희박하다면, 굳이 브릿지를 놓는 것은 과잉 설계가 될 수 있다. 하지만 에이든 피자처럼 비즈니스가 계속 확장되는 상황이라면, 이 다리는 미래의 고통을 미리 막아주는 보험에 가깝다.
전체 코드
/** * 1. 구현 계층 (Implementation): 결제 수단 축 */interface PaymentMethod { processPayment(amount: number): void; refund(amount: number): void;}class CardPayment implements PaymentMethod { processPayment(amount: number): void { console.log(`[결제] 카드로 ${amount}원 승인 완료`); } refund(amount: number): void { console.log(`[환불] 카드 결제 ${amount}원 취소 완료`); }}class CashPayment implements PaymentMethod { processPayment(amount: number): void { console.log(`[결제] 현금 ${amount}원 영수증 발행 완료`); } refund(amount: number): void { console.log(`[환불] 현금 ${amount}원 반환 완료`); }}class PointPayment implements PaymentMethod { processPayment(amount: number): void { console.log(`[결제] 포인트 ${amount}점 차감 완료`); } refund(amount: number): void { console.log(`[환불] 포인트 ${amount}점 복구 완료`); }}/** * 2. 추상화 계층 (Abstraction): 주문 방식 축 */abstract class OrderType { // 이것이 바로 다리(Bridge)! 구현체를 합성으로 들고 있는다. constructor(protected payment: PaymentMethod) {} changePaymentMethod(payment: PaymentMethod): void { this.payment = payment; console.log(`[주문] 결제 수단을 ${payment.constructor.name}으로 변경`); } abstract processOrder(menu: string, amount: number): void; cancelOrder(amount: number): void { console.log("주문 취소 절차 시작"); this.payment.refund(amount); }}/** * 정교화된 추상화 (Refined Abstraction) */class DineInOrder extends OrderType { processOrder(menu: string, amount: number): void { console.log(`[홀] ${menu} 주문 - 테이블 서빙 준비`); this.payment.processPayment(amount); }}class DeliveryOrder extends OrderType { processOrder(menu: string, amount: number): void { console.log(`[배달] ${menu} 주문 - 배달 대행 호출`); this.payment.processPayment(amount); }}class TakeoutOrder extends OrderType { processOrder(menu: string, amount: number): void { console.log(`[포장] ${menu} 주문 - 포장지 준비`); this.payment.processPayment(amount); }}/** * 매장 운영 시뮬레이션 */function run() { console.log("--- 다양한 조합의 주문 처리 ---"); // 1. 홀 주문 + 카드 결제 const order1 = new DineInOrder(new CardPayment()); order1.processOrder("콤비네이션 피자", 24000); // 2. 배달 주문 + 포인트 결제 const order2 = new DeliveryOrder(new PointPayment()); order2.processOrder("불고기 피자", 22000); // 3. 포장 주문 + 현금 결제 const order3 = new TakeoutOrder(new CashPayment()); order3.processOrder("치즈 피자", 18000); // 4. 결제 수단 런타임 교체 시뮬레이션 (상속으로는 불가능한 유연성) console.log("\n--- 주문 도중 결제 수단 변경 ---"); const specialOrder = new DineInOrder(new CashPayment()); console.log("(손님이 지갑을 안 가져오셔서 포인트로 결제하시겠답니다)"); // 명시적으로 교체한 뒤, 아직 결제하지 않은 주문을 처리한다. specialOrder.changePaymentMethod(new PointPayment()); specialOrder.processOrder("포인트로 바꾼 피자", 15000);}run();브릿지 패턴 덕분에 에이든 피자는 어떤 조합의 주문도 두렵지 않은 유연한 구조를 갖게 되었다. 결제 수단과 주문 방식이 제각기 성장해도 우리 코드는 평온할 것이다. 그런데 또 다른 고민이 머리를 스친다. 주문이 하루에 수만 건으로 늘어나니, 피자 객체 하나하나마다 들어가는 공통된 재료 정보를 매번 메모리에 올리는 게 부담스러워지기 시작했다. 수만 개의 치즈 객체, 수만 개의 도우 객체... 이들은 어차피 다 똑같이 생겼는데, 굳이 따로따로 만들어야 할까?
댓글
댓글을 불러오는 중...