Language/Java

Effective Java - 빌더 패턴 안전하게 사용하기 (+Use Lombok)

JaeHoney 2022. 6. 18. 00:49

점층적 생성자 패턴

점층적 생성자 패턴은 필수 매개변수만 받는 생성자를 만들고, 선택 매개변수가 더 필요할 때마다 추가로 생성자를 구현하는 방식이다.

 

점층적 생성자 패턴 안전하게 객체를 생성할 수 있다는 장점이 있다.

public class NutritionFacts {	
    private final int servingSize;
    private final int servings;
    private final int calories;
    private final int fat;
    private final int sodium;
    
    public NutritionFacts(int servingSize, int servings, int calories, int fat) {
    	this.servingSize = servingSize;
    	this.servings = servings;
    	this.calories = calories;
    	this.fat = fat;
    }
    
    public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium) {
    	this.servingSize = servingSize;
    	this.servings = servings;
    	this.calories = calories;
    	this.fat = fat;
    	this.sodium = sodium;
    }
}

문제는 매개변수가 많을 때 코드가 복잡해지고, 매개변수 순서를 고려하지 않으면 위험하고, 가독성이 나쁘다는 점이 있다.

자바 빈즈 패턴

자바 빈즈 패턴선택 매개변수가 많을 때 활용할 수 있다. 기본 생성자로 객체를 생성한 후에, Setter를 호출하여 값을 설정하는 방식이다.

public class NutritionFacts {
    private int servingSize = -1;
    private int servings = -1;
    private int calories = 0;
    private int fat = 0;
    private int sodium = 0;
    private int carbohydrate = 0;

    public NutritionFacts() { }

    public void setServingSize(int val) { ... }
    public void setServings(int servings) { ... }
    public void setCalories(int calories) { ... }
    public void setFat(int fat) { ... }
    public void setSodium(int sodium) { ... }
    public void setCarbohydrate(int carbohydrate) { ... }
}

코드가 길어지지만 가독성은 점층적 생성자 방식보다 나아보인다. 매개변수 순서도 걱정하지 않아도 된다. 문제는 객체가 완전히 생성되기 전에는 일관성(consistency)이 무너진 상태가 된다.

 

이러한 단점을 완화하고자 생성이 끝난 객체를 수동으로 얼리고(freezing) 얼리기 전에는 사용할 수 없도록 하기도 한다.

빌더 패턴

위 두가지 방식의 단점을 해결하는 패턴이다. 필수 매개변수를 받는 생성자(또는 정적 팩토리 메서드)를 호출해서 빌더 객체를 얻는다. 빌더는 선택 매개변수를 세팅하는 메서드를 제공한다.

 

일반적으로 빌더는 생성할 클래스의 Inner static 클래스로 만든다. 

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;

    private NutritionFacts(Builder builder) {
        servingSize = builder.servingSize;
        servings = builder.servings;
        calories = builder.calories;
        fat = builder.fat;
        sodium = builder.sodium;
        carbohydrate = builder.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;
        }
        
        // build() 호출로 최종 불변 객체를 얻는다.
        public NutritionFacts build() {
            return new NutritionFacts(this);
        }
    }
}

이러한 빌더는 아래와 같은 방식으로 사용할 수 있다.

NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)
        .calories(100)
        .fat(35)
        .sodium(40)
        .carbohydrate(1)
        .build();

해당 방식의 빌더 패턴은 안전하면서 유연함까지 제공한다. 추가적으로 사용하기 쉽고, 읽기도 쉽다.

 

문제는 필수 매개변수를 받는 빌더 생성자의 가독성이 떨어지는 부분은 여전히 존재한다는 점인데 클린 아키텍처에서는 IDE의 발전에 따라 파라미터명 힌트를 주기 때문에 염려하지 않아도 된다고 말한다.

Lombok

Lombok을 사용하면 위 빌더를 사용하는 클래스를 다음과 같이 변경할 수 있다.

@AllArgsConstructor
@Builder(builderMethodName = "hiddenBuilder", access = AccessLevel.PRIVATE)
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 NutritionFactsBuilder builder(int servingSize, int servings) {
        return hiddenBuilder()
                .servingSize(servingSize)
                .servings(servings);
    }
}

