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

Choizz·2023년 7월 8일
0

이펙티브 자바

목록 보기
2/13

오늘은 이펙티브 자바 내용 중 빌더에 대해 포스팅하려고 합니다. 빌더 패턴을 통한 객체 생성은 이전 포스팅에서 다뤘던 적이 있어, 이번에는 개념적으로 접근해보려고 합니다.

참고: Lombok에서 @Builder을 자바 코드로 직접 만들어 보자.


1. 빌더는 언제 사용해야 할까?

객체를 생성할 시 생성자나 정적 팩토리 메서드를 사용합니다.

  • 빌더는 객체에 선택적인 매개변수가 많을 때, 고려해보면 좋습니다.

  • 선택적인 매개변수란 밑의 코드에서 Person의 name, height, weight 필드를 선택해서 객체를 생성할 필요가 있을 때, 선택적으로 매개변수를 사용하는 경우를 의미합니다.

    public class Person{
     	private String name;
        private int height;
        private int weight;
        //....
    }

    매개변수가 많아질 경우

  • 만약, 이 외에도 굉장히 많은 필드들이 Person 객체에 있다고 가정해 보면, new 생성자로 객체를 생성할 경우 굉장히 피곤해질것 같습니다.

    1) 개발자는 클라이언트 코드에서 생성자로 써야하는 매개변수의 순서가 헷갈릴수 있습니다. 물론 IDE에서 알려주기도 합니다.

    2) 코드를 읽는 것 또한 어려워 질 것입니다. 예를 들어, 자료형도 같고 필드에 담기는 값도 비슷하다면 더욱 헷갈릴 것입니다.

	Person person = new Person(name, height, weight, 주소, 성별 ...);

2. 빌더를 사용하면..

  • 만약 여기서 빌더를 사용한다고 해보면 객체를 생성하는데 사용하는 매개변수를 메서드 이름으로 명시할 수 있기 때문에 더욱 가독성이 증가합니다.
  • 또한, 클라이언트가 원하는 매개변수를 선택해서 객체를 생성할 수 있습니다.
	//메서드 체이닝을 통한 객체 생성
    //lombok의 @Builder를 사용할 경우 객체에 필수적인 필드를 지정할 수 없습니다.
	Person person = Person.builder().height(180).build();
    
    //Builder 객체에 필수적으로 들어가야할 필드(name)를 지정할 수 있습니다.
    Pserson person1 = new Builder("name").weight(70).build();

계층적 구조에서 빌더 사용

  • 계층적으로 설계된 클래스와 함께 사용하기에 좋습니다.
  • 아래 코드(백기선님 강의 참고)는 계층적인 구조를 가집니다.
    - Pizza <- Calzone

  • (1)번 코드는 제너릭을 사용해 본인 타입의 빌더를 받게합니다.
  • (2)번 코드를 보면, 일반적인 빌더는 this를 리턴하지만 상위 클래스에서 this를 사용하면 리턴 타입을 Builder<T>로 지정해야 합니다.
// this를 사용했을 경우
 public Builder<T> addTopping(Topping topping) {
 	toppings.add(Objects.requireNonNull(topping));
          
    return this;
}

abstract Pizza build();
  • 이렇게 되면, 하위 타입의 클래스에서 Pizza#addTopping()을 사용한 후 build() 메서드를 사용하게 되면 Pizza를 리턴하기 때문에, 형 변환을 해줘야 합니다.
  • 그래서, (3)처럼 self() 라는 추상메서드를 정의하고 하위 타입에서 직접 구현하여 this를 리턴하게 합니다.
public abstract class Pizza {
    public enum Topping { HAM, MUSHROOM, ONION, PEPPER, SAUSAGE }
    final Set<Topping> toppings;
	//(1)
    abstract static class Builder<T extends Builder<T>> {
        EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);
        
        //(2)
        public T addTopping(Topping topping) {
            toppings.add(Objects.requireNonNull(topping));
            
            return self();
        }

        abstract Pizza build();

        //(3)
        protected abstract T self();
    }
    
    Pizza(Builder<?> builder) {
        toppings = builder.toppings.clone(); // 아이템 50 참조
    }
}
  • 밑의 (1)번 코드를 보면, Calzone는 Pizza를 상속하기 때문에 Calzone.Builder 클래스 또한 Pizza.Builder의 하위 타입으로 문제없이 Calzone.Builder를 받을 수 있습니다.
    • 따라서, Pizza#addToping()을 사용할 수 있습니다.
    • (3)에서 Calzone#self() 를 구현하여 this를 리턴하게 함으로써 Calzone의 Builder를 리턴하기 때문에 (2)번의 Calzone#sauceInside()를 사용할 수 있게 됩니다.
