본문 바로가기

OOP, FP/이펙티브 자바

이펙티브 자바 02. 점층적 생성자 패턴, 자바빈 패턴, 빌더 패턴

필수적 요소와 선택적 요소

클래스의 인스턴스를 생성할 때, 필수적인 요소와 선택적인 요소가 여러개 필요한 경우가 있다.

정적 팩토리 메소드나 생성자는 선택적 인자가 많은 상황에 잘 적응하지 못한다는 문제점을 갖고 있다.


마트에서 포장 판매되는 음식을 살 때, 음식에 붙어있는 영양 성분표를 예로 들어보자.


영양 성분표에는 반드시 포함되어야 하는 항목이 몇 가지 되지 않는다.

총 제공량(serving size), 1회 제공량(servings per container), 1회 제공량당 칼로리(calories per servings) 등이 필수적인 항목이다.

반면에, 선택적인 항목은 적어도 20개가 넘는다.

총 지방 함량(total fat), 포화 지방 함량(saturated fat), 트랜스 지방 함량(trans fat) 등이 선택적인 항목이다.


점층적 생성자 패턴

이 영양 성분표 클래스에는 어떤 생성자나 정적 팩토리 메소드가 적합할까?

보통 개발자들은 이런 클래스에 점층적 생성자 패턴을 사용한다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Foo {
    private int fooInt; // 선택
    private String fooStr; // 필수
    private List<Bar> bars; // 선택
 
    public Foo(String fooStr) {
        this(fooStr, 0);
    }
 
    public Foo(String fooStr, int fooInt) {
        this(fooStr, fooInt, new ArrayList<Bars>());
    }
 
    public Foo(String fooStr, int fooInt,  List<Bar> bars) {
        this.fooStr = fooStr;
        this.fooInt = fooInt;
        this.bars = bars;
    }
}
cs

위와 같은 생성자의 구조를 사용하는 클래스를 많이 보았을 것이다.

필수 인자만 받는 생성자를 하나 정의하고, 선택적 인자를 하나 받는 생성자를 추가하고,  두 개의 선택적 인자를 받는 생성자를 추가하는 식으로, 생성자를 쌓아 올리듯 추가하는 것이다.

결국 모든 선택적 인자를 다 받는 생성자를 정의하면 끝나게 된다.

이제 영양 성분표 클래스를 점층적 생성자 패턴을 이용해 구현해보자.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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 NutritionFacts(int servingSize, int servings) {
        this(servingSize, servings, 0);
    }
    
    public NutritionFacts(int servingSize, int servings, int calories) {
        this(servingSize, servings, calories, 0);
    }
    
    public NutritionFacts(int servingSize, int servings, int calories, int fat) {
        this(servingSize, servings, calories, fat, 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;
    }
}
cs

지면 관계상 선택적 필드는 4개만 사용했다.

이 클래스로 객체를 생성할 때는 설정하려는 인자 개수에 맞는 생성자를 골라 호출하면 된다.


1
NutritionFacts cocalCola = new NutritionFacts(240810033527);
cs

그런데 이렇게 하다 보면, 설정할 필요가 없는 필드에도 인자를 전달해야 하는 경우가 생긴다.

생성자를 정의하는 부분에서 fat이나 sodium 등을 설정하기 위해 0을 전달한 것이 그런 예이다.

지금은 인자가 6개 뿐이라 괜찮아보일 수도 있지만, 인자 수가 늘어나면 곤란하게 될 것이다.


문제가 하나 더 있다.

영양 성분표 클래스를 보면 필드의 자료형이 모두 int 형이다.

때문에 생성자의 자료형도 모두 int형으로 같은 형이 연속해서 정의되어있다.

이런 경우 개발자는 클라이언트측에서 각 인자를 올바르게 전달했는지 알 방법이 없다.

클라이언트가 두 개 인자의 순서를 실수로 뒤집어도 컴파일러는 알지 못하며, 프로그램 실행 도중에 문제가 생기게 되는 것이다.

예를 들어, 클라이언트측에서 실수로 총 제공량(savingSize)의 값을 1회 제공량(savings)에 전달해도 객체는 문제없이 생성 될 것이다.

이는 이후 영양 성분표를 출력한다거나하는 과정(런타임)에서 문제가 생기게 된다.


