13. Strategy — 배달 방식은 바뀔 수 있다, 런타임에도
작성일:2026.05.21|수정일:2026.08.04|조회수:0

에이든 피자의 주방에서 피자가 나오는 속도가 제법 안정화되었다. 이제 문제는 주방 밖, 즉 '손님에게 피자를 전달하는 과정'에서 터져 나왔다. 처음에는 홀에서 드시는 손님만 계셨기에 서빙만 잘하면 그만이었지만, 소문을 듣고 찾아오는 분들이 늘면서 상황이 복잡해졌다. 포장 손님이 생겼고, 곧이어 배달 요청이 빗발쳤다.
우리는 급하게 Order 클래스에 배달 로직을 집어넣기 시작했다. 처음에는 if (type === 'dine-in') 한 줄이면 충분했다. 하지만 자체 배달 기사님을 고용하고, 나중에는 배달 앱 API까지 연동하게 되면서 이 코드는 걷잡을 수 없이 부풀어 올랐다. 새로운 배달 수단이 추가될 때마다 핵심 도메인인 Order 클래스를 매번 열어 수정해야 하는 상황. 이건 우리가 원하던 그림이 아니었다.
class Order { constructor(public id: string, public type: string) {} deliver() { if (this.type === 'dine-in') { console.log(`${this.id}번 주문: 테이블로 서빙합니다.`); } else if (this.type === 'takeout') { console.log(`${this.id}번 주문: 포장해서 카운터에 둡니다.`); } else if (this.type === 'own-delivery') { console.log(`${this.id}번 주문: 자체 기사님께 배차합니다.`); } else if (this.type === 'app-delivery') { console.log(`${this.id}번 주문: 배달 앱 API로 배차 요청을 보냅니다.`); } }}이 구조의 문제는 명확하다. 배달 방식은 에이든 피자의 핵심 비즈니스 로직이라기보다는, 상황에 따라 교체 가능한 '수단'에 가깝다. 그런데 지금은 Order 클래스가 세상의 모든 배달 방식을 껴안고 있다. 배달 앱 API가 바뀌거나 새로운 퀵 서비스 업체와 계약할 때마다 우리는 주문 시스템 전체를 다시 컴파일해야 할 판이다.
무엇이 불편한가
지금의 코드는 OCP(Open-Closed Principle), 그러니까 "확장에는 열려 있고 변경에는 닫혀 있어야 한다"는 원칙과 잘 맞지 않는다. 새로운 배달 방식이 추가될 때마다 deliver() 메서드의 if-else 문은 한 층씩 더 깊어질 것이고, 그 안에서 사용하는 외부 API나 구체적인 로직들이 Order 클래스를 오염시킬 것이다.
또한, 이 방식은 런타임에 유연하게 대처하기 어렵다. 손님이 매장에서 먹겠다고 주문했다가 갑자기 포장으로 바꾸겠다고 하면 어떻게 해야 할까? 지금 구조에서는 type 속성을 바꾸는 것 말고는 방법이 없는데, 만약 타입에 따라 필요한 부수 작업이 많다면 코드는 더 지저분해질 것이다. 배달 로직을 단위 테스트하는 것도 고역이다. Order 객체를 통째로 만들어서 복잡한 상태를 설정해야만 겨우 배달 로직 한 줄을 검증할 수 있기 때문이다.
우리는 이 배달 로직을 Order에게서 떼어내고 싶었다. 배달이라는 행위 자체를 하나의 '전략(Strategy)'으로 보고, 필요할 때마다 알맞은 전략을 갈아 끼우는 방식. 주문은 그저 "배달해 주세요"라고 말만 하면 되고, 구체적으로 어떻게 배달할지는 전달받은 전략 객체가 알아서 처리하는 그런 구조가 간절했다.
Strategy 패턴
Strategy 패턴은 알고리즘 군을 정의하고 각각을 캡슐화하여 상호 교체 가능하게 만드는 패턴이다. GoF의 원래 의도는 다음과 같다.
알고리즘 군을 정의하고 각각을 캡슐화하여 서로 교환 가능하게 만들며, 알고리즘을 사용하는 클라이언트와 독립적으로 변경할 수 있게 한다.
에이든 피자에 대입해 보면, DeliveryStrategy라는 인터페이스를 정의하고 이를 구현하는 여러 '배달 전략' 클래스들을 만드는 것이다. Order는 특정 배달 방식에 의존하는 대신 DeliveryStrategy 인터페이스에만 의존한다. 이렇게 하면 Order 코드를 한 줄도 건드리지 않고도 새로운 배달 방식을 무한히 추가할 수 있다.
이 패턴의 핵심은 '합성(Composition)'이다. Order가 배달 기능을 직접 상속받아 구현하는 것이 아니라, 배달 기능을 가진 객체를 '보유'하고 그에게 일을 위임하는 것이다. 덕분에 실행 중에도 전략을 바꿀 수 있다.
배달 전략을 캡슐화하기
먼저 모든 배달 전략의 공통 분모인 DeliveryStrategy 인터페이스를 선언한다.
interface DeliveryStrategy { deliver(orderId: string): void;}이제 각 상황에 맞는 구체적인 전략들을 구현한다. 각 클래스는 오직 자기가 맡은 배달 방식에만 집중한다.
class DineInStrategy implements DeliveryStrategy { deliver(orderId: string) { console.log(`[홀 서빙] ${orderId}번 주문을 테이블로 가져다드립니다.`); }}class TakeoutStrategy implements DeliveryStrategy { deliver(orderId: string) { console.log(`[포장] ${orderId}번 주문을 포장하여 카운터 대기열에 올립니다.`); }}class AppDeliveryStrategy implements DeliveryStrategy { deliver(orderId: string) { console.log(`[배달앱] ${orderId}번 주문의 배차를 외부 API로 요청합니다.`); }}마지막으로 Order 클래스를 수정한다. 이제 Order는 배달 방식이 무엇인지 일일이 알 필요가 없다. 그저 전략 하나를 품고 있다가, 때가 되면 실행할 뿐이다.
class Order { private strategy: DeliveryStrategy; constructor(public id: string, strategy: DeliveryStrategy) { this.strategy = strategy; } // 런타임에 전략을 교체할 수 있는 마법의 메서드 setStrategy(strategy: DeliveryStrategy) { this.strategy = strategy; } deliver() { this.strategy.deliver(this.id); }}이제 우리는 런타임에 전략을 마음껏 갈아 끼울 수 있다. "저... 그냥 싸주세요"라고 말하는 변덕스러운 손님 앞에서도 우리는 당황하지 않고 order.setStrategy(new TakeoutStrategy())만 호출하면 된다.
TypeScript 포인트
TypeScript(그리고 JavaScript) 환경에서 Strategy 패턴을 사용할 때 반드시 클래스를 고집할 필요는 없다. 사실 Strategy 패턴의 본질은 '동작을 값으로 취급하는 것'이다. 일등 함수(First-class function)를 지원하는 TypeScript에서는 함수 타입 그 자체가 훌륭한 인터페이스 역할을 한다.
type DeliveryStrategyFn = (orderId: string) => void;const dineIn: DeliveryStrategyFn = (id) => console.log(`서빙: ${id}`);const takeout: DeliveryStrategyFn = (id) => console.log(`포장: ${id}`);class OrderFn { constructor(public id: string, private deliverFn: DeliveryStrategyFn) {} deliver() { this.deliverFn(this.id); }}이 방식은 훨씬 간결하고, 어떤 면에서는 더 TypeScript답다. 하지만 전략이 단순히 함수 하나로 끝나지 않고 내부 상태를 가져야 하거나, 여러 관련 메서드를 묶어서 관리해야 할 때는 여전히 클래스 기반의 구현이 더 견고한 구조를 제공한다. 에이든 피자에서는 협력 객체들과의 명확한 관계를 위해 기본적으로 클래스 인터페이스를 선호하되, 아주 단순한 로직은 함수형 대안을 고려하기로 했다.
Template Method vs Strategy
9편에서 다뤘던 Template Method 패턴을 기억하는가? 두 패턴 모두 알고리즘의 변하는 부분을 분리한다는 점에서 비슷해 보일 수 있다. 하지만 결정적인 차이가 있다.
| 비교 항목 | Template Method | Strategy |
|---|---|---|
| 구현 방식 | 상속 (Inheritance) | 합성 (Composition) |
| 변경 시점 | 상속으로 변형을 정의하므로 구현 구조가 컴파일 타임에 고정됨 | 런타임에 전략 객체를 교체 가능 |
| 알고리즘 구조 | 뼈대는 고정, 일부 단계만 서브클래스가 구현 | 알고리즘 전체를 전략 객체로 교체 |
| 의존성 | 자식이 부모가 정한 알고리즘 골격을 따름 | Context는 Strategy 추상화에 의존해 위임하고, ConcreteStrategy는 Context와 독립적으로 구현 가능 |
Template Method가 "레시피의 빈칸 채우기"라면, Strategy는 "조리 기구 통째로 바꾸기"에 가깝다. 에이든 피자 주방에서 피자를 만드는 과정(도우-토핑-굽기)은 변하지 않는 골격이 중요하므로 Template Method가 적합했고, 배달 방식은 상황에 따라 완전히 다른 로직이 필요하므로 Strategy가 제격이다. "상속보다는 합성을 사용하라"는 객체지향의 격언을 보여주는 사례에 가깝다.
트레이드오프
Strategy 패턴을 도입하면서 우리는 코드의 유연성을 얻었다. 새로운 배달 방식이 추가되어도 기존 코드는 안전하며, 각 전략은 독립적으로 테스트할 수 있다. Order 클래스는 더 이상 외부 배달 API에 오염되지 않는다.
하지만 세상에 공짜는 없다. 전략이 늘어날수록 관리해야 할 클래스(또는 함수)의 개수가 많아진다. 또한, 사용자가 어떤 전략이 있는지 알아야 하고 적절한 전략을 선택해서 넘겨줘야 한다는 부담이 생긴다. 만약 배달 방식이 앞으로도 딱 두 가지만 존재할 것이 확실하다면, 굳이 Strategy 패턴을 써서 구조를 복잡하게 만드는 대신 단순한 if 문으로 남겨두는 것이 더 현명한 선택일 수도 있다. 에이든 피자는 서울 본점과 부산 지점을 넘어 전국으로 뻗어나갈 야망이 있기에, 이 정도 복잡성은 미래를 위한 투자로 받아들이기로 했다. 기분 좋은지는 나중에 결산해봐야 알겠지만.
전체 코드
/** * Strategy Interface: 배달 전략을 위한 인터페이스 */interface DeliveryStrategy { deliver(orderId: string): void;}/** * Concrete Strategy: 홀 식사 전략 */class DineInStrategy implements DeliveryStrategy { deliver(orderId: string): void { console.log(`[홀 서빙] ${orderId}번 주문: 테이블로 안전하게 서빙을 완료했습니다.`); }}/** * Concrete Strategy: 포장 전략 */class TakeoutStrategy implements DeliveryStrategy { deliver(orderId: string): void { console.log(`[포장] ${orderId}번 주문: 포장 완료 후 카운터 보관함에 배치했습니다.`); }}/** * Concrete Strategy: 배달 앱 연동 전략 */class AppDeliveryStrategy implements DeliveryStrategy { deliver(orderId: string): void { console.log(`[배달앱] ${orderId}번 주문: 배달 파트너 API를 통해 배차 요청을 전송했습니다.`); }}/** * Context: 배달 전략을 사용하는 주문 클래스 */class Order { private strategy: DeliveryStrategy; constructor(public id: string, initialStrategy: DeliveryStrategy) { this.strategy = initialStrategy; console.log(`${this.id}번 주문이 생성되었습니다.`); } /** * 런타임에 전략을 변경한다. */ public setDeliveryStrategy(strategy: DeliveryStrategy): void { console.log(`${this.id}번 주문의 배달 방식이 변경되었습니다.`); this.strategy = strategy; } /** * 현재 설정된 전략에 배달을 위임한다. */ public processDelivery(): void { this.strategy.deliver(this.id); }}// 1. 매장 식사 주문 생성const order1 = new Order("ORD-001", new DineInStrategy());order1.processDelivery();// 2. 처음엔 배달이었으나 포장으로 변경하는 시나리오const order2 = new Order("ORD-002", new AppDeliveryStrategy());order2.processDelivery(); // 일단 배달 요청// 손님이 마음을 바꿔서 직접 가지러 오기로 함order2.setDeliveryStrategy(new TakeoutStrategy());order2.processDelivery();/** * TypeScript 포인트: 함수형 대안 * 아주 단순한 로직이라면 아래와 같이 함수 타입으로도 충분하다. */type SimpleDeliveryFn = (id: string) => void;const droneDelivery: SimpleDeliveryFn = (id) => console.log(`[드론] ${id}번 공중 배송 중...`);// 함수형 전략을 사용하는 주문 클래스 예시class FunctionalOrder { constructor(public id: string, private deliverFn: SimpleDeliveryFn) {} process() { this.deliverFn(this.id); }}const order3 = new FunctionalOrder("ORD-003", droneDelivery);order3.process();배달 방식이 정리되니 에이든 피자의 서비스 범위가 한층 넓어졌다. 이제 우리는 홀, 포장, 배달을 자유자재로 넘나들며 피자를 팔 수 있게 되었다.
그런데 배달 방식이 다양해질수록 매장 내부의 소통이 꼬이기 시작했다. 캐셔는 주방에 주문을 넣어야 하고, 주방은 완성 소식을 기사님께 알려야 하며, 홀 화면에는 진행 상황을 띄워야 한다. 직원들이 서로 직접 전화를 돌리다 보니 누가 누구에게 말했는지, 누구의 말이 맞는지 알 수 없는 혼란이 찾아왔다. 모든 직원이 서로의 연락처를 저장하고 직접 대화하는 대신, 중앙에서 이 모든 소통을 조율해 줄 '관제탑' 같은 존재가 필요해졌다.
댓글
댓글을 불러오는 중...