본문 바로가기
JAVA/effective-java

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

by saniii 2024. 7. 19.

 

정적 팩토리와 생성자는 선택적 매개변수가 많을 때 대응하기 어렵다는 공통적인 제약이 있다.

꼭 내용이 들어가지 않아도 되는 선택적 매개변수가 많을 때 우리는 다음과 같은 선택지가 있다.

1. 점층적 생성자 패턴, telescoping constructor pattern

→ 필수 매개변수만 받는 생성자에서 선택 매개변수를 하나씩 추가해가면서 다수의 생성자를 만들어 사용하는 방식

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

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

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

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

    public NutritionsFacts_tcp(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;
    }
}

 

점층적 생성자 패턴에서는 원하는 매개변수가 모두 들어간 생성자 중 가장 짧은 것을 골라 사용하게 된다.

단점

  • 사용자가 값을 설정하고 싶지 않은 매개변수임에도 어쩔 수 없이 값을 지정해줘야하는 경우가 발생한다.
  • 매개변수 개수가 많아지면
    • 지옥의 생성자 자가증식….
    • 클라이언트 코드를 작성하거나 읽기 어려움
    • 생성자의 매개변수를 잘못 매칭함으로서 생길 수 있는 휴먼 에러들…

 

2. 자바빈즈 패턴, JavaBeans pattern

매개변수가 없는 생성자로 객체를 만들고 setter로 원하는 매개변수의 값을 설정한다.

public class Nutritions_javabeans {
    private int servingSize; // (mL, 1회 제공량) 필수
    private int servings; // (회, 총 n회 제공량) 필수
    private int calories; // (1회 제공량당) 선택
    private int fat; // (g/1회 제공량) 선택
    private int sodium; // (mg/1회 제공량) 선택
    private int carbohydrate; // (g/1회 제공량) 선택

    public Nutritions_javabeans() {}

    public void setServingSize(int servingSize) { this.servingSize = servingSize; }
    public void setServings(int servings) { this.servings = servings; }
    public void setCalories(int calories) { this.calories = calories; }
    public void setFat(int fat) { this.fat = fat; }
    public void setSodium(int sodium) { this.sodium = sodium; }
    public void setCarbohydrate(int carbohydrate) { this.carbohydrate = carbohydrate; }
}

 

점층적 생성자 패턴에서 보이던 비슷비슷해보이는 생성자 더미들은 없지만 자바빈즈 패턴에는 심각한 단점이 있다.

단점

  • 객체가 완전히 생성되기 전까지 일관성이 무너진 상태이다.
    • 클래스를 불변으로 만들 수 없다.
  • 객체 하나를 만들기 위해서 여러 메서드를 호출해야한다.

물론 이런 단점을 극복하고자 freezing이란 기법이 존재하지만 다루기 어렵다. 또한 freeze()가 쓰이고 있는지 컴파일러가 보증해줄 수 없기 때문에 런타임 오류에 취약하다.

 

3. 빌더 패턴, Builder pattern

(클라이언트는) 필수 매개변수만으로 생성자나 정적 팩토리를 호출하여 빌더 객체를 얻고, 이 빌더 객체가 제공하는 setter()로 원하는 선택 매개변수의 값을 빌더 객체에 지정한다. 마지막으로 Builder 객체로 타겟 객체를 생성하는 로직을 가진 build()로 원하던 객체를 얻는다.

public class Nutritions_builder {
    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; // (mL, 1회 제공량) 필수
        private final int servings; // (회, 총 n회 제공량) 필수

        private int calories = 0; // (1회 제공량당) 선택
        private int fat = 0; // (g/1회 제공량) 선택
        private int sodium = 0; // (mg/1회 제공량) 선택
        private int carbohydrate = 0; // (g/1회 제공량) 선택

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

        public Nutritions_builder build() {
            return new Nutritions_builder(this);
        }
    }

    private Nutritions_builder(Builder builder) {
        servingSize = builder.servingSize;
        servings = builder.servings;
        calories = builder.calories;
        fat = builder.fat;
        sodium = builder.sodium;
        carbohydrate = builder.carbohydrate;
    }
}

// 클라이언트는 이렇게 가져다 쓸 수 있어요
Nutritions_builder newInstance = new Nutritions_builder.Builder(240, 8)
																					.calories(100)
																					.sodium(35)
																					.carbohydrate(27)
																					.build();


단점

  • 객체를 만드려면 우선 빌더객체부터 만들어져야 한다.
    → 빌더 생성 비용이 크지는 않지만 성능에 민감한 상황에서는 문제가 될 수도 있다.
  • 코드가 장황해진다.

장점

  • fluent
    → 메서드 호출이 흐르듯 연결된다~, method chaining을 의미
  • 가독성이 좋다.
  • 자바빈즈보다 안전하다.
  • 빌더의 메서드를 여러번 사용함으로써 가변인수 매개변수를 여러 개 사용할 수 있다.
  • 계층적으로 설계된 클래스와 함께 사용하기 좋다.
불변, immutable → 어떠한 변경도 허용하지 않는다.

불변식, invariant → 프로그램이 실행되는 동안, 혹은 정해진 기간 동안 반드시 만족해야하는 조건

 

 

