제육's 휘발성 코딩
article thumbnail
반응형

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

정적 팩터리 메서드와 생성자에는 매개변수가 많을수록 대응이 어렵다는 공통점이 있다. 예를 들어, 개발자가 실수로 매개변수의 값의 순서를 다르게 대입했으나, 하필 타입이 같으면 컴파일 오류가 발생하지 않는 경우가 있다.

이 문제를 해결하기 위한 대안으로는 자바 빈즈 패턴빌더 패턴이 존재한다.

생성자나 정적 팩터리가 처리해야 할 매개변수가 많다면 빌더 패턴을 선택하는 것이 바람직하다.

 

점층적 생성자 패턴

점층적 생성자 패턴은 필수 매개변수만 받는 생성자를 시작으로 선택 매개변수를 늘려가는 방식으로 매개변수가 많아질수록 클라이언트 코드를 작성하기 어렵다.

public class Book {
  private final String title;
  private int isbn;
  private int price;

  public Book(String title) {
    this(title, 0, 0);
  }

  public Book(String title, int isbn) {
    this(title, isbn, 0);
  }


  public Book(String title, int isbn, int price) {
    this.title = title;
    this.isbn = isbn;
    this.price = price;
  }
}
  • 필수 매개변수인 title을 기점으로 선택 매개변수를 늘려가는 방식

 

자바 빈즈 패턴

NutritionFacts cola = new NutritionFacts();
cola.setSize(100);
cola.setServings(8);
cola.setSodium(36);
  • 세터를 활용하여 자바빈즈를 구현할 수 있다.

자바 빈즈를 사용해서 점층적 생성자 방식에 비해 가독성과 사용성이 증가되었다. 하지만 객체 하나를 만들기 위해 메서드를 여러 개 호출해야하고, 객체가 완성되기 전까지 일관성이 무너진 상태에 놓이는 치명적 단점이 발생한다. 이는 곧 클래스를 불변으로 만들 수 없게 되며, 스레드의 안정성을 얻기 위해 추가적으로 작업이 필요해진다.

따라서 freezing 기법을 사용하여 생성이 끝난 객체를 얼리고, 얼리기 전에는 사용할 수 없는 방법을 이용해서 해결하기도 하는데, 거의 쓰이지 않는다. 또한 컴파일러가 freeze 메서드 호출 여부를 보증하지 못한다.

 

빌더 패턴

빌더 패턴이란 복잡한 객체 생성 방식을 정의하는 클래스를 분리하여, 서로 다른 표현이라도 이를 생성할 수 있는 동일한 절차를 제공하는 디자인 패턴(생성 패턴)이다.

클라이언트는 필요한 객체를 직접 만드는 대신, 필수 매개변수만으로 생성자 또는 정적 팩터리를 호출하여 빌더 객체를 얻는다. 그런 다음 빌더 객체가 제공하는 일종의 세터 메서드들로 원하는 선택 매개변수들을 설정하고 build 메서드를 통해 원하는 객체를 얻는다.

 

점층적 생성자 패턴과 자바빈즈 패턴의 장점을 취한 빌더 패턴

public class NutritionFacts {
  private final int servingSize;
  private final int servings;
  private final int calories;
  private final int fat;
  private final int sodium;
  private final int carbohydrate;

  public static class Builder {
    // 필수 매개 변수
    private final int servingSize;
    private final int servings;

    // 선택 매개 변수
    private int calories = 0;
    private int fat = 0;
    private int sodium = 0;
    private int carbohydrate = 0;

    public Builder(int servingSize, int servings) {
      this.servingSize = servingSize;
      this.servings = servings;
    }

    public Builder calories(int val) {
      calories = val;
      return this;
    }

    public Builder fat(int val) {
      fat = val;
      return this;
    }

    public Builder sodium(int val) {
      sodium = val;
      return this;
    }

    public Builder carbohydrate(int val) {
      carbohydrate = val;
      return this;
    }

    public NutritionFacts build() {
      return new NutritionFacts(this);
    }
  }

  private NutritionFacts(Builder builder) {
    servingSize = builder.servingSize;
    servings = builder.servings;
    calories = builder.calories;
    fat = builder.fat;
    sodium = builder.sodium;
    carbohydrate = builder.carbohydrate;
  }
}
  • 빌더의 세터 메서드들은 빌더 자신을 반환하기 때문에 연쇄적으로 호출할 수 있다. 이러한 방식을 fluent API 혹은 method chaining이라 한다.
public class NutritionFactsTest {
    NutritionFacts nf = new NutritionFacts.Builder(240, 9)
            .calories(100).sodium(35).build();
}
  • 다음과 같이 빌더 패턴을 사용할 수 있다.

 

계층적으로 설계된 클래스의 빌더 패턴

빌더 패턴은 계층적으로 설계된 클래스와 함께 사용하기 좋다. 각 계층의 클래스에 관련 빌더를 멤버로 정의하고, 추상 클래스는 추상 빌더를 갖고, 구체 클래스는 구체 빌더를 갖게 한다. 다음 예시는 피자의 다양한 종류를 표현하는 계층구조이다.

import java.util.EnumSet;
import java.util.Objects;
import java.util.Set;

public abstract class Pizza {
  public enum Topping { HAM, MUSHROOM, ONION, PEPPER, SAUSAGE}
  final Set<Topping> toppings;

  abstract static class Builder<T extends Builder<T>> {
    EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);

    public T addTopping(Topping topping) {
      toppings.add(Objects.requireNonNull(topping));
      return self();
    }

    abstract Pizza build();

    protected abstract T self();
  }

  Pizza(Builder<?> builder) {
    toppings = builder.toppings.clone();
  }
}
  • addTopping() 을 사용하여 메서드 호출 때마다 들어오는 topping을 하나의 필드로 모아준다.

 

