본문 바로가기
Java

[Effective java] item2 : 생성자에 매개변수가 많다면 빌더를 고려하라

by supniverse 2021. 3. 1.

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

도입

많은 사용자들이 무심코 @Builder를 선언하여서, 해당 객체의 빌더를 만들어서 쓰는 경우가 많다. 대부분 처음 시작하는 사람들이 유명한 강사님들의 강의를 보고 아! 이렇게 쓰는구나 하고 그냥 쓰는 경우가 대부분일 것이다. 이번 시간을 통해 왜 빌더패턴을 쓰게 되었고 무엇이 좋길래 많은 분들이 그렇게 애용하는지에 대해 알아보도록 하자.

생성자

대부분 어떠한 객체를 만들 때는 생성자 new Human() 이렇게 객체를 생성하고, 초기 생성자에 반드시 들어가야 할 필요 정보들을 넣어주고 객체를 생성하였었다. 그래서 이에 대해 초기 생성 시에 필요한 정보들을 넣어줄 때, 초기 필요정보의 구성이 달라 점층적 생성자 패턴을 통해 이 문제를 해결에 나갔다.

public class Human {

    private int age;
    private String name;
    private int weight;
    private int height;

    // 기본 생성자
    public Human() {}    

    // 나이, 이름을 이용한 초기화
    public Human(int age, String name) {
        this.age = age;
    this.name = name;
  }

    // 전체 속성을 이용한 초기화
    public Human(int age, String name, int weight, int height){
    this.age = age;
    this.name = name;
    this.weight = weight;
    this.height = height;
  }
}

요구사항에 따라 필수 매개 변수만 받는 생성자, 1개, 2개 ~ 모든 매개변수를 받는 생성자가 필요하게 된다.

Human human = new Human(40, sup, 80, 180);

사용자는 여기서 자신이 원하는 생성자를 골라 위처럼 호출하면 된다.

하지만, 대부분 사용자가 정말로 원하는 생성자 매개변수만 입력하여서 호출할 수 있는 경우는 없었다. 대부분 그래서 자신이 원치 않는 매개변수에는 0 혹은 null을 넣어서 생성자를 호출하고 나도 그런 경우가 많았었다.

조금 더 깔끔하게 객체를 생성하고 싶은 경우가 있는데 저런 경우에는 일단 길이도 길어지고 굳이 넣을 필요 없는 데이터를 추가해주기에 보기가 싫은 경우가 많았다.

 

더욱이, Human이 가진 속성이 15개 20개 막 이렇게 늘어나게 된다면 어떡할 것인가? 물론 객체지향에선 이 부분들이 또 잘게 쪼개지겠지만, 만약 어쩔 수 없는 클래스에 속성들이 이렇게나 많다면, 이 모든 조합을 생성자로 만들 수 없을 것이니 사용자는 엄청난 매개변수를 넣는 생성자를 호출하여 객체를 생성할 것이다.

이렇게 되면 실수로 매개변수의 순서를 잘못 넣었는데, 우연히 같은 타입의 매개변수가 입력되어 컴파일 타임에 발견되지 못한다면 버그로 이어질 가능성까지도 있다.(item 51) 또한 해당 값이 무엇을 셋팅하는지?(정적 팩토리 메서드처럼), 혹은 파라미터로 넘겨준 값이 명시적으로 무엇인지 알지 못하기 때문에 명확하지 않다.

setter

그래서 다음으로 생각해 보는 것은 setter를 이용한 자바 빈즈 패턴(javaBeans pattern)이다.

setter를 이용하면 내가 원하는 속성만을 setter를 이용해서 설정해 줄 수 있다.

기본 생성자로 객체를 생성하고 그 이후에 변수를 이용해 setter로 내가 원하는 속성만 설정해주는 것이다.

public class Human {
    // 매개변수는 기본값으로 초기화
    private int age = -1;      // 필수
    private String name = -1;     // 필수
    private int weight = 0;
    private int height = 0;

	  // 기본 생성자
    public Human() {}    

    public void setAge(int age) {
        this.age = age;
    }
    
    public void setName(String name) {
        this.name = name;
    }
    
    public void setWeight(int weight) {
        this.weight = weight;
    }
    
    public void setHeight(int height) {
        this.height = height;
    }
}
Human human = new Human();
human.setAge = 65;
human.setName = sup;
human.setWeight = 80;
human.setHeight = 200;

