Concepts/Design Pattern

객체 생성 패턴 - 빌더 패턴

ocwokocw 2021. 2. 17. 21:57

- 참조: GoF의 디자인 패턴
https://ko.wikipedia.org/wiki/%EB%B9%8C%EB%8D%94_%ED%8C%A8%ED%84%B4
http://www.javabyexamples.com/builder-vs-fluent-interface

- 빌더 패턴

빌더 패턴이란 복잡한 객체를 생성하는 방법과 표현하는 방법을 정의하는 클래스를 분리하여, 서로 다른 표현의 결과에 대해서도 동일한 생성 절차를 제공하는 패턴이다.

 

요리사는 피자를 만든다. 매운 피자와 하와이안피자를 만들 수 있으며, 서로 다른 피자를 만든다고 해도 1명의 요리사가 레시피만 바꿔가면서 만들 수 있어야 한다.

빌더 패턴의 참여자는 4 요소가 있다.

  • 추상 빌더(Builder): 제품(피자)의 일부(도우, 소스, 토핑)를 생성하는 추상 인터페이스이다. (PizzaBuilder)
  • 구체 빌더(ConcreteBuilder): 추상 빌더를 구현하며, 제품의 일부(도우, 소스, 토핑)을 모아 표현을 정의하고 관리한다. (HawaiianPizzaBuilder, SpicyPizzaBuilder)
  • 관리자(Director): 추상 빌더를 사용한다. (Cook)
  • 제품(Product): 생성할 복합 객체이다. (HawaiianPizza, SpicyPizza)

빌더패턴에서 위의 요소들이 협력하는 시나리오는 다음과 같다.

  1. 고객은 요리사에게 Spicy 피자를 주문한다. (사용자는 Director 객체를 생성한다.)
  2. 요리사는 Spicy 피자 레시피를 생각한다. (Director 객체는 자신이 원하는 SpicyPizzaBuilder를 합성한다.)
  3. 도우위에 소스를 바르고 토핑을 올린다. (Builder는 Director의 요청을 처리하여 제품의 부분들을 추가한다.)
  4. 고객은 피자를 받는다. (사용자는 Builder 에서 제품을 검색한다.)

이런 협력과정을 시퀀스 다이어그램으로 나타내면 아래처럼 된다. 아래 시퀀스 다이어그램을 이용해서 차근차근 생각해보면 빌더패턴이 어떻게 동작하는지 알 수 있을 것이다.


- Java 구현 예제

위의 빌더 패턴 예제를 구현해보자. 우선 제품(피자)를 먼저 정의하자.

 

public class Pizza {

	private String dough;
	private String sauce;
	private String topping;
	
	public void setDough(String dough) {
		this.dough = dough;
	}

	public void setSauce(String sauce) {
		this.sauce = sauce;
	}

	public void setTopping(String topping) {
		this.topping = topping;
	}

	@Override
	public String toString() {
		return "Pizza [dough=" + dough + ", sauce=" + sauce + ", topping=" + topping + "]";
	}
}

 

이제 제품(피자)을 만들기 위해 제품의 부분들(도우, 소스, 토핑)을 생성하는 인터페이스를 정의한다. 그리고 해당 인터페이스를 구현하는 콘크리트 빌더(하와이안 피자 빌더, 스파이시 피자 빌더)를 정의한다.

 

public abstract class PizzaBuilder {

	protected Pizza pizza;
	
	public abstract void buildDough();
	public abstract void buildSauce();
	public abstract void buildTopping();
	
	public void makeNewPizza() {
		pizza = new Pizza();
	}
	
	public Pizza getPizza() {
		return pizza;
	}
}

public class HawaiianPizzaBuilder extends PizzaBuilder {

	@Override
	public void buildDough() {
		pizza.setDough("Plain Dough");
	}

	@Override
	public void buildSauce() {
		pizza.setSauce("Plain Sauce");
	}

	@Override
	public void buildTopping() {
		pizza.setTopping("Pineapple");
	}
}

public class SpicyPizzaBuilder extends PizzaBuilder {

	@Override
	public void buildDough() {
		pizza.setDough("Plain Dough");
	}

	@Override
	public void buildSauce() {
		pizza.setSauce("Hot Sauce");
	}

	@Override
	public void buildTopping() {
		pizza.setTopping("Plain Topping");
	}
}

 

이제 빌더를 사용하는 관리자(Cook)를 정의한다.

 

public class Cook {

	private PizzaBuilder pizzaBuilder;
	
	public void thinkToConstructPizza(PizzaBuilder pizzaBuilder) {
		this.pizzaBuilder = pizzaBuilder;
	}
	
	public void constructPizza() {
		pizzaBuilder.makeNewPizza();
		pizzaBuilder.buildDough();
		pizzaBuilder.buildSauce();
		pizzaBuilder.buildTopping();
	}
}

 