import java.util.Objects;

public class NyPizza extends Pizza {

  public enum Size {SMALL, MEDIUM, LARGE}
  private final Size size;

  public static class Builder extends Pizza.Builder<Builder> {
    private final Size size;

    public Builder(Size size) {
      this.size = Objects.requireNonNull(size);
    }


    @Override
    public NyPizza build() {
      return new NyPizza(this);
    }

    @Override
    protected Builder self() {
      return this;
    }
  }

  private NyPizza(Builder builder) {
    super(builder);
    size = builder.size;
  }
}
  • 각 하위 클래스의 빌더가 정의한 build() 는 상위 클래스의 반환 타입(Pizza)이 아닌 하위 타입을 반환한다. 이 기능을 공변 반환 타이핑(covariant return typing)이라 하며, 이 기능을 사용하면 클라이언트가 형변환에 신경 쓰지 않고도 빌더를 사용할 수 있다.
public class PizzaTest {

    public static void main(String[] args) {
        NyPizza pizza = new NyPizza.Builder(NyPizza.Size.SMALL)
                .addTopping(Pizza.Topping.SAUSAGE).addTopping(Pizza.Topping.ONION).build();
        Calzone calzone = new Calzone.Builder().addTopping(Pizza.Topping.HAM).sauceInside().build();
    }
}
  • 빌더를 사용하여 가변인수를 처리할 수 있다.

 

Lombok @Builder

Lombok은 보일러플레이트 메서드(getter, setter, constructor 등)를 자동으로 생성해주는 라이브러리이다. 이 중 @Builder 어노테이션은 클래스 레벨이나 생성자에 붙여주면 파라미터를 통해 자동으로 빌더 패턴을 생성해준다.

 

클래스 레벨 @Builder

@Builder
public class BuildMe {

  private String username;
  private int age;

}
  • 클래스 레벨에서 @Builder를 사용하면 모든 요소를 받는 package 생성자가 자동으로 생성자가 생성되며, 이 생성자에 어노테이션 붙인 것과 동일하게 동작한다.
public class BuildMe {
  private String username;
  private int age;

  BuildMe(String username, int age) {
    this.username = username;
    this.age = age;
  }

  public static BuildMe.BuildMeBuilder builder() {
    return new BuildMe.BuildMeBuilder();
  }

  public static class BuildMeBuilder {
    private String username;
    private int age;

    BuildMeBuilder() {
    }

    public BuildMe.BuildMeBuilder username(String username) {
      this.username = username;
      return this;
    }

    public BuildMe.BuildMeBuilder age(int age) {
      this.age = age;
      return this;
    }

    public BuildMe build() {
      return new BuildMe(this.username, this.age);
    }

    public String toString() {
      return "BuildMe.BuildMeBuilder(username=" + this.username + ", age=" + this.age + ")";
    }
  }
}
  • 바이트 코드로 변형해보면 다음과 같이 package 생성자와 BuildMeBuilder 라는 빌더 클래스가 생성된다.
  • final 키워드간 경우나 반드시 초기화되어야 하는 필드의 경우 @Builder.Default 속성을 사용하거나 선언 시점에 또는 생성자에서 초기화하는 편이 좋다.

 

생성자 레벨 @Builder

생성자 레벨에서는 생성자의 파라미터 필드에 대해서만 빌더 메서드를 생성한다.

public class BuildMe {
  private String username;
  private int age;

  public BuildMe(String username) {
    this.username = "Mr/Mrs. " + username;
    this.age = 1;
  }

  public static BuildMe.BuildMeBuilder builder() {
    return new BuildMe.BuildMeBuilder();
  }

  public static class BuildMeBuilder {
    private String username;

    BuildMeBuilder() {
    }

    public BuildMe.BuildMeBuilder username(String username) {
      this.username = username;
      return this;
    }

    public BuildMe build() {
      return new BuildMe(this.username);
    }

    public String toString() {
      return "BuildMe.BuildMeBuilder(username=" + this.username + ")";
    }
  }
}
  • age가 생성자에서 다루지 않으므로 생성된 빌더에는 age 필드에 값을 설정하는 부분이 없는 것을 볼 수 있다.

 

빌더 장점

  • 가독성과 사용성이 높아 유연한 설계가 가능 (자바빈즈보다 안전)
  • 인자에 불변식 적용이 가능
    • 클래스 변수에 final 키워드를 붙이거나 Setter를 막아두는 방식
    • build 메서드가 호출하는 생성자에서 여러 매개변수에 걸친 불변식을 검사
    • 빌더로부터 매개변수를 복사한 후 해당 객체 필드를 검사하여 문제 발생 시 메시지를 담아 예외 처리
  • 설정 메서드마다 가변인자 사용 가능

 

빌더 단점

  • 객체를 만들기 위해 빌더를 생성 (비용 발생)

REFERENCES

https://velog.io/@park2348190/Lombok-Builder%EC%9D%98-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC

https://velog.io/@kwj2435/Builder-%ED%8C%A8%ED%84%B4-%EC%82%AC%EC%9A%A9-%EC%9D%B4%EC%9C%A0

https://velog.io/@ajufresh/%EC%83%9D%EC%84%B1%EC%9E%90%EC%97%90-%EB%A7%A4%EA%B0%9C%EB%B3%80%EC%88%98%EA%B0%80-%EB%A7%8E%EB%8B%A4%EB%A9%B4-%EB%B9%8C%EB%8D%94%EB%A5%BC-%EA%B3%A0%EB%A0%A4%ED%95%98%EB%9D%BC

반응형
profile

제육's 휘발성 코딩

@sasca37

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요! 맞구독은 언제나 환영입니다^^