생성자에 매개변수가 많다면 빌더를 고려하라

김종준·2023년 3월 16일
0

이펙티브자바

목록 보기
2/63

생성자에 매개변수가 많다면 빌더를 고려하라

점층적 생성자 패턴을 사용할 수 있지만, 매개변수 개수가 많아지면 클라이언트 코드를 작성하거나 읽기 어려워진다.

그리고 타입이 같은 매개변수가 연달아 늘어서 있으면 찾기 어려운 버그로 이어질 수 있다.

클라이언트가 실수로 매개변수의 순서를 바꿔도 컴파일러는 알 수 없고, 결국 런타임에 엉뚱한 동작을 하게 된다.

자바빈즈 패턴

매개변수가 없는 생성자로 객체를 만든 후, 세터 메서드를 호출해 원하는 매개변수의 값을 설정하는 방식이다.

이는 객체 하나를 만들기 위해 메서드 여러 개를 호출해야 하고, 객체가 완전히 생성되기 전까지는 일관성이 무너진 상태에 놓이게 된다는 단점이 있다.

그렇기에 자바빈즈 패턴에서는 클래스를 불변으로 만들 수 없으며 스레드 안정성을 얻으려면 프로그래머가 추가 작업을 해줘야 한다.

빌더 패턴

필요한 객체를 직접 만드는 대신, 필수 매개 변수만으로 생성자를 호출해 빌더 객체를 얻는다.

그런 다음 빌더 객체가 제공하는 일종의 세터 메서드들로 원하는 선택 매개변수들을 설정한다.

마지막으로 매개변수가 없는 build 메서드를 호출해 객체를 얻는다.

빌더는 생성할 클래스 안에 정적 멤버 클래스로 만들어 두는게 보통이다.

빌더의 세터 메서드들은 빌더 자신을 반환하기 때문에 연쇄적으로 호출할 수 있다.

이런 방식의 호출이 흐르듯 연결된다는 뜻으로 플루언트 API 혹은 메서드 연쇄라 한다.

유효성 검사의 경우 빌더의 생성자와 메서드에서 입력 매개변수를 검사하고, build 메서드가 호출하는 생성자에서 여러 매개변수에 걸친 불변식을 검사한다.

공격에 대비해 이런 불변식을 보장하려면 빌더로부터 매개변수를 복사한 후 해당 객체 필드도 검사해야 한다.

이런 빌더 패턴은 계층적으로 설계된 클래스와 함께 쓰기 좋다.

하지만 빌더 패턴에 장점만 있는 것은 아니다.

객체를 만들려면 빌더 부터 만들어야 하고 이는 성능이 민감한 상황에서는 문제가 될 수 있다.

그럼에도 불고하고 매개변수가 많아지면 빌더 패턴으로 전환하는 것 보다 애초에 빌더로 시작하는 것이 시간이 지날수록 매개변수가 많아지는 API 특성상 더 좋은 경우가 많다고 한다.

Lombok @Builder

@Builder 어노테이션은 우리의 클래스를 위해 복잡한 빌더 API를 생성해 준다.

즉, @Builder 만으로도 아래와 같은 코드를 사용할 수 있도록 해주는 것이다.

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

이러한 @Builder의 경우 클래스, 생성자 또는 메서드에 사용할 수 있다.

@Builder는 "method" 유스케이스를 통해 가장 쉽게 설명할 수 있다.

@Builder는 다음 7가지 항목을 생성한다.

  • builder라는 정적 메서드와 함께 FooBuilder 이름을 가진 inner static class를 생성한다.
  • target의 각 필드에 대응되는 non-static non-final 필드를 생성한다.
  • package 접근 제어자를 가지고 있는 빈 생성자를 생성한다.
  • target의 setter와 유사한 메서드를 제공한다.
    하지만 이 메서드는 builder 그 자신을 리턴한다는 특징이 있다.
    그렇기에 이를 체인과 같은 형태로 사용할 수 있다.
  • build 메서드는 각 필드를 전달하여 메서드를 호출한다.
    이 메서드는 targer이 반환하는 것과 동일한 타입을 반환한다.

위에 나열된 각 요소는 이미 존재한다면 자동으로 건너뛴다.(매개변수 개수를 무시하고 이름만 확인한다.)

예시를 통해 확실히 이해해보자.

