2026년 2월

일회용 Method 빌더패턴

Closure와 omit으로 빌드타임에 method 중복 호출을 막는 방법

빌더 패턴 다시 생각하기

Builder 패턴은 복잡한 객체를 단계적으로 조립할 때 자주 씁니다. 햄버거를 예로 들면, 번·패티·치즈·야채·소스를 하나씩 설정한 뒤

build()
로 완성품을 만듭니다. 전통적인 class builder는 method마다
return this
로 체인을 이어갑니다.

// setBun을 두 번 호출해도 타입 오류 없음 — 마지막 값만 남음
const overwritten = new HamburgerBuilder()
  .setBun("sesame")
  .setBun("brioche")
  .build();

overwritten.describe();
// Bun: brioche
// ...

전통적인 Builder 패턴에서

setBun()
,
setPatty()
,
setSauce()
는 여러 번 호출할 수 있습니다. 마지막에 호출한 값만 남고, 이전 값은 덮어씌워집니다.

저는 이런 구조가 불안하다고 느꼈습니다. 잘못 사용하면 builder가 여러 함수를 넘어다니면서 side effect를 일으킬 수 있어 보였습니다. 번·패티· 소스를 1번만 선택하게 강제할 수 있을까요? 강제할 수 있다면 빌드타임에 막을 수 있을까요?

아이디어 1 — Builder를 두 요소로 나누기

문제를 해결하기 위해서 떠오른 첫번째 생각은 'method를 사용할 때마다, builder의 정의가 새롭게 작성되도록 만드면 되겠다.' 였습니다. 사용한 method를 뺀 새로운 builder를 다시 만들어 사용하면 될 것 같았습니다.

그래서 일단 builder를 data 객체와 method 객체로 나누어 보았습니다. 왜냐하면 data 부분은 변경점이 없고, method 부분만 재정의 되어야하기 때문에, 관심사를 분리하기 위해서입니다.

type Data = {
  bun: string;
  patty: string;
  cheese: boolean;
  vegetables: string[];
  sauce: string;
};

const _data = {
  bun: "plain",
  patty: "beef",
  cheese: false,
  vegetables: [],
  sauce: "none",
};

이제 method 객체를 함수로 감싸서, method를 호출할 때마다 method 객체를 새로 만들어 만들어 반환하도록 합니다. 이제 method를 사용할 때마다, builder의 type을 바꿀 수 있는 기반이 마련되었습니다.

추가적으로 class 형식의 builder 패턴과 같이, method와 같이 data가 흐르도록 파라미터를 수정합니다.

function createBuilder() {
  const method = {
    setBun: (bun: string) => {
      _data.bun = bun;
      return createBuilder();
    },
    setPatty: (patty: string) => {
      _data.patty = patty;
      return createBuilder();
    },
    addCheese: () => {
      _data.cheese = true;
      return createBuilder();
    },
    addVegetable(vege: string) {
      _data.vegetables.push(vege);
      return createBuilder();
    },
    setSauce(sauce: string) {
      _data.sauce = sauce;
      return createBuilder();
    },
    build() { ... },
  };

  return method;
}

아이디어 2 — 호출된 method 제거하기

한번 호출된 method는 계속 호출할 수 없어야합니다. 이를 위해서는 어떤 method가 제거되었는지 정보가 필요합니다. createBuilder 함수에

omits
배열을 추가하여, data와 함께 흐르도록 했습니다.

function createBuilder<O extends keyof typeof builder = never>(
  data: Data = _data,
  omits: O[] = [],
) {
  const method = {
    setBun: (bun: string) => {
      return createBuilder({ ...data, bun }, [...omits, "setBun"]);
    },
    ...,
    addCheese: () => {
      return createBuilder({ ...data, cheese: true }, omits);
    },
    ...,
    build() { ... },
  };

  return omit(method, omits);
}

setBun()
은 한번만 호출할 수 있도록 강제하기 위해,
omits
에 자신의 이름을 추가합니다. 이렇게하면 제외된 함수가 누적되어 전달되기 때문에, 한번 호출한 method는 계속 제거되어 더 이상 호출할 수 없게됩니다.

반면에

addCheese()
는 여러 번 호출할 수 있기 때문에 기존
omits
를 그대로 넘겨줍니다. 제외된 목록에 자신이 없으므로, 여전히 여러 번 호출할 수 있습니다.