→ 근데.. 계층적으로 설계된 클래스와 함께 사용하기 좋다. ......? 에? 뭔 말?

계층적으로 설계된 클래스란?

 

아하 계층적으로 설계되었다고 해서 뭔가 낯설게 느껴졌는데 사실 우리가 잘 아는 것이다. 상속을 이용하여 설계된 클래스 구조!

// 상위 클래스
public class Animal {
    private String name;

    public Animal(String name) {
        this.name = name;
    }

    public void eat() {
        System.out.println(name + " is eating.");
    }

    public String getName() {
        return name;
    }
}

// 하위 클래스
public class Dog extends Animal {
    public Dog(String name) {
        super(name);
    }

    public void bark() {
        System.out.println(getName() + " is barking.");
    }
}

// 또 다른 하위 클래스
public class Cat extends Animal {
    public Cat(String name) {
        super(name);
    }

    public void meow() {
        System.out.println(getName() + " is meowing.");
    }
}

// 추상 클래스
public abstract class Bird extends Animal {
    public Bird(String name) {
        super(name);
    }

    public abstract void fly();
}

// 인터페이스
public interface Pet {
    void play();
}

// 인터페이스 구현
public class Parrot extends Bird implements Pet {
    public Parrot(String name) {
        super(name);
    }

    @Override
    public void fly() {
        System.out.println(getName() + " is flying.");
    }

    @Override
    public void play() {
        System.out.println(getName() + " is playing.");
    }
}

 

이런 구조들이 되겠다.

 

 

그렇다면 계층적으로 설계된, 그러니까 상속을 이용해서 설계된 클래스들은 왜 빌더 패턴을 사용하기 좋다고 하는 걸까.

이펙티브 자바에 나와있는 예시는 다음과 같다.

public abstract class Pizza {
    public enum Topping {
        CHEESE, PEPPERONI, CLAMS, VEGGIE
    }
    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(Objects.requireNonNull(topping));
            return self();
        }
        abstract Pizza build();
        protected abstract T self();
    }

    Pizza(Builder<?> builder) {
        toppings = builder.toppings.clone();
    }
}

 

그리고 구현 클래스의 예제

public class NyPizza extends Pizza {
    public enum Size { SMALL, MEDIUM, LARGE }
    private final Size size;

    private NyPizza(Builder builder) {
        super(builder);
        size = builder.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;
        }
    }
}

public class Calzone extends Pizza {
    private final boolean sauceInside;

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

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

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

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

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

 

이렇게 사용했을 때 장점은 다음과 같다.

  1. 추상 클래스로 선언한 Builder의 선언에 따라 하위 클래스의 빌더가 정의한 build 메서드는 구체 하위 클래스를 반환한다. : Pizza가 아니라 NyPizza, Calzone를 반환한다는 뜻. → 공변환 타이핑(covariant return typing) 이렇게 되면 클라이언트는 형변환에 신경쓰지 않고 빌더를 사용할 수 있다.
  2. 추상 메서드 self를 더해서 하위 클래스에서 형변환하지 않고 메서드 연쇄를 지원할 수 있다.
  3. (내 생각) 상위 클래스, Pizza에서 피자에 모두 공통되게 들어가는 topping이라는 필드에 대해서 값을 셋팅해줄 수 있는 빌더 메서드를 지원함으로서 Pizza 구현 클래스들에게 공통적인 로직을 제공할 수 있다는 장점이 있을 것 같다.

 

개인적인 생각정리

나도 빌더를 많이 쓰지만, 무지성으로 롬복이 이끄는대로 사용했었는데..ㅎㅎㅋ 항상 빌더를 쓸까 정팩메로 쓸까 고민되는 지점이 휴먼 에러로 빌더에 특정 필드를 누락시켰을때, 값이 null로 지정되어도 개발할 당시에 내가 알 방법이 없어서 위험하다는 지점이었다. 그런데 이번에 빌더 클래스를 똑바로 읽고 나니까, 필수적으로 값이 지정되어야 하는 요소에 대해서 빌더 생성자의 파라미터에 값을 넣도록 할 수 있다는 것을 알게 되어서 이런식으로 쓰면 좀 더 편리하고 안전하게 빌더를 쓸 수 있을 것 같다. 그리고 값 셋팅해주는 메서드에 유효성 검증 로직을 넣을 수 있다는 점도…..

 

흠 그런데 마지막에 계층적 설계에서 빌더가 주는 장점을 아직은 공감을 못하겠다. 내 생각인 3번째는 내 생각이니까 그러려니하는데……아직 1,2번 장점은… 직접 겪게 되면 더 와닿을듯

만일 이런 예제가 생긴다면 여기에 다시 기록해두도록 하겠음….

 

 


[ 참고 ]

https://m.yes24.com/Goods/Detail/65551284

 

이펙티브 자바 Effective Java 3/E - 예스24

자바 플랫폼 모범 사례 완벽 가이드 - Java 7, 8, 9 대응자바 6 출시 직후 출간된 『이펙티브 자바 2판』 이후로 자바는 커다란 변화를 겪었다. 그래서 졸트상에 빛나는 이 책도 자바 언어와 라이브

m.yes24.com

 

그리고... thanks to 약 500장의 책을 함께 끝낸 스터디원느님들......

댓글