선택적 매개변수가 많을 경우 사용하는 생성자 패턴 3가지가 있다.

1. 점층적 생성자 패턴

정의

필수 매개변수만 받는 생성자, 필수 매개변수와 선택 매개변수 1개를 받는 생성자, 선택 매개변수 2개, … 형태로 선택 매개변수를 전부 다 받는 생성자까지 늘려가는 패턴이다.

단점

매개변수 개수가 많아지면 클라이언트 코드를 작성하거나 읽기 어렵다.
⇒ 각 매개변수 값의 의미를 헷갈릴 수 있고, 매개변수가 몇 개인지 주의해서 세어 보아야 한다.

생성자 체이닝 방식

  • 점층적 생성자 패턴의 구현 방식 중 하나이다.
  • this() 키워드로 하나의 생성자가 다른 생성자를 호출하여 초기화 작업을 재사용하는 방식이다.
  • 중복 코드를 줄일 수 있어 가독성과 효율성이 높다.

생성자 체이닝 방식으로 구현한 점층적 생성자 패턴 예시

public class Coffee {
    private final String size;      // 필수
    private final boolean milk;     // 선택
    private final boolean sugar;    // 선택
    private final String syrup;     // 선택

    // 필수 매개변수만 받는 생성자
    public Coffee(String size) {
        this(size, false);
    }
    
    public Coffee(String size, boolean milk) {
        this(size, milk, false); 
    }

    public Coffee(String size, boolean milk, boolean sugar) {
        this(size, milk, sugar, null);
    }

    public Coffee(String size, boolean milk, boolean sugar, String syrup) {
        this.size = size;
        this.milk = milk;
        this.sugar = sugar;
        this.syrup = syrup;
    }
}

만약 체이닝 방식을 사용하지 않고, 모든 생성자가 최종 생성자를 직접 호출한다면?

public class Coffee {
    private final String size;      // 필수
    private final boolean milk;     // 선택
    private final boolean sugar;    // 선택
    private final String syrup;     // 선택

    // 각 생성자에 선택 매개변수 기본값을 개별적으로 설정해줘야 한다. 
    // -> 중복 코드 발생, 기본값을 변경하거나 새로운 선택 매개변수를 추가하려면 모든 생성자를 수정해야 한다.
    public Coffee() {
        this("Medium", false, false, null);
    }

    public Coffee(String size) {
        this(size, false, false, null);
    }
    
    public Coffee(String size, boolean milk) {
        this(size, milk, false, null);
    }

    // ...

    // 최종 생성자
    public Coffee(String size, boolean milk, boolean sugar, String syrup) {
        this.size = size;
        this.milk = milk;
        this.sugar = sugar;
        this.syrup = syrup;
    }
}

2. 자바빈즈 패턴

점층적 생성자 패턴의 단점을 보완한 방법.

정의

매개변수가 없는 생성자로 객체를 만든 후, setter 메서드들을 호출해 원하는 매개변수의 값을 설정하는 방식이다.

단점

  1. 하나의 객체를 만들려면 메서드를 여러 개 호출해야 한다.
  2. 객체가 완전히 생성되기 전까지는 일관성이 무너진 상태(객체가 불완전한 상태로 사용되는 상태)에 놓이게 된다.
    ⇒ 클래스를 불변으로 만들 수 없다.
    ⇒ 객체가 불완전한 상태로 생성되더라도 컴파일 시점에 오류를 잡을 수 없다. (안전장치 부재)

예시 코드

public class Coffee {
    private String size = "Medium";      // 필수
    private boolean milk = false;     // 선택
    private boolean sugar = false;    // 선택
    private String syrup = null;     // 선택

    // 기본 생성자
    public Coffee() {
    }

    public void setSize(String size) {
        this.size = size;
    }

    public void setMilk(boolean milk) {
        this.milk = milk;
    }

    public void setSugar(boolean sugar) {
        this.sugar = sugar;
    }

    public void setSyrup(String syrup) {
        this.syrup = syrup;
    }

