Programming/Java

[Youtube][이팩티브 자바] #2 생성자 매개변수가 너무 많아? 빌더 패턴을 써 봐

bisi 2020. 5. 6. 20:31

백기선님의 유투브 강의 내용을 정리하였습니다.

 

백기선님 Github 바로가기

Youtube 바로가기

 

 

강의내용 필기 


주제 2: 생성자 매개 변수가 많은 경우에 빌더 패턴 사용을 권장

 

static 팩토리 메소드와 public 생성자 모두 매개변수가 많이 필요한 경우에 불편해진다. NutritiaonFatc 라는 클래스를 예를 들고있다. 해당 클래스는 몇몇 반드시 필요한 빌드(반드시 셋팅이 되어야하는..)와 부가적인 필드(셋팅이 되지 않아도 되는..)를 가질 수 있는데, 그런 경우에 필수적인 매개변수를 생성자에 부가적인 필드를 하나씩 추가하여 여러 생성자를 만들 수 있다. 

 

해결책 1: 생성자

public class NutritionFacts {

    private int servingSize;
    private int sodium;
    private int carbohydrate;
    private int servings;
    

    public NutritionFacts(int servingSize, int sodium, int carbohydrate, int servings) {
        this.servingSize = servingSize;
        this.sodium = sodium;
        this.carbohydrate = carbohydrate;
        this.servings = servings;
        
    }

    public static void main(String[] args) {
        NutritionFacts cocaCola = new NutritionFacts(240, 8, 100, 0); 
    }

}
  • primitive type은 기본값으로 셋팅됨. Reference Type이라면 NULL로 셋팅됨. 
  • 생성자를 만들어서 쓰면, 많아질 경우 무엇을뜻하는지 알 수 가 없다. 240이라는 숫자가 무슨뜻인지 모름.
  • 알려면 직접 생성자안에 들어가서 무엇을 의미하는지 확인해야 한다. 생성자 안에서도 변수 이름만 보고 모를땐, 주석을 봐야하는 경우도 있다. 
  • 필요없는 매개변수도 넘겨야할 때가 있고, 보통은 0같은 기본 값으로 넘긴다. 

 

해결책 2: 자바빈

또 다른 대안으로는 아무런 매개변수를 받지 않는 생성자를 사용해서 인스턴스를 만들고, 세터를 사용해서 필요한 설정을 할 수 있다.   

 

package item2;

public class NutritionFacts {

    private int servingSize;
    private int sodium;
    private int carbohydrate;
    private int servings;

    public NutritionFacts() {
    }

    public void setServingSize(int servingSize) {
        this.servingSize = servingSize;
    }

    public void setSodium(int sodium) {
        this.sodium = sodium;
    }

    public void setCarbohydrate(int carbohydrate) {
        this.carbohydrate = carbohydrate;
    }

    public void setServings(int servings) {
        this.servings = servings;
    }

    public static void main(String[] args) {

        NutritionFacts cocaCola = new NutritionFacts();
        cocaCola.setServingSize(240);
        cocaCola.setServings(8);
        cocaCola.setSodium(35);
        cocaCola.setCarbohydrate(27);

    }

}

 

  • 장점은 무엇을 셋팅하는 명확하다.
  • 단점은 코드가 장황하다.
  • 최종적인 인스턴스를 만들기까지 여러번의 호출을 해야한다. 그리고 중간에 사용될 경우 안정적이지 않은 상태로 사용될 경우가 있다.
  • 쓰레드 안정성을 보장하려면 추가적인 수고가 필요하다. 
  • 이런 단점을 보완하기 위해 java script에는 Freezing이라는 기능이 있다. Object.feeze() 메서든느 객체를 얼려 버린다는 의미로 객체에 새로운 속서을 추가할 수 없고, 객체에 원래 존재하던 속성을 제거할 수 없으며, 객체 속성, 열거 가능하성, 설정가능성, 값쓰기 가능성을 변경 할수 없게 만든다는 것을 의미한다. (Read는 가능한데, Write는 불가능한 상태)
  • 그치만.. Java에는 메소드를 찾을수가 없다!(Java Script에만 있는 듯 하다.) 구글링을 쫌 해보면 flag를 통해 구현가능하긴 하다.

 

해결책 3: 빌더

신축적인 생성자의 안정성과 자바빈을 사용할 때 얻을수 있엇던 가독성(무엇을 가져올 것인지 알수 있는것)을 모두 취할 수 있는 대안이 있다. 바로 빌더 패턴이다. 

신축적인 생성자 의미 : 필수적인 매개별수와 부가적인 매개변수 조합으로 여러 생성자를 만들 수 있다는 것.
public class NutritionFacts {

    private int servingSize;
    private int sodium;
    private int carbohydrate;
    private int servings;

    public NutritionFacts(int servingSize, int sodium) {
        this.servingSize = servingSize;
        this.sodium = sodium;
    }

    public NutritionFacts(int servingSize, int sodium, int carbohydrate) {
        this.servingSize = servingSize;
        this.sodium = sodium;
        this.carbohydrate = carbohydrate;
    }

    public NutritionFacts(int servingSize, int sodium, int carbohydrate, int servings) {
        this.servingSize = servingSize;
        this.sodium = sodium;
        this.carbohydrate = carbohydrate;
        this.servings = servings;
    }
}

 

