본문 바로가기
Computer Sience/Java

[Effective Java] 아이템2: 생성자에 매개변수가 많다면 빌더를 고려해라

by 제우제우 2024. 10. 22.

Chapter 02: 객체 생성과 파괴

객체를 만들어야 할 때와 만들지 말아야 할 때를 구분하는 법

올바른 객체 생성 방법에 대한 설명

빌더 등장 배경

디자인 패턴 - 빌더 패턴 정리

 

[Design Pattern] 빌더(Builder) 패턴

빌더 (Builder) 패턴동일한 프로세스를 거쳐 다양한 구성의 인스턴스를 만드는 방법(복잡한) 객체를 만드는 프로세스를 독립적으로 분리할 수 있다빌더 패턴 적용 Before@Getter @Setter @ToSt

20240228.tistory.com

이펙티브 자바 아이템1 정적 팩터리 메소드

 

[Effective Java] 아이템1: 생성자 대신 정적 팩터리 메소드를 고려해라

Chapter 02: 객체 생성과 파괴객체를 만들어야 할 때와 만들지 말아야 할 때를 구분하는 법올바른 객체 생성 방법에 대한 설명정적 팩터리 메서드클래스 인스턴스를 얻는 기본적인 방법 → public 생

20240228.tistory.com

정적 팩터리와 생성자에는 같은 제약이 있다.

→ 선택적 매개변수가 많을 때 대응하기 어렵다는 점 

 

점층적 생성자 패턴(telescoping constructor pattern) 

/**
 * 식품 포장 영양 정보를 표현하는 클래스
 */
public class NutritionFacts {
    private final int servingSize;  // (mL, 1회 제공량)     필수
    private final int servings;     // (회, 총 n회 제공량)   필수
    private final int calories;     // (1회 제공량당)       선택
    private final int fat;          // (g/1회 제공량)       선택
    private final int sodium;       // (mg/1회 제공량)      선택
    private final int carbohydrate; // (g/1회 제공량)       선택

    public NutritionFacts(int servingSize, int servings){
        this(servingSize, servings, 0);
    }
    public NutritionFacts(int servingSize, int servings, int calories) {
         this(servingSize, servings, calories, 0, 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 static void main(String[] args) {
        NutritionFacts cocaCola = new NutritionFacts(240, 8);
    }
}

 

필수 매개변수만 받는 생성자

필수 매개변수만 받는 생성자와 선택 매개변수 받는 생성자 2개 

.... 3개

모든 필드의 매개변수를 받는 생성자 까지 

 

현재는 매개변수가 겨우 6개여서 그렇지 수가 늘어나면 생성자 개수도 늘어나고 각 생성자를 호출하는 부분마다 기본값 또한 넘겨줘야 한다. 

→ 너무 복잡하다. 

자바빈즈 패턴(JavaBeans Pattern)

/**
 * 식품 포장 영양 정보를 표현하는 클래스
 * 자바빈즈 패턴 - 일관성이 깨지고 불변으로 만들 수 없다.
 */
@Setter
public class NutritionFacts {
    private int servingSize;
    private int servings;
    private int calories;
    private int fat;
    private int sodium;
    private int carbohydrate;
    public NutritionFacts() {}

    public static void main(String[] args) {
        NutritionFacts cola = new NutritionFacts();
        cola.setServingSize(240);
        cola.setServings(8);
        cola.setCalories(100);
        cola.setSodium(35);
    }
}

 

매개변수가 없는 생성자(기본 생성자)로 객체를 만든 후 세터(setter) 메서드들을 호출해 원하는 매개변수의 값을 설정하는 방식

 

특징 

점층적 생성자 패턴의 단점이 보이지 않는다. (어떤 필드를 설정하는지 명확)

코드가 길긴하지만 인스턴스를 만들기 쉽다.

→ 코드 길이도 롬복을 활용하면 해결 가능하다. 

 

하지만 자바빈즈 패턴은 심각한 단점이 있다.

자바빈즈 패턴(JavaBeans Pattern) 단점 

1. 하나의 객체를 만들려면 메서드를 여러 개 호출해야 함.

이는 객체 생성 과정이 다소 번거로워질 수 있다. (특히 필드가 많아질수록) 

 

2. 객체가 완전히 생성되기 전까지는 일관성(consistency)이 무너진 상태에 놓이게 된다. 

만약 객체의 필드가 초기화되어있지 않다면 필드를 사용하는 메서드에서 의도치 않은 동작이나 예외가 발생하게 된다.
이것은 의도하지 않은 예외이고 일관성이 무너졌다고 설명한다. 

점층적 생성자 패턴에서는 객체의 초기화를 각 생성자의 값을 통해서 확인이 가능하지만
자바 빈즈 패턴에서는 필드 검증 로직이 각 setter에 있다고 하더라도 해당 setter가 호출되지 않으면
의미가 없으니까 래퍼런스 타입의 참조에는 NullPointerException 같은 상황이 발생한다.

 

3. 클래스를 불변으로 만들 수 없다

JavaBeans 패턴은 setter 메소드를 사용해 객체의 속성을 나중에 수정할 수 있기 때문에 기본적으로 

불변 객체(immutable object)를 만들 수 없다. 

불변 객체는 생성 후 상태가 변경되지 않는 객체를 의미하며, 이는 멀티스레딩 환경에서 안정성을 보장해 준다.

 

책에서는 이를 해결하기 위한 방법으로 freeze 메소드를 소개한다. 

freeze 메소드는 객체의 속성을 더 이상 변경할 수 없도록 상태를 고정하는 역할을 한다. 

최초 freeze가 호출된 후에는 setter 메소드를 호출해도 객체의 속성을 변경할 수 없게 만드는 방식으로 일관성을 유지할 수 있다. 

 

책에 예제 코드가 없다.

내가 생각하는 freeze 메소드? 

class Car {
    private String make;
    private String model;
    private boolean frozen = false;    
    public void setMake(String make) {
        if (frozen) throw new IllegalStateException("Cannot modify frozen object");
        this.make = make;
    }   
    public void freeze() {
        frozen = true;
    }
}

 

하지만 이 방식 역시 프로그래머가 feeze 메소드를 호출했는지 컴파일러가 보증할 방법이 없어서

런타임 오류에 취약하다. 

빌더

점층적 생성자의 안정성과 자바 빈즈 패턴의 가독성 

각 객체 생성 방법의 장점만을 가지는 빌더 패턴 

 

빌더 패턴을 적용한 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);
        }
    }
    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;
    }
}