    public static void main(String[] args) {
        // Coffee 객체 생성
        Coffee coffee = new Coffee();

        // 필드 설정
        coffee.setSize("Large");
        coffee.setMilk(true);
        coffee.setSugar(false);
        coffee.setSyrup("Vanilla");
    }
}

3. 빌더 패턴

정의

  • 점층적 생성자 패턴의 안정성과 자바 빈즈 패턴의 가독성을 겸비한 패턴이다.
  • 빌더의 세터 메서드들은 빌더 자신을 반환하기 때문에 연쇄적으로 호출할 수 있다.
    ⇒ 이런 방식을 fluent API 혹은 method chaining 라 한다.

장점

  1. 클라이언트 코드를 작성하기 쉽고, 읽기도 쉽다.
  2. 계층적으로 설계된 클래스와 함께 쓰기에 좋다.

동작 과정

  1. 필수 매개변수만으로 생성자(혹은 정적 팩토리)를 호출해 Builder 객체를 얻는다.
  2. Builder 객체가 제공하는 세터 메서드들로 원하는 선택 매개변수를 설정한다.
  3. 매개변수가 없는 build() 메서드를 호출해 객체를 얻는다.

예시 코드

public class Coffee {  
	private final String size;   // 필수  
	private final boolean milk; // 선택  
	private final boolean sugar; // 선택  
	private final String syrup;  // 선택
  
    // Builder 클래스    
    public static class Builder {  
        private final String size;  // 필수  
        
        // 선택 매개변수는 기본값으로 초기화
        private boolean milk = false;
        private boolean sugar = false; 
        private String syrup = null;   
  
        // 필수 매개변수를 받는 생성자        
        public Builder(String size) {  
            if (size == null || size.isEmpty()) { 
                throw new IllegalArgumentException("Size cannot be null or empty"); 
            } 
            this.size = size;
        }  
	    
        // 선택적인 필드들을 설정할 수 있는 메서드들  
        public Builder milk(boolean milk) {  
            this.milk = milk;  
            return this;  
        }  
        public Builder sugar(boolean sugar) {  
            this.sugar = sugar;  
            return this;  
        }  
        public Builder syrup(String syrup) {  
            this.syrup = syrup;  
            return this;  
        }  
        
        // Coffee 객체를 빌드하는 메서드  
        public Coffee build() {  
            validate(); // 빌드 전 유효성 검사
            return new Coffee(this);  
        }  
        
        // 불변식 검사: 필수 매개변수 및 객체 상태 검증  
        private void validate() {  
            // 선택적 필드에 대한 검증 (예: syrup가 null일 경우 처리)  
            // 기본값을 지정해주지 않으면 기본값을 부여할 수도 있다.    
            if (syrup != null && syrup.length() > 20) {  
                throw new IllegalArgumentException("Syrup name is too long.");  
            }  
            // 추가적인 비즈니스 로직 또는 불변식 검사 로직을 넣을 수 있음  
            // 예: milk가 true일 때 syrup이 반드시 있어야 하는 등의 조건을 체크    
            if (milk && syrup == null) {  
                throw new IllegalArgumentException("If milk is added, syrup must also be set.");  
            }
        }

    }  
    
    // private 생성자  
    private Coffee(Builder builder) {  
        this.size = builder.size;  
        this.milk = builder.milk;  
        this.sugar = builder.sugar;  
        this.syrup = builder.syrup;  
    }  
}
// 객체 생성
public static void main(String[] args) {  
    try {  
        Coffee coffee = new Coffee.Builder("Medium")  
            .milk(true)  
            .syrup("Vanilla")  
            .build();  
        
        Coffee invalidCoffee = new Coffee.Builder("Large")  
            .milk(true)  
            .build();  

        System.out.println("Coffee created.");  
    } catch (IllegalArgumentException e) {  
        System.out.println("Error while creating coffee: " + e.getMessage());  
    }
}