관리자(Cook)와 피자의 빌더들을 이용해서 올바로 동작하는지 테스트 해보자.

 

public static void main(String[] args){
	
	Cook cook = new Cook();
	PizzaBuilder spicyPizzaBuilder = new SpicyPizzaBuilder();
	cook.thinkToConstructPizza(spicyPizzaBuilder);
	cook.constructPizza();
	Pizza spicyPizza = spicyPizzaBuilder.getPizza();
	
	PizzaBuilder HawaiianPizzaBuilder = new HawaiianPizzaBuilder();
	cook.thinkToConstructPizza(HawaiianPizzaBuilder);
	cook.constructPizza();
	Pizza hawaiianPizza = HawaiianPizzaBuilder.getPizza();
	
	System.out.println("spicyPizza: " + spicyPizza);
	System.out.println("hawaiianPizza: " + hawaiianPizza);
}

- Fluent API 와 빌더 패턴

위의 소스코드를 보고 이렇게 생각하는 사람도 있을 것이다. "내가 Effective Java 에서 봤던 빌더 패턴은 이게 아닌데? 점을 찍으면서 메소드 체이닝 패턴으로 객체를 생성해야 하는데?"

 

이는 Fluent API 형식으로 빌더 패턴을 구현한 것이다. 빌더 패턴의 정의를 다시 한번 생각해보자. 결국 표현과 생성과정을 분리했어도 목적 자체는 객체의 생성 과정을 제어하기 위한 패턴이다. 꼭 메소드 체이닝을 써야한다는 조건은 없다.

 

그렇다고 해서 Effective Java 에서 구현한 메소드 체이닝 방식의 빌더 패턴이 쓸데없다는것은 아니다. 이렇게 구현하면 특별한 장점이 있는데, Validation과 불변 객체를 만들 수 있다. 위에서 만든 Pizza를 메소드 체이닝 방식의 빌더 패턴으로 변경해보자.

 

public class FluentPizza {

	private String dough;
	private String sauce;
	private String topping;
	
	private FluentPizza(Builder builder) {
		this.dough = builder.dough;
		this.sauce = builder.sauce;
		this.topping = builder.topping;
	}
	
	public static class Builder {
		
		private String dough;
		private String sauce;
		private String topping;

		public Builder(String dough) {
			super();
			this.dough = dough;
		}
		
		public Builder setSauce(String sauce) {
			this.sauce = sauce;
			return this;
		}
		
		public Builder setTopping(String topping) {
			this.topping = topping;
			return this;
		}
		
		public FluentPizza build() {
			return new FluentPizza(this);
		}
	}

	@Override
	public String toString() {
		return "FluentPizza [dough=" + dough + ", sauce=" + sauce + ", topping=" + topping + "]";
	}
}

 

사용자는 아래와 같이 사용하면 된다.

 

public static void main(String[] args){

	FluentPizza spicyPizza = new FluentPizza.Builder("Plain Dough")
		.setSauce("Spicy sauce")
		.setTopping("Plain topping")
		.build();
	
	FluentPizza hawaiianPizza = new FluentPizza.Builder("Plain Dough")
		.setSauce("Plain sauce")
		.setTopping("Pineapple topping")
		.build();
	
	System.out.println("spicyPizza: " + spicyPizza);
	System.out.println("hawaiianPizza: " + hawaiianPizza);
}

 

  1. FluentPizza의 속성과 똑같은 속성들을 갖는 Builder 클래스를 정의한다.
  2. FluentPizza 내부에 Builder 를 static 으로 선언한것은 FluentPizza를 생성하기 전 시점에 Builder를 사용해야 하기 때문이다.
  3. Builder 내부의 setter 메소드에서 this를 반환해야 메소드 체이닝 패턴을 사용할 수 있다. 
  4. 제품의 일부를 모두 빌드했다면 종결 메소드 build()를 호출하여 FluentPizza의 생성자를 호출한다. 
  5. 이때 생성자 인자는 Builder 이며 Builder 의 속성들을 FluentPizza의 속성으로 복사한다.

위와 같이 Fluent API 형태로 만든 Builder 패턴은 왜 Validation과 불변 객체를 만드는데 용이할까? 사실 자바 Bean 패턴으로도 Validation 함수를 만들어서 정합성을 체크하면 그만이긴하다. 하지만 자바 Bean 패턴은 validation을 할 시점을 특정할 수 없다. 반면 Builder 패턴은 build()를 호출하면 모든 속성에 대한 설정을 완료한것이므로 객체 validation 시점을 알 수 있게 된다.

 

또 Building을 하는 과정에서 설정 메소드를 제공했을 뿐, 완성된 FluentPizza에 대해서 설정 메소드를 제공하지 않았기 때문에 한번 생성한 객체가 불변 객체임을 보장해준다. 불변 객체는 테스트도 용이하고, 방어복사를 고려할 필요가 없다.