본문 바로가기

CS ﹒ Algorithm/DesignPatterns

디자인 패턴 (4) Builder Pattern : 빌더 패턴 _ 어노테이션부터 직접 구현까지

 

 

Builder Pattern

 

빌더 패턴은 아마도 개발 환경에서 (프레임워크가 기본 제공해주는 것 외에)가장 많이 쓰이는 패턴 중 하나가 아닐까 싶다.

LOMBOK 환경에서는 어노테이션 하나만 붙이면 직접 구현할 필요 없이 사용할 수 있기 때문에..

 

사실 나도 빌더 패턴을 직접 구현해보지는 않고 지금까지 어노테이션을 붙여서 사용해왔는데, 이번 기회에 구현 방식을 하나하나 확인해볼 수 있었다.

 

빌더패턴의 장단점이나 기본 생성자, setter방식과의 차이점은 모든 구현이 끝나고 다시 설명하도록 하겠다.

 

 

1
2
3
4
5
6
7
8
9
10
11
12
@AllArgsConstructor
public class Sandwich {
    private String bread;
    private String lettuce;
    private String ham;
    private String jam;
    private String egg;
    private String chickenTender;
    private String cheese;
    private String avocado;
    private String tomato;
}
cs

 

별 실용성은 없는 샌드위치라는 클래스가 있다.

보기에는 별 문제 없이 깔끔해보이지만 그건 AllArgsConstructor 어노테이션 덕분이고, 실은 아래에 끔찍한 생성자가 붙어야 한다.

 

1
2
3
4
5
6
7
8
9
10
11
    public Sandwich(String bread, String lettuce, String ham, String jam, String egg, String chickenTender, String cheese, String avocado, String tomato) {
        this.bread = bread;
        this.lettuce = lettuce;
        this.ham = ham;
        this.jam = jam;
        this.egg = egg;
        this.chickenTender = chickenTender;
        this.cheese = cheese;
        this.avocado = avocado;
        this.tomato = tomato;
    }
cs

 

1
2
        Sandwich tenderSandwich =
                new Sandwich("식빵","양상추",null,"칠리소스","계란","텐더","치즈",null,null);
cs

 

 

예를 들자면 이런 것 말이다.

인스턴스를 만들 때에도 아래의 코드처럼 일일히 필요 없는 필드값은 null로 지정해주던지, 혹은 생성자를 점층식으로 만들어줘야 한다.

점층식 생성자라는 것은 생성자를 여러개 만드는 것을 의미한다. 굳이 코드를 보여주지 않아도 알 것이라고 생각하고 넘어가겠다.

(너무 길어진다.)

 

게다가 모두 String 타입이기 때문에 bread를 작성할 자리에 lettuce를 넣는다면? ham을 넣는다면?

컴파일러 입장에서는 내가 입력한 "호밀빵"이 bread인지 lettuce인지 알 바 아니다.

최저 임금 받으니 최소한의 일만 한다는 알바생처럼 컴파일러는 String이면 모두 통과시켜준다.

이는 고작 Sandwich 클래스에서는 별 문제가 없겠지만, 중요한 비즈니스 로직에서는 이렇게 입력된 DB를 뒤집어 엎어야하는 끔찍한 혼란이 벌어질 수도 있다.

 

이를 해결하기 위해 빌더 패턴을 적용해보겠다.

가장 고전적인 빌더 패턴부터 제일 편한 어노테이션 방식까지 살펴보자.

 

 

 

1
2
3
4
5
6
7
8
9
10
11
12
public interface SandwichBuilder {
    SandwichBuilder bread(String bread);
    SandwichBuilder lettuce(String lettuce);
    SandwichBuilder ham(String ham);
    SandwichBuilder jam(String jam);
    SandwichBuilder egg(String egg);
    SandwichBuilder chickenTender(String chickenTender);
    SandwichBuilder cheese(String cheese);
    SandwichBuilder avocado(String avocado);
    SandwichBuilder tomato(String tomato);
    Sandwich getSandwich();
}
cs

 

