지금까지 다른 사람들의 코드에서 다음과 같은 패턴을 종종 볼 수 있었다.
...
public Pizza exec() {
...
return Pizza.Builder()
.dough(2)
.sause(1)
.cheese(3)
.pepperoni(7)
.olive(4)
.build();
}
...
빌더 패턴 이전에는 점층적 생성자 패턴과 자바 빈 패턴이 있었다. 점층적 생성자 패턴의 문제를 어느정도 해결한 것이 자바 빈 패턴이지만, 이 또한 다른 문제를 유발했다. 그리고 이것을 해결하기 위해 등장한 패턴이 빌더 패턴이다. 각 패턴의 특징과 문제점을 차례대로 살펴보자.
Telescoping Constructor Pattern, 점층적 생성자 패턴은 필수 매개변수와 함께 선택 매개변수를 0개, 1개, 2개 .. 를 받은 형태로, 다양한 매개변수를 입력받아 인스턴스를 생성하고 싶을 때 사용하던 생성자를 오버로딩하는 방식이다.
// 생성자를 오버로딩하는 '점층적 생성자 패턴'
class Pizza {
// 필수 매개변수
private int dough;
private int sause;
private int cheese;
// 선택 매개변수
private int pepperoni;
private int olive;
// 모든 재료가 들어간 Pizza
public Pizza(int dough, int sause, in cheese, int pepperoni, int olive) {
this.dough = dough;
this.sause = sause;
this.cheese = cheese;
this.pepperoni = pepperoni;
this.olive = olive;
}
// 올리브가 없는 Pizza
public Pizza(int dough, int sause, in cheese, int pepperoni) {
this.dough = dough;
this.sause = sause;
this.cheese = cheese;
this.pepperoni = pepperoni;
}
// 올리브, 페퍼로니가 없는 Pizza
public Pizza(int dough, int sause, in cheese) {
this.dough = dough;
this.sause = sause;
this.cheese = cheese;
}
}
public static void mian(String[] args) {
// 모든 재료가 있는 피자
Pizza pizza1 = new Pizza(2, 1, 2, 5, 6);
// 도우와 소스, 치즈만 있는 피자
Pizza pizza2 = new Pizza(2, 1, 1);
// 도우와 소스, 치즈, 올리브만 있는 피자
Pizza pizza3 = new Pizza(2, 3, 2, 0 ,6);
}
🔍 다른 생성 패턴과 비교 : 팩토리 메소드 패턴과 추상 팩토리 패턴
- 팩토리 메소드 패턴 : 조건에 따른 객체 생성을 팩토리 클래스로 위임하여, 팩토리 클래스에서 객체를 생성하는 패턴 (팩토리는 만 그대로 객체를 찍어내는 공장!)
-> 마우스를 제조할 건데, SamsungMouse와 LGMouse 중 어떤 것을 제조할지 입력 받아서 제조한다. MouseFactory 클래스에서 입력값을 받아 case에 따라서 둘 중 하나를 제조하는 것과 같다.- 추상 팩토리 패턴 : 서로 관련이 있는 객체들을 통째로 묶어서 팩토리 클래스로 만들고, 이들 팩토리를 조건에 따라 생성하도록 다시 팩토리를 만들어서 객체를 생성하는 패턴
-> (마우스, 키보드, 모니터로 이루어진) 컴퓨터를 제조할 건데, SamsungComputer와 LGComputer 중 어떤 것을 제조할지 입력 받아서 제조한다. 이 ComputerFactory는 삼성 또는 엘지를 입력 받아서 값에 따라 MouseFactory와 KeyboardFactory, MonitorFactory를 생성하고, 각 팩토리는 해당 회사의 마우스/키보드/모니터를 생성하는 것과 같다.
이러한 팩토리 메소드 패턴과 추상 팩토리 패턴은 객체를 생성할 때 생성자(Constructor)만 사용하기에, 점층적 생성자 패턴과 같은 문제가 발생한다.
이러한 단점을 보안하기 위해 Setter 메서드를 사용한 자바 빈(Bean) 패턴이 고안되었다. 매개변수가 없는 생성자로 객체 생성 후 Setter 메서드를 이용해 클래스 필드의 초깃값을 설정하는 방식이다.
// 생성자를 오버로딩하는 '점층적 생성자 패턴'
class Pizza {
// 필수 매개변수
private int dough;
private int sause;
private int cheese;
// 선택 매개변수
private int pepperoni;
private int olive;
public Pizza() {}
public void setDough(int dough) {
this.Dough = Dough;
}
public void setSause(int sause) {
this.sause = sause;
}
public void setCheese(int cheese) {
this.cheese = cheese;
}
public void setPepperoni(int pepperoni) {
thispepperoni. = pepperoni;
}
public void setOlive(int olive) {
this.olive = olive;
}
}
public static void mian(String[] args) {
// 모든 재료가 있는 피자
Pizza pizza1 = new Pizza();
pizza1.setDough(2);
pizza1.setSause(3);
pizza1.setCheese(1);
pizza1.setPepperoni(4);
pizza1.setOlive(3);
// 도우와 소스, 치즈만 있는 피자
Pizza pizza2 = new Pizza();
pizza2.setDough(2);
pizza2.setSause(3);
pizza2.setCheese(1);
// 도우와 소스, 치즈, 올리브만 있는 피자
Pizza pizza3 = new Pizza();
pizza3.setDough(2);
pizza3.setSause(3);
pizza3.setCheese(1);
pizza3.setOlive(3);
}
하지만 이러한 방식은 객체 생성 시점에 모든 값들을 주입하지 않아 일관성(consistency) 문제와 불변성(immutable) 문제가 나타나게 된다.
일관성 문제
: 필수 매개변수란 객체가 초기화될 때 반드시 설정되어야 하는 값이다. 하지만 개발자가 깜빡하고 setDough(), setSause(), setCheese() 메서드를 호출하지 않았다면 이 객체는 일관성이 무너진 상태가 된다. 즉, 객체는 유효하지 않으며, 만일 다른 곳에서 피자 인스턴스를 사용하게 된다면 런타임 예외가 발생할 수 있다.
불변성 문제
: 자바 빈즈 패턴의 Setter 메서드는 객체를 처음 생성할 때 필드값을 설정하기 위해 존재하는 메서드이다. 하지만 객체를 생성했음에도 여전히 외부적으로 Setter 메서드를 노출하고 있으므로, 협업 과정에서 누군가 Setter 메서드를 호출해 함부로 객체를 조작할 수 있게 된다. 이것을 불변성을 보장할 수 없다 한다.
-> 빈즈 패턴의 일관성 문제는 어느정도 생성자와 결합함으로써 극복할 수 있으나, 불변성 문제는 해결할 수 없다. 따라서 Setter를 사용하는 빈즈 패턴은 지양해야 한다.
💡 Builder 패턴 이전에 사용되었던 '점층적 생성자 패턴'과 '자바 빈 패턴'이 무엇인지, 어떤 위험성을 갖는지에 관해 다루어 보았다. 이제 이 위험성을 극복한 빌더 패턴이 무엇인지, 생김새부터 구현방법, 사용법과 장단점까지 알아보자!
빌더 패턴은 이러한 문제들을 해결하기 위해 별도의 Builder 클래스를 만들어 메소드를 통해 step-by-step 으로 값을 입력받은 후에 최종적으로 build() 메소드로 하나의 인스턴스를 생성하여 리턴하는 패턴이다.
글의 처음에 나타낸 예시처럼, Builder 패턴은 메서드를 체이닝(Chining) 형태로 호출함으로써 자연스럽게 인스턴스를 구성하고 마지막에 builde() 메서드를 통해 최종적으로 객체를 생성하도록 되어 있다.
빌더 패턴을 이용하면 더이상 생성자 오버로딩 열거를 하지 않아도 되며, 데이터의 순서에 상관없이 객체를 만들어내 생성자 인자 순서를 파악할 필요도 없고 잘못된 값을 넣는 실수도 하지 않게 된다. 점층적 생성자 패턴과 자바빈즈 패턴 두 가지의 장점만을 취하였다고 볼 수 있다.
빌더 클래스 구현하기
1. 먼저 Builder 클래스를 만들고 필드 멤버 구성을 만들고자 하는 Pizza 클래스 멤버 구성과 똑같이 구성한다.
class PizzaBuilder {
private int dough;
private int sause;
private int cheese;
private int pepperoni;
private int olive;
}
class PizzaBuilder {
private int dough;
private int sause;
private int cheese;
private int pepperoni;
private int olive;
public PizzaBuilder dough(int dough) {
this.dough = dough;
return this;
}
...
public PizzaBuilder olive(int olive) {
this.olive = olive;
return this;
}
}
-> 여기서 주목할 부분은 return this 부분이다. PizzaBuilder 객체 자신을 리턴함으로써 메서드 호출 후 연속적으로 빌더 메서드들을 체이닝(Chaining)하여 호출할 수 있게 되는 것이다!
class PizzaBuilder {
private int dough;
private int sause;
private int cheese;
private int pepperoni;
private int olive;
public PizzaBuilder dough(int dough) { ... }
public PizzaBuilder sause(int sause) { ... }
public PizzaBuilder cheese(int cheese) { ... }
public PizzaBuilder pepperoni(int pepperoni) { ... }
public PizzaBuilder olive(int olive) { ... }
public Pizza build() {
return new Pizza(dough, sause, cheese, pepperoni, olive); // Pizza 생성자 호출
}
}
위에서 구성한 빌더 객체는 아래와 같이 사용할 수 있다.
(물론 글의 초반에서 나타낸 예시와 같이 바로 리턴하는 것도 가능하다.)
public static void main(String[] args) {
Pizza pizza = new PizzaBuilder()
.dough(2)
.sause(1)
.cheese(3)
.pepperoni(7)
.olive(4)
.build();
System.out.println(pizza);
}
1. 필요한 데이터만 설정할 수 있다.
위에서 예시로 든 Pizza에서 설명된 장점이다. 빌더 패턴을 사용하지 않을 경우, 특정 파라미터가 필요 없는 상황이라면 Dummy 값을 넣어주거나 해당 파라미터가 없는 생성자를 새로 만들어 주어야 한다. 그러나 빌더 패턴을 사용하면 불필요한 작업을 제거하고, 반복적인 변경에 대해 동적으로 처리할 수 있다.
2. 유연성을 확보할 수 있다.
예를 들어, Pizza 클래스에 새로운 토핑을 나타내는 변수 pineapple을 추가해야 한다고 하자. 하지만 이미 다음과 같이 생성자로 객체를 만드는 코드가 있다. (위에서 본 예시와 동일한 것이다.)
public static void mian(String[] args) {
// 모든 재료가 있는 피자
Pizza pizza1 = new Pizza(2, 1, 2, 5, 6);
// 도우와 소스, 치즈만 있는 피자
Pizza pizza2 = new Pizza(2, 1, 1);
// 도우와 소스, 치즈, 올리브만 있는 피자
Pizza pizza3 = new Pizza(2, 3, 2, 0 ,6);
}
pineapple을 추가하고자 하면, 생성자로 객체를 만드는 위와 같은 기존 코드에 pineapple에 해당하는 값을 추가해 줘야 한다. 즉, 우리는 새롭게 추가되는 변수 때문에 기존의 코드를 일일이 수정해야 하는 상황에 직면하는 것이다. 하지만 빌더 패턴을 이용하면 새로운 변수가 추가되는 상황이 생겨도 기존 코드에 영향을 주지 않아서, 유연하게 받아들일 수 있다.
3. 가독성을 높일 수 있다.
빌더 패턴을 사용하면 매개변수가 많아져도 가독성을 높일 수 있다. 위의 예시들을 살펴 보았다면 확연히 드러나는 차이임을 확인할 수 있을 것이다. 생성자로 객체를 생성하는 경우에는 매개변수가 많아질수록 코드 가독성이 급격하게 떨어진다. 생성자를 호출하는 부분에서 각 값이 무엇을 의미하는지 바로 파악하기 힘들고, setter 사용으로 코드가 방대하게 길어지는 것 또한 해결할 수 있다는 점이 빌더 패턴의 장점이다.
4. 변경 가능성을 최소화할 수 있다.
앞서 언급했듯, setter 패턴은 불필요하게 변경 가능성을 열어두는 행위이다. 이는 다른 협업자가 setter를 호출해 값을 변경할 수 있으므로, 유지보수 시에 값이 할당된 지점을 찾기 힘들게 만들며, 불필요한 코드 리딩 등을 유발하는 것이 가장 큰 문제점이다. 따라서 빌더 패턴을 선택해 사용한다면 변경 가능성을 차단할 수 있으므로 위험성을 낮출 수 있다.
추가 기능적 빌더 패턴의 장점
// 1. 빌더 클래스 전용 리스트 생성
List<StudentBuilder> builders = new ArrayList<>();
// 2. 객체를 최종 생성 하지말고 초깃값만 세팅한 빌더만 생성
builders.add(
new StudentBuilder(2016120091)
.name("홍길동")
);
builders.add(
new StudentBuilder(2016120092)
.name("임꺽정")
.grade("senior")
);
builders.add(
new StudentBuilder(2016120093)
.name("박혁거세")
.grade("sophomore")
.phoneNumber("010-5555-5555")
);
// 3. 나중에 빌더 리스트를 순회하여 최종 객체 생성을 주도
for(StudentBuilder b : builders) {
Student student = b.build();
System.out.println(student);
}
@Builder는 lombok의 어노테이션이다. 이는 빌더 클래스를 작성하는 과정을 간편화하기 위해 등장했다.
사용법은 매우 간단하다. 빌더 패턴을 적용할 객체에 @Builder 어노테이션을 달기만 하면 된다.
@Builder
public class Pizza {
Integer Dough;
Integer Sause;
Integer Cheese;
Integer Pepperoni;
Integer Olive;
}
이렇게 어노테이션만 달면 사용 가능한 Builder가 생성되며, 앞서 작성했던 것과 같이 빌더를 통해 객체를 생성할 수 있다. 이는 @Builder 어노테이션을 사용했기에 Pizza.builder()로 바로 접근해 객체의 값을 설정하고, 마지막에 build()로 객체를 생성할 수 있다.
public static void main(String[] args) {
Pizza pizza = Pizza.builder()
.dough(2)
.sause(1)
.cheese(3)
.pepperoni(7)
.olive(4)
.build();
System.out.println(pizza);
}
📝 정리하기