우선 @Builder를 사용하면 얻는 가장 기본적인 코드이다.

  public static class PersonBuilder {
      private String name;
      private String city;

      PersonBuilder() {
      }

      public PersonBuilder name(final String name) {
          this.name = name;
          return this;
      }

      public PersonBuilder city(final String city) {
          this.city = city;
          return this;
      }

      public Person build() {
          return new Person(this.name, this.city);
      }

      public String toString() {
          return "Person.PersonBuilder(name=" + this.name + ", city=" + this.city + ")";
      }
  }

이때 city의 값을 항상 대문자로 유지하고 싶어 아래와 같이 구현하였다고 가정해보자.

@Builder
@Getter
public class Person {

    private String name;
    private String city;

    public static class PersonBuilder {

        private String city;

        public PersonBuilder city(String city) {
            this.city = city.toUpperCase();
            return this;
        }
    }
}

이런 상황에서 @Builder는 위에서 이미 구현한 부분을 제외한 나머지 부분의 구현을 완성하여 @Builder를 사용할 수 있도록 해준다.

이 역시 확인해보자.

public static class PersonBuilder {
      private String name;
      private String city;

      public PersonBuilder city(String city) {
          this.city = city.toUpperCase();
          return this;
      }

      PersonBuilder() {
      }

      public PersonBuilder name(final String name) {
          this.name = name;
          return this;
      }

      public Person build() {
          return new Person(this.name, this.city);
      }

      public String toString() {
          return "Person.PersonBuilder(name=" + this.name + ", city=" + this.city + ")";
      }
  }

Lombok은 @Builder를 통해 빌더 객체를 만들 때 컬랙션 파라미터/필드를 위한 @Singular라는 어노테이션을 지원한다.

@Builder
@Getter
public class Person {

    private String name;
    private String city;
  	@Singular
  	private List<String> job;
}

이는 전체 리스트를 통해 받는 것이 아닌 하나의 요소를 받아 리스트에 추가할 수 있도록 해준다.

// singular O
Person.builder()
  .job("Mythbusters")
  .job("Unchained Reaction")
  .build();

// singular x
Person.builder()
  .job(List.of("Mythbusters", "Unchained Reaction"))
  .build();

클래스에 @Builder를 적용하는 것은

마치 클래스에 @AllArgsConstructor(access = AccessLevel.PACKAGE)를 추가하고

AllArgsConstructor에 @Builder 어노테이션을 적용한 것과 같다.

다만 이 방법은 명시적 생성자를 직접 생성하지 않은 경우에만 작동한다.

명시적 생성자가 있는 경우 클래스가 아닌 생성자에 @Builder를 사용해야 한다.

그리고 @Value와 @Builder를 모두 사용한다면 @Builder가 생성하려는 package-private 생성자가 승리하고

@Value가 생성하는 생성자는 억제된다는 점에 유의해야 한다.

@Builder을 사용하여 자신의 클래스의 인스턴스를 생성하는 빌더를 생성하는 경우,

@Builder(toBuilder = true)를 사용하여 클래스의 인스턴스 메서드도 생성하면 이 인스턴스 의 모든 값으로 시작하는 새로운 빌더가 생성된다.

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

// name과 city만 변경된 새로운 객체를 생성
Person otherP = p.toBuilder()
  								.name("Adam Savage")
  								.city("San Francisco")
  								.build();

빌드 세션 중에 특정 필드/파라미터가 설정되지 않는 경우, 항상 0 / null / false를 반환하는데 @Builder.Default는 이들의 기본값을 지정할 수 있도록 해준다.

@Builder.Default private final long created = System.currentTimeMillis();

지금까지 살펴본 @Builder는 한 가지 아쉬운 점이 있다.

위의 빌더 패턴을 소개할 때 "필요한 객체를 직접 만드는 대신, 필수 매개 변수만으로 생성자를 호출해 빌더 객체를 얻는다."라는 설명을 한 적이 있다.

하지만 지금 기본 설정의 @Builder에는 필수 매개변수를 받을 방법이 없다.

마지막으로 필수 매개 변수를 받는 @Builder를 만들어보자.

@Builder(builderMethodName = "internalBuilder")
public class Person {
  @NonNull
  private String name;
  private String city;
  
  public static PersonBuilder builder(String name) {
    return internalBuilder().name(name);
  }
}

builderMethodName라는 옵션을 통해 @Builder가 만들어주는 builderinternalBuilder로 변경하였다.

대신 builder를 위와 같이 internalBuilder를 활용하여 name 값을 필수로 받는 메서드로 변경하고 internalBuilder를 통해 빌더 객체를 반환하도록 변경하면 필수 매개변수를 받는 @Builder를 만들 수 있다.

0개의 댓글