우선 빌더 패턴의 인터페이스를 만들어준다, 왜 죄다 반환값이 SandwichBuilder인지는 밑의 코드를 보면 이해할 수 있다.

 

 

 

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
59
60
61
62
63
64
65
66
67
68
69
70
public class CustomSandwichBuilder implements SandwichBuilder {
    private String bread;
    private String lettuce;
    private String ham;
    private String jam;
    private String egg;
    private String chickenTender;
    private String cheese;
    private String avocado;
    private String tomato;
 
    @Override
    public SandwichBuilder bread(String bread) {
        this.bread = bread;
        return this;
    }
 
    @Override
    public SandwichBuilder lettuce(String lettuce) {
        this.lettuce = lettuce;
        return this;
    }
 
    @Override
    public SandwichBuilder ham(String ham) {
        this.ham = ham;
        return this;
    }
 
    @Override
    public SandwichBuilder jam(String jam) {
        this.jam = jam;
        return this;
    }
 
    @Override
    public SandwichBuilder egg(String egg) {
        this.egg = egg;
        return this;
    }
 
    @Override
    public SandwichBuilder chickenTender(String chickenTender) {
        this.egg = egg;
        return this;
    }
 
    @Override
    public SandwichBuilder cheese(String cheese) {
        this.cheese = cheese;
        return this;
    }
 
    @Override
    public SandwichBuilder avocado(String avocado) {
        this.avocado = avocado;
        return this;
    }
 
    @Override
    public SandwichBuilder tomato(String tomato) {
        this.tomato = tomato;
        return this;
    }
 
    public Sandwich getSandwich() {
        if (bread == nullthrow new IllegalArgumentException("빵이 있어야 샌드위치죠..");
        return new Sandwich(bread, lettuce, ham, jam, egg, chickenTender, cheese, avocado, tomato);
    }
}
cs

 

어우.. 길다.

원리는 다음과 같다.

우선 Sandwich와 같은 변수들을 가진 SandwichBuilder의 인스터스를 생성한다.

그리고 SandwhichBuilder의 메서드들을 이용해 필요한 변수들에게 값을 준다.

모든 값을 지정했으면 마지막으로 getSandwich를 호출하여 같은 값을 가진 샌드위치를 생성해준다.

보통 get 단계에서 검증 로직을 실행한다.

 

 

 

1
2
3
4
5
6
7
        SandwichBuilder sandwichBuilder = new CustomSandwichBuilder();
        Sandwich hamCheeseSandwich = sandwichBuilder.bread("호밀빵")
                .jam("딸기잼")
                .lettuce("양상추")
                .ham("슬라이스햄")
                .cheese("치즈")
                .getSandwich();
cs

 

그리고 이렇게 꺼내서 사용하면 된다.

엄청 쉽다.

약간 응용하면 이런 것 또한 가능하다.

 

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RequiredArgsConstructor
public class ReadyMadeSandwich {
    private final SandwichBuilder sandwichBuilder;
 
    public Sandwich getHamCheeseSandwich() {
        return sandwichBuilder.bread("호밀빵")
                .jam("딸기잼")
                .lettuce("양상추")
                .ham("슬라이스햄")
                .cheese("치즈")
                .tomato("도뭬이로")
                .getSandwich();
    }
 
    public Sandwich getChickenTenderSandwich() {
        return sandwichBuilder.bread("플레인식빵")
                .jam("칠리소스")
                .lettuce("양상추")
                .cheese("체다치즈")
                .tomato("도메이도")
                .getSandwich();
    }
}
cs

 

미리 만들어진 레시피대로 반환해주는 ReadyMadeSandwich 클래스를 만들었다.

sandwichBuilder 구현체만 주입받으면 클라이언트는 아주 편하게 원하는 인스턴스를 생성할 수 있다.

 

 

 

1
2
        ReadyMadeSandwich readyMadeSandwich = new ReadyMadeSandwich(new CustomSandwichBuilder());
        Sandwich tenderSandwich2 = readyMadeSandwich.getChickenTenderSandwich();
cs

 

어떤가? 이제 두 줄까지 줄어들었다.

 

그런데 누군가는 이렇게 별도의 클래스를 만들고 관리해주는 것부터 귀찮을 수 있다.

그러면 그냥 static 클래스로 기존 클래스 안에 모두 밀어넣어도 상관 없다.

어차피 해당 클래스의 생성을 위한 것이지 다른 곳에서 쓸 일은 없기 때문이다.

 

 

 

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
 
@AllArgsConstructor
public class Sandwich {
    private String bread;
    private String lettuce;
    private String ham;
    private String jam;
    private String egg;
    private String chickenTender;
    private String cheese;
    private String avocado;
    private String tomato;
 