NutritionFacts 클래스는 불변 클래스이다. 

 

servingSize & servings 필드는 설정이 필수인 필드로서 Builder 생성자로 받게 했다. 

빌더의 새터 메소드들은 빌더 자신을 반환하기 때문에 연쇄적으로 호출할 수 있다. 

이런 방식을 플루언트 API(fluent API) 혹은 메소드 연쇄 (method chaining)이라 한다. 

 

클라이언트 코드 

public class Client {
    public static void main(String[] args) {
        NutritionFacts cocaColaNutritionFacts = new NutritionFacts.Builder(240, 8) // 필수 값
                .calories(100).sodium(35).carbohydrate(27).build();
    }
}

해당 클라이언트 코드는 쓰기 쉽고, 읽기 쉽다. 

 

유효성 검사를 도입한다면? 

 

일찍 검사

Builder의 생성자 / 각 setter 메소드에서 검사 

 

전체 검사

Builder의 build 메소드가 호출하는 생성자에서 여러 매개변수에 걸친 검사

 

문제가 있다고 하면 메시지를 담아서 IllegalArgumentException을 던지면 된다. 

계층적 구조 클래스에 빌더 적용하기

빌더 패턴은 계층적으로 설계된 클래스와 함께 사용하기 좋다.

추상 클래스에는 추상 빌더를, 구체 클래스에는 구체 빌더를 갖게 한다. 

/**
 * 계층적으로 설계된 클래스와 잘 어울리는 빌더 패턴
 */
abstract public class Pizza {
    public enum Topping { // Pizza Toppings
        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){
            toppings.add(topping);
            return self();
        }
        abstract Pizza build();
        protected abstract T self(); // this 반환하도록 구현
    }
    /**
     * EnumSet implements Cloneable
     */
    Pizza(Builder<?> builder){
        toppings = builder.toppings.clone();
    }
}

// 도미노피자 - 계층적 빌더를 활용한 하위 클래스
public class DominoPizza extends Pizza{

    private final boolean sauceInside;
    
    public static class Builder extends Pizza.Builder<Builder>{
        private boolean sauceInside = false; // 기본값
        public Builder sauceInside(){
            this.sauceInside = true;
            return this;
        }
        @Override
        public DominoPizza build() {
            return new DominoPizza(this);
        }
        @Override
        protected Builder self() {
            return this;
        }
    }
    private DominoPizza(Builder builder) {
        super(builder);
        this.sauceInside = builder.sauceInside;
    }
    @Override
    public String toString(){
        return "toppings: " + toppings.toString() + " sauceInside: " + sauceInside;
    }
}

