조영제
16 min readMar 6, 2021

typescript 디자인패턴(0) — Builder(빌더) 패턴

이번에는 Builder 패턴의 정의와 왜쓰는지. 그리고 class와 typescript 를 사용해서 타입 정의 및 사용하게 편하게 만들기 위해 작업한 과정을 설명해보려한다.

Builder 패턴의 정의

The builder pattern is a design pattern designed to provide a flexible solution to various object creation problems in object-oriented programming. The intent of the Builder design pattern is to separate the construction of a complex object from its representation

출처 : 위키피디아

Separate the construction of a complex object from its representation so that the same construction process can create different representations.

출처: ”Design Patterns, Elements of Reusable Object-Oriented Software”

위 두개의 정의를 보고 Builder 패턴에 대해 하나의 문장으로 정리하자면은 “객체의 표현과 생성과정을 분리” 라 할수 있겠다. 이게 무슨말이나? 아래 예시를 한번 살펴보자.

사용 예시

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

  1. 객체클래스와 빌더클래스를 분리하는 방법
  2. 객체 클래스 내부에 빌더 클래스를 포함할 수 있는 방법

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

House.ts

class House {
name: string;
address: number;
contructor(houseBuilder: HouseBuilder) {
this.name = houseBuilder._name;
this.address = houseBuilder._address;
}
}

HouseBuilder.ts

class HouseBuilder {
_name: string;
_address: number;
setName(arg: string) {
this.name = arg;
return this;
}
setAddress(arg: number) {Ï
this.address = arg;
return this;
}
build() {
return new House(this);
}
}
new HouseBuilder().setName("이름").setAddress(123).build();

Builder pattern의 장점(생성자 항목이 많을경우 가지고 있는 property가 많을경우)

1. 코드 가독성이 올라간다

생성자에 들어갈 파라미터의 순서나 항목을 알것없이 호출코드만 봐도 어떤 객체를 만드는지 알수 있다.

-> as is
new House("이름",123);
-> to be
new HouseBuilder().setName("이름").setAddress(123).build();

2. 코드 작성할때도 편하다.

-> as is (House 클래스에 관련한수가 다짜져 있다고 가정)
const house = new House();
house.setName("이름");
house.setAddress(123);
-> to be
const house = new HouseBuilder().setName("이름").setAddress(123).build();

3. 객체의 생성과 표현 관심사 분리가 가능하다.

앞서 설명한 빌더 패턴의 목적과도 같다. 무슨 말인지는 아래 예시를 한번 보도록 하자.

-> as isclass House {
name: string;
address: number;
constructor() {
this.name = "";
this.address = -1;
}
setName(arg: string) {
this.name = arg;
return this;
}
setAddress(arg: number) {
this.address = arg;
return this;
}
getNameAndAddress() {
return `${this.name}_${this.address}`;
}
getAddressPlusString(arg: string) {
return arg + this.address;
}
}
-> to beclass House {
name: string;
address: number;
contructor(houseBuilder: HouseBuilder) {
this.name = houseBuilder._name;
this.address = houseBuilder._address;
}
getNameAndAddress() {
return `${this.name}_${this.address}`;
}
getAddressPlusString(arg: string) {
return arg + this.address;
}
}
class HouseBuilder {
_name: string;
_address: number;
setName(arg: string) {
this._name = arg;
return this;
}
setAddress(arg: number) {Ï
this._address = arg;
return this;
}
build() {
return new House(this);
}
}

위의 예제같이 객체의 생성과 표현을 분리할 수 있게 되었고 그로인해 기존에 코드를 분석하기도 편해졌고 모델 클래스내에서 하는 역할이 많아져서 추가로 처리해야될 로직이 늘어난 경우에도 객체의 생성에 관련된 부분을 Builder로 뺄수 있어서 좀 더 길이가 짧고 깔끔하게 처리가 가능하다.

4. 수정에도 용이하다.

만약에 기존 파라미터를 받는 생성자 내에서 새로운 property가 추가되었다고 하면은 전 생성자를 사용하는 부분을 수정을 해야될것이다. 그러나 Builder 패턴을 사용하게 된다면 추가되는 property가 필수적인 속성이라 하면은 Builder클래스 하나만 수정을해도되고 필수 property가 아니라 하더라도 해당 property를 사용하는 부분만 추가적으로 set함수를 사용하면된다.!

근데 이거… 과연 쓸가?