요약하자면, 점층적 생성자 패턴은 잘 동작하지만 인자 수가 늘어나면 클라이언트 코드를 작성하기가 어려워지고 읽기 어려운 코드가 된다.

그 많은 인자가 무슨 값인지 알 수 없게되고, 그 의미를 알려면 인자를 주의 깊게 봐야한다.


자바빈 패턴

생성자에 전달되는 인자 수가 많을 때 적용 가능한 두 번째 대안은 자바빈 패턴이다.

인자 없는 생성자를 호출하여 기본 객체를 만든 다음, 세터를 이용하여 필드 값을 채워나가는 패턴이다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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 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;
    }
}
cs

이러한 자바빈 패턴에는 점층적 생성자 패턴에 있던 문제는 없다.


1
2
3
4
5
6
NutritionFacts cocaCola = new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);
cocaCola.setSodium(35);
cocaCola.setCarbohydrate(27);
cs

기본값으로 초기화가 이루어진 상태에서 원하는 필드만 채워나가기 때문에 설정할 필요가 없는 필드에 인자를 전달할 필요가 없으며,

각각의 필드의 값을 설정하는 세터를 이용하기 때문에 인자가 헷갈릴 일도 없다.


그러나 자바빈 패턴에는 심각한 단점이 존재한다.

1회의 함수 호출로 객체 생성을 끝낼 수 없으므로, 객체 일관성이 일시적으로 깨질 수 있다.

생성자의 인자가 유효한지 검사하여 일관성을 보장하는 단순한 방법을 자바빈 패턴에서는 사용할 수 없다.

일관성이 깨진 객체를 사용할 때 생기는 문제는 실제 버그 위치에서 한참 떨어진 곳에서 발생하므로 디버깅 하기도 어렵다.

예를 들어, 기본값으로 초기화되는 원시 타입(primitive type)이 아닌 사용자 정의 타입(reference type)를 필드의 경우

생성자 인스턴스를 생성해주지 않거나 세터를 호출하지 않으면 null값을 갖게 된다. 그렇게 생성된 객체를 이곳 저곳에 넘기다보면 어디선가 NullPointerException이 발생하게 된다.


자바빈 패턴의 또 다른 문제는 변경 불가능(immutable) 클래스를 만들 수 없다는 것이다.

때문에, 스레드 안정성을 제공하기 위해 더 많은 일을 해야한다.

이 문제는 생성이 끝난 객체는 얼리고(freezing), 얼지 않은 객체는 사용할 수 없도록 하는 코드를 수작업으로 추가해서 방지할 수도 있다.

하지만 이 방법은 까다로우며 거의 쓰이지도 않는다.

게다가 컴파일러 입장에서는 프로그래머가 객체를 얼리는 메소드를 제대로 호출했는지 알 방법이 없기 때문에 실행 도중 오류가 발생할 수 있다.


빌더 패턴

빌더 패턴은 필요한 객체를 직접 생성하는 대신, 필수 인자들을 생성자에 전부 전달하여 빌더 객체를 만든다.

그런 다음 빌더 객체에 정의된 설정 메소드들을 호출하여 선택적 인자들을 추가해 나간다.

필요한 인자를 모두 적용하고 나면 build 메소드를 이용하여 변경 불가능(immutable) 객체를 만드는 패턴이다.

객체를 생성하기 위한 패턴이지만, 팩토리 메소드 패턴이나 추상 팩토리 패턴과는 조금 다르다.


빌더 패턴은 점층적 생성자 패턴의 안정성에 자바빈 패턴의 가독성을 결합한 대안이다.

클래스의 속성들을 final 불변으로 선언하고, 정적 내부 클래스로 빌더라는 클래스를 정의하여 객체 생성을 위임한다.

빌더 클래스는 빌더가 만드는 객체 클래스의 정적 멤버 클래스로 정의한다.

그 이유는 추후 static 멤버와 관련된 규칙에서 등장한다.(규칙 22)