public class PizzaHut 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 = size;
        }
        @Override
        PizzaHut build() {
            return new PizzaHut(this);
        }
        @Override
        protected Builder self() {
            return this;
        }
    }
    private PizzaHut(Builder builder) {
        super(builder);
        this.size = builder.size;
    }
}

 

self() 메소드는 재귀적 제네릭 패턴울 사용하는 빌더 패턴에서 유연한 메소드 체이닝을 지원하기 위해 사용된다. 

이 패턴을 통해 빌더가 계층 구조에서 올바른 타입을 반환할 수 있도록 보장한다. 

 

만약 self() 메소드가 없다면?

public Builder<T> addTopping(Topping topping){
    toppings.add(topping);
    return this;
}

DominoPizza.Builder builder = (DominoPizza.Builder) new DominoPizza.Builder()
        .addTopping(Pizza.Topping.SAUSAGE);

DominoPizza.Builder builder2 = (DominoPizza.Builder) builder.addTopping(Pizza.Topping.MUSHROOM).addTopping(Pizza.Topping.SAUSAGE);
DominoPizza dominoPizza = builder2.sauceInside().build();

System.out.println(dominoPizza);

addTopping 메소드를 호출하면 부모 빌더타입으로 나와서 이후에 자신만의 필드 관련 설정을 하려면(빌더 체이닝을 계속 하려면) 다시 형변환을 해야한다. 

 

self() 메소드를 사용하지 않는 방법을 고려해봤다. 

abstract static class Builder<T extends Builder<T>> {
    EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);
    public T addTopping(Topping topping){
        toppings.add(topping);
        return (T) this;
    }
    abstract Pizza build();
    protected abstract T self(); // this 반환하도록 구현
}

 

일단 동작은 self() 메소드를 통해서 this를 반환과 동일하게 동작하지만 

컴파일 경고가 나온다. 

return 할 때 강제로 형 변환을 하기 때문에 타입 안정성에 대한 경고이다.

하지만 내 생각은 경고와 다르게 무조건 안전하다고 생각한다. 

제네릭 T 자체가 Builder를 상속을 받는 타입이기 때문이다. 

이전에 컴파일러가 주는 경고를 없애는 방법 @SuppressWarnings("unchecked") 에 대해서 공부했는데 

이럴 때 사용하는 건가 싶었다.  

 

헷갈렸던 부분 

abstract static class Builder<T extends Builder<T>> {
    EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);
    public T addTopping(Topping topping){
        toppings.add(topping);
        System.out.println(this.getClass());
        return self(); // return T (this)
    }
    abstract Pizza build();
    protected abstract T self(); // this 반환하도록 구현
}

public static class Builder extends Pizza.Builder<Builder>{
    private boolean sauceInside = false; // 기본값
    public Builder sauceInside(){
        this.sauceInside = true;
        return this;
    }
    @Override
    public DominoPizza build() {
        return new DominoPizza(this);
    }
    @Override
    protected Builder self() {
        System.out.println(this.getClass());
        return this;
    }
}

addTopping() & self() 메소드 
class com.example.effectivejavacode._02.item2._04_hierarchicalbuilder.DominoPizza$Builder
class com.example.effectivejavacode._02.item2._04_hierarchicalbuilder.DominoPizza$Builder

this가 가리키는 인스턴스는 DominoPizza.Builder 이다. 

하지만 타입은 현재 호출된 메소드의 타입을 따라가기 때문에

addTopping()에서 this의 타입은 Pizza.Builder 이다. 

 

클라이언트 코드 

public class PizzaCustomer {
    public static void main(String[] args) {
        DominoPizza dominoPizza = new DominoPizza.Builder()
                .sauceInside() // 소스 넣기
                .addTopping(Pizza.Topping.MUSHROOM)
                .addTopping(Pizza.Topping.SAUSAGE)
                .build();

        System.out.println(dominoPizza);
    }
}

정리

빌더 패턴은 유연하다.

빌더 하나로 여러 객체를 순회하면서 만들 수 있고, 빌더에 넘기는 매개변수에 따라 다른 객체를 만들 수 있다.

빌더 패턴의 단점은 객체를 만들려면 빌더부터 만들어야 하는 게 단점이다.

하지만 매개변수가 많다면(보통 API는 시간이 지날수록 매개변수가 많아진다) 

생성자나 정적 팩토리 대신 빌더를 사용하는 게 좋겠다.