8. Composite — 단품이든 세트든 똑같이 계산하고 싶다
작성일:2026.05.21|수정일:2026.07.07|조회수:0

에이든 피자의 시범 운영도 이제 중반에 접어들었다. Decorator 패턴 덕분에 토핑 추가 시스템은 자리를 잡았고, 손님들은 자기만의 괴상한 피자 조합을 만드는 재미에 푹 빠졌다. 하지만 평화도 잠시, 이번에는 메뉴판의 '구성'이 말썽을 부리기 시작했다.
처음에는 피자 단품만 팔았다. 하지만 곧 손님들은 "피자랑 콜라랑 사이드 메뉴를 묶어서 싸게 파는 세트는 없나요?"라고 묻기 시작했다. 그래서 '에이든 패밀리 세트', '커플 세트' 같은 묶음 상품을 만들었다. 그런데 장바구니 시스템을 만들다 보니 코드가 아주 고약해졌다. 장바구니에 담긴 녀석이 단품 피자인지, 아니면 여러 메뉴가 섞인 세트인지에 따라 가격을 계산하는 방식이 완전히 달랐기 때문이다.
// 장바구니 합계를 구하려 할 때 마주하게 되는 불편한 코드let totalPrice = 0;for (const item of cart) { if (item instanceof SinglePizza) { totalPrice += item.getPrice(); } else if (item instanceof ComboSet) { // 세트 메뉴면 그 안의 아이템들을 또 돌면서 합산해야 한다 totalPrice += item.getSubItems().reduce((sum, sub) => sum + sub.getPrice(), 0); }}이런 코드를 보고 있으면 한숨부터 나온다. 만약 '세트 메뉴' 안에 또 다른 '작은 세트'가 포함된다면 어떻게 될까? if-else는 꼬리에 꼬리를 물고, 루프는 중첩될 것이다. 단품이든 세트든 손님 입장에서는 똑같이 장바구니에 담긴 '하나의 주문 항목'일 뿐인데, 개발자인 나는 왜 이 둘을 계속 구분하며 고통받아야 하는 걸까.
무엇이 불편한가
지금의 가장 큰 문제는 단품(Leaf)과 묶음(Composite)을 다루는 방식이 이원화되어 있다는 점이다. 사용하는 쪽에서는 항상 "너는 혼자니, 아니면 무리니?"라고 물어봐야 한다. 물어보는 행위 자체도 번거롭지만, 더 큰 문제는 이 질문이 장바구니 합계 계산뿐만 아니라 영수증 출력, 주방 전달용 요약본 생성 등 시스템 곳곳에서 반복된다는 것이다.
이런 구조는 변화에 취약하다. 새로운 형태의 묶음 상품이 나오거나, 묶음 방식이 바뀌면 그 질문을 던지던 모든 곳을 찾아가서 코드를 수정해야 한다. 책상 서랍을 정리할 때 작은 상자 안에 더 작은 상자를 넣는 것은 자연스러운 일인데, 코드에서는 그 상자들을 하나하나 열어서 내용물을 확인해야만 전체 무게를 잴 수 있는 꼴이다.
우리는 '부분'과 '전체'를 똑같은 눈으로 바라볼 방법이 필요했다. 상자 안에 무엇이 들어있든, 그 상자 자체를 하나의 물건으로 취급하고 "너의 가격이 얼마니?"라고 물으면 알아서 답해주는 구조 말이다.
Composite 패턴
Composite 패턴은 객체들을 트리 구조로 구성하여, 개별 객체와 복합 객체를 동일하게 다룰 수 있게 하는 패턴이다. GoF의 원래 의도는 다음과 같다.
객체들의 관계를 트리 구조로 구성하여 부분-전체 계층을 표현한다. 사용자가 단일 객체와 복합 객체를 모두 동일하게 다루도록 한다.
이 패턴의 핵심은 인터페이스의 통일이다. 단품 메뉴인 SingleItem도, 여러 메뉴를 포함하는 ComboSet도 모두 똑같은 OrderComponent 인터페이스를 구현한다.
interface OrderComponent { getName(): string; getPrice(): number;}이렇게 하면 사용하는 쪽에서는 장바구니에 담긴 녀석이 단품인지 세트인지 알 필요가 없다. 그저 getPrice()를 호출하기만 하면 된다. 단품은 자기 가격을 말할 것이고, 세트는 자기가 품고 있는 아이템들의 가격을 합쳐서 말할 것이다. 세트 안에 또 세트가 있다면? 그 안쪽 세트도 똑같이 행동할 테니 자연스럽게 재귀적으로 전체 합계가 계산된다.
부분과 전체를 하나로 묶기
이제 에이든 피자의 메뉴들을 트리 구조로 만들어보자. 먼저 잎(Leaf)에 해당하는 단품 클래스다.
class SingleItem implements OrderComponent { constructor(private name: string, private price: number) {} getName() { return this.name; } getPrice() { return this.price; }}다음은 이들을 묶어주는 복합 객체(Composite), ComboSet이다. 이 클래스는 OrderComponent들을 배열로 관리하며, 인터페이스의 메서드들을 재귀적으로 처리한다.
class ComboSet implements OrderComponent { private items: OrderComponent[] = []; constructor(private name: string, private discount: number = 0) {} add(item: OrderComponent) { this.items.push(item); } getPrice(): number { // 내부 아이템들의 가격을 모두 합친 뒤 할인을 적용한다 const total = this.items.reduce((sum, item) => sum + item.getPrice(), 0); return total - this.discount; } getName(): string { return `${this.name} (${this.items.map(i => i.getName()).join(', ')})`; }}이제 우리는 아주 복잡한 주문 구조도 가볍게 만들어낼 수 있다. '피자+콜라' 세트를 만들고, 그걸 '윙+봉' 세트와 묶어서 '에이든 빅 페스티벌 세트'를 만드는 식이다. 전체 가격을 알고 싶다면 가장 바깥쪽 세트의 getPrice()만 부르면 된다. 코드는 짧아졌고, 구조는 선명해졌다. 중첩된 루프와 instanceof의 늪에서 드디어 탈출한 것이다.
TypeScript 포인트
Composite 패턴을 TypeScript로 구현할 때 가장 큰 고민거리는 "자식 관리 메서드(add, remove)를 어디에 둘 것인가"이다.
두 가지 선택지가 있다. 첫째는 공통 인터페이스인 OrderComponent에 넣는 것이다. 이렇게 하면 단품과 세트를 정말 완벽하게 똑같이 취급할 수 있다(투명성). 하지만 단품인 SingleItem에 add() 메서드가 있는 것은 논리적으로 어색하다. 호출하면 에러를 던지거나 아무 일도 안 하게 해야 하는데, 이는 "인터페이스에 정의된 것은 모두 의미 있게 동작해야 한다"는 원칙에 어긋난다.
둘째는 ComboSet 클래스에만 넣는 것이다. 이렇게 하면 단품에 억지로 메서드를 만들 필요가 없어 안전하다(안전성). 하지만 사용하는 쪽에서 add()를 호출하려면 객체가 ComboSet 타입인지 확인하는 과정이 다시 필요해진다.
에이든 피자에서는 후자의 방식을 택하되, TypeScript의 타입 가드나 클래스 설계를 통해 안전하게 처리하는 것을 권장한다. 굳이 모든 단품에게 "너한테 메뉴 추가할 수 있어?"라고 물어보는 무의미한 과정을 인터페이스에 담고 싶지는 않기 때문이다.
또한 getPrice() 같은 메서드를 구현할 때 Array.prototype.reduce 같은 고차 함수를 쓰면 재귀 구조를 꽤 깔끔하게 표현할 수 있다. 함수형 프로그래밍의 기법이 객체지향 패턴의 뼈대 위에서 근육처럼 붙는 셈이다.
트레이드오프
Composite 패턴은 복잡한 계층 구조를 다룰 때 꽤 큰 편안함을 준다. 클라이언트 코드는 객체가 단일인지 복합인지 신경 쓰지 않아도 되므로 단순해지고, 새로운 종류의 구성 요소를 추가하기도 쉽다. 트리 구조가 깊어지더라도 가격을 묻는 코드는 여전히 root.getPrice() 근처에 머문다.
하지만 설계를 너무 일반화하다 보면 제약 사항을 걸기가 어려워질 수 있다. 예를 들어 "에이든 패밀리 세트에는 피자가 반드시 포함되어야 한다"거나 "세트 안에 세트는 최대 2단계까지만 가능하다" 같은 규칙을 넣으려면, 런타임에 타입을 체크하는 로직이 다시 들어가야 한다. 모든 것을 똑같이 취급하기로 한 대가로, 서로의 차이를 구분하는 데 더 많은 노력이 필요해지는 것이다.
또한 트리 구조가 너무 거대해지면 재귀 호출에 따른 성능 고민이 생길 수도 있다. 하지만 피자가게 장바구니가 메모리를 걱정해야 할 만큼 깊어지는 날이 온다면, 그건 코드 걱정보다 세무 조사를 먼저 걱정해야 할 정도로 장사가 잘된다는 뜻이니 기쁘게 받아들여도 좋을 것이다.
전체 코드
// Component: 단품과 세트가 모두 따라야 하는 인터페이스interface OrderComponent { getName(): string; getPrice(): number; print(indent: string): void;}// Leaf: 단품 메뉴class SingleItem implements OrderComponent { constructor(private name: string, private price: number) {} getName() { return this.name; } getPrice() { return this.price; } print(indent: string) { console.log(`${indent}- ${this.name}: ${this.price.toLocaleString()}원`); }}// Composite: 세트 메뉴 (묶음 상품)class ComboSet implements OrderComponent { private items: OrderComponent[] = []; constructor( private name: string, private discount: number = 0 ) {} add(item: OrderComponent) { this.items.push(item); } remove(item: OrderComponent) { const index = this.items.indexOf(item); if (index > -1) this.items.splice(index, 1); } getPrice(): number { const total = this.items.reduce((sum, item) => sum + item.getPrice(), 0); return Math.max(0, total - this.discount); } getName(): string { return this.name; } print(indent: string) { console.log(`${indent}[+] ${this.name} (할인: ${this.discount.toLocaleString()}원)`); for (const item of this.items) { item.print(indent + " "); } }}class CompositeDemo { run(): void { const pepperoni = new SingleItem("페퍼로니 피자", 17000); const margherita = new SingleItem("마르게리타 피자", 15000); const coke = new SingleItem("콜라", 2500); const wings = new SingleItem("버팔로 윙", 8000); const pizzaCokeSet = new ComboSet("피콜 세트", 1000); pizzaCokeSet.add(margherita); pizzaCokeSet.add(coke); const fullPartySet = new ComboSet("에이든 파티 세트", 3000); fullPartySet.add(pizzaCokeSet); fullPartySet.add(pepperoni); fullPartySet.add(wings); const cart = new ComboSet("전체 장바구니"); cart.add(fullPartySet); cart.add(new SingleItem("갈릭 디핑 소스", 500)); console.log("=== 주문 내역 ==="); cart.print(""); console.log("================"); console.log(`최종 합계: ${cart.getPrice().toLocaleString()}원`); }}new CompositeDemo().run();장바구니까지 완성되면서 에이든 피자의 주문 시스템은 이제 꽤 복잡한 조합도 받아낼 준비를 마쳤다. 단품이든 세트든, 토핑이 가득한 커스텀 피자든 같은 방식으로 계산되고 관리된다.
이제 우리는 주문을 넘어 주방 안쪽으로 눈을 돌려본다. 피자를 만드는 과정을 가만히 지켜보니 한 가지 흥미로운 점이 발견됐다. 마르게리타든 페퍼로니든, 결국 피자를 만드는 큰 흐름은 똑같다는 것이다. 도우를 밀고, 소스를 바르고, 토핑을 얹고, 오븐에 굽는다. 이 공통된 '레시피의 뼈대'를 코드로 우아하게 표현하고, 각 피자마다 다른 세부 단계만 살짝 바꿔 끼울 수는 없을까?
댓글
댓글을 불러오는 중...