public class Calzone extends Pizza {
    private final boolean sauceInside;
	
    //(1)
    public static class Builder extends Pizza.Builder<Calzone.Builder> {
        private boolean sauceInside = false; // 기본값
		
        //(2)
        public Builder sauceInside() {
            sauceInside = true;
            return this;
        }

        @Override public Calzone build() {
            return new Calzone(this);
        }
		
        //(3)
        @Override protected Builder self() { return this; }
    }

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

    @Override public String toString() {
        return String.format("%s로 토핑한 칼초네 피자 (소스는 %s에)",
                toppings, sauceInside ? "안" : "바깥");
    }
}
public class PizzaTest {
    public static void main(String[] args) {
        Calzone calzone = new Calzone.Builder() //self()를 리턴함으로써 형변환을 하지 않음
                .addTopping(HAM)//Pizza의 메서드를 사용
                .sauceInside()// Calzone가 가진 메서드를 사용할 수 있다.
                .build();
        
        System.out.println(pizza);
        System.out.println(calzone);
    }
}
  • 정리하면
    • 상위 클래스에서 빌더를 사용하고, 상속을 받으면,
      • 하위 클래스들은 상위 클래스에서 빌더에 있는 메서드를 사용할 수 있게 됩니다.
      • 이때 상위 클래스에서 self()라는 추상 메서드를 정의하고 하위 클래스에서 self()this를 리턴하게 하면 상위 클래스의 빌더에 있는 메서드 뿐만 아니라 본인이 정의한 메서드도 사용할 수 있기 때문에 확정성이 있습니다.

빌더의 단점

  • 빌더를 사용할때, 본인이 선택하지 않은 필드는 null 값을 가질 수도 있습니다.
  • lombok을 사용하지 않을 경우 구현 코드가 방대하게 증가할 수 있습니다.

빌더 외의 방법

빌더 외에도 선택적 매개변수가 많을 때 고려할 수 있는 방안이 있습니다. 빌더만큼 효율적인지는 못한 것 같습니다.

(1) 점층적 생성자 체이닝

  • this()를 사용해서 생성자를 체이닝하면서 객체를 생성하는 것을 말합니다.
  • (1)번, (2)번 생성자를 사용하면 각 생성자에 넣은 매개변수만 초기화되고 나머지는 필드는 코딩되어 있는 값으로 설정됩니다.
  • (3)번 같이 모든 매개변수를 담은 생성자는 생성자들 중 맨 밑에 넣어주어야 나중에 변경이 있을 때 조금 더 수월하게 변경할 수 있습니다.
public class NutritionFacts {
    private int servingSize;
    private int servings;
    private int calories;
    private int fat;
    private int sodium;
    private int carbohydrate;
	
    //(1)
    public NutritionFacts(final int servingSize, final int servings) {
        this(servingSize, servings, 0);
    }

	//(2)
    public NutritionFacts(final int servingSize, final int servings, final int calories) {
        this(servingSize, servings, calories, 0, 0, 0);
    }

	//(3)
    public NutritionFacts(final int servingSize, final int servings, final int calories,
        final int fat, final int sodium, final int carbohydrate) {
        this.servingSize = servingSize;
        this.servings = servings;
        this.calories = calories;
        this.fat = fat;
        this.sodium = sodium;
        this.carbohydrate = carbohydrate;
    }
}

단점

  • 이 방법은 매개변수가 많을 때, 코드를 작성하고 읽기가 어렵습니다.

(2) 자바 빈즈 규약 setter 사용

  • setter를 사용하여 필드 값을 설정해 주는 것을 말합니다.
NutritionFacts nf = new NutritionFacts();
nf.setServingSize(1);
nf.setServings(2);
nf.setCalories(3);
nf.setFat(4);
....

단점

  • 완전한 객체를 만들기 위해서는 메서드를 여러 번 호출해야 합니다.
  • 클래스를 불변으로 만들 수 없습니다.

정리

  • 빌더는 보통 객체를 생성할 때 매개변수가 많거나, 클라이언트가 필요한 필드를 선택하여 객체를 생성할 때 사용하면 좋습니다.
  • 모든 경우 빌더를 사용하는 것 보다 new 생성자, 생성자 체이닝, setter 등을 상황에 맞게 사용하는 것이 좋을 것 같습니다.

reference

profile
집중

0개의 댓글