4. Prototype — 단골 손님은 항상 같은 걸 시킨다

작성일:2026.05.21|조회수:2

4. Prototype — 단골 손님은 항상 같은 걸 시킨다

에이든 피자에는 벌써 단골이 생겼다. 시범 운영 중인 가게에 단골이 생긴다는 건 좋은 일이다. 문제는 그 단골이 매번 꽤 복잡한 커스텀 피자를 주문한다는 점이다. 씬 도우, 토마토 소스, 모차렐라, 페퍼로니, 블랙 올리브, 웰던. 주문을 받을 때마다 Builder 체인을 다시 쓰다 보니, 어느 순간 직원이 손님의 얼굴보다 메서드 체인을 먼저 떠올리게 됐다.

TS
const pizza = new PizzaBuilder()
  .setDough('thin')
  .setSauce('tomato')
  .addTopping('mozzarella')
  .addTopping('pepperoni')
  .addTopping('black-olive')
  .setWellDone()
  .build();

물론 이 코드는 읽기 좋다. 2편에서 Builder를 도입한 이유가 바로 그것이었다. 하지만 매일 같은 주문을 매번 처음부터 조립하는 건 다른 문제다. "지난번이랑 똑같이 주세요"라는 말을 코드도 이해했으면 좋겠다.

무엇이 불편한가

지금 불편한 점은 Builder 체인이 길다는 것이 아니다. 진짜 문제는 이미 한 번 확정된 복잡한 주문 상태를 매번 생성 절차로 다시 재현하고 있다는 데 있다. 단골 주문은 사실상 하나의 완성된 객체인데, 코드에서는 여전히 매번 도우를 고르고, 소스를 고르고, 토핑을 하나씩 올리는 절차로만 남아 있다.

이 구조에서는 "지난번이랑 똑같이 주세요"라는 요구가 코드에 잘 들어맞지 않는다. 같은 주문을 다시 만들 때마다 누락이 생길 수 있고, 단골 주문에서 오늘만 치즈를 조금 더하는 경우에도 원래 주문과 오늘 주문을 명확히 구분하기 어렵다. 원본이 되는 주문과 이번에 실제로 처리할 주문이 섞이면, 단골 관리가 아니라 단골 취향을 몰래 바꾸는 시스템이 된다.

우리가 원하는 것은 자주 쓰는 주문을 원형으로 저장해두고, 필요할 때마다 복사본을 꺼내는 구조다. 원형은 그대로 보존하고, 오늘 주문에 필요한 변경은 복사본에만 적용하면 된다. "같은 걸 하나 더"라는 말을 생성 절차의 반복이 아니라 객체 복제로 표현하고 싶은 것이다.

Prototype 패턴

Prototype 패턴은 이런 상황에서 등장한다. GoF의 원래 의도는 다음과 같다.

생성할 객체의 종류를 원형 인스턴스로 명시하고, 그 원형을 복사해서 새로운 객체를 만든다.

처음 보면 그냥 객체를 복사하는 기법처럼 보인다. 하지만 핵심은 복사 자체가 아니라, 새 객체를 만들 때 구체 클래스를 다시 고르고 긴 생성 절차를 반복하지 않아도 된다는 점이다. 이미 의미 있는 상태를 가진 객체를 원형으로 삼고, 그 상태에서 출발하는 새 객체를 만든다. 게임으로 치면 일종의 세이브 포인트를 마련해두는 느낌이다.

에이든 피자에서는 단골 주문을 원형으로 저장해두고, 오늘 주문이 들어오면 복제본을 꺼내면 된다. 단골이 "오늘은 치즈만 조금 더요"라고 말해도 원본은 그대로 두고 복제본만 수정하면 된다.

TS
interface Cloneable<T> {
  clone(): T;
}

class CustomPizza implements Cloneable<CustomPizza> {
  constructor(
    readonly dough: string,
    readonly sauce: string,
    private toppings: string[],
    readonly wellDone: boolean,
  ) {}

  addTopping(topping: string): void {
    this.toppings.push(topping);
  }

  getToppings(): readonly string[] {
    return this.toppings;
  }

  clone(): CustomPizza {
    return new CustomPizza(
      this.dough,
      this.sauce,
      [...this.toppings],
      this.wellDone,
    );
  }
}

얕은 복사의 함정

clone()에서 중요한 부분은 토핑 배열을 그대로 넘기지 않고 [...]로 복사한다는 점이다. 이 한 줄을 빼먹으면 Prototype 편은 갑자기 괴담이 된다 .

TS
clone(): CustomPizza {
  return new CustomPizza(this.dough, this.sauce, this.toppings, this.wellDone);
}

처음에는 이렇게 구현하고 싶을 수 있다. 겉으로는 멀쩡하다. 하지만 원본과 복제본이 같은 toppings 배열을 공유한다. 오늘 주문에 extra-cheese를 추가했는데 단골의 기본 주문에도 치즈가 추가된다. 손님이 매일 같은 걸 시키는 게 아니라, 코드가 매일 손님의 취향을 몰래 바꿔놓는 상황이 된다. 이 정도면 단골 관리가 아니라 단골 개조다.

그래서 복제할 때는 공유해도 되는 값과 복사해야 하는 값을 구분해야 한다. 문자열처럼 불변 값은 그대로 써도 괜찮다. 배열이나 객체처럼 내부가 바뀔 수 있는 값은 새로 복사해야 한다. 이것이 Prototype에서 깊은 복사가 중요한 이유다.