빌더 패턴의 코드를 살펴보자.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
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) {
        this.servingSize = builder.servingSize;
        this.servings = builder.servings;
        this.calories = builder.calories;
        this.fat = builder.fat;
        this.sodium = builder.sodium;
        this.carbohydrate = builder.carbohydrate;
    }
    
    public static class Builder {
        // 필수 인자
        private int servingSize;
        private 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) {
            this.calories = val;
            return this;
        }
        
        public Builder fat(int val) {
            this.fat = val;
            return this;
        }
        
        public Builder carbohydrate(int val) {
            this.carbohydrate = val;
            return this;
        }
        
        public Builder sodium(int val) {
            this.sodium = val;
            return this;
        }
        
        public NutritionFacts build() {
            return new NutritionFacts(this);
        }
    }
}
cs

우선 NutritionFacts 객체의 필드들이 final로 선언되어 변경 불가능하다는 사실이 중요하다.

빌더에 정의된 설정 메소드는 빌더 객체 자신을 반환하므로, 설정 메소드를 호출하는 코드는 쭉 이어서 쓸 수 있다.


1
2
3
4
5
NutritionFacts cocaCola = new NutritionFacts.Builder(2408)
                .calories(100)
                .sodium(35)
                .carbohydrate(27)
                .build();
cs

많은 라이브러리에서 위와 같은 구조를 제공한다.

생성자와 마찬가지로, 빌더 패턴을 사용하면 인자에 불변식(invariant)을 적용할 수 있다.

build 메소드에서 실제 객체로 인자가 복사된 다음에 불변 규칙을 위반하였는지 검사하여 IllegalStateException을 던져 어떤 불변식을 위반했는지 알릴 수 있다.

각각의 설정 메소드에서 불변식을 강제하는 경우, build가 실제로 호출되기 전에 신속하게 알리기 위해 IllegalArgumentException을 던진다.


생성자와 비교했을 때 빌더 패턴이 갖는 또 한가지 장점은 여러 개의 가변 인자(varargs)를 받을 수 있다는 것이다.

생성자는 메소드와 마찬가지로 하나의 가변 인자만 가질 수 있지만, 빌더는 인자마다 별도의 설정 메소드를 사용하므로

설정 메소드마다 하나씩, 필요한 만큼 가변 인자를 사용할 수 있다.


1
2
3
4
5
6
7
8
private int count = 0;
 
public NutritionFacts build() {
    count++;
    return new NutritionFacts(this);
}
 
public int nutritionFactsCount() { return count; };
cs

또한 빌더 패턴은 유연하다. 하나의 빌더 객체로 여러 객체를 만들 수 있다.

다른 객체를 생성해야 할 때마다 빌더 객체의 설정 메소드를 호출하면 다음에 생성될 객체를 바꿀 수 있다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Builder builder = new NutritionFacts.Builder(2408);
 
NutritionFacts cocaCola = builder
        .calories(100)
        .sodium(35)
        .carbohydrate(27)
        .build();
 
NutritionFacts zeroCoke = builder
        .calories(0)
        .build();
 
System.out.println(builder.nutritionFactsCount());
System.out.println(cocaCola.getCalories() + ", " + zeroCoke.getCalories());
 
결과
2
1000
cs

위와 같이 빌더가 만든 객체의 수를 카운팅하거나, 객체가 만들어질때마다 자동으로 증가하는 일련번호 같은 것이 좋은 예이다.


제네릭을 이용하여 인자가 설정된 빌더는 훌륭한 추상화가 가능하며 이러한 빌더는 훌륭한 추상적 팩토리다.

클라이언트는 그런 빌더를 어떤 메소드에 넘겨서, 해당 메소드가 클라이언트에게 하나 이상의 객체를 만들어 주도록 할 수 있다.

자바가 제공하는 추상적 팩토리로는 Class 객체가 있다.

이 객체의 newInstance 메소드가 build 메소드 구실을 하는데, 여러가지 문제가 존재한다.

무인자 생성자를 호출하는데, 그런 생성자가 존재하지 않아도 컴파일 오류가 발생하지 않는다는 점,

throws 절이 없음에도 무인자 생성자에서 생긴 예외를 계속 밖으로 던진다는 점이 그 문제점이다.

다시 말해, Class.newInstance 메소드는 컴파일 시점에 예외 검사가 가능해야 한다는 규칙을 깨뜨린다.

추상척 팩토리 역할을 하는 Builder 인터페이스에는 그런 문제점이 없다.


빌더 패턴의 단점

빌더 패턴에서 객체를 생성하려면 우선 빌더 객체를 생성해야 한다.

