선택적 매개변수가 많을 경우 사용하는 생성자 패턴 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
메서드들을 호출해 원하는 매개변수의 값을 설정하는 방식이다.
단점
- 하나의 객체를 만들려면 메서드를 여러 개 호출해야 한다.
- 객체가 완전히 생성되기 전까지는 일관성이 무너진 상태(객체가 불완전한 상태로 사용되는 상태)에 놓이게 된다.
⇒ 클래스를 불변으로 만들 수 없다.
⇒ 객체가 불완전한 상태로 생성되더라도 컴파일 시점에 오류를 잡을 수 없다. (안전장치 부재)
예시 코드
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
라 한다.
장점
- 클라이언트 코드를 작성하기 쉽고, 읽기도 쉽다.
- 계층적으로 설계된 클래스와 함께 쓰기에 좋다.
동작 과정
- 필수 매개변수만으로 생성자(혹은 정적 팩토리)를 호출해
Builder
객체를 얻는다. Builder
객체가 제공하는 세터 메서드들로 원하는 선택 매개변수를 설정한다.- 매개변수가 없는
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
인스턴스 생성 과정
-
생성자를 호출하는
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
인스턴스를 생성한다.
-
원하는 인스턴스를 만들기 위해
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)...
-
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의 메서드 오버라이딩에서 허용된 규칙으로, 객체지향 프로그래밍에서 메서드 체이닝 패턴을 효과적으로 지원한다.
용도
- 메서드 체이닝
-
특화된 하위 클래스 타입 반환.
하위 클래스에서 구체적인 타입을 반환할 필요가 있을 때 사용한다.class Shape { Shape clone() { return new Shape(); } } class Circle extends Shape { @Override Circle clone() { // 공변 반환 타이핑 return new Circle(); } }
7. 빌더 사용 시의 이점
1) 가변인수 매개변수를 여러 개 사용 가능
방법:
- 각각의 가변인수 매개변수를 메서드로 나눠서 선언하는 방법.
- 위 예시의
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) 상당한 유연성
- 빌더 하나로 여러 객체를 순회하면서 만들 수 있다.
- 빌더에 넘기는 매개변수에 따라 다른 객체를 만들 수도 있다.
- 객체마다 부여되는 일련번호와 같은 특정 필드는 빌더가 알아서 채우도록 할 수도 있다.
8. 빌더 패턴의 단점
1) 성능에 영향을 끼칠 수 있다.
빌더 생성 비용이 크지는 않지만, 성능에 민감한 상황에서는 문제가 될 수 있다.
2) 코드가 장황하다.
매개변수가 4개 이상은 되어야 값어치를 한다.
하지만 API는 시간이 지날수록 매개변수가 많아지는 경향이 있으므로, 애초에 빌더로 시작하는 편이 나을 때가 많다.