단골 주문 저장소

Prototype은 저장소와 함께 쓰면 더 자연스럽다. 에이든 피자에서는 단골 이름으로 원형 피자를 저장해두고, 꺼낼 때마다 복제본을 반환한다.

TS
class FavoriteOrderRegistry {
  private favorites = new Map<string, CustomPizza>();

  save(customerName: string, pizza: CustomPizza): void {
    this.favorites.set(customerName, pizza.clone());
  }

  get(customerName: string): CustomPizza {
    const pizza = this.favorites.get(customerName);
    if (!pizza) {
      throw new Error(`${customerName}님의 단골 주문이 없습니다`);
    }
    return pizza.clone();
  }
}

저장할 때도 복제하고, 꺼낼 때도 복제한다. 조금 과해 보일 수 있지만, 원본 보호를 위해서는 이 편이 안전하다. 저장소 안의 원형 객체는 기준이다. 기준이 흔들리면 "지난번이랑 똑같이"라는 말이 성립하지 않는다.

TS
const registry = new FavoriteOrderRegistry();
registry.save('김단골', pizza);

const today = registry.get('김단골');
today.addTopping('extra-cheese');

이제 오늘의 변형은 오늘 주문에만 적용된다. 단골의 기본 주문은 여전히 원래 모습 그대로 남아 있다.

TypeScript로 구현할 때 알아야 할 것들

clone(): this 형태를 쓰면 상속 구조에서 더 유연하다. 하위 클래스가 clone()을 호출했을 때 자기 타입을 유지할 수 있기 때문이다. 다만 구현이 복잡해질 수 있어 예제에서는 명시적으로 CustomPizza를 반환했다. 시리즈의 목표는 타입 마술쇼가 아니라 패턴 이해다. 타입 마술쇼는 박수는 받을 수 있지만, 끝나고 나면 관객이 아무것도 기억하지 못할 때가 많다.

structuredClone()도 선택지다. 단순 데이터 객체라면 꽤 편하다. 하지만 클래스 인스턴스의 메서드와 프로토타입까지 의도대로 보존되는 것은 아니다. Prototype 패턴에서 중요한 것은 단순히 데이터를 복사하는 것이 아니라, 객체가 스스로 어떤 복제가 올바른지 아는 것이다. 그래서 클래스 기반 예시에서는 직접 clone()을 구현하는 편이 설명에 더 잘 맞는다.

그래서 이게 항상 좋은 선택인가

Prototype은 복잡한 객체를 반복해서 만들 때 좋다. 특히 생성 비용이 크거나, 초기 설정이 길거나, 원형을 조금씩 변형해 여러 객체를 만들 때 유용하다. 단골 주문처럼 "기본값이 있고, 매번 약간만 바뀌는" 도메인에는 잘 어울린다.

하지만 복사 규칙이 복잡한 객체에서는 부담이 된다. 순환 참조가 있거나, 외부 리소스를 잡고 있거나, 깊은 클래스 계층이 있다면 clone() 구현은 생각보다 어려워진다. 복제는 단순해 보이지만, 정확한 복제는 늘 어렵다. 사람도 그렇고 객체도 그렇다. 잘은 모르지만 아마 복제양 만드는 것도 쉽지는 않았을 것이다.

전체 코드

TS
interface Cloneable<T> {
  clone(): T;
}

class CustomPizza implements Cloneable<CustomPizza> {
  constructor(
    readonly dough: string,
    readonly sauce: string,
    private toppings: string[],
    readonly wellDone: boolean,
  ) {}

  addTopping(topping: string): void {
    this.toppings.push(topping);
  }

  getToppings(): readonly string[] {
    return [...this.toppings];
  }

  clone(): CustomPizza {
    return new CustomPizza(
      this.dough,
      this.sauce,
      [...this.toppings],
      this.wellDone,
    );
  }

  describe(): string {
    return `${this.dough} / ${this.sauce} / ${this.toppings.join(', ')} / ${this.wellDone ? '웰던' : '기본 굽기'}`;
  }
}

class FavoriteOrderRegistry {
  private favorites = new Map<string, CustomPizza>();

  save(customerName: string, pizza: CustomPizza): void {
    this.favorites.set(customerName, pizza.clone());
  }

  get(customerName: string): CustomPizza {
    const pizza = this.favorites.get(customerName);
    if (!pizza) {
      throw new Error(`${customerName}님의 단골 주문이 없습니다`);
    }
    return pizza.clone();
  }
}

const favoritePizza = new CustomPizza(
  'thin',
  'tomato',
  ['mozzarella', 'pepperoni', 'black-olive'],
  true,
);

const registry = new FavoriteOrderRegistry();
registry.save('김단골', favoritePizza);

const todayOrder = registry.get('김단골');
todayOrder.addTopping('extra-cheese');

console.log(todayOrder.describe());
console.log(registry.get('김단골').describe());

다음 편에서는 피자 생성 패턴의 마지막으로, 매장 전체에서 하나만 있어야 하는 객체를 다룬다. 주문 번호를 관리하는 POS가 두 개 켜지는 순간, 에이든 피자는 같은 번호의 주문 두 건을 받게 된다.

댓글

댓글을 불러오는 중...