    public Sandwich(Builder builder) {
        this.bread = builder.bread;
        this.lettuce = builder.lettuce;
        this.ham = builder.ham;
        this.jam = builder.jam;
        this.egg = builder.egg;
        this.chickenTender = builder.chickenTender;
        this.cheese = builder.cheese;
        this.avocado = builder.avocado;
        this.tomato = builder.tomato;
    }
 
    public String toString() {
        StringBuilder sb = new StringBuilder();
 
        if (bread != null) sb.append(bread).append(" ");
        if (lettuce != null) sb.append(lettuce).append(" ");
        if (ham != null) sb.append(ham).append(" ");
        if (jam != null) sb.append(jam).append(" ");
        if (egg != null) sb.append(egg).append(" ");
        if (chickenTender != null) sb.append(chickenTender).append(" ");
        if (cheese != null) sb.append(cheese).append(" ");
        if (avocado != null) sb.append(avocado).append(" ");
        if (tomato != null) sb.append(tomato).append(" ");
 
        return sb.toString();
    }
 
 
    public static class Builder {
        private String bread;
        private String lettuce;
        private String ham;
        private String jam;
        private String egg;
        private String chickenTender;
        private String cheese;
        private String avocado;
        private String tomato;
 
        public Builder() {
        }
 
        public Builder bread(String bread) {
            this.bread = bread;
            return this;
        }
 
        public Builder lettuce(String lettuce) {
            this.lettuce = lettuce;
            return this;
        }
 
        public Builder ham(String ham) {
            this.ham = ham;
            return this;
        }
 
        public Builder jam(String jam) {
            this.jam = jam;
            return this;
        }
 
        public Builder egg(String egg) {
            this.egg = egg;
            return this;
        }
 
        public Builder chickenTender(String chickenTender) {
            this.egg = egg;
            return this;
        }
 
        public Builder cheese(String cheese) {
            this.cheese = cheese;
            return this;
        }
 
        public Builder avocado(String avocado) {
            this.avocado = avocado;
            return this;
        }
 
        public Builder tomato(String tomato) {
            this.tomato = tomato;
            return this;
        }
 
