1. Factory Method — 메뉴가 늘어날수록 if-else가 길어진다
작성일:2026.05.21|조회수:31

아, 누가 개발자 인생의 끝은 치킨집이라 했던가. 나는 치킨 대신 피자가 더 좋은 관계로 피자집을 차리기로 했다. 그 이름도 에이든 피자! 장사를 시작하기는 했지만 정식 오픈이라 부르기에는 아직 민망하고, 그렇다고 집에서 냉동 피자를 데워 먹는 수준도 아니어서 일단 "시범 운영"이라는 이름을 붙였다. 뭐든 이름을 붙이면 일이 조금 더 그럴듯해 보인다. 실제로 그럴듯해지는지는 별개의 문제지만.
장사 첫날인 만큼 메뉴는 간단하게 준비했다. 마르게리타와 페퍼로니. 손님이 메뉴 이름을 말하면 매장 시스템이 피자 객체를 만들고, 준비하고, 굽고, 자르고, 포장하면 끝이다. 그래서 첫 번째 PizzaStore 클래스는 꽤 귀엽게 시작했다.
class PilotPizzaStore {
orderPizza(type: string): Pizza {
const pizza = this.createPizza(type);
pizza.prepare();
pizza.bake();
pizza.cut();
pizza.box();
return pizza;
}
private createPizza(type: string): Pizza {
if (type === 'margherita') return new MargheritaPizza();
if (type === 'pepperoni') return new PepperoniPizza();
throw new Error(`알 수 없는 피자: ${type}`);
}
}처음 이 코드를 썼을 때는 별문제가 없어 보였다. 클래스 안에 주문 흐름이 있고, 피자를 만드는 메서드도 같은 클래스 안에 있다. if 두 개 정도야 코드라기보다 주문 메모에 가깝다. 이 정도 분기를 보고 디자인 패턴을 꺼내는 사람은 피자 한 판을 자르기도 전에 도우의 탄성 계수부터 계산할 지도 모르겠다.
문제는 빌어먹을 손님들이 메뉴판에는 없는 오만가지를 주문하기 시작했다는 것이었다. 누군가는 BBQ 치킨 피자를 찾았고, 누군가는 포테이토 피자를 찾았고, 누군가는 "여기 혹시 불고기 피자는 안 해요?"라고 물었다. 하지 않는다고 말하면 끝날 일이지만, 장사를 시작한 사람의 마음이 그렇게 단단하지는 않다. 돈은 벌 수 있을 때 벌어야 한다. 메뉴판은 조금씩 두꺼워졌고, createPizza()도 그에 맞춰 살이 붙기 시작했다.
class PilotPizzaStore {
orderPizza(type: string): Pizza {
const pizza = this.createPizza(type);
pizza.prepare();
pizza.bake();
pizza.cut();
pizza.box();
return pizza;
}
private createPizza(type: string): Pizza {
if (type === 'margherita') return new MargheritaPizza();
if (type === 'pepperoni') return new PepperoniPizza();
if (type === 'bbq-chicken') return new BBQChickenPizza();
if (type === 'potato') return new PotatoPizza();
if (type === 'bulgogi') return new BulgogiPizza();
throw new Error(`알 수 없는 피자: ${type}`);
}
}이 정도를 문제라고 부를 수는 없다. 하지만 새 메뉴를 추가할 때마다 매번 이미 잘 동작하고 있는 PilotPizzaStore를 다시 열어야 한다는 사실은 좀 찜찜하다. 잘 돌아가는 코드를 건드릴 때마다 아무 이유 없이 뭔가가 망가질 것 같은 기분이 드는데, 그 기분은 헛된 것이 아니다.
가끔은 실제로 망가진다.
시범 운영 셋째 날, 부산에서 지인이 팝업 형태로 에이든 피자 부산 지점을 열어보자고 했다. 서울 본점은 평범한 토마토 소스를 쓰지만, 부산 지점은 해산물 소스를 쓰기로 했다. 그러면 같은 마르게리타라도 서울 마르게리타와 부산 마르게리타는 다른 객체가 된다. 이제 PizzaStore는 메뉴뿐 아니라 지점까지 알아야 했다.
type Branch = 'seoul' | 'busan';
class BranchAwarePizzaStore {
constructor(private readonly branch: Branch) {}
orderPizza(type: string): Pizza {
const pizza = this.createPizza(type);
pizza.prepare();
pizza.bake();
pizza.cut();
pizza.box();
return pizza;
}
private createPizza(type: string): Pizza {
if (this.branch === 'seoul' && type === 'margherita') return new SeoulMargheritaPizza();
if (this.branch === 'seoul' && type === 'pepperoni') return new PepperoniPizza();
if (this.branch === 'seoul' && type === 'bbq-chicken') return new BBQChickenPizza();
if (this.branch === 'busan' && type === 'margherita') return new BusanMargheritaPizza();
if (this.branch === 'busan' && type === 'pepperoni') return new PepperoniPizza();
throw new Error(`${this.branch} 지점에 없는 메뉴: ${type}`);
}
}으앙앙아! 이쯤 되면 이 클래스는 피자를 주문받는 클래스라기보다 에이든 피자의 운영 방침 전체를 암기한 직원에 가깝다. 메뉴가 무엇인지도 알고, 지점이 어디인지도 알고, 어떤 지점에서 어떤 재료를 쓰는지도 알고 있다. 사람으로 치면 유능한 직원이지만, 코드로서는 이보다 두려운 게 없다. (넌 너무 많은 걸 알고 있군, 탕!)
무엇이 불편한가
지금 불편한 점은 단순히 if-else가 길다는 것이 아니다. 길다는 건 증상이고, 진짜 문제는 결정이 한 클래스에 몰려 있다는 것이다. BranchAwarePizzaStore는 주문 흐름도 알고, 메뉴별 구체 클래스도 알고, 지점별 차이도 안다. 이름은 Store인데, 하는 일은 거의 작은 본사다.
이 구조에서는 변화가 생길 때마다 같은 클래스를 다시 열게 된다. 새 메뉴가 생겨도 열고, 부산 지점의 레시피가 바뀌어도 열고, 제주 지점을 새로 열어도 열게 된다. 시범 운영 기간에는 이런 일이 하루에도 몇 번씩 생길 수 있다. 결국 BranchAwarePizzaStore는 가게의 모든 변화가 지나가는 개찰구가 되고, 우리는 그 개찰구 앞에서 매번 줄을 선다.
겉으로 보기에는 orderPizza()가 깔끔하다는 점도 함정이다. 주문 흐름은 정돈되어 보이지만, 그 깔끔함은 결정 로직을 createPizza() 안으로 밀어 넣어서 얻은 것이다. 책상 위가 깨끗해졌다고 해서 방이 정리된 것은 아니다. 장롱에 처박아둔 물건들은 언젠가 드러나게 되어있다.
Factory Method 패턴
Factory Method는 이런 상황에서 등장한다. GoF의 원래 의도는 다음과 같다.
객체를 생성하는 인터페이스를 정의하되, 어떤 클래스를 인스턴스화할지는 서브클래스가 결정하도록 한다.
처음 보면 조금 딱딱한 문장이다. 하지만 에이든 피자의 상황으로 바꾸면 뜻이 꽤 단순해진다. 피자를 주문받고, 준비하고, 굽고, 자르고, 포장하는 흐름은 모든 지점이 공유한다. 다만 "마르게리타 주문이 들어왔을 때 정확히 어떤 마르게리타 객체를 만들 것인가"는 지점마다 다르다. 그러니 공통 흐름은 부모 클래스가 가지고, 구체적인 피자 생성 결정은 각 지점 클래스가 맡으면 된다.
Factory Method의 핵심은 객체 생성을 한곳에 더 멋지게 숨기는 것이 아니다. 결정해야 하는 위치를 바꾸는 것이다. 지금까지는 BranchAwarePizzaStore 한 클래스가 모든 결정을 떠안았다. Factory Method를 적용하면 SeoulPizzaStore는 서울 메뉴를 결정하고, BusanPizzaStore는 부산 메뉴를 결정한다. 서울 직원에게 부산 해산물 소스 레시피를 외우라고 하지 않는 셈이다. 서로에게도 좋고, 정신 건강에도 좋다.
패턴의 등장인물은 네 가지다. Pizza는 Product다. 실제 피자인 SeoulMargheritaPizza, BusanMargheritaPizza, PepperoniPizza 같은 클래스들은 ConcreteProduct다. PizzaStore는 피자를 주문받는 공통 흐름을 정의하는 Creator고, SeoulPizzaStore와 BusanPizzaStore는 어떤 피자를 만들지 결정하는 ConcreteCreator다. 이름은 거창하지만 관계는 단순하다. PizzaStore는 피자를 어떻게 주문 처리하는지는 알고, 무엇을 만들지는 모른다. 그 모르는 부분을 서브클래스가 채운다.
먼저 피자의 공통 모양을 정한다
리팩토링의 첫 단계는 모든 피자가 따라야 하는 모양을 정하는 것이다. 어떤 피자든 이름이 있고, 준비하고, 굽고, 자르고, 포장할 수 있어야 한다. 마르게리타든 페퍼로니든 부산식 해산물 마르게리타든 이 흐름에서 벗어나지는 않는다. 적어도 피자라고 부르려면 상자에 담을 수는 있어야 한다.
interface Pizza {
readonly name: string;
prepare(): void;
bake(): void;
cut(): void;
box(): void;
}여기서 Pizza를 구체 클래스가 아니라 인터페이스로 둔 것이 중요하다. 주문 처리 흐름은 실제 클래스가 SeoulMargheritaPizza인지 BusanMargheritaPizza인지 알 필요가 없다. 그 객체가 prepare(), bake(), cut(), box()를 제공한다면 충분하다. 피자를 먹는 손님이 밀가루 브랜드까지 알 필요는 없는 것과 비슷하다. 물론 어떤 손님은 물어본다. 그런 손님은 언젠가 3편 Abstract Factory에서 다시 만날 가능성이 높다.
이제 구체적인 피자들을 만든다. 서울 마르게리타는 토마토 소스를 쓰고, 부산 마르게리타는 해산물 소스를 쓴다.
class SeoulMargheritaPizza implements Pizza {
readonly name = '서울 마르게리타';
prepare() {
console.log('도우를 펴고 토마토 소스와 모차렐라를 올린다');
}
bake() {
console.log('230도 오븐에서 12분 굽는다');
}
cut() {
console.log('삼각형으로 여섯 조각을 낸다');
}
box() {
console.log('에이든 피자 서울 본점 상자에 담는다');
}
}
class BusanMargheritaPizza implements Pizza {
readonly name = '부산 마르게리타';
prepare() {
console.log('도우를 펴고 해산물 소스와 모차렐라를 올린다');
}
bake() {
console.log('230도 오븐에서 13분 굽는다');
}
cut() {
console.log('삼각형으로 여섯 조각을 낸다');
}
box() {
console.log('에이든 피자 부산 지점 상자에 담는다');
}
}아직 패턴의 핵심은 나오지 않았다. 지금까지 한 일은 피자의 공통 인터페이스를 만들고, 구체 피자 클래스를 몇 개 준비한 것이다. 중요한 변화는 이제부터다. 주문 흐름은 공통으로 유지하되, 어떤 피자를 만들지는 지점에게 맡겨야 한다.
주문 흐름은 부모가, 생성 결정은 자식이
에이든 피자의 모든 지점은 주문을 받으면 같은 순서를 따른다. 피자를 만들고, 준비하고, 굽고, 자르고, 포장한다. 이 흐름 자체는 서울이든 부산이든 크게 다르지 않다. 그러니 이 흐름은 PizzaStore라는 추상 클래스에 둔다.
abstract class PizzaStore {
orderPizza(type: string): Pizza {
const pizza = this.createPizza(type);
console.log(`\n[주문 접수] ${pizza.name}`);
pizza.prepare();
pizza.bake();
pizza.cut();
pizza.box();
return pizza;
}
protected abstract createPizza(type: string): Pizza;
}여기서 orderPizza()는 구체 피자 클래스를 직접 생성하지 않는다. 대신 this.createPizza(type)을 호출한다. 그런데 createPizza()는 이 클래스 안에 구현되어 있지 않다. protected abstract로 선언되어 있을 뿐이다. 말하자면 PizzaStore는 "나는 주문 처리 절차는 알지만, 실제 메뉴판은 각 지점이 알아서 채워라"라고 말하는 셈이다.
이 메서드가 바로 Factory Method다. 패턴 이름 때문에 별도의 PizzaFactory 클래스를 떠올리기 쉽지만, Factory Method에서 공장은 반드시 독립된 객체일 필요가 없다. 여기서는 PizzaStore의 createPizza() 메서드가 공장 역할을 한다. 조금 더 정확히 말하면, 공장 문은 부모 클래스에 달려 있고, 그 문 뒤에서 실제로 무엇을 꺼낼지는 자식 클래스가 정한다.
이제 서울 본점은 서울 메뉴만 알면 된다.
class SeoulPizzaStore extends PizzaStore {
protected override createPizza(type: string): Pizza {
switch (type) {
case 'margherita':
return new SeoulMargheritaPizza();
case 'pepperoni':
return new PepperoniPizza();
case 'bbq-chicken':
return new BBQChickenPizza();
default:
throw new Error(`서울 본점에 없는 메뉴: ${type}`);
}
}
}부산 지점은 부산 메뉴만 알면 된다. 부산 마르게리타가 해산물 소스를 쓴다는 사실은 이제 부산 지점 안에 갇힌다. 서울 본점은 그 사실을 몰라도 되고, 몰라야 한다. 모르는 것이 설계상 더 건강한 경우가 있다. 현실에서는 무지가 죄가 될 때도 있지만, 객체지향에서는 적절한 무지가 결합도를 낮춘다.
class BusanPizzaStore extends PizzaStore {
protected override createPizza(type: string): Pizza {
switch (type) {
case 'margherita':
return new BusanMargheritaPizza();
case 'pepperoni':
return new PepperoniPizza();
default:
throw new Error(`부산 지점에 없는 메뉴: ${type}`);
}
}
}이제 사용하는 쪽에서는 지점 객체를 만들고 orderPizza()만 호출하면 된다. 같은 'margherita'를 주문해도 서울 본점에서는 서울 마르게리타가 나오고, 부산 지점에서는 부산 마르게리타가 나온다. 주문 처리 흐름은 바뀌지 않았고, 중앙의 거대한 지점 분기 클래스도 사라졌다.
const seoulStore = new SeoulPizzaStore();
seoulStore.orderPizza('margherita');
const busanStore = new BusanPizzaStore();
busanStore.orderPizza('margherita');이 차이가 작아 보일 수 있다. 하지만 설계에서 작은 위치 이동이 큰 차이를 만든다. 이전에는 "모든 지점의 모든 메뉴"를 한 클래스가 알아야 했다. 이제는 각 지점이 자기 메뉴만 알면 된다. 서울 메뉴를 고칠 때 부산 코드를 열지 않아도 되고, 부산 메뉴를 추가할 때 서울 본점의 주문 흐름을 건드리지 않아도 된다. 장사가 조금 덜 무섭다.
TypeScript로 구현할 때 알아야 할 것들
Factory Method를 TypeScript로 구현할 때 가장 먼저 마주하는 선택은 interface와 abstract class다. Pizza는 인터페이스로 충분하다. 주문 처리 코드 입장에서는 피자가 어떤 데이터와 메서드를 제공하는지만 중요하지, 공통 구현을 물려받을 필요는 없다. 그래서 interface Pizza는 "피자라면 최소한 이런 일을 할 수 있어야 한다"는 계약으로 딱 맞다.
반면 PizzaStore는 인터페이스로 만들기 어렵다. orderPizza()라는 공통 구현을 실제로 가지고 있어야 하기 때문이다. PizzaStore를 인터페이스로 만들면 서울 지점과 부산 지점이 orderPizza()를 각각 구현해야 하고, 그러면 prepare → bake → cut → box 흐름이 여러 곳에 복사된다. 중복된 주문 흐름은 나중에 조리 순서가 바뀔 때 똑같이 여러 번 고쳐야 한다. 그런 코드는 언젠가 한 군데만 수정되고 나머지는 잊힌다. 그리고 잊힌 코드는 대체로 가장 바쁜 금요일 저녁에 존재감을 드러낸다.
protected abstract createPizza()도 눈여겨볼 만하다. abstract는 이 메서드를 자식 클래스가 반드시 구현해야 한다는 뜻이고, protected는 외부에서 직접 호출하지 말라는 뜻이다. 손님이 store.createPizza('margherita')를 호출해서 피자 객체만 덜렁 가져가는 흐름은 에이든 피자가 원하는 주문 방식이 아니다. 손님은 orderPizza()로 주문해야 하고, 그래야 준비·굽기·자르기·포장이라는 절차가 빠지지 않는다.
반환 타입을 Pizza로 고정하는 것도 중요하다. SeoulPizzaStore.createPizza() 내부에서는 new SeoulMargheritaPizza()를 반환하지만, 바깥에서는 그 객체를 Pizza로만 다룬다. 이 덕분에 orderPizza()는 구체 클래스 이름을 하나도 몰라도 된다. 구체적인 것은 지점 안에 숨기고, 바깥에는 공통 인터페이스만 드러낸다. 타입스크립트의 타입 시스템은 이런 경계를 코드에 직접 표시해준다.
메뉴 타입을 string으로 둘지, 'margherita' | 'pepperoni' | 'bbq-chicken' 같은 유니언 타입으로 둘지도 고민할 수 있다. 내부 코드에서만 메뉴를 다룬다면 유니언 타입이 오타를 줄여준다. 다만 실제 주문은 외부 입력에서 들어오는 경우가 많고, 그 값은 결국 런타임에 검증해야 한다. 이 글에서는 패턴의 흐름을 보여주기 위해 string을 사용했다. 시범 운영 첫날부터 메뉴 코드 검증 시스템까지 완벽했다면 에이든 피자는 이미 이 글의 도움 없이도 잘 살고 있었을 것이다.
그래서 이게 항상 좋은 선택인가
Factory Method를 적용하면 생성 결정이 각 지점으로 이동한다. 공통 주문 흐름은 안정적으로 유지되고, 지점별 메뉴 차이는 지점 클래스 안에서 처리된다. 서울 본점이 부산 레시피를 몰라도 되고, 부산 지점이 서울의 BBQ 치킨 판매 여부를 신경 쓰지 않아도 된다. 코드가 각자의 사정을 각자의 자리에서 처리하기 시작한다.\
하지만 이 패턴이 언제나 필요한 것은 아니다. 메뉴가 두 개뿐이고 지점도 하나뿐이라면 처음의 작은 PilotPizzaStore가 훨씬 낫다. 그 상황에서 추상 클래스와 서브클래스를 만들기 시작하면, 피자 한 판을 만들기 위해 밀가루 농장부터 설립하는 꼴이 될 수 있다. 패턴은 불편함을 해결하기 위해 쓰는 것이지, 코드에 훈장을 달기 위해 쓰는 것이 아니다.
또 하나 조심해야 할 점은 클래스 수가 늘어난다는 것이다. 지점이 늘어나면 PizzaStore의 서브클래스가 늘어나고, 지역별 피자 차이가 커지면 구체 피자 클래스도 늘어난다. 물론 이것은 단점이면서 동시에 장점이다. 책임이 파일과 클래스 단위로 흩어졌다는 뜻이기도 하기 때문이다. 문제는 그 흩어짐이 설계를 명확하게 만드는지, 아니면 파일 탐색기만 풍성하게 만드는지다. 둘은 생각보다 자주 헷갈린다.
그러니 기준은 단순하다. 새 메뉴나 새 지점이 생길 때마다 중앙 생성 클래스를 계속 고치고 있다면 Factory Method를 고려할 만하다. 반대로 변화가 거의 없고 분기문도 짧다면, 지금은 그냥 두어도 된다. 디자인 패턴을 배운 뒤 가장 어려운 일은 패턴을 쓰는 것이 아니라, 쓰지 않아도 되는 순간에 참는 것이다.
전체 코드
interface Pizza {
readonly name: string;
prepare(): void;
bake(): void;
cut(): void;
box(): void;
}
class SeoulMargheritaPizza implements Pizza {
readonly name = '서울 마르게리타';
prepare() {
console.log('도우를 펴고 토마토 소스와 모차렐라를 올린다');
}
bake() {
console.log('230도 오븐에서 12분 굽는다');
}
cut() {
console.log('삼각형으로 여섯 조각을 낸다');
}
box() {
console.log('에이든 피자 서울 본점 상자에 담는다');
}
}
class BusanMargheritaPizza implements Pizza {
readonly name = '부산 마르게리타';
prepare() {
console.log('도우를 펴고 해산물 소스와 모차렐라를 올린다');
}
bake() {
console.log('230도 오븐에서 13분 굽는다');
}
cut() {
console.log('삼각형으로 여섯 조각을 낸다');
}
box() {
console.log('에이든 피자 부산 지점 상자에 담는다');
}
}
class PepperoniPizza implements Pizza {
readonly name = '페퍼로니';
prepare() {
console.log('도우를 펴고 토마토 소스, 모차렐라, 페퍼로니를 올린다');
}
bake() {
console.log('230도 오븐에서 15분 굽는다');
}
cut() {
console.log('삼각형으로 여덟 조각을 낸다');
}
box() {
console.log('에이든 피자 기본 상자에 담는다');
}
}
class BBQChickenPizza implements Pizza {
readonly name = 'BBQ 치킨';
prepare() {
console.log('도우를 펴고 BBQ 소스, 닭고기, 양파를 올린다');
}
bake() {
console.log('230도 오븐에서 17분 굽는다');
}
cut() {
console.log('삼각형으로 여덟 조각을 낸다');
}
box() {
console.log('에이든 피자 기본 상자에 담는다');
}
}
abstract class PizzaStore {
orderPizza(type: string): Pizza {
const pizza = this.createPizza(type);
console.log(`\n[주문 접수] ${pizza.name}`);
pizza.prepare();
pizza.bake();
pizza.cut();
pizza.box();
return pizza;
}
protected abstract createPizza(type: string): Pizza;
}
class SeoulPizzaStore extends PizzaStore {
protected override createPizza(type: string): Pizza {
switch (type) {
case 'margherita':
return new SeoulMargheritaPizza();
case 'pepperoni':
return new PepperoniPizza();
case 'bbq-chicken':
return new BBQChickenPizza();
default:
throw new Error(`서울 본점에 없는 메뉴: ${type}`);
}
}
}
class BusanPizzaStore extends PizzaStore {
protected override createPizza(type: string): Pizza {
switch (type) {
case 'margherita':
return new BusanMargheritaPizza();
case 'pepperoni':
return new PepperoniPizza();
default:
throw new Error(`부산 지점에 없는 메뉴: ${type}`);
}
}
}
const seoulStore = new SeoulPizzaStore();
seoulStore.orderPizza('margherita');
seoulStore.orderPizza('bbq-chicken');
const busanStore = new BusanPizzaStore();
busanStore.orderPizza('margherita');이제 에이든 피자는 적어도 메뉴 생성 책임을 조금 더 건강한 위치로 옮겼다. 서울 본점과 부산 지점은 각자 자기 메뉴를 알고, 공통 주문 흐름은 PizzaStore가 관리한다. PizzaStore.orderPizza() 안에 있는 prepare → bake → cut → box 흐름은 나중에 9편 Template Method에서 다시 자세히 보게 된다. 사실 지금도 이미 그 냄새가 난다. 디자인 패턴은 가끔 이렇게 서로의 옷자락을 밟고 등장한다.
그런데 시범 운영은 이제 막 시작했을 뿐이다. 손님들은 곧 피자를 더 세밀하게 고르고 싶어 한다. 도우는 씬으로 해달라, 소스는 반만 발라달라, 치즈는 많이 넣어달라, 올리브는 빼달라, 굽기는 바삭하게 해달라. 이 요구사항을 전부 생성자 인자로 밀어 넣는 순간, 우리는 new Pizza(size, dough, sauce, cheese, toppings, bakeLevel, memo) 같은 것을 마주하게 된다. 인자가 일곱 개인 생성자를 마지막으로 호출한 게 언제인지 기억하는 분이라면, 그 기억이 얼마나 유쾌하지 않았는지도 기억할 것이다.
댓글
댓글을 불러오는 중...