빌더 객체를 만드는 오버헤드가 성능에 큰 영향을 미치지 않으므로 대부분 문제가 없지만,

하드웨어와 밀접한 프로그램이나 성능이 중요한 상황에서는 그런 오버헤드가 성능에 큰 문제가 될 수도 있다.


또한, 빌더 패턴은 점층적 생성자 패턴보다 많은 코드가 필요하다.

때문에 인자가 충분히 많은 상황(4개 이상), 특히 선택적 요소가 많은 상황에서 유용하다.

처음에는 인자가 적더라도 나중에 새로운 인자를 추가해야할 상황이 올 수도 있으므로 주의해야 한다.

우선 생성자와 정적 팩토리로 시작하더라도, 인자 개수가 통제할 수 없을 정도로 많아지면 빌더 패턴을 적용하는 것도 한 방법이다.

그러나, 이미 정의된 생성자와 정적 팩토리를 처리하기 곤란할 수 있다. 생성자와 정적 팩토리 메소드는 대부분 접근 범위가 public이기 때문에 API에 공개된 상태이기 때문이다.

그러니 처음부터 빌더 패턴을 적용하는 것이 어떤지 충분히 고려해야한다.


빌더 패턴은 인자가 많은 생성자나 정적 팩토리가 필요한 클래스를 설계할 때 유용하며 특히, 대부분의 인자가 선택적 인자인 상황에 유용하다. 클라이언트 코드 가독성은 점층적 생성자 패턴을 따를 때보다 훨씬 좋아질 것이며, 그 결과물은 자바빈을 사용할 때보다 훨씬 안전할 것이다.


요약

점층적 생성자 패턴

생성자를 쌓아 올리듯 추가하는 패턴


단점

1. 설정할 필요가 없는 필드에도 인자를 전달해야 하는 경우가 생긴다.

2. 클라이언트가 두 개 인자의 순서를 실수로 뒤집어도 컴파일러는 알지 못하며, 프로그램 실행 도중에 문제가 생기게 되는 것이다.

3. 점층적 생성자 패턴은 잘 동작하지만 인자 수가 늘어나면 클라이언트 코드를 작성하기가 어려워지고 읽기 어려운 코드가 된다.


자바빈 패턴

인자 없는 생성자를 호출하여 기본 객체를 만든 다음, 세터를 이용하여 필드 값을 채워나가는 패턴


장점

1. 기본값으로 초기화가 이루어진 상태에서 원하는 필드만 채워나가기 때문에 설정할 필요가 없는 필드에 인자를 전달할 필요가 없다.

2. 각각의 필드의 값을 설정하는 세터를 이용하기 때문에 인자가 헷갈리지 않는다.


단점

1. 생성자의 인자가 유효한지 검사하여 일관성을 보장하는 단순한 방법을 자바빈 패턴에서는 사용할 수 없어 디버깅이 어렵다.

2. 변경 불가능(immutable) 클래스를 만들 수 없으며 이를 해결하기 위한 방법도 까다로워 잘 사용하지 않는다.


빌더 패턴

필수 인자들을 생성자에 전부 전달하여 빌더 객체를 만든 뒤, 설정 메소드를 호출하여 선택적 인자들을 추가해 나가며

필요한 인자를 모두 적용하고 나면 build 메소드를 이용하여 변경 불가능(immutable) 객체를 만드는 패턴


장점

1. 점층적 생성자 패턴의 안정성에 자바빈 패턴의 가독성을 결합한 대안

2. 변경 불가능(immutable) 객체를 생성하며, 불변식(invariant)을 적용할 수 있다

3. 여러 개의 가변 인자(varargs)를 받을 수 있다

4. 빌더 패턴은 유연하다. 하나의 빌더 객체로 여러 객체를 만들 수 있다

5. 제네릭을 이용하여 인자가 설정된 빌더는 훌륭한 추상화가 가능하며 이러한 빌더는 훌륭한 추상적 팩토리를 만들며 Class.newInstance의 단점을 보완한다.


단점

1. 빌더 객체를 생성하는 오버헤드가, 하드웨어와 밀접한 프로그램이나 성능이 중요한 상황에서는 성능에 큰 문제가 될 수도 있다.

2. 빌더 패턴은 점층적 생성자 패턴보다 많은 코드가 필요하다.