[이펙티브 자바] 아이템 2. 생성자에 매개변수가 많다면 빌더를 고려하라.

6 minute read

이펙티브 자바를 공부한 내용입니다.


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

앞서 공부했던 정적 팩토리 메서드와 생성자를 사용할 경우 똑같은 제약사항이 한가지 있다.

매개변수가 다양할 때의 상황에 사용하기가 불편하다는 점이다.

책의 예제인 NutritionFacts 클래스를 통해 알아보자.


문제점

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 NutritionFacts(int servingSize, int servings) {
        this(servingSize, servings, 0);
    }

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

    public NutritionFacts(int servingSize, int servings, int calories, int fat) {
        this(servingSize, servings, calories, fat, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium) {
        this(servingSize, servings, calories, fat, sodium, 0);
    }
    
    public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium, int carbohydrate) {
        this.servingSize = servingSize;
        this.servings = servings;
        this.calories = calories;
        this.fat = fat;
        this.sodium = sodium;
        this.carbohydrate = carbohydrate;
    }
    
}
public class Client {

    public static void main(String[] args) {
        NutritionFacts nutritionFacts = new NutritionFacts(10, 50, 30, 0);
    }

}

NutritionFacts 클래스는 반드시 필요한 필드인 servingSize, servings와
선택적인 필드인 calories, fat, sodium, carbohydrate가 있다.
이와 같은 클래스는 클라이언트가 사용할 때 원하는 매개변수를 포함하는 생성자 중 가장 최소화 된 것을 사용할 수 있다.

이때 사용하지 않는 매개변수는 0 또는 “” 과 같은 의미 없는 기본 값을 넘기는 경우가 있는데, 당연하게도 동작에는 문제 없지만,
이런 코드는 의미가 없으며, 읽기 또한 어렵다.

(과연 생성자 호출만 보고 저 숫자들이 무엇을 의미하는지, 0이 실제 값인지 무의미한 값인지 쉽게 알 수 있을지 생각해보자.)


대안 1. JavaBeans pattern

자바빈은 일종의 규약이다.
흔히 사용하는 VO, DTO와 같은 POJO 클래스가 대부분 자바빈이다.
필드는 private으로 작성하되, getter와 setter를 통해 필드에 접근한다.

위와 같은 생성자를 통해 객체를 생성하는 것이 아니라,
기본 생성자로 객체를 생성 후, setter를 통해 값을 주입하는 방식이다.

public class NutritionFacts {

    private int servingSize;       // 필수
    private int servings;          // 필수
    private int calories;          // 선택
    private int fat;               // 선택
    private int sodium;            // 선택
    private int carbohydrate;      // 선택


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

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

    // setter... 

}
public class Client {

    public static void main(String[] args) {
        NutritionFacts nutritionFacts = new NutritionFacts();
        nutritionFacts.setServingSize(100);
        nutritionFacts.setServings(3);
        nutritionFacts.setCalories(100);
    }

}

이러한 방식을 사용했을 때 이전과는 달리 명확하게 값을 주입하는 것이 가능해진다.

하지만 지금은 예시이기 때문에 필드의 가지수가 많지 않지만,
수십개의 필드가 있는 클래스라면 필요에 따라 전부 setter 메서드를 호출 해야하며,
객체가 완전히 생성되기 전에 객체가 사용될 가능성이 있어
스레드 간의 안정성을 얻기 위해서는 추가적인 작업을 해줘야 한다.


대안 2. Builder Pattern

빌더 패턴은 생성자를 사용했을때의 안정성과 setter를 사용했을때의 가독성을 모두 얻을 수 있는 방법이다.

먼저 필요한 객체를 직접 만들지 않고, 필수 매개변수만으로 생성자를 호출하여 빌더 객체를 얻는다.
이후 빌더 패턴이 제공하는 일종의 setter 메서드를 통해 원하는 선택 매개변수를 설정한다.
마지막으로 build 메서드를 호출해 필요한 객체를 얻게 된다.

빌더 패턴이 적용된 NutritionFacts 보기
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 calories) {
            this.calories = calories;
            return this;
        }

        // 선택 값 주입
        public Builder fat(int fat) {
            this.fat = fat;
            return this;
        }

        // 선택 값 주입
        public Builder sodium(int sodium) {
            this.sodium = sodium;
            return this;
        }

        // 선택 값 주입
        public Builder carbohydrate(int carbohydrate) {
            this.carbohydrate = carbohydrate;
            return this;
        }
        
        // 객체 생성
        public NutritionFacts build() {
            return new NutritionFacts(this);
        }
        
    }

    // build 호출 시 적용되는 NutritionFacts 생성자
    public NutritionFacts(Builder builder) {
        this.servingSize = builder.servingSize;
        this.servings = builder.servings;
        this.calories = builder.calories;
        this.fat = builder.fat;
        this.sodium = builder.sodium;
        this.carbohydrate = builder.carbohydrate;
    }

}


