본문 바로가기
Design Pattern/Creational Design

[Design Pattern] (Creational) 빌더 패턴(Builder Pattern)

by song.ift 2023. 3. 29.

디자인 패턴 유형

  • Creation Design Pattern
    • 객체의 생성에 관련된 패턴
    • 객체의 생성 로직을 숨김으로써 유연성을 향상시킨다.
  • Strutural Design Pattern
    • 클래스와 객체의 구성에 관련된 패턴
  • Behavioral Design Pattern
    • 객체와 객체 간의 통신에 관련된 패턴

 

[Creational Design Pattern] 빌더 패턴(Builder Pattern)

빌더라는 이름처럼 객체를 생성하는 것에 관한 디자인 패턴이다. 백과사전에 의하면 빌더 패턴(Builder pattern)이란 복합 객체의 생성 과정과 표현 방법을 분리하여 동일한 생성 절차에서 서로 다른 표현 결과를 만들 수 있게 하는 패턴이다.

말이 어려운데, 객체를 만들어 사용하다 보면 간혹 객체 생성자가 너무 길어지거나 복잡해지는 경우가 생긴다. 이런 경우 빌더 패턴은 객체의 생성 과정을 분리해 순차적이고 직관적으로 만든다. 객체를 불변(Immutable)하게 유지할 수도 있다.

 

특징

  • 너무 길어지는 생성자를 피할 수 있다.
  • 코드 가독성이높다.
  • 객체의 Immutability(불변성)를 유지할 수 있다.
  • 코드가 길어진다.

 

문제 상황

많은 필드와 중첩된 객체들을 힘들게 단계별로 초기화해야 하는 복잡한 객체를 상상해보면 된다. 이러한 초기화 코드는 일반적으로 많은 매개변수가 있는 괴물 같은 생성자 내부에 묻혀 있다. 또, 더 최악의 상황에는 클라이언트 코드 전체에 흩어져 있을 수도 있다.

매개변수가 많은 생성자의 단점은 모든 매개변수가 항상 필요한 것은 아니라는 점이다.

const user1 = new User('Andy', 10, null, 30);
const user2 = new User('June', 11, 150, null);
const user3 = new User('Gary', 10);

User 객체는 생성자 파라미터로 name, age, height, weight를 순서대로 받는다. 키와 몸무게를 밝히고 싶지 않은 사람들이 있으므로 두 정보는 선택적으로 적도록 한다. 이런 경우 몇 가지 불편한 점이 있다.

  • 매번 파라미터 순서를 체크해야함.
  • 파라미터의 개수가 늘어나면 실수할 가능성도 커짐.
  • 코드를 처음보는 사람은 숫자들이나 null이 의미하는 것을 직관적으로 파악할 수 없음.

만약 파라미터의 개수가 더 길어진다면 더욱 복잡해지며 실수할 가능성이 커지게 된다. 이런 문제 상황을 피하기 위해 빌더 패턴을 사용할 수 있다.

 

해결책

빌더 패턴은 자신의 클래스에서 객체 생성 코드를 추출하여 builders(건축업자들)​라는 별도의 객체들로 이동하도록 제안한다.

빌더 패턴은 복잡한 객체들을 단계별로 생성할 수 있도록 한다. 빌더는 제품이 생성되는 동안 다른 객체들이 제품에 접근​(access)​하는 것을 허용하지 않는다.

이 패턴은 객체 생성을 일련의 단계들​(ex. build­Walls​(벽 건설), build­Door​(문 건설) 등)​로 정리하며, 객체를 생성하고 싶으면 위 단계들을 builder(빌더) 객체에 실행하면 된다. 또 중요한 점은 모든 단계를 호출할 필요가 없다는 것으로, 객체의 특정 설정을 제작하는 데 필요한 단계들만 호출하면 된다.

일부 건축 단계들은 제품의 다양한 표현을 건축해야 하는 경우, 다른 구현들이 필요할 수 있다. 예를 들어, 오두막의 벽은 나무로 지을 수 있지만 성벽은 돌로 지어야 한다. 이런 경우 같은 건축 단계들의 집합을 다른 방식으로 구현하는 여러 다른 빌더 클래스를 생성할 수 있으며, 그런 다음 건축 프로세스​(즉, 건축 단계에 대한 순서화된 호출들의 집합)​내에서 이러한 빌더들을 사용하여 다양한 종류의 객체를 생성할 수 있다.

 

구현

보통 Builder 패턴을 구현할 때는 두가지 방법으로 많이 구현한다.

  1. 객체 클래스 내부에 빌더 클래스를 포함할 수 있는 방법 (inner class를 이용한 빌더 패턴)
  2. 객체클래스와 빌더클래스를 분리하는 방법 (상속을 이용한 빌더 패턴)