4. 불변식(Invariant)

  • 프로그램이 실행되는 동안, 혹은 정해진 기간 동안 반드시 만족해야 하는 조건을 말한다.
    다시 말해 변경을 허용할 수는 있으나 주어진 조건 내에서만 허용한다는 뜻이다.
    ⇒ 이를 통해 객체의 예측 가능성을 보장하며, 프로그램의 안정성을 높일 수 있다.

  • 불변식이 깨진 상황 예시:
    1) 리스트의 크기는 반드시 0 이상이어야 하니, 만약 한순간이라도 음수 값이 된다면 불변식이 깨진 것이다.
    2) 기간을 표현하는 Period 클래스에서 start 필드의 값은 반드시 end 필드의 값보다 앞서야 하므로, 두 값이 역전되면 불변식이 깨진 것이다.

  • 가변 객체에도 불변식은 존재할 수 있으며, 불변은 불변식의 극단적인 예라고 할 수 있다.

5. 계층적으로 설계된 클래스와 잘 어울리는 빌더 패턴

각 계층의 클래스에 관련 빌더를 멤버로 정의한다.
추상 클래스는 추상 빌더를, 구체 클래스는 구체 빌더를 갖게 한다.

import java.util.*;

// 코드 2-4 계층적으로 설계된 클래스와 잘 어울리는 빌더 패턴 (19쪽)

// 참고: 여기서 사용한 '시뮬레이트한 셀프 타입(simulated self-type)' 관용구는
// 빌더뿐 아니라 임의의 유동적인 계층구조를 허용한다.

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) {
            toppings.add(Objects.requireNonNull(topping));
            return self();
        }

        abstract Pizza build();

        // 하위 클래스는 이 메서드를 재정의(overriding)하여
        // "this"를 반환하도록 해야 한다.
        protected abstract T self();
    }
    
    Pizza(Builder<?> builder) {
        toppings = builder.toppings.clone(); // 아이템 50 참조
    }
}
import java.util.Objects;

// 코드 2-5 뉴욕 피자 - 계층적 빌더를 활용한 하위 클래스 (20쪽)
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;
    }

    @Override public String toString() {
        return toppings + "로 토핑한 뉴욕 피자";
    }
}

NyPizza 인스턴스 생성 과정

  1. 생성자를 호출하는 new 키워드를 찾는다.

     // NyPizza.java
     public static class Builder extends Pizza.Builder<Builder> {
         // ...
    
         // pizza 클래스의 builder 추상 메서드 오버라이드
         // 인스턴스 생성하여 메모리에 적재 & 생성자 호출
         @Override public NyPizza build() {
             return new NyPizza(this);
         }
    
         // ...
     }
    

    Builder 구현체 인스턴스를 생성하여 build() 메서드를 호출해야 한다는 것을 알 수 있다.

     NyPizza nyPizza = new NyPizza.Builder(NyPizza.Size.MEDIUM)...
    

    Size를 매개변수로 넘겨 Builder 인스턴스를 생성한다.

  2. 원하는 인스턴스를 만들기 위해 Pizza 의 일반 메서드 addTopping() 을 호출한다.

     // Pizza.java
     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);  // 다른 패키지의 상속 클래스는 접근 비허용
    
         // self() 로 Builder 자신을 리턴받기 때문에 연쇄 호출이 가능하다.
         public T addTopping(Topping topping) {
             toppings.add(Objects.requireNonNull(topping));
             return self();
         }
    
         //...
    
         // 하위 클래스는 이 메서드를 재정의(overriding)하여 자기 자신을 반환한다.
         protected abstract T self();
    
         //...
     }
    
     NyPizza nyPizza = new NyPizza.Builder(NyPizza.Size.MEDIUM).addTopping(Pizza.Topping.PEPPER).addTopping(Pizza.Topping.SAUSAGE)...
    


  3. build() 를 호출해 NyPizza 인스턴스를 얻고, NyPizza 생성자 호출을 통해 Pizza 생성자 호출.
    ⇒ 각 생성자는 필드 초기화 역할을 한다.

     // NyPizza.java
     public static class Builder extends Pizza.Builder<Builder> {
         private final Size size;
    
         // ...
            
         @Override public NyPizza build() {
             return new NyPizza(this);
         }
    
         // ...
     }
    
     //...
    
     private NyPizza(Builder builder) {
         super(builder);         // 부모 클래스 Pizza의 생성자 호출
         size = builder.size;    // NyPizza 고유의 필드 초기화
     }
    
     // Pizza.java
     Pizza(Builder<?> builder) {
         // 구현체(NyPizza) Builder의 addTopping() 으로 추가한 toppings를 클론하여 Pizza 클래스의 toppings 필드 초기화
         toppings = builder.toppings.clone();    
     }
    

    build() 메서드처럼 하위 클래스의 메서드가 상위 클래스의 메서드가 정의한 반환 타입(Pizza)이 아닌, 그 하위 타입(NyPizza)을 반환하는 기능을 공변 반환 타이핑 이라고 한다.

     NyPizza nyPizza = new NyPizza.Builder(NyPizza.Size.MEDIUM).addTopping(Pizza.Topping.PEPPER).addTopping(Pizza.Topping.SAUSAGE).build();
     System.out.println(nyPizza.toString()); // [PEPPER, SAUSAGE]로 토핑한 뉴욕 피자
    

    최종적으로 인스턴스 생성 완료 및 결과 확인.

