9. Template Method — 어떤 피자든 만드는 순서는 같다
작성일:2026.05.21|수정일:2026.07.07|조회수:0

에이든 피자의 주방은 오늘도 분주하다. 주문 시스템이 정교해질수록 주방으로 쏟아지는 주문서도 늘어났다. 그런데 며칠 동안 요리사들의 움직임을 가만히 지켜보다 보니 묘한 기시감이 들었다. 마르게리타 요리사든, 페퍼로니 요리사든, 아니면 새로 온 BBQ 치킨 요리사든 하는 짓이 다들 비슷했기 때문이다.
그들은 모두 도우를 펴는 것으로 시작해서, 오븐에 굽고, 피자를 자르는 것으로 끝냈다. 달라지는 것은 중간에 어떤 소스를 바르고 어떤 토핑을 얹느냐 정도였다. 그런데 코드 속 요리사 클래스들은 마치 서로 남남인 것처럼, 각자의 make() 메서드 안에 이 과정을 처음부터 끝까지 다시 적어두고 있었다.
class MargheritaChef { make() { console.log('도우를 정성스럽게 폅니다'); // 반복 console.log('토마토 소스를 바릅니다'); console.log('모차렐라 치즈를 얹습니다'); console.log('오븐에서 15분간 굽습니다'); // 반복 console.log('여섯 조각으로 자릅니다'); // 반복 }}class PepperoniChef { make() { console.log('도우를 정성스럽게 폅니다'); // 복사 & 붙여넣기 console.log('토마토 소스를 바릅니다'); console.log('페퍼로니와 치즈를 얹습니다'); console.log('오븐에서 15분간 굽습니다'); // 복사 & 붙여넣기 console.log('여섯 조각으로 자릅니다'); // 복사 & 붙여넣기 }}이건 명백한 낭비다. 도우를 펴는 법이나 오븐 온도를 조절하는 법이 바뀌면, 우리는 모든 요리사 클래스를 찾아다니며 코드를 수정해야 한다. 어떤 요리사는 실수로 자르는 단계를 빼먹을지도 모르고, 어떤 요리사는 오븐에 넣기도 전에 소스부터 바르는 창조적인 실수를 저지를지도 모른다. 알고리즘의 뼈대는 하나인데, 살을 붙이는 방식이 제각각인 셈이다.
무엇이 불편한가
지금의 코드는 "피자를 만드는 절차"라는 지식이 여러 클래스에 흩어져 있다는 점이 가장 불편하다. 공통된 단계인 prepDough(), bake(), cut()이 모든 Chef 클래스에 복제되어 있다. 중복 코드는 그 자체로도 관리하기 싫은 녀석이지만, 진짜 위험한 건 "순서의 일관성"이 보장되지 않는다는 것이다.
피자 제작의 전체 흐름은 에이든 피자의 비즈니스 규칙이다. 이 규칙이 각 요리사 클래스의 구현에 의존하고 있으면 안 된다. 반대로, 요리사들은 전체 흐름이 어떻게 돌아가는지 고민할 필요 없이 자기가 맡은 세부 단계(소스, 토핑)에만 집중할 수 있어야 한다. 하지만 지금은 모든 요리사가 전체 공정 지도를 각자 주머니에 넣고 다니는 꼴이다.
결국 공정 지도는 하나로 고정되어야 했다. 변하지 않는 부분은 부모가 잡고, 변하는 부분만 자식들이 채워 넣는 구조. 자식들이 전체 흐름을 망가뜨리지 못하게 하면서도, 자기만의 레시피를 넣을 자리는 남겨두는 설계가 필요했다.
Template Method 패턴
Template Method 패턴은 바로 이럴 때 "알고리즘의 뼈대"를 정의하기 위해 사용한다. GoF의 원래 의도는 다음과 같다.
알고리즘의 구조를 메서드에 정의하고, 하위 클래스에서 알고리즘 구조를 변경하지 않고 특정 단계를 재정의할 수 있게 한다.
에이든 피자의 주방에 대입해보면 아주 명쾌하다. BasePizzaChef라는 추상 클래스를 만들고, 여기에 make()라는 거대한 템플릿 메서드를 둔다. 이 메서드는 전체 조리 순서를 엄격하게 관리한다. 도우 펴기 → 소스 바르기 → 토핑 얹기 → 굽기 → 자르기. 여기서 도우 펴기, 굽기, 자르기는 모든 요리사가 공유하는 일반 메서드로 구현하고, 소스 바르기와 토핑 얹기는 자식이 반드시 구현해야 하는 추상 메서드로 남겨두는 것이다.
이 패턴의 등장인물은 둘이다. AbstractClass는 알고리즘의 골격을 정의하는 템플릿 메서드를 가진다. ConcreteClass는 알고리즘의 빈칸을 채우는 구체적인 단계들을 구현한다. 핵심은 제어권이 부모에게 있다는 것이다. 자식은 부모가 호출해줄 때만 일할 수 있다. 이를 흔히 "할리우드 원칙(먼저 전화하지 마세요, 우리가 전화할게요)"이라고 부른다.
레시피의 뼈대를 고정하기
리팩토링을 시작해보자. 먼저 모든 요리사의 부모가 될 BasePizzaChef를 만든다.
abstract class BasePizzaChef { // 이것이 템플릿 메서드다. 순서는 절대 변하지 않는다. public make(): void { this.prepDough(); this.addSauce(); this.addToppings(); this.bake(); this.cut(); this.afterBake(); // 선택적으로 사용할 수 있는 훅(Hook) } private prepDough() { console.log('도우를 펴서 준비합니다.'); } private bake() { console.log('화덕에서 바삭하게 굽습니다.'); } private cut() { console.log('삼각형 조각으로 자릅니다.'); } // 자식이 반드시 구현해야 하는 단계 protected abstract addSauce(): void; protected abstract addToppings(): void; // 선택적으로 구현할 수 있는 단계 (Hook) protected afterBake(): void { // 기본적으로는 아무것도 하지 않는다. }}여기서 make() 메서드는 공정의 설계도다. 조리 순서가 바뀌지 않도록 보장한다. prepDough(), bake(), cut()은 내부적으로 처리되는 공통 작업이므로 private으로 숨겼다. 반면 addSauce()와 addToppings()는 자식이 채워야 하므로 protected abstract로 열어두었다.
재미있는 부분은 afterBake()다. 이건 "훅(Hook)"이라고 부르는 메서드다. 추상 메서드와 달리 부모가 이미 빈 구현을 가지고 있어서, 자식이 원할 때만 오버라이드하면 된다. 예를 들어 BBQ 피자는 다 굽고 나서 소스를 한 번 더 발라야 하는데, 그럴 때 이 훅을 사용하면 템플릿의 구조를 깨지 않고도 기능을 추가할 수 있다.
이제 구체적인 요리사들은 자기 일에만 집중한다.
class MargheritaChef extends BasePizzaChef { protected override addSauce() { console.log('상큼한 토마토 소스를 바릅니다.'); } protected override addToppings() { console.log('신선한 모차렐라 치즈와 바질을 올립니다.'); }}class BBQChickenChef extends BasePizzaChef { protected override addSauce() { console.log('달콤 짭짤한 BBQ 소스를 바릅니다.'); } protected override addToppings() { console.log('훈제 닭고기와 구운 양파를 올립니다.'); } // 훅을 사용하여 다 구운 뒤에 특수 소스를 추가한다 protected override afterBake() { console.log('완성된 피자 위에 허니 머스터드를 살짝 뿌립니다.'); }}이렇게 바꾸면 요리사 클래스에서 반복되던 공정이 사라진다. 요리사들은 더 이상 도우를 펴거나 오븐 온도를 걱정하지 않고, "어떤 소스와 토핑을 쓸 것인가"에만 집중한다. 전체 흐름은 부모가 잡고 있으니, 신입 요리사가 들어와도 조리 순서를 제멋대로 바꿀 가능성이 줄어든다.
TypeScript 포인트
Template Method를 TypeScript로 구현할 때 가장 아쉬운 점은 final 키워드가 없다는 것이다. Java 같은 언어에서는 public final void make()라고 선언해서 자식 클래스가 템플릿 메서드 자체를 오버라이드하는 사고를 원천 봉쇄할 수 있다. 하지만 TypeScript에서는 그런 기능이 기본적으로 제공되지 않는다.
이 문제를 해결하기 위해 관례적으로 주석을 달거나, 템플릿 메서드를 private으로 선언하고 공개된 다른 메서드에서 호출하게 하는 등의 꼼수를 쓰기도 하지만, 완벽하지는 않다. 만약 정말로 엄격하게 막고 싶다면 ts-final 같은 라이브러리를 쓰거나 커스텀 린트 규칙을 만들어야 한다. 시범 운영 중인 에이든 피자에서는 일단 "이 메서드는 건드리지 마세요"라는 주석과 동료 간의 신뢰로 버티기로 했다. 가끔 그 신뢰가 무너져서 피자가 조각나기도 하지만, 그게 또 시범 운영의 묘미 아니겠는가.
또 protected abstract 조합은 TypeScript에서 꽤 쓸 만한 도구다. 자식만 볼 수 있게 하면서 반드시 구현하게 강제하는 이 조합은, 템플릿의 구멍을 뚫어두기에 가장 적합한 형태다. 반환 타입을 정의할 때도 추상 클래스 타입을 사용하면, 나중에 어떤 종류의 요리사가 추가되더라도 주문 처리 시스템은 영향을 받지 않는다.
트레이드오프
Template Method 패턴은 알고리즘의 중복을 제거하고 흐름을 강제하는 데 탁월하다. 공통 코드가 한곳에 모여 있으니 수정이 쉽고, 자식 클래스는 세부 구현에만 집중할 수 있어 코드의 가독성이 좋아진다. "뼈대는 내가 잡을 테니 살은 네가 붙여라"라는 철학은 대규모 시스템에서 일관성을 유지하는 강력한 무기가 된다.
하지만 이 무기는 상속이라는 양날의 검을 쓴다. 부모와 자식이 매우 강하게 결합된다는 뜻이다. 부모 클래스에 단계가 하나 추가되면 모든 자식 클래스가 그 영향을 받는다. 또한 상속 구조가 깊어지면 템플릿 메서드 안에서 어떤 일이 일어나는지 파악하기 위해 여러 파일을 오르내려야 하는 "추적 피로감"이 생길 수 있다.
더 큰 문제는 런타임에 조리 방식을 바꿀 수 없다는 점이다. 요리사가 결정되는 순간 조리법도 고정된다. 만약 상황에 따라 굽는 방식이나 자르는 방식을 동적으로 바꾸고 싶다면, 상속 기반의 Template Method보다는 합성 기반의 Strategy 패턴이 더 나은 선택일 수 있다. 이 둘의 비교는 13편 Strategy 패턴에서 다시 다룰 예정이다.
전체 코드
/** * AbstractClass: 알고리즘의 골격을 정의하는 추상 클래스 */abstract class BasePizzaChef { /** * 템플릿 메서드: 전체 조리 과정을 정의한다. * TypeScript에는 final이 없으므로 오버라이드하지 않도록 주의가 필요하다. */ public make(): void { console.log(`\n--- ${this.constructor.name} 조리 시작 ---`); this.prepDough(); this.addSauce(); this.addToppings(); this.bake(); this.cut(); this.afterBake(); console.log(`--- 조리 완료 ---`); } // 공통 단계: 모든 피자가 동일함 private prepDough() { console.log("공통: 도우를 얇고 둥글게 폅니다."); } private bake() { console.log("공통: 화덕 오븐에서 400도 온도로 10분간 굽습니다."); } private cut() { console.log("공통: 피자 커터로 8조각을 냅니다."); } // 추상 단계: 자식 클래스가 반드시 자기만의 방식으로 구현해야 함 protected abstract addSauce(): void; protected abstract addToppings(): void; /** * 훅(Hook) 메서드: 자식이 필요에 따라 선택적으로 오버라이드한다. */ protected afterBake(): void { // 기본적으로는 아무 작업도 수행하지 않는다. }}/** * ConcreteClass: 마르게리타 전담 요리사 */class MargheritaChef extends BasePizzaChef { protected override addSauce() { console.log("마르게리타: 신선한 토마토 소스를 듬뿍 바릅니다."); } protected override addToppings() { console.log("마르게리타: 생 모차렐라 치즈와 바질 잎을 올립니다."); }}/** * ConcreteClass: 페퍼로니 전담 요리사 */class PepperoniChef extends BasePizzaChef { protected override addSauce() { console.log("페퍼로니: 약간 매콤한 토마토 소스를 바릅니다."); } protected override addToppings() { console.log("페퍼로니: 짭짤한 페퍼로니와 체더 치즈를 가득 올립니다."); }}/** * ConcreteClass: BBQ 치킨 전담 요리사 (훅 사용) */class BBQChickenChef extends BasePizzaChef { protected override addSauce() { console.log("BBQ치킨: 스모키한 BBQ 소스를 골고루 바릅니다."); } protected override addToppings() { console.log("BBQ치킨: 훈제 치킨과 적양파, 할라피뇨를 토핑합니다."); } // 다 구워진 뒤에 소스를 추가로 뿌리는 훅 사용 protected override afterBake() { console.log("BBQ치킨 [HOOK]: 달콤한 마요네즈 드레싱을 격자 무늬로 뿌립니다."); }}// 주방 가동const margheritaChef = new MargheritaChef();margheritaChef.make();const pepperoniChef = new PepperoniChef();pepperoniChef.make();const bbqChef = new BBQChickenChef();bbqChef.make();주방의 조리 공정이 정리되면서 에이든 피자의 주방은 한결 평화로워졌다. 요리사들은 각자 자기 레시피에만 집중하고, 공통된 절차는 부모가 든든하게 지켜준다. 이제 일단 피자는 안정적으로 완성된다.
그런데 피자가 완성되면 그 소식을 기다리는 사람들이 한둘이 아니다. 홀 진열 화면에는 "조리 완료"라고 띄워야 하고, 손님 스마트폰으로 알림도 보내야 한다. 배달 기사님께도 호출이 가야 하고, 사장님용 매출 분석 시스템에도 기록을 남겨야 한다. 이 모든 사람에게 요리사가 직접 전화를 돌려야 할까? "피자 다 됐어요!"라고 다섯 군데에 일일이 외치는 건 요리사가 할 짓이 아니다.
댓글
댓글을 불러오는 중...