public class Client {
    
    public static void main(String[] args) {
        NutritionFacts nutritionFacts = new NutritionFacts.Builder(100, 5)
                .calories(100)
                .fat(30)
            .build();
    }

}

이러한 방식을 사용하면 NutritionFacts 클래스는 불변객체로 생성할 수 있고,
필수 요소는 빌더의 생성자를 통해,
선택 요소는 빌더의 setter를 통해 객체를 생성할 수 있다.

또한 각 setter 메서드는 Builder 자기 자신을 반환하기 때문에
연쇄적인 메서드형태로 만들 수 있다. (보통 method chaining 또는 chaining method라고 부른다)

이는 Python, Scala, C# 등 일부 언어에 존재하는 Named Optional Parameters를 흉내 낸 것이다.
(위 언어들은 인자에 어떤 매개변수를 사용하는지 작성할 수 있다.)

// C#
PrintOrderDetails("Gift Shop", 31, productName: "Red Mug");

Builder Pattern의 장점

빌더의 생성자나 메서드에서 유효성 검사를 할 수 있고,
검증에 실패하면 IllegalArgumentException을 던져 어떤 매개변수가 잘못된 값인지 알 수 있다.

또한 계층적으로 설계된 클래스와 함께 쓰기 좋다.


계층적 구조의 클래스에서 사용되는 Builder Pattern 예제
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) {
            this.toppings.add(Objects.requireNonNull(topping, "Topping is Null"));
            return self();
        }

        protected abstract T self();
        
        abstract Pizza build();
        
    }
    
    public Pizza(Builder<?> builder) {
        this.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
        protected Builder self() {
            return this;
        }

        @Override
        public Pizza build() {
            return new NyPizza(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 sauceInside = false;

        public Builder sauceInside() {
            sauceInside = true;
            return this;
        }

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

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

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

}
public class Client {

    public static void main(String[] args) {
        NyPizza nyPizza = new NyPizza.Builder(NyPizza.Size.SMALL)
                .addTopping(Pizza.Topping.SAUSAGE)
                .addTopping(Pizza.Topping.ONION)
            .build();


        Calzone calzone = new Calzone.Builder()
                .addTopping(Pizza.Topping.MUSHROOM)
                .sauceInside()
                .build();
    }

}

추상메서드 self를 통해 하위 클래스가 형변환을 하지 않더라도 Chaining Method를 사용할 수있다.

또한 생성자나 정적 팩토리 메서드 방식으로는 가변인자(varargs)를 맨 마지막에 한번만 사용 가능하지만
적절한 메서드로 나눠 선언한다면 여러개의 가변인자를 사용할 수 있다는 소소한 장점이 있다.

위 예제의 addTopping이 그 예시이다.

이렇게 빌더패턴은 굉장히 유연하기 때문에 하나의 빌더로 여러 객체를 순회하면서 만들 수 있고,
매개변수에 따라 다른 객체를 만들 수 있다.
일련번호와 같은 필드는 빌더가 알아서 만들도록 구현할 수도 있다.


Builder Pattern의 단점

빌더 패턴을 적용했을 때 장점은 굉장히 많지만 단점도 존재한다.

단점은 아래와 같다.

  1. 객체 하나 만들기 위해 너무나 많은 코드가 필요하다.
    매개변수가 4개 이상이 되어야 값어치를 한다고 한다. (Lombok을 통해 개선가능)
  2. 객체를 만들기 위해서는 빌더가 필요하다.
    성능에 민감한 상황이라면 문제가 될 수 있다.

Lombok @Builder

Lombok은 BoilerPlate 즉 반복되는 상용구를 제거해주기 위한 라이브러리이다.

getter, setter, 기본생성자, 전체생성자, toString, equalsAndHashCode 등등
다양한 코드를 개발자 몰래 생성해준다.

왜 몰래라고 하냐면..

Lombok은 소스코드를 변화시키는 것이 아니라
AnnotationProcessor을 사용하여 컴파일 시점에
Lombok Processor가 Lombok Annotation을 확인하여
필요한 메서드를 생성 후 바이트 코드로 변경시켜주기 때문이다.

최근에는 JDK14에 record라는 특수한 클래스가 생겨
Lombok을 어느정도 대체할 수도 있을 것 같다.

자세한 내용은 롬복 공식 홈페이지를 확인하자.


참조자료

C# Named Optional Parameters
https://docs.microsoft.com/ko-kr/dotnet/csharp/programming-guide/classes-and-structs/named-and-optional-arguments

Lombok
https://projectlombok.org/

JDK14 Record
https://docs.oracle.com/en/java/javase/14/language/records.html

Tags:

Categories:

Updated:

Leave a comment