        public Sandwich getSandwich() {
            if (bread == nullthrow new IllegalArgumentException("빵이 있어야 샌드위치죠..");
            return new Sandwich(bread, lettuce, ham, jam, egg, chickenTender, cheese, avocado, tomato);
        }
    }
 
cs

 

1
2
3
4
5
6
7
8
9
        Sandwich 이름짓기귀찮은샌드위치 = new Sandwich.Builder()
                .bread("빵빵")
                .avocado("애보케도")
                .chickenTender("췩췩퉨더")
                .jam("딸기쫨")
                .tomato("도메이로")
                .getSandwich();
 
 
cs

 

 

 

다소 지저분해 보일 수는 있지만 개인적으로 사용성 측면에서 이게 더 좋은 것 같다.

마지막으로 제일 간편한 어노테이션 방식까지 살펴보도록 하겠다.

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class SandiwichAnno {
    private String bread;
    private String lettuce;
    private String ham;
    private String jam;
    private String egg;
    private String chickenTender;
    private String cheese;
    private String avocado;
    private String tomato;
 
    @Builder
    public SandiwichAnno(String bread, String lettuce, String ham, String jam, String egg) {
        this.bread = bread;
        this.lettuce = lettuce;
        this.ham = ham;
        this.jam = jam;
        this.egg = egg;
    }
}
cs

 

1
2
3
4
        SandiwichAnno 더쉽게만든샌드위치 = SandiwichAnno.builder()
                .lettuce("에그머니나")
                .ham("으헴헴")
                .build();
cs

 

 

 

엄청나게 간편해졌다.

@Builder 어노테이션의 경우 클래스 레벨에 붙이면 모든 필드 변수가 포함되고, 생성자에 붙이면 내가 생성자에 파라미터로 넣은 필드 변수만 포함된다.

 

 

 

여기까지 읽고 어떤 사람은 이런 생각을 할 수도 있다. " 이거 setter로도 되는 거 아닌가? "

 

setter(Java Beans Pattern)와 Builder Pattern의 가장 큰 차이점은 Builder Pattern으로 만들어진 객체는 Immutable(불변) 속성을 가지게 된다는 것이다.

빌더 패턴을 사용해서 객체를 생성하면 내가 추후 바꿔야하는 값(혹은 변경해도 되는 값)에만 setter를 만들어주고 나머지는 완벽하게 보호할 수 있다. 그러나 setter를 사용하면 객체는 늘 열려있는 상태이기 때문에 언제든 값이 변경될 수 있다.

또한 객체의 모든 빌드 변수가 추후 변경해도 아무 상관이 없어서 정말 setter로만 관리해도 된다고 치더라도, setter로 내가 모든 값을 지정해주기 전까지 객체는 불완전한 상태로 존재하게 된다.

생성자를 전혀 거치지 않기 때문에 내가 필요한 값을 모두 세팅했는지 일일히 눈으로 확인하는 것 밖에 방법이 없고, 컴파일러는 판단 기준이 없으니 이걸 제어해줄 방법이 없다.

 

길게도 썼다. 제일 중요한 걸 말해주자면 " 구글도 어지간하면 setter 쓰지 말래요. "

이제 신뢰도가 확 올랐을 것이다.

 

그럼 이제 생성자와의 차이에 대해 이야기해보자.

 

사실 그냥 생성자를 사용해서 객체를 만드는 것도 그리 나쁜 방법이라고 할 수는 없다. 네 가지 이유가 있는데 다음과 같다.

1. 생성자는 컴파일 단계에서 대부분의 문제를 체크해줄 수 있다.

2. 별도의 클래스나 메서드를 사용하지 않아도 된다.

3. 요즘의 IDE는 인스턴스 생성 시 각 위치에 어떤 필드에 대한 값을 입력해야하는지 모두 알려준다.

4. 생성자를 사용해서도 불변 객체를 만드는데 아무런 지장이 없다.

(참고로 "만들면서 배우는 클린 아키텍처" 저자인 톰 홈버그는 빌더 패턴보다 생성자의 사용을 권장하고 있으며 빌더 패턴이 오히려 실수의 여지가 있다고 이야기 한다. 이쪽도 일리 있다.)

 

하지만 나는 다음의 이유로 생성자보다 빌더 패턴을 추천한다.

1. IDE가 인스턴스 생성 시 개발자를 도와주는 것은 사실이지만 기능이 많아진 만큼 프로그램이 무거워져서 헬퍼 기능이 똑바로 작동하지 않을 때가 있다.

2. IDE가 도와주는 것은 결국 보조 역할일 뿐이며 보조를 해주고 있음에도 개발자가 실수로 같은 타입의 값을 순서를 바꿔서 넣는다면 역시나 컴파일러에서 제지해줄 방법이 없다.

3. 코드를 유지보수하는 관점에서도 생성된 인스턴스가 어떤 값들을 가지고 어떻게 결합되어 있는지 보다 직관적으로 확인할 수 있다.

 

구글을 찾아봐도 빌더패턴에 대한 견해는 꽤나 갈리기 때문에 직접 사용해보고 장단점을 고려해서 사용하는 것이 좋다.

나는 개인적으로 나의 꼼꼼함을 신뢰하지 않고, 다시 코드를 볼 때 파라미터가 늘어져있으면 끔찍하기 때문에 빌더 패턴을 선호한다.