빌더 패턴은 만들려는 객체를 바로 만들지 않고 클라이언트는 빌더(생성자 또는 static 팩토리)에 필수적인 매개변수를 주면서 호출해 Builder 객체를 얻은 다음 빌더 객체가 제공하는 세터와 비슷한 메소드를 사용해서 부가적인 필드를 채워넣고, 최종적으로 build라는 메소드를 호출해서 만들려는 객체를 생성한다.

 

NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)
.calories(100).sodium(35).carbohydrate(27).build();
240, 8은 필수 매개변수이며, 100,35,27은 부가적인(optional) 매개변수 이다.

 

빌더 패턴으로 파이썬이나 스칼라가 제공하는 Named Optional Parameter를 모방할 수 있다.

 

Named Optional Parameter   
C# 예제 : PrintOrderDetails(orderNum : 31, productName: "Red Mug", sellerName: "Gift shop");

 

  • 빌더의 생성자나 메소드에서 유효성 확인을 할 수도 있고 여러 매개 변수를 혼합해서 확인해야 하는 경우에는 build 메소드에서 호출하는 생성자에서 할 수 있다. 빌더에서 매개변수를 객체로 복사해온 다음에 확인하고, 검증에 실패하면, IllegalArumentException을 던지고 에러메시지로 어떤 매개변수가 잘못됐는지 알려 줄 수 있다.
  • 가독성이 좋다는 장점!
  • 클래스 계층 구조를 잘 활용할 수 있다. 추상 빌더를 가지고 있는 추상 클래스를 만들고 하위 클래스에서는 추상 클래스를 상속받으며 각 하위 클래스 빌더도 추상 빌더를 상속받아 만들 수 있다. 

 

 

해당 설명을 잘 구현해 놓은 코드 (아직 이해중..)

public abstract class Pizza {

    public enum Topping {
        HAM, MUSHROOM, ONION
    }

    final EnumSet<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;
    }

}
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;
    }
}
public class Calzone extends Pizza {

    private final boolean sauceInside;

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

        public Builder sauceInde() {
            sauseInside = true;
            return this;
        }

		// Covariant 클래스 
        @Override
        public Calzone build() {
            return new Calzone(this);
        }

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

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

}

이때 추상 빌더는 재귀적인 타입 매개변수를 사용하고 self라는 메소드를 사용해 self-type 개념을 모방할 수 있다. 하위 클래스에서는 build 메소드의 리턴 타입으로 해당 하위 클래스 타입을 리턴하는 Covariant 리턴 타이핑을 사용하면 클라이언트 타입 캐스팅을 할 필요가 없어진다. 

NyPizza nyPizza = new NyPizza.Builder(SMALL)
    .addTopping(Pizza.Topping.SAUSAGE)
    .addTopping(Pizza.Topping.ONION)
    .build();

Calzone calzone = new Calzone.Builder()
    .addTopping(Pizza.Topping.HAM)
    .sauceInde()
    .build();

 

빌더는 가변인자 (vargars) 매개변수를 여러개 사용할 수 있다는 소소한 장점도 있다. (생성자나 팩토리는 가변인자를 맨 마지막 매개변수에 한번밖에 못 쓰기 때문!) 또한 토핑 예제에서 본것 처럼 여러 메소드 호출을 통해 전달받은 매개 변수를 모아 하나의 필드에 담는 것도 가능하다. 

 

단점으로는 성능에 민감한 상황에서는 객체를 만들기 전에 먼저 빌더를 만들어야하기 때문에 주의해야할 수 있는데,

하지만 Class를 불러오고 객체를 생성하는데 성능에 미치는 영향은 완전 미미하기 때문에 성능 때문에 빌더 패턴을 못쓰는 경우는 .. 없다고 본다.

파라미터가 많은 경우(4개이상정도?) 사용하면 유용하다

 

참고 해결책 : lombok에서 제공하는 빌더 

필수적인 필드가 있는 경우엔 애매할 수도 있다. 

Builder라는 annotation을 사용하면 빌더 패턴을 사용할수 있다. 

import lombok.Builder;

@Builder
public class NutritionFacts {

	private int servingSize;
    private int sodium;
    private int carbohydrate;
    private int servings;

    public static void main(String[] args) {
        NutritionFacts nutritionFacts = NutritionFacts.builder()
                .servings(10)
                .carbohydrate(100)
                .build();

    }

}

 

빌더 패턴은 세터를 안만들어도 되서 그점은 간편하다. 

롬복은 간단하게 구현 가능하고, 추상클래스를 사용하거나 상속구조를 사용하는 것 같이 복잡한것은 구현이 어렵다.

 

@Singular : Collection 사용가능. 추가도 가능. Collections을 추가로 만드는 것이 아니라 메소드에 추가하는 것임.

@Builder.Defualt : default라고 지정되어 있는것 빼고 생성자를 만들어 준다. 

import lombok.Builder;
import lombok.Singular;

import java.util.List;

@Builder
public class NutritionFacts {

    @Builder.Default private int servingSize = 10;
    private int sodium;
    private int carbohydrate;
    private int servings;
    @Singular private List<String> names;

    public static void main(String[] args) {
        NutritionFacts nutritionFacts = NutritionFacts.builder()
                .servings(10)
                .carbohydrate(100)
                .name("keesun")
                .clearNames()
                .build();

    }

}

 

롬복에서 빌더를 사용할 때는 가급적이면 간단하게 사용하여 사람들이 쉽게 이용할 수 있도록 하는게 좋다. 

사용하면 간단해서 좋지만, 유지보수 하는 다른 개발자가 봤을때 롬복을 보고 한번에 알수 없기 때문에 (문서를 참고해야함) 사용할지 말지는 신중하게 결정해야 할 것 같다.