6. 공변 반환 타이핑(covariant return typing)

정의

  • 상위 클래스의 메서드를 오버라이드(override)할 때 반환 타입을 더 구체적인 하위 타입으로 지정할 수 있는 기능이다.
  • Java의 메서드 오버라이딩에서 허용된 규칙으로, 객체지향 프로그래밍에서 메서드 체이닝 패턴을 효과적으로 지원한다.

용도

  1. 메서드 체이닝
  2. 특화된 하위 클래스 타입 반환.
    하위 클래스에서 구체적인 타입을 반환할 필요가 있을 때 사용한다.

     class Shape {
         Shape clone() {
             return new Shape();
         }
     }
    
     class Circle extends Shape {
         @Override
         Circle clone() { // 공변 반환 타이핑
             return new Circle();
         }
     }
    

7. 빌더 사용 시의 이점

1) 가변인수 매개변수를 여러 개 사용 가능

방법:

  1. 각각의 가변인수 매개변수를 메서드로 나눠서 선언하는 방법.
  2. 위 예시의 addTopping() 메서드처럼 여러 번 호출하고, 호출 때 넘겨진 매개변수들을 하나의 필드로 모으도록 하는 방법.

가변인수(Variable Arguments):

  • 메서드의 파라미터로 전달되는 인수의 개수가 고정되어 있지 않을 때 사용된다.
  • 파라미터로 넘겨지는 값들은 컴파일시 배열로 처리된다.
  • 만일 매개변수가 가변 인자 외에 다른 매개 변수들도 받는다면, 반드시 가변 인자를 메서드 파라미터 가장 마지막에 위치하도록 정의해야 한다.
  • 메서드 파라미터에 타입... 매개변수명 문법으로 사용 가능하다.
public void printNumbers(int... numbers) {
    for (int number : numbers) {
        System.out.println(number);
    }
}

printNumbers(1, 2, 3); // 숫자 1, 2, 3 출력
printNumbers(10);      // 숫자 10 출력
printNumbers();        // 아무것도 출력하지 않음

2) 상당한 유연성

  1. 빌더 하나로 여러 객체를 순회하면서 만들 수 있다.
  2. 빌더에 넘기는 매개변수에 따라 다른 객체를 만들 수도 있다.
  3. 객체마다 부여되는 일련번호와 같은 특정 필드는 빌더가 알아서 채우도록 할 수도 있다.

8. 빌더 패턴의 단점

1) 성능에 영향을 끼칠 수 있다.

빌더 생성 비용이 크지는 않지만, 성능에 민감한 상황에서는 문제가 될 수 있다.

2) 코드가 장황하다.

매개변수가 4개 이상은 되어야 값어치를 한다.
하지만 API는 시간이 지날수록 매개변수가 많아지는 경향이 있으므로, 애초에 빌더로 시작하는 편이 나을 때가 많다.