[소프트웨어] 디자인 패턴 - 빌더 패턴

빌더 패턴

빌더 패턴(builder pattern)이란 클래스를 인스턴스화하여 다양한 복합 객체(complex object)를 만드는 과정을 단순화한 디자인 패턴이다. 빌더 패턴을 사용하면 복합 객체를 만드는 동일한 구성 프로세스가 서로 다른 표현을 만들 수 있도록 하여 복합 객체를 구성하는 로직과 표현을 선택하는 로직을 분리할 수 있다.

복합 객체란 여러 프로퍼티를 갖고 있는 객체를 말한다. 프로퍼티 객체를 제품(product) 객체로 표현한다. 복합 객체를 구성하는 것은 제품 객체를 생성(인스턴스화)하고 조합(객체 간 의존 관계 설정)하는 과정이다. 제품 객체도 복합 객체일 수 있으므로 트리 구조를 가지는 객체를 구성하는 경우에도 빌더 패턴이 사용된다. 이후 설명은 빌더 패턴을 통해 빌더라는 객체가 제품 객체를 생성 및 조합하여 복합 객체를 만드는 흐름을 대상으로 한다.

복합 객체를 만드는 유연하지 못한 방법은 보통 다음과 같은 과정으로 이루어진다. 복합 객체를 필요로 하는 클라이언트 클래스 내에서 직접 객체들을 인스턴스화하고 조립한다.

  1. 제품1 인스턴스화
  2. 제품2 인스턴스화
  3. 복합 객체에 추가


생성자의 매개변수로 객체를 전달하는 방법으로 복합 객체를 만드는 코드는 다음과 같다.

class ComplexObject {
  Product1 product1;
  Product2 product2;
  
  public ComplexObject(Product1 product1, Product2 product2) {
    this.product1 = product1;
    this.product2 = product2;
  }
}

class Client {
  public getComplexObject() {
    Product1 product1 = new Product1();
    Product2 product2 = new Product2();
    
    ComplexObject complexObject = new ComplexObject(product1, product2);
  }
}


이 경우 복합 객체가 많은 수의 제품 객체를 프로퍼티로 가지는 경우 생성자의 파라미터 개수가 많아진다. 파라미터가 많은 복합 객체의 생성자가 가진 문제는 다음과 같다.

  1. 클라이언트는 생성자 호출 시 전달할 인자의 종류와 순서를 함께 알고 있어야 한다.
  2. 동일한 타입의 파라미터일 경우 생성자 호출 시 순서가 잘못 전달되어도 컴파일 시점에 에러가 발생되지 않는다.
  3. 전체 파라미터 중 일부 파라미터만 사용한다면 사용되지 않는 파라미터에 대해서는 null 참조나 기본값을 인자로 전달하는 불필요한 코드가 존재하게 된다.


빌더 패턴은 클라이언트 클래스에서 생성된 제품 객체를 조합하여 복합 객체를 구성 및 생성하는 코드를 추출하여 빌더(builder)​라는 별도의 객체로 이동시킨 것이다. 클라이언트는 빌더 객체에게 복합 객체 생성을 요청하며 빌더는 제품 객체를 조합하여 복합 객체를 구성한 후 클라이언트에게 반환한다. 제품 객체의 생성 자체를 빌더 객체 내에 위치시킬 수도 있다.

빌더 패턴을 사용한 제품 객체 생성 및 조합, 복합 객체 구성 코드는 다음과 같다.

class ComplexObject {
  Product1 product1;
  Product2 product2;
  
  public ComplexObject(Product1 product1, Product2 product2) {
    this.product1 = product1;
    this.product2 = product2;
  }
}

class Builder {
  Product1 product1;
  Product2 product2;
  
  public void setProduct1() {
    this.product1 = new Product1();
  }
  
  public void setProduct2() {
    this.product2 = new Product2();
  }
  
  public ComplextObject getComplextObject() {
    return new ComplexObject(product1, product2);
  }
}

