3. Abstract Factory — 이탈리아 세트와 미국 세트, 재료부터 다르다
작성일:2026.05.21|조회수:7

에이든 피자에 세트 메뉴가 생겼다. 피자만 팔던 시절에는 메뉴 하나만 잘 만들면 됐지만, 세트 메뉴는 피자와 사이드와 음료가 함께 움직인다. 이탈리아 세트에는 화덕 마르게리타, 브루스케타, 에스프레소가 들어가고, 미국 세트에는 팬 피자, 치킨 윙, 콜라가 들어간다. 여기까지는 아름답다. 문제는 코드가 이 아름다움을 보장하지 못한다는 것이다.
const meal = new Meal(
new ItalianPizza(),
new ChickenWing(),
new Espresso(),
);이 조합은 어딘가 이상하다. 이탈리아 피자와 에스프레소 사이에 치킨 윙이 끼어 있다. 물론 세상에는 치킨 윙과 에스프레소를 함께 먹는 사람이 있을지도 모른다. 하지만 그분의 취향까지 타입 시스템이 보호해야 하는지는 별개의 문제다.
무엇이 불편한가
1편의 Factory Method는 어떤 피자 객체를 만들어야 하는지에 대해 각각의 지점에게 그 책임을 맡겼다. 하지만 세트 메뉴에서는 피자 하나만이 아니라 관련된 객체 여러 개가 함께 생성되어야 한다. 피자, 사이드, 음료가 서로 같은 테마에 속해야 하는데, 각각을 따로 만들면 잘못된 조합이 들어갈 여지가 생긴다.
이 문제는 단순히 코드 스타일의 문제가 아니다. 제품군의 일관성 문제다. 이탈리아 세트라는 이름이 붙었다면 그 안의 구성품은 모두 이탈리아 테마여야 한다. 미국 세트라면 미국 테마여야 한다. 조합 규칙이 도메인에 있다면, 그 규칙은 코드 구조에도 드러나야 한다.
Abstract Factory 패턴
Abstract Factory는 관련된 객체들의 패밀리를 생성하는 인터페이스를 제공한다. 구체 클래스를 클라이언트가 직접 지정하지 않아도, 같은 패밀리에 속한 제품들을 일관되게 만들 수 있게 해준다. GoF의 원래 의도는 다음과 같다.
구체적인 클래스를 지정하지 않고 서로 관련되거나 의존적인 객체들의 패밀리를 생성할 수 있게 한다.
에이든 피자에서는 MealFactory가 이 역할을 맡는다.
interface Pizza {
getName(): string;
}
interface Side {
getName(): string;
}
interface Drink {
getName(): string;
}
interface MealFactory {
createPizza(): Pizza;
createSide(): Side;
createDrink(): Drink;
}이 인터페이스는 "세트 하나를 만들려면 피자, 사이드, 음료를 각각 만들 수 있어야 한다"는 계약이다. 중요한 점은 클라이언트가 이 계약만 본다는 것이다. 이탈리아 세트인지 미국 세트인지는 구체 팩토리가 결정한다.
class ItalianPizza implements Pizza {
getName() { return '화덕 마르게리타'; }
}
class Bruschetta implements Side {
getName() { return '브루스케타'; }
}
class Espresso implements Drink {
getName() { return '에스프레소'; }
}
class ItalianMealFactory implements MealFactory {
createPizza(): Pizza { return new ItalianPizza(); }
createSide(): Side { return new Bruschetta(); }
createDrink(): Drink { return new Espresso(); }
}미국 세트도 같은 인터페이스를 구현한다.
class PanPizza implements Pizza {
getName() { return '팬 페퍼로니 피자'; }
}
class ChickenWing implements Side {
getName() { return '치킨 윙'; }
}
class Cola implements Drink {
getName() { return '콜라'; }
}
class AmericanMealFactory implements MealFactory {
createPizza(): Pizza { return new PanPizza(); }
createSide(): Side { return new ChickenWing(); }
createDrink(): Drink { return new Cola(); }
}이제 세트를 조립하는 코드는 구체 팩토리가 무엇인지 몰라도 된다.
class Meal {
constructor(
readonly pizza: Pizza,
readonly side: Side,
readonly drink: Drink,
) {}
describe(): string {
return `${this.pizza.getName()} + ${this.side.getName()} + ${this.drink.getName()}`;
}
}
class MealAssembler {
assemble(factory: MealFactory): Meal {
return new Meal(
factory.createPizza(),
factory.createSide(),
factory.createDrink(),
);
}
}MealAssembler는 이탈리아 세트를 만드는지 미국 세트를 만드는지 모른다. 하지만 전달받은 팩토리 하나에서 모든 구성품을 만들기 때문에 조합은 어긋나지 않는다. 이것이 Abstract Factory의 핵심이다. 객체 하나가 아니라 관련 객체 묶음을 한 번에 책임진다.
Factory Method와 무엇이 다른가
Factory Method는 보통 "하나의 제품"을 만드는 메서드를 서브클래스가 오버라이드한다. 1편의 PizzaStore.createPizza()가 그 예다. 반면 Abstract Factory는 관련된 여러 제품을 만드는 메서드 묶음을 하나의 팩토리 객체로 제공한다. createPizza(), createSide(), createDrink()가 함께 움직인다.
짧게 기억하면 이렇다. Factory Method는 메서드 하나의 결정권을 서브클래스에 넘기는 패턴이고, Abstract Factory는 제품군 전체의 생성 책임을 팩토리 객체에 맡기는 패턴이다. 둘은 경쟁자가 아니라 친척에 가깝다.
TypeScript로 구현할 때 알아야 할 것들
TypeScript에서는 인터페이스만으로도 제품군의 형태를 꽤 명확하게 표현할 수 있다. MealFactory는 어떤 구체 클래스를 반환하는지 드러내지 않고, Pizza, Side, Drink라는 추상 타입만 반환한다. 클라이언트는 반환된 객체의 공통 동작만 사용한다.
새로운 테마를 추가할 때도 기존 조립 코드는 그대로 둔다. 예를 들어 한국 세트를 추가하고 싶다면 KoreanMealFactory를 만들면 된다. MealAssembler는 수정하지 않는다. 이것이 OCP에 가까운 확장 방식이다.
다만 새 제품 종류를 추가할 때는 이야기가 달라진다. 세트에 디저트를 추가한다면 MealFactory 인터페이스에 createDessert()가 생기고, 모든 ConcreteFactory를 수정해야 한다. Abstract Factory는 제품군 추가에는 강하지만, 제품 종류 추가에는 약하다. 장사가 잘돼서 디저트를 시작하는 순간 모든 공장이 갑자기 회의를 소집하는 구조다.
그래서 이게 항상 좋은 선택인가
관련 객체들의 조합이 어긋나면 안 되는 경우 Abstract Factory는 매우 유용하다. UI 테마, 운영체제별 컴포넌트, 데이터베이스별 드라이버 묶음처럼 "같은 패밀리"라는 개념이 강할수록 잘 맞는다. 에이든 피자의 세트 메뉴도 그렇다.
하지만 단순히 객체 하나를 만들기 위해 Abstract Factory를 꺼내면 지나치다. 피자 한 판을 만들기 위해 피자·사이드·음료 공장을 전부 세울 필요는 없다. 패턴이 제공하는 보장은 공짜가 아니다. 인터페이스와 클래스 수가 늘어나고, 제품 종류가 바뀔 때 전체 팩토리가 영향을 받는다.
전체 코드
interface Pizza {
getName(): string;
}
interface Side {
getName(): string;
}
interface Drink {
getName(): string;
}
interface MealFactory {
createPizza(): Pizza;
createSide(): Side;
createDrink(): Drink;
}
class ItalianPizza implements Pizza {
getName(): string {
return '화덕 마르게리타';
}
}
class Bruschetta implements Side {
getName(): string {
return '브루스케타';
}
}
class Espresso implements Drink {
getName(): string {
return '에스프레소';
}
}
class ItalianMealFactory implements MealFactory {
createPizza(): Pizza {
return new ItalianPizza();
}
createSide(): Side {
return new Bruschetta();
}
createDrink(): Drink {
return new Espresso();
}
}
class PanPizza implements Pizza {
getName(): string {
return '팬 페퍼로니 피자';
}
}
class ChickenWing implements Side {
getName(): string {
return '치킨 윙';
}
}
class Cola implements Drink {
getName(): string {
return '콜라';
}
}
class AmericanMealFactory implements MealFactory {
createPizza(): Pizza {
return new PanPizza();
}
createSide(): Side {
return new ChickenWing();
}
createDrink(): Drink {
return new Cola();
}
}
class Meal {
constructor(
readonly pizza: Pizza,
readonly side: Side,
readonly drink: Drink,
) {}
describe(): string {
return `${this.pizza.getName()} + ${this.side.getName()} + ${this.drink.getName()}`;
}
}
class MealAssembler {
assemble(factory: MealFactory): Meal {
return new Meal(
factory.createPizza(),
factory.createSide(),
factory.createDrink(),
);
}
}
const assembler = new MealAssembler();
const italianMeal = assembler.assemble(new ItalianMealFactory());
console.log(italianMeal.describe());
const americanMeal = assembler.assemble(new AmericanMealFactory());
console.log(americanMeal.describe());다음 편에서는 이렇게 만들어진 복잡한 피자를 매번 처음부터 다시 만드는 대신, 단골 손님의 "마스터, 늘 먹던거로(나: 락스 온 더 락이요?)"를 코드로 표현하는 방법을 본다. 단골은 고맙지만, 매번 Builder 체인을 처음부터 쓰게 만드는 단골은 쬐끔 덜 고맙다.
댓글
댓글을 불러오는 중...