이 경우 아래와 같이 사용할 수 있다.

NutritionFacts cocaCola = NutritionFacts.builder(30, 20)
        .servingSize(30)
        .calories(100)
        .fat(35)
        .sodium(40)
        .carbohydrate(1)
        .build();

Lombok을 사용하면 훨씬 코드가 깔끔해진다. 하지만 Class단위로 @Build 애노테이션을 사용하게 되면서 이런 것이 가능해진다.

Person.builder("john").surname("smith").name("mark").build();

Builder에서 name을 세팅한 후 다시 name() 메서드를 호출한다. 이런 점은 조심해서 사용해야 한다.

 

추가 QueryDSL을 사용하는 경우 에러가 나는 부분 확인했습니다. 해당 경우 @Setter와 @Accessors(fluent = true)를 가진 inner class로 Builder를 만들면 해결 가능합니다.

 

참고

추가로 매개변수가 없는 기본 생성자로 빌더를 제공하고 Assert나 Objects가 제공하는 메서드를 통해 예외를 터트리는 방법도 있다.

@Builder
public Member(Long id, String nickname, String email, LocalDate birthday, LocalDateTime createdAt) {
    this.id = id;
    this.nickname = Objects.requireNonNull(nickname);
    this.email = Objects.requireNonNull(email);
    this.birthday = Objects.requireNonNull(birthday);
    this.createdAt = createdAt;
}

해당 방법은 Effective Java에서 제시하는 필수 생성자 이후에 빌더를 사용하는 방법의 필수 생성자의 가독성 문제 및 순서가 어긋날 수 있는 문제에 대한 해결로 많이 사용한다.

 

하지만, 해당 방법도 실제 요청이 들어오고 객체가 생성될 때야 비로소 Runtime에 예외가 터진다는 단점이 있다. 즉, TradeOff 정도로 생각하면 좋을 것 같다. (클린아키텍처에서는 권장되지 않는다! 대신 생성자를 사용하라고 한다.)

  • 무지성 Builder는 사용하지 말자!
    • (OOP의 객체의 자율성 - 객체는 자신의 상태를 스스로 관리해야 하며 완전한 상태를 유지해야 한다.)
  • 사용하는 각각의 기술(생성자 / 빌더)의 장단점을 명확히 알고 사용해야 한다.

Kotlin

Kotlin 사용하면 이러한 고민(생서자 vs 빌더)의 문제를 깔끔하게 해결한다. Kotlin의 생성자는 아래와 같이 사용할 수 있기에 Java 생성자에 비해 훨씬 명확하다!

val user = User(
    name = "Hyung1Jung",
    email = "Hyung1Jung@gmail.com",
    age = 18
)

그래서 특별한 경우를 제외하고는 Kotlin에서는 빌더를 사용할 이유가 없다.

 

해당 이슈와 더불어서 Kotlin은 Effective Java에서 다루는 지침들을 대부분 반영했다. 그래서 그냥 Kotlin을 써라는 내용이 많다.. ㅎㅎ (참고만 하도록 하자!)


마무리

개인적으로 빌더 패턴은 안전하지 않다는 이유로 현업에서 생성자나 정적 팩토리 메서드를 여러 개 만들어서 많이 사용했다. 하지만 위 방식대로 필수 매개변수를 받는 생성자를 호출한 후 빌더를 적용한다면 안전하게 빌더를 사용할 수 있을 것 같다.

 

빌더도 비용이 들어가기도 하고, 위험도 따른다. 가령 선택적 매개변수들이 관계를 가지고 있다면 객체로 잘 표현했다면 상관이 없지만, 그렇지 못하다면 빌더를 사용할 때 오류를 범할 수 있다.

(예를 들면, 주문의 댓글 내용은 있지만 댓글 작성자의 정보를 세팅을 안했다던가..)

 

정리하면 빌더 패턴은 정말 유용하다! 잘 사용하면 안전하게도 사용할 수 있기 때문에 선택적 매개변수가 많은 경우 충분히 고려할만한 것 같다.


Reference