class Client {
  public getComplexObject() {
    Builder builder = new Builder();
    
    builder.setProduct1(new Product1());
    builder.setProduct2(new Product2());
    
    ComplexObject complexObject = builder.getComplexObject();
  }
}


클라이언트가 빌더와 인터페이스를 통해 상호작용하도록 하면 빌더 객체들이 다양한 방식으로(서로 다른 구현으로) 같은 작업(복합 객체를 구성하는 것)을 실행할 수 있게 된다. 이는 다형성을 적용하는 것이다. 빌더 인터페이스를 사용하여 서로 다른 기능을 수행하는 빌더 구현체를 정의한 후 클라이언트가 적절한 빌더 객체에게 복합 객체를 요청하도록 한다. 클라이언트는 인터페이스를 통해 다양한 형태의 복합 객체를 얻을 수 있다.

빌더 패턴에서 클라이언트는 다음과 같이 복합 객체 구성을 자유롭게 수행할 수 있다. 제품 객체의 생성 및 초기화(프로퍼티 설정)를 클라이언트가 수행한다.

  1. 클라이언트는 복합 객체의 프로퍼티 설정을 접근 제한 없이 자유롭게 수행할 수 있다.
  2. 클라이언트는 복합 객체의 프로퍼티 설정을 순서 설정 및 초기값 설정에 대한 제약 없이 자유롭게 수행할 수 있다.


그러나 빌더 패턴의 이러한 동작 방식에서 복합 객체의 구성(또는 표현)을 변경하고자 하는 경우 새로운 제품 객체를 생성하도록 클라이언트 코드를 수정해야 한다. 따라서 복합 객체의 변경은 클라이언트의 변경을 유발한다. 즉, 클라이언트는 복합 객체에 의존한다.

이러한 문제는 클라이언트가 직접 제품 객체들을 생성하고 조합하기 때문이다. 클라이언트에게 복합 객체 구성에 대한 제한을 두지 않는 것이 항상 문제가 되지는 않지만 올바른 복합 객체 초기화를 위해 별도의 객체 정의가 필요하기도 하다. 즉, 클라이언트는 직접 제품 객체들을 특정 순서대로 초기화할 수도 있지만 이를 별도의 책임으로 분리할 수도 있다. 특정 순서대로 제품 객체들을 인스턴스화(제품 객체 생성 및 초기값 설정)하도록 강제하고 그러한 책임을 별도로 분리하기 위해 디렉터(director)라는 클래스를 선택적으로 정의하기도 한다. 디렉터는 특정 순서로 제품 객체를 생성하는 책임만 있다.

class Director {
  public constructComplexObject(Builder builder) {
    builder.setProduct1(new Product1());
    builder.setProduct2(new Product2());
  }
}

class Builder {
  Product1 product1;
  Product2 product2;
  
  public void setProduct1() {
    this.product1 = new Product1();
  }
  
  public void setProduct2() {
    this.product2 = new Product2();
  }
  
  public ComplextObject getComplextObject() {
    return new ComplexObject(product1, product2);
  }
}

class Client {
  public getComplexObject() {
    Director director = new Director();
    Builder builder = new Builder();
    director.constructComplexObject(builder);
    
    ComplexObject complexObject = builder.getComplexObject();
  }
}


빌더 패턴에 디렉터를 도입하면 클라이언트 클래스에서 제품 객체 생성 코드를 추출하여 디렉터라는 별도의 객체로 이동시킬 수 있다. 클라이언트는 디렉터 객체에게 제품 객체 생성을 요청하며 디렉터는 제품 객체를 생성한 후 빌더 객체의 메서드의 인자로 전달하여 빌더 객체가 제품 객체를 조합하여 복합 객체를 구성하도록 한다. 이후 빌더 객체에게 제품 객체를 요청하면 클라이언트에게 반환한다.