이런 식으로 클래스를 만들고 사용대상은 해당 클래스를 생성하여 속성들을 설정해준다.

일단 생성자를 이용한 점층적 생성자패턴 보다는 훨씬 깔끔해 보인다. 내가 원하는 속성들만 골라서 설정해줄 수 있고 명시적이다.

하지만, 여기서도 문제점은 있다.

  • set을 해주기 위해 코드가 장황해진다.
  • 안정적이지 않을 수 있다. (필요한 속성들에 대한 설정이 받아지지 않을 수 있다.)
    1번의 함수 호출로 속성들이 객체 구성이 끝나지 않으므로, 일관성이 무너진 상태이다.
  • setter가 있기 때문에 불변 클래스로 만들지 못한다. (setter를 이용해서 변경되기 때문에 불변이 되지 못한다.) (item17)
  • setter로 있기 때문에 thread safe하지 않다. (setter을 할 경우 lock을 걸어야 한다.)
    • 그래서 이걸 해결하기 위해 freeze 라는 자바스크립트의 기능을 사용할 수 있는데 자바에서는 해당 기능을 제공하지 않는다.

이런 문제로 인해 setter를 이용하여서 쓰기에는 따르는 제약들이 많다.

 

builder

위의 예시의 이유들로 인해 생성자의 매개변수가 많을 경우에 builder를 이용하여서 생성하라라고 책에서는 추천하고 있다. builder는 점층적 생성자 패턴 의 안정성과 자바 빈즈 패턴 의 선택적 설정을 통한 가독성을 겸비한 빌더 패턴 이다. 어떻게 사용하는 것일까?

클라이언트는 필요한 객체를 직접 만드는 대신, 필수 매개 변수만으로 생성자를 호출해 원하는 객체가 아닌 해당 객체의 builder를 얻는다. 그런 다음 빌더 객체가 제공하는 일종의 setter 메서드를 이용해 선택적으로 매개변수를 설정한다. 그리고 다 설정했다면 마지막으로 build 를 호출해서 객체를 가져온다.

public class Human {

  private final int age;
  private final String name;
  private final int weight;
  private final int height;

  public static class Builder {
    // 필수 매개변수
    private final int age;
    private final String name;

    // 선택 매개변수 - 기본 값으로 초기화한다.
    private final int weight = 0;
    private final int height = 0;

    public Builder(int age, String name) {
      this.age = age;
      this.name = name;
    }   

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

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

  private Human(Builder builder) {
      age = builder.age;
      name = builder.name;
      weight = builder.weight;
      height = builder.height;
  }
}

위처럼 클래스는 속성들은 불변의 형태로 선언하여 사용할 수 있다.

Human human = new Human.Builder(40, "sup").weight(70).height(80).build;

간단하게 꼭 받아야 하는 매개변수들을 받고, 선택적으로 사용자가 원하는 매개변수들을 선언해 줄 수 있다.

또한, 메서드 호출이 흐르듯 연결되는 fluent API 혹은 메서드 체이닝(method chaining)을 활용해 보기에도 간편하게 보일 수 있다. 이 빌더 패턴은 명명된 선택적 매개변수 (named optional parameters)라는 파이썬과 스칼라에 있는 것을 흉내 낸 것이라고 한다.

 

그리고 코드에는 해당 기능이 생략되었지만 build를 호출했을 때 각 매개변수에 대해 유효성 검사를 할 수 있다.

아니면 builder의 생성자와 각 매개변수의 set을 할 때 유효성을 검사하고, 마지막 build를 호출할 때 여러 매개변수 간의 불변식(invariant)도 검사할 수 있다. 이렇게 불변식을 보장하려면, 빌더로부터 매개변수를 복사한 후 해당 객체 필드들을 검증해야 한다.(item50) 검사해서 잘못된 점을 발견하면 어떤 필드가 잘못되었는지 자세히 알려주는 메시지를 담아서 IllegalArgumentException 을 던지면 된다.(item75)