타입 추론과 method 가용성

체인이 진행될수록

omits
가 쌓이고, builder 타입에서 해당 method가 사라집니다. 각 단계에 대응하는 체인 코드는 탭으로 전환해 확인할 수 있습니다.

const builder = createBuilder();
// 사용 가능: setBun, setPatty, addCheese, addVegetable, setSauce, build

Class builder는 가능하지만 비추천

Closure 없이 class로도 비슷한 제약을 줄 수 있습니다. 관례대로

return this
대신
return this as ...
로 반환 타입을 강제합니다. 하지만 method마다
as
타입이 달라지고, generic
O
를 누적해야 해서 정의가 금방 복잡해집니다.

type OnceMethod =
  | "setBun"
  | "setPatty"
  | "addCheese"
  | "addVegetable"
  | "setSauce"
  | "build";

class OnceHamburgerBuilder<O extends OnceMethod = never> {
  private bun = "plain";
  private patty = "beef";
  private cheese = false;
  private vegetables: string[] = [];
  private sauce = "none";

  setBun(bun: string) {
    this.bun = bun;
    return this as Omit<
      OnceHamburgerBuilder<O | "setBun">,
      "setBun"
    >;
  }

  setPatty(patty: string) {
    this.patty = patty;
    return this as Omit<
      OnceHamburgerBuilder<O | "setPatty">,
      "setPatty"
    >;
  }

  addCheese() {
    this.cheese = true;
    return this as OnceHamburgerBuilder<O>;
  }

  addVegetable(vege: string) {
    this.vegetables.push(vege);
    return this as OnceHamburgerBuilder<O>;
  }

  setSauce(sauce: string) {
    this.sauce = sauce;
    return this as Omit<
      OnceHamburgerBuilder<O | "setSauce">,
      "setSauce"
    >;
  }

  build() {
    return new Hamburger(
      this.bun,
      this.patty,
      this.cheese,
      this.vegetables,
      this.sauce,
    );
  }
}

한계도 명확합니다.

as
키워드를 사용했기 때문에, type을 통해 재호출은 막을 수 있어도 런타임에는 동일한 instance가 돌아갑니다. 이는 타입 검사만 우회할 뿐 실제 동작을 바꾸지 않으므로, 타입을 무시하고 호출하면 여전히 덮어쓰기가 일어납니다.

Closure +

omit
방식은 호출마다 새 builder 객체를 만들고 method를 type에서 뿐만 아니라 런타임에서도 제거하므로, 이런 문제가 발생하지 않습니다.

한계점

createBuilder 정의부 타입이 매우 복잡합니다. IDE에서 hover하면 중첩된

Omit<...>
트리가 이어집니다. 타입이 복잡해서, typescript LSP의 동작이 오래걸릴 수 있습니다. 즉, 대규모 코드베이스에서는 적합하지 않은 방식일 수 있습니다.

function createBuilder<
  O extends
    | "setBun"
    | "setPatty"
    | "addCheese"
    | "addVegetable"
    | "setSauce"
    | "build" = never,
>(
  data?: Data,
  omits?: O[],
): Omit<{
  setBun: (bun: string) => Omit<{
    setBun: (bun: string) => Omit<..., O | "setBun">;
    setPatty: (patty: string) => Omit<{
      setBun: (bun: string) => Omit<..., O | "setBun" | "setPatty">;
      setPatty: (patty: string) => Omit<..., O | "setBun" | "setPatty">;
      addCheese: () => Omit<..., O | "setBun" | "setPatty">;
      addVegetable(vege: string): Omit<..., O | "setBun" | "setPatty">;
      setSauce(sauce: string): Omit<...>;
      build(): Hamburger;
    }, O | ... 1 more ... | "setPatty">;
    addCheese: () => Omit<...>;
    addVegetable(vege: string): Omit<...>;
    setSauce(sauce: string): Omit<...>;
    build(): Hamburger;
  }, O | "setBun">;
  ... 4 more ...;
  build(): Hamburger;
}, O>

메모리 overhead가 있습니다. Class builder는 하나의 reference를 유지하지만, closure builder는 method 호출마다 새 객체를 생성합니다. 비슷한 객체를 쉽게 만들기 위한 가벼운 wrapper를 제공하는 것이 builder의 본래 목적이라고 한다면, 이 방식은 메모리 관점에서 불리할 수 있습니다.