개인적으로 하나의 클래스는 하나의 역할만 해야된다 생각해서 2번 방법으로 작성을 하는걸 개인적으로 더 선호한다.

 

객체 클래스 내부에 빌더 클래스를 포함할 수 있는 방법 (inner class를 이용한 빌더 패턴)

아래는 빌더 패턴을 이용해서 User 클래스를 만든 것이다. 이 클래스는 name과 age를 속성으로 가진다. 주목할 것은 Builder라는 static변수다. Builder 안에는 익명의 클래스가 들어있으며, 이 클래스는 User와 같은 속성들을 가진다. Builder 클래스 마지막에 build 함수에서 User 객체를 생성한다.

(#은 자바스크립트에서 private변수를 만드는 방법. 객체의 immutability를 유지하기 위해 사용함. constructor까지 private으로 만들어 사용하기 위해서는 typescript를 사용해야함)

export default class User {
  #name;
  #age;
  
  constructor(builder) {
    this.#name = builder.getName();
    this.#age = builder.getAge();
  }

  getName() {
    return this.#name;
  }

  getAge() {
    return this.#age;
  }

  static Builder = class {
    #name = "";
    #age = 0;

    getName() {
      return this.#name;
    }

    setName(name) {
      this.#name = name;
      return this;
    }

    getAge() {
      return this.#age;
    }

    setAge(age) {
      this.#age = age;
      return this;
    }

    build() {
      return new User(this);
    }
  };
}

객체를 생성하는 방법은 아래와 같다. 이제는 height와 weight처럼 선택적으로 속성을 받는 경우에도 좋은 가독성을 유지할 수 있다.

const user = new User.Builder()
  .setName('kate')
  .setAge(15);
  • 불필요한 생성자를 만들지 않는다.
  • 인자들의 가독성이 좋아진다.
  • 인자들의 순서가 섞여도 상관 없다.

다른 예시코드

class Person {
  constructor(build) {
    if (build) {
      this.id = build.id;
      this.name = build.name;
      this.age = build.age;
    }
  }

  static get Build() {
    class Build {
      name(name) {
        this.name = name;
        return this;
      }
      
      age(age) {
        this.age = age;
        return this;
      }
      
      height(height) {
        this.height = height;
        return this;
      }
      
      build() {
        return new Person(this);
      }
    }
    return new Build();
  }
}

const personBuilder1 = person.build;
const person1 = personBuilder1.name("tatiana").age(26);
const personBuilder2 = person.build;
const person2 = personBuilder2.name("James").age(50);

 

객체클래스와 빌더클래스를 분리하는 방법 (상속을 이용한 빌더 패턴)

먼저 슈퍼 클래스로 Builder 클래스를 정의한다. Builder 클래스를 상속 받는 하위 클래스들은 생성자에서 init() 함수를 호출해 필드의 setter를 생성한다.

class Builder {
  init() {
    Object.keys(this).forEach((key) => {
      const setterName = `set${key.substr(0, 1).toUpperCase()}${key.substr(1)}`;

      this[setterName] = (value) => {
        this[key] = value;
        return this;
      };
    });
  }

  build() {
    return this;
  }
}

class User extends Builder {
  constructor() {
    super();

    this.id = -1;
    this.name = null;
    this.age = 0;

    super.init();
  }
}
function testBuilderPattern() {
  const user = new User()
    .setId(1)
    .setName('jason')
    .setAge(22).build();

  console.log(user);
}

testBuilderPattern();

상속을 이용한 방법은 클래스에 존재하는 멤버 변수들의 setter 함수(instance를 반환 하는) 들을 자동으로 만들어 주므로 위의 콘솔 출력과 같이 setter 함수들이 생성 된다.

 

문제점

앞서 방식들을 살펴봤지만, 지금부터는 다시 처음부터 시작해서 방식들을 훑어보고,  문제점을 파악하며 리팩토링 하는 과정을 살펴볼것이다.

 

고전적인 방식

먼저 객체의 상태를 빌드 하기 위해 "withers"를 사용하는 간단하고 이해하기 쉬운 빌더 부터 시작. 각 "wither"는 체이닝을 하기 위해 this를 반환한다. build 메서드는 결국 일반 JavaScript 객체를 반환한다.

class ProductBuilder {
  constructor() {
    this.name = 'A Product'
    this.price = 9.99
    this.category = 'other'
  }

  withName(name) {
    this.name = name
    return this
  }

  withPrice(price) {
    this.price = price
    return this
  }

  withCategory(category) {
    this.category = category
    return this
  }

  build() {
    return {
      name: this.name,
      price: this.price,
      category: this.category,
    }
  }
}

console.log(
  new ProductBuilder()
    .withName('Harry Potter')
    .withCategory('book')
    .build()
)
// =>
//    {
//      name: 'Harry Potter',
//      price: 9.99,
//      category: 'book'
//    }

간단하고 익숙하지만 장황하다. 빌더는 세 개의 필드만 필요하지만 벌써 꽤 커졌고 많은 보일러 플레이트가 필요하다. 이 크기는 필드의 수에 따라 선형적으로 증가한다.

이 기법도 괜찮지만, 강력한 JavaScript의 기능을 사용하지 않고, Java스러운 접근법을 취하고 있다.

 

메타 프로그래밍을 통한 빌더 생성

앞의 예시중 몇 가지 단점, 즉 반복적인 "wither" 메서드에 대해 문제가 있었다. 각각의 "withers" 메서드를 수동으로 입력하기보다는 자동으로 생성해보자.

class ProductBuilder {
  constructor() {
    this.name = 'A metaprogrammed product'
    this.price = 9.99
    this.category = 'other'

    // 각 프로퍼티에 따라 "wither" 메서드 생성
    Object.keys(this).forEach(key => {
      const witherName = `with${key.substring(0, 1).toUpperCase()}${key.substring(1)}`
      this[witherName] = value => {
        this[key] = value
        return this
      }
    })
  }

  build() {
    // 빌더에서 함수가 아닌 프로퍼티의 배열을 가져옴
    const keysNoWithers = Object.keys(this).filter(key => typeof this[key] !== 'function')

    // 키 배열을 객체로 변환
    return keysNoWithers.reduce((returnValue, key) => {
      return {
        ...returnValue,
        [key]: this[key],
      }
    }, {})
  }
}

console.log(
  new ProductBuilder()
    .withName('Harry Potter')
    .withCategory('book')
    .build()
)
// =>
//    {
//      name: 'Harry Potter',
//      price: 9.99,
//      category: 'book'
//    }

이 패턴은 첫 번째 예제와 같은 결과를 만든다. 생성자에서는 객체의 프로퍼티를 통해, 자동으로 "wither" 메서드를 생성한다. 그리고, build 메서드에서는 결과 객체를 만들기 위해 객체의 프로퍼티를 한 번 더 사용다. 여기서 JavaScript의 멋진 기능인 Object.keysreduce와 object rest spread를 사용하고 있다.

처음에는 지나치게 복잡해 보일 수 있다.(한 개의 빌더는 복잡하다), 진짜 강력함은 빌더가 많을 때 발휘된다. 일반화된 부분을 공통의 슈퍼클래스로 쉽게 추출할 수 있어, 새로운 빌더를 엄청 쉽게 만들 수 있다.

class BaseBuilder {
  init() {
    Object.keys(this).forEach((key) => {
      const witherName = `with${key.substring(0,1).toUpperCase()}${key.substring(1)}`;
      this[witherName] = (value) => {
        this[key] = value;
        return this;
      };
    });
  }

  build() {
    const keysNoWithers = Object.keys(this).filter((key) => (
      typeof this[key] !== 'function'
    ));

    return keysNoWithers.reduce((returnValue, key) => {
      return {
        ...returnValue,
        [key]: this[key]
      };
    }, {});
  }
}

class ProductBuilder extends BaseBuilder {
  constructor() {
    super();

    this.name = 'A metaprogrammed product';
    this.price = 9.99;
    this.category = 'other';

    super.init();
  }
}

class SandwichBuilder extends BaseBuilder {
  constructor() {
    super();

    this.meat = 'ham';
    this.cheese = 'swiss';

    super.init();
  }
}


console.log(
  new ProductBuilder()
    .withName('Harry Potter')
    .withCategory('book')
    .build()
);
console.log(
  new SandwichBuilder()
    .withMeat('Roast Beef'
    .withCheese('Havarti')
    .build()
);
// =>
//    {
//      name: 'Harry Potter',
//      price: 9.99,
//      category: 'book'
//    }
// =>
//    {
//      name: 'Roast Beef',
//      cheese: 'Havarti'
//    }

상속하에 구축된 이 일반적인 구조를 사용함으로써, 공통 인터페이스의 예측 가능성과 새로운 빌더 생성의 단순함을 얻게 된다. 필요에 따라 사용자 정의 기능을 추가하기 위해 쉽게 추가 메서드를 삽입할 수 있기 때문에, 빌더들은 여전히 확장 가능성을 열어두고 있다.

하지만 여전히 장황한 "wither" 구문을 따르고 있다. 이 접근법은 본질적으로 객체 지향적이다. OOP에서는 문제가 없지만, JavaScript는 유연성이 뛰어나고 여러 패러다임을 채택한다. 좀 더 함수형 프로그래밍 기법을 활용해 빌더에 접근하면 어떨까.

 

단순 함수 사용

클래스와 "withers"를 제거하고, 대신 빌더를 함수로 모델링해본다.

const buildProduct = (overrides = {}) => {
  const defaults = {
    name: 'A functional product',
    price: 9.99,
    category: 'other',
  }

  return { ...defaults, ...overrides }
}

console.log(
  buildProduct({
    name: 'Harry Potter',
    category: 'book',
  })
)
// =>
//    {
//      name: 'Harry Potter',
//      price: 9.99,
//      category: 'book'
//    }

여기서 두 객체를 합치기 위해 object rest spread 을 사용한다.

우아하지만 가장 명백한 단점은, overrides 객체의 내용에 대한 통제가 없다는 점이며, 이로인해 실수를 발생시킬 수 있다. 위의 예에서 buildProduct({nme: 'Harry Potter'}) 를 한다면 기본 name 을 가지고 추가 프로퍼티 "nme" 를 가진 객체를 만들어 낼 것이다. 이는 이상적이지 않다. 그럼 다시 고쳐보면.

 

import { difference } from 'lodash';

const buildProduct = (overrides = {}) => {
  const defaults = {
    name: 'A functional product',
    price: 9.99,
    category: 'other'
  };

  // overrides에 추가 키를 도입하지 못하도록 막음 
  const extraOverrides = difference(
    Object.keys(overrides),
    Object.keys(defaults)
  );
  
  if (extraOverrides.length > 0) {
    throw new Error(`Invalid builder! ${extraOverrides}`);
  }

  return {...defaults, ...overrides};
};

console.log(
  buildProduct({
    name: 'Harry Potter',
    category: 'book',
    keyThatDoesNotExist: 'oops'
  })
);
// => Error: Invalid builder! keyThatDoesNotExist

lodash의 배열 비교 유틸리티를 사용하여, 이 문제를 예방할 수 있다. 그리고 다른 재사용 가능한 코드 조각들과 마찬가지로. 이를 많은 빌더들이 사용하는 함수로 추출할 수 있다.

위의 모든 예에서 만들어지는 개체는 단순하다. 이 함수형 기법은 여러 중첩된 객체를 포함하는 더 크고 복잡한 객체를 제대로 관리하지 못한다. 깊은 합병(deep merging)을 할 수 있지만, 이는 추론하기 힘들기 때문에 피하는게 좋다. 대신 여러 빌더를 함께 합성하는 것을 추천한다.

import { difference } from 'lodash'

const preventExtraOverrides = (defaults, overrides) => {
  const extraOverrides = difference(Object.keys(overrides), Object.keys(defaults))

  if (extraOverrides.length > 0) {
    throw new Error(`Invalid builder! ${extraOverrides}`)
  }
}

const buildProductName = (overrides = {}) => {
  const defaults = {
    name: 'A product',
    description: 'A product description',
  }

  preventExtraOverrides(defaults, overrides)

  return { ...defaults, ...overrides }
}

const buildProductPrice = (overrides = {}) => {
  const defaults = {
    price: 50,
    taxRate: 0.08,
  }

  preventExtraOverrides(defaults, overrides)

  return { ...defaults, ...overrides }
}

const buildProduct = (overrides = {}) => {
  const defaults = {
    name: buildProductName(),
    price: buildProductPrice(),
    category: 'other',
  }

  preventExtraOverrides(defaults, overrides)

  return { ...defaults, ...overrides }
}

console.log(
  buildProduct({
    name: buildProductName({ name: 'Harry Potter' }),
    price: buildProductPrice({ price: 9.99 }),
  })
)
// =>
//  {
//    name: {
//      name: 'Harry Potter',
//      description: 'A product description'
//    },
//    price: {
//      price: 9.99,
//      taxRate: 0.08
//    },
//    category: 'other'
//  }

빌더를 합성함으로써, 객체를 작고 독립적으로 분해했기 때문에 쉽게 추론할 수 있다. 각 빌더는 비슷한 패턴을 따르지만, 스스로 포함되기 때문에 특정 상황에 맞춤화할 수 있다.

 

그래서 어떤 방법이 최고일까

상황에 따라 다르다. 독특한 상황에는 어떤 패턴이 다른 것들 보다 더 적절할 수 있다. JavaScript에 미숙하다면, 아마 고전적인 접근법으로 시작해서 메타 프로그래밍 기법을 향해 나아가는 것이 결국 적절할 것이다. 또 함수형 패러다임에 익숙하다면 함수형 접근법을 사용하면 좋은 것처럼 다양하다.

전반적으로, JavaScript는 다중 패러다임 언어로, 많은 요구에 맞게 변화할 수 있다는 것을 기억하면 좋다.

 

댓글