  • 불변은 어떠한 변경도 허락하지 않는다는 뜻이다. 대표적으로 String 객체는 한번 만들어질 수 없는 불변 객체이다.
  • 한편, 위의 언급한 불변식(invariant)은 프로그램이 실행되는 동안, 혹은 정해진 기간 동안 반드시 만족해야 하는 조건을 말한다. 더 설명하면 변경을 허용할 수는 있으나 주어진 조건 내에서만 허용하는 뜻이다.
    예를 들어 리스트 크기는 항상 0이어야 한다. 음수이면 불변 식이 깨진 것이다. 또 예약을 할 때 start와 end가 있다면 항상 start가 end보다 전이어야 한다. end가 start보다 전이면 불변 식이 깨진 것이다.
  • 따라서 넓게 보면 불변은 불변식의 극단적인 예이다.

이렇게 3가지의 생성자를 만들 수 있는 예를 보았고 왜 builder를 써야 하는지 생각해 보았다.

그럼 builder를 쓰게 되었으니 이왕이면 어떤 곳에서 어떻게 하면 더 자를 수 있는지도 알아보자

 

builder의 응용

빌더 패턴은 계층적으로 설계된 클래스와 함께 쓰기에 좋다.  말로만 들으면 무슨 뜻인지 모른다. 그러니 코드를 통해 알아보자.

아래 Pizza와 이를 상속한 NyPizza와 Calzone가 있다.

public abstract class Pizza {

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

  abstract static class Builder<T extends Builder<T>> {         // 재귀적 타입 한정(item 30)을 사용하는 제네릭 타입
     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();        // 하위 클래스에서 형변환하지 않고 메서드를 연쇄 지원하기 위함 (simulated self-type)	
  }

  Pizza(Builder<?> builder) {
     toppings = builder.toppings.clone();
  }
}

 

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
    Pizza build() {
      return new NyPizza(this);
    }

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

  NyPizza(Builder builder) {
    super(builder);
    size = builder.size;
  }
}

 

public class Calzone extends Pizza{

  private final boolean sauceInside;

  public static class Builder extends Pizza.Builder<Builder> {
  
    private boolean sauceInside = false;

    public Builder() {
      sauceInside = true;
    }

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

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

  Calzone(Builder builder) {
    super(builder);
    sauceInside = builder.sauceInside;
  }
}

하위 클래스 Builder의 build 메서드는 해당하는 구체 하위 클래스를 반환하도록 하고 있다.

NyPizza.Builder는 NyPizza를 Calzone.Builder는 Calzone를 반환한다.

 

여기서 볼 수 있는 건 하위 클래스의 메서드가 상위 클래스의 메서드가 정의한 반환 타입이 아닌(Pizza가 아닌) , 그 하위 타입(NyPizza, Calzone)을 반환하는 기능을 공변 반환 타입(covariant return typing)이라고 한다. 이 공변 반환 타입을 사용하면 클라이언트가 후에 하위 타입을 빌더로 반환받을 때 형 변환에 신경 쓰지 않고도 빌더를 사용할 수 있다.

NyPizza nyPizza = new NyPizza.Builder(NyPizza.Size.MEDIUM)
                             .addTopping(Pizza.Topping.SAUSAGE)
                             .addTopping(Pizza.Topping.PEPPER)
                             .build();
                             
Calzone calzone = new Calzone.Builder()
                             .addTopping(Pizza.Topping.HAM)
                             .sauceInside()
                             .build();

 

이렇게 유연하게 빌더 하나로 여러 매개 변수를 설정하기도 하고 메서드를 이용해 내부 매개 변수를 설정하기도 하며 사용할 수 있고 또한 이 빌더 하나로 여러 객체를 순회하면서 만들 수 있고 빌더에 넘기는 매개변수에 따라 다른 객체를 만들 수도 있다.

물론 이렇게 좋은 점만 있는 것은 아니다. 단점이 있기도 하다.

  • 빌더 생성 비용이 크지는 않지만, 성능에 민감하다면 빌더를 생성하는 것이 문제가 된다.
  • 점층적 생성자 패턴보다는 코드가 장황하다. 4개 이상 정도의 매개변수가 있을 때 쓰기가 좋다
    다만, API는 점점 시간이 지날수록 매개변수가 많아지는 경향이 있다.
  • 또한 해당 Builder를 만들기 위해서 작성해야 할 코드가 많다.

 

이렇게 빌더 패턴을 사용해야 하는 이유와 방법들에 대해서 알아보았다.

많은 매개변수를 이용해서 객체를 생성할 때에 어떠한 방법이 좋을지 고민했다면 빌더 패턴을 이용하는 것을 추천하겠다. 물론 적은 양은 점층적 생성자 패턴을 쓸 수 있지만, 나중에 매개변수가 늘어나는 것을 고려해 보고 확장할 여지가 있다면 빌더패턴을 쓰는 것을 추천하겠다.

 

출처

댓글