빌더 패턴을 사용하면 클라이언트는 복합 객체를 구성하는데 필요하거나 필요하지 않은 제품 객체가 무엇인지에 대해서 알 필요가 없다. 클라이언트가 복합 객체의 구성(또는 표현)이 변경된 복합 객체를 조회하고자 하는 경우, 새로운 제품 객체를 생성하여 복합 객체를 구성하는 빌더 객체에게 요청하면 된다. 따라서 복합 객체의 변경은 클라이언트의 변경을 유발하지 않는다. 즉, 클라이언트는 복합 객체에 의존하지 않는다.

class Director {
  public constructComplexObject(Builder builder) {
    builder.setProduct1(new Product1());
    builder.setProduct2(new Product2());
  }
}

class interface Builder {
  setProduct1();
  setProduct2();
  ComplextObject getComplextObject();
}

class BuilderA implements Builder {
  Product1 product1;
  Product2 product2;
  
  public void setProduct1() {
    this.product1 = new Product1();
  }
  
  public void setProduct2() {
    this.product2 = new Product2();
  }
  
  public ComplextObject getComplextObject() {
    return new ComplexObject(product1, product2);
  }
}

class BuilderB implements Builder {
  Product1 product1;
  Product2 product2;

  public void setProduct1() {
    this.product1 = new Product1();
  }
  
  public void setProduct2() {
    this.product2 = new Product2();
  }
  
  public ComplextObject getComplextObject() {
    return new ComplexObject(product1, product2);
  }
}

class Client {
  public getComplexObject() {
    Director director = new Director();

    Builder builderA = new BuilderA();
    director.constructComplexObject(builderA);
    
    ComplexObject complexObject = builderA.getComplexObject();

    Builder builderB = new BuilderB();
    director.constructComplexObject(builderB);
    
    ComplexObject complexObject = builderB.getComplexObject();
  }
}


앞서 빌더 패턴이란 복합 객체를 만드는 동일한 구성 프로세스가 서로 다른 표현을 만들 수 있도록 복합 객체를 구성하는 로직과, 표현을 선택하는 로직을 분리하는 것이라고 했다. 복합 객체를 서로 다르게 표현한다는 것은 동일한 인터페이스를 구현하는 서로 다른 빌더 객체에게 복합 객체 생성을 요청하면 빌더 객체는 서로 다른 제품 객체를 생성 및 조합하여 복합 객체를 다르게 구성한다는 의미이다. 구성 프로세스가 동일하다는 것은 빌더 객체가 제품 객체를 생성하는 순서를 정하고 이를 따르도록 하는 것을 의미한다. 디렉터 객체를 사용하는 경우 제품 객체의 인스턴스화 순서는 디렉터 객체가 정하고 빌더 객체는 정해진 순서에 따라 제품 객체를 생성 및 조합하여 복합 객체를 클라이언트에게 반환한다.


빌더 패턴과 객체의 올바른 상태 유지

빌더 패턴은 제품 객체를 직접 생성하고 복합 객체를 구성하는 책임을 클라이언트가 아닌 빌더에게 이동시키는 목적도 있지만 클라이언트가 직접 불필요한 제품 객체를 초기화한 후 복합 객체의 프로퍼티로 할당하는 과정을 수행하지 않도록 하는 목적도 있다. 복합 객체가 어떤 프로퍼티를 반드시 가져야 하는지, 어떤 프로퍼티는 선택적으로 가져도 되는지에 대한 관심은 클라이언트가 아닌 빌더 객체에게 있다. 클라이언트는 빌더 객체에게 원히는 제품 객체를 요청한 후 올바르게 초기화된 또는 의존 객체의 주입이 완료된 복합 객체를 반환받아 사용하기만 하면 된다.

빌더 패턴을 통해 복합 객체들을 생성하는 동안 클라이언트는 미완성된 복합 객체에 접근할 수 없다. 따라서 클라이언트가 불완전한 복합 객체를 반환받고 이를 사용함으로써 발생하는 예기치 않은 동작들을 막을 수 있다.