일단 결론부터 말하자면은 “지금 저상태로는” 안쓴다. 그래서 지금부터는 좀더 쓰기편하고 타입 안정성을 높이고 개발자의 실수를 줄일 수있고 반복되는 코드도 피할 수 있도록 기존 문제점을 하나씩나열하며 좀더 개선해나가보자

문제점 1 : 새로운 property 를 추가나 삭제할려할때 해야될 작업이 너무많다.

****예를들어 층수 정보를 추가하기 위해 floor이라는 number 데이터를 추가한다고 하면은 House 클래스HouseBuilder 클래스의 floor 속성을 추가해야되고 House 클래스의 생성자와 HouseBuilder 클래스의 set 함수를 추가적으로 만들어줘야한다.

이 부분은 한곳의 수정이 다른데 영향을 많이 받게 된다는것은 유지보수성에도 좋지않고 개발 편의상으로도 좋지않다

개선 방향

class House {
name: string;
address: number;
getNameAndAddress() {
return `${this.name}_${this.address}`;
}
getAddressPlusString(arg: string) {
return arg + this.address;
}
}
class HouseBuilder {
object: House;
constructor() {
this.object = new House();
}
setName(arg: string) {
this.object._name = arg;
return this;
}
setAddress(arg: number) {
this.object._address = arg;
return this;
}
build() {
return this.object;
}
}

해당 수정을 통해서 새로운 property가 추가 되었을때마다 HouseBuilder에 새로운 property를 추가할 필요 없고 추가적인 set 함수만 추가해주면 바로 쓸 수 있다.

문제점 2 : 모든 Builder 클래스에 반복되는 선언이 많다.

현재 전 빌더클래스마다 constructor와 build 그리고 object property 선언이 들어간다.

개선방향

interface ConstructorType<T> {
new (): T;
}
class BuilderCommon<T> {
public object: T;
constructor(ctor: ConstructorType<T>) {
this.object = new ctor();
}
build(): T {
return this.object;
}
}
class House {
name: string;
address: number;
getNameAndAddress() {
return `${this.name}_${this.address}`;
}
}
class HouseBuilder extends BuilderCommon<House> {
constructor() {
super(House);
}
setName(arg: string) {
this.object.name = arg;
return this;
}
setAddress(arg: number) {
this.object.address = arg;
return this;
}
}

모든 빌더클래스에 공통적으로 들어갈 부분은 하나의 클래스로 빼고 그를 상속하면 반복되는 코드를 줄일수 있지 않을가 생각했다. 그러나 generic 을 사용해서 클래스의 생성자에서 초기화를 수행할때 new T() 방식으로 선언을 하게되면 generic 타입인지라 new 키워드를 지원하지않는 다른 타입이 오게될 수 있으므로 타입스크립트에서 에러를 내게 된다. 이 문제를 해결하기 위해서 실제 BuilderCommon 클래스내에 new 키워드가 존재하지 않는 객체만 파라미터로 받을수 있게 추가 타입을 정의해서 해결하였다.

문제점 3 : 새로운 타입이 추가되었을때 좀더 간편하게 만들고 개발자들의 실수를 줄일수 있는 방법은 없을가?

저거를 만들고 쓸려하다보니 몇가지 불만인점이 있었다. 예를들어 floor 타입이 추가되었다고 할때 setFloor 함수도 선언을 해줘야됐고 안에 들어가는 타입도 매번 확인하고 지정해줘야했다. 그래서 이 부분을 typescript 로 타입을 선언하고 implements 를 하게되면 vscode의 implements 자동완성 기능도 지원을 받을수도 있고 property추가만 하고 set 함수를 빠져먹고 구현을 안했을때도 typescript 단에서 에러를 잡아 좀 더 그 상황을 빨리 알 수 있으면 어떨가 하는 생각이 들었다. 먼저 구현전체코드는 아래와 같다.

type NonFunctionPropertyNames<T> = {
[K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];
type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;type IBuilder<T> = {
[k in keyof NonFunctionProperties<T> as `set${Capitalize<string & k>}`]: (
arg: T[k]
) => IBuilder<T>;
};
interface NoParamConstructor<T> {
new (): T;
}
class BuilderCommon<T> {
public object: T;
constructor(ctor: NoParamConstructor<T>) {
this.object = new ctor();
}
build(): T {
return this.object;
}
}
class House {
name: string;
address: number;
constructor() {
this.name = "";
this.address = -1;
}
getNameAndAddress() {
return `${this.name}_${this.address}`;
}
getAddressPlusString(arg: string) {
return arg + this.address;
}
}
class HouseBuilder extends BuilderCommon<House> implements IBuilder<House> {
constructor() {
super(House);
}
setName(arg: string) {
this.object.name = arg;
return this;
}
setAddress(arg: number) {
this.object.address = arg;
return this;
}
}
new HouseBuilder().setName("#21").setAddress(123).build();

새로 추가된부분은 아래와 같다. 이해가 가기 쉽게 파트를 3개로 나누어서 실제 결과값또한 첨부하였다.

1번
type NonFunctionPropertyNames<T> = {
[K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];
// type NonFunctionPropertyNames<House> = "name" | "address";
------------------------------------------------------------------------
2번
type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;
/* type NonFunctionProperties<House> = {
name: string;
address: number;
} */
------------------------------------------------------------------------
3번
type IBuilder<T> = {
[k in keyof NonFunctionProperties<T> as `set${Capitalize<string & k>}`]: (
arg: T[k]
) => IBuilder<T>;
};
/* type IBuilder<House> = {
setName: (arg: string) => IBuilder<House>;
setAddress: (arg: number) => IBuilder<House>;
} */
------------------------------------------------------------------------class HouseBuilder extends BuilderCommon<House> implements IBuilder<House> {
constructor() {ㅁ
super(House);
}
setName(arg: string) {
this.object.name = arg;
return this;
}
setAddress(arg: number) {
this.object.address = arg;
return this;
}
}

1번 부분에서 클래스의 함수인부분을 never 타입을 반환해 무시하게 하고 실질적으로 property 인 부분만 뽑아내는 작업을 진행한다.

2번부분에서 typescript Pick 유틸함수를 통해 클래스 T가 가지고 있는 요소중에서 property 요소와 따로 타입만을 뽑아낸다.

3번 부분에서 모든 key를 돌면서 타입스크립트 4.1 버전부터나온 Template Literal Types 를 이용해서 자동적으로 set함수의 타입을 선언할 수 있도록 제작하였다.

어… 근데 문제가(vscode interface auto suggestions 문제)? -2021/03/06 기준

자 이제 interface 도 선언해서 이제 모델 클래스만 선언을 하더라도 빌더 클래스 내에서 타입체크 및 개발자의 실수도 줄일 수 있게 되었고 vscode에서 interface 를 implement 했을때 자동으로 목업을 만들어주는 기능을 사용하면은 개발 속도도 빠르게 구현을 할 수 있다는 생각이 들었다.

그러나 현재 vscode 내에선(intellij 에서는 확인을 안해봤다.) Template Literal Types 이 타입스크립트 4.1 버전부터 나온기능이라 그런지 자동완성에 버그가 있었다. setName과 setAddress 함수가 정의가 되어야 했는데 실제로는 아래와같이 name함수와 setAddress 함수가 정의가되었다…

😅 안된다

처음엔 타입을 내가 잘못정의를 해서 그런거라 생각을 했는데 확인해보니 그렇진 않았고(setName과 setAddress 함수를 구현시 editor 창에서 에러를 띄어주지않음) 기존 로직에 버그가 있는거 같았다. 일단 github 이슈에 해당 문제를 제보한후 답변을 기다리는 중이다.

Builder 패턴은 언제 사용해야할까?

지금까지 Builder 패턴에 대해 알아 보았다. Builder 패턴은 들어가는 property가 많아 좀 복잡한 경우, 객체의 표현과 생성과정을 분리해서 좀 더 클래스마다 역할분리를 명확히 하고싶은경우, 코드의 가독성을 높이고 싶은경우에는 해당 패턴을 사용하는걸 추천한다.

물론 Builder 패턴을 사용하는데 있어서 추가적으로 선언해야줘거나 만들어야될 함수가 만들어져 개발자가 좀 더 피곤해 질수 있다는 생각도 들지만은 typescript와 상속을 통해서 어느정도의 코드 반복과 개발자 실수를 막을수 있고 프로젝트의 크기가 커지면 커질수록 해당 패턴을 사용한것이 안하는것보단 결과물도 더 나을거라 생각한다.

참고자료

https://jdm.kr/blog/217

https://asfirstalways.tistory.com/350

https://github.com/fbeline/design-patterns-JS/blob/master/src/creational/builder/builder_es6.js

https://medium.com/@itayelgazar/the-builder-pattern-in-node-js-typescript-4b81a70b2ea5

조영제
조영제

Written by 조영제

두나무 NFT에서 웹 프론트엔드 개발자로 근무하고 있습니다. 일하는거랑 새로 무언가를 배우는것을 좋아합니다.