복합 객체가 올바르게 초기화되도록 프로퍼티를 검증하는 코드를 빌더 객체 내에 위치시킬 수도 있다. 빌더 객체의 초기화된 제품 객체를 인자로 받아 빌더 객체의 멤버 변수로 설정하는 setter 메서드나 최종적으로 복합 객체의 생성자를 호출하여 복합 객체를 인스턴스화하는 메서드에서 복합 객체의 상태 검증을 수행할 수 있다. 하지만 이 경우 SOLID 원칙 중 단일 책임 원칙(SRP)을 위반하는지 고려해봐야 한다. 복합 객체의 상태를 검증하는 코드는 대부분 해당 복합 객체와 관련된 비즈니스 로직이므로 빌더 객체와 비즈니스 요구 사항을 결합시키게 된다. 빌더 객체는 복합 객체를 구성하기 위해 제품 객체들을 생성 및 조합하는 책임만 수행하도록 할지, 아니면 복합 객체의 상태 검증까지 수행하도록 할지 고민이 필요하다. 대안으로 복합 객체의 프로퍼티를 검증하는 코드를 복합 객체(도메인 모델 또는 엔티티) 내에 위치시킬 수도 있다.


롬복의 @Builder 어노테이션

자바 라이브러리인 롬복(Lombok)은 빌더 패턴을 사용하여 자바 객체를 구성하는 기능을 제공하기 위해 @Builder라는 어노테이션을 제공한다. @Builder 어노테이션은 빌더 패턴을 통해 클래스를 인스턴스화(복합 객체를 생성)하는데 필요한 코드를 자동으로 생성해준다. 사용 예는 다음과 같다.

@Builder
class Person {
  private String name;
  private String city;
  private List<String> job; 
}

Person.builder()
  .name("Adam Savage")
  .city("San Francisco")
  .job("Mythbusters")
  .job("Unchained Reaction")
  .build(); 


@Builder 어노테이션이 자동 생성하는 메서드는 다음과 같다.

  • builder(): 복합 객체를 생성하는 빌더 객체를 반환한다. 복합 객체 내에 정의된다.
  • 프로퍼티명과 동일한 이름의 메서드: 빌더 객체의 setter 메서드와 동일하다. 인자로 전달받은 제품 객체를 빌더 객체의 멤버 변수에 할당한다. 빌더 객체 내에 정의된다.
  • build(): 복합 객체를 생성한 후 반환한다. 빌더 객체 내에 정의된다.


기본적으로 복합 객체의 프로퍼티를 설정하지 않으면 0, false, null과 같이 해당 타입의 기본값이 설정된다. @Builder.Default를 사용하여 기본값을 직접 지정할 수 있다.

@Builder 어노테이션을 클래스에 설정하는 경우 다음과 같은 동작이 가능하다.

  • 모든 프로퍼티를 인자로 받는 기본 생성자를 만든다. 따라서 클라이언트는 복합 객체의 모든 프로퍼티를 설정할 수 있다.
  • 클라이언트가 반드시 설정해야 하는 필수 프로퍼티를 명시적으로 지정할 수 없다.


빌더 메서드를 사용자 정의하거나 생성자 또는 메서드에 @Builder 어노테이션을 설정함으로써 클라이언트의 프로퍼티 설정에 대한 제약을 할 수 있다.


특정 프로퍼티 설정 제한

@Builder 어노테이션을 클래스 대신 생성자에 사용하고 생성자의 인자를 복합 객체의 일부 프로퍼티로만 정의하는 경우 클라이언트는 복합 객체의 특정 프로퍼티만 설정할 수 있다. 즉, @Builder 어노테이션을 생성자에 사용하는 경우 클라이언트는 생성자의 인자로 정의한 프로퍼티만 설정 가능하다. 기본 생성자에 @Builder 어노테이션을 사용한다면 클라이언트는 모든 프로퍼티를 설정할 수 없으며 빌더 객체가 반환하는 복합 객체만 받을 수 있게 된다. 프로퍼티 중 일부 또는 전체를 클라이언트가 설정하지 못하도록 하기 위해 이 방법을 사용한다.

class ComplexObject {
  private Product1 product1;
  private Product2 product2;
  private Product3 product3;

  @Builder
  public ComplexObject(Product2 product2, Product3 product3) {
    this.product2 = product2;
    this.product3 = product3;
  }
}

class Client {
  public getComplexObject() {
    ComplexObject complexObject = ComplexObject.builder()
      // 프로퍼티 설정이 불가능하다.
      // .product1(new Product1())
      .product2(new Product2())
      .product3(new Product3())
      .build();
  }


프로퍼티 변경 처리

자동으로 생성되는 메서드를 사용하는 대신 사용자 정의하여 복합 객체 구성 시 특정 프로퍼티를 변경 처리하거나 추가적인 로직을 실행할 수 있다.

class ComplexObject {
  private Product1 product1;
  private Product2 product2;
  private Product3 product3;

  @Builder
  public ComplexObject(Product1 product1, Product2 product2, Product3 product3) {
    this.product1 = product1;
    this.product2 = product2;
    this.product3 = product3;
  }
  
  public static class ComplexObjectBuilder {
    private Product1 product1;
    private Product2 product2;
    private Product3 product3;
  
    public ComplexObjectBuilder product1(Product1 Product1) {
      프로퍼티 처리 로직
      this.product1 = product1;
      return this;
    }
  } 
}

class Client {
  public getComplexObject() {
    ComplexObject complexObject = ComplexObject.builder()
      .product1(new Product1())
      .product2(new Product2())
      .product3(new Product3())
      .build();
  }


필수 프로퍼티 설정

기본으로 제공하는 빌더 메서드를 사용자 정의하여 복합 객체의 필수 프로퍼티 설정을 강제할 수 있다. 클라이언트가 필수 프로퍼티는 반드시 설정하도록 하고 선택 프로퍼티는 선택적으로 설정하도록 하기 위해 이 방법을 사용한다. 이 방법은 생성자 호출 시 인자의 종류와 순서를 알아야하는 기존 복합 객체 구성 방식과 동일하다.

class ComplexObject {
  // 필수 프로퍼티
  private Product1 product1;
  // 선택 프로퍼티
  private Product2 product2;
  private Product3 product3;

  @Builder
  public ComplexObject(Product2 product2, Product3 product3) {
    this.product2 = product2;
    this.product3 = product3;
  }
  
  public static ComplexObjectBuilder builder(Product1 product1) {
    ComplexObjectBuilder complexObjectBuilder = new ComplexObjectBuilder();
    complexObjectBuilder.product1(product1);
    return complexObjectBuilder;
  } 
}

class Client {
  public getComplexObject() {
    ComplexObject.builder(new Product1())
      .product2(new Product2())
      .product3(new Product3())
      .build();
  }


기본 빌더 메서드 대체

@Builder 어노테이션을 클래스나 생성자 대신 메서드에 사용하는 경우 기본 빌더 메서드 생성을 막고


코틀린의 빌더 패턴

코틀린의 클래스는 주 생성자와 하나 이상의 부 생성자를 가질 수 있다. 주 생성자는 클래스 헤더의 일부이며, 클래스 이름과 선택 타입 파라미터 뒤에 위치한다.

class Person constructor(firstName: String) { ... }


코틀린의 생성자는 다음과 같이 사용할 수 있다.

class Person(
  val firstName: String,
  val lastName: String,
  var age: Int,
) { ... }


코틀린은 자체적인 문법으로 빌더 패턴을 적용하여 복합 객체를 구성한다. 따라서 자바처럼 롬복의 @Builder 어노테이션 설정이 필요하지 않다.


참고

Comments