티스토리 뷰
그동안 스프링으로 개발을 하면서 아무 생각없이 @Builder
어노테이션을 사용하여 객체를 생성해왔다.
그러던 어느날 필드가 7개나 되는 객체를 builder()
메소드를 호출하여 생성하던 중 이거 잘못하다 필드 하나 빠뜨리겠는데? 라는 생각이 들었다. 또한 어떤 필드에 대한 메소드를 호출해야 하는지 기억이 나지 않아 해당 클래스 파일을 왔다갔다하며 객체를 생성할 수 밖에 없었다.
물론 builder()
메소드 호출을 통한 객체 생성 시에도 IDE의 도움을 받을 순 있지만 필드의 갯수가 매우 많은 경우에는 헷갈릴 가능성이 많다. 무엇보다 만약 필수적인 필드를 빠뜨렸을 때 일반 생성자의 경우에는 컴파일 오류가 발생하여 이를 금방 알아차릴 수 있다. 그러나 @Builder
의 경우에는 실행 오류만이 발생한다. 특히 엔티티의 @Column(nullable = false)
라고 선언한 필드를 빠뜨리게 되면 해당 객체에 대한 insert SQL이 DB에 전달될 때 당연히 SQL Integrity Constraint Violation Exception이 발생한다. (not null인 필드를 null로 설정했기 때문)
즉 일반 생성자는 컴파일 오류가 @Builder
는 실행 오류가 발생한다는 것이다. 가장 좋은 오류는 컴파일 오류인데 왜 굳이 @Builder
를 사용해야 하는 것일까? 그리고 이 Builder Pattern이 대체 뭔가하는 궁금증이 일어 자료를 찾아 보게 되었다.
Builder Pattern
Builder Pattern은 디자인 패턴의 생성 패턴 중 하나이다. 이때 생성 패턴은 인스턴스를 만드는 절차를 추상화하는 패턴을 의미한다. 쉽게 말해 객체를 보다 쉽게 만들 수 있도록 해주는 패턴인 것이다. 이때 빌더 패턴은 복잡한 객체를 step-by-step으로 생성할 수 있도록 해주는데, 즉 복잡한 객체를 생성하는 방법을 정의하는 클래스(Builder)와 표현하는 방법을 정의하는 클래스(Product)로 분리하여, 서로 다른 표현 또는 타입이더라도 이를 생성할 수 있는 동일한 절차를 제공하는 패턴이다. 빌더 패턴은 생성해야 하는 객체의 필드 갯수가 많으면서 선택적일 때 더 유용하다.
클래스의 필드가 많으면서 선택적인 경우
이 경우 빌드 패턴만으로 구현이 가능한 것은 아니다. 빌드 패턴에 대해 자세히 알아보기 전 필드가 많으면서 선택적인 클래스의 생성자를 정의하는 3가지 방법을 먼저 살펴보자.
1. 서브 클래스
모든 클래스가 공통적으로 가지고 있는 필드를 정의한 상위 클래스와 이를 상속하여 필요한 필드를 확장한 서브 클래스를 구현할 수 있다. 예를 들어 집이라는 클래스에는 structure
, door
, window
, roof
가 필수적인 필드라면 다음과 같이 생성자를 정의하고 Home
클래스를 상속받은 서브 클래스 Apart
클래스에 선택적인 필드를 추가할수 있다.
public class Home {
private String structure;
private int door;
private int window;
private String roof;
public Home(String structure, int door, int window, String roof) {
this.structure = structure;
this.door = door;
this.window = window;
this.roof = roof;
}
}
public class Apart extends Home {
private String elevator;
private String parkingLot;
public Apart(String structure, int door, int window, String roof, String elevator, String parkingLot) {
super(struecture, door, window, roof);
this.elevator = elevator;
this.parkingLot = parkingLot;
}
}
위 클래스들을 정의하고 elevator
, parkingLot
필드가 필요없다면 Home
생성자를 호출하면 된다. 반대로 elevator
, parkingLot
필드값을 설정하고 싶다면 Apart
클래스의 생성자를 호출한다. 그러나 이 방식의 문제점은 선택적인 필드의 갯수가 많아질 수록 서브 클래스의 수가 방대해지며, 단순히 선택적인 필드에 대한 생성자를 위해 서브 클래스를 정의하였기 때문에 상속의 의미가 모호해질 수 있다는 것이다.
2. 여러 개의 생성자
해당 방식은 점층적 생성자 패턴(Telescoping Constructor Pattern)이라고도 불리는 디자인 패턴으로, 필수 매개변수만을 가지는 생성자를 필두로 선택 매개변수를 가지는 생성자를 추가하여 여러 생성자를 가지는 패턴을 의미한다. 즉 생성자를 오버로딩하는 것이다. 위의 예제에 해당 패턴을 적용하면 다음과 같은 코드를 작성할 수 있다.
public class Home {
private String structure;
private int door;
private int window;
private String roof;
private String elevator;
private String parkingLot;
public Home(String structure, int door, int window, String roof) {
this(structure, door, window, roof, null, null);
}
public Home(String structure, int door, int window, String roof, String elevator, String parkingLot) {
this.structure = structure;
this.door = door;
this.window = window;
this.roof = roof;
this.elevator = elevator;
this.parkingLot = parkingLot;
}
}
첫번째 방식에 비해 클래스의 수가 1개로 줄어든다는 장점은 있지만, 여전히 선택적인 필드를 위해 여러개의 생성자를 정의해주어야 한다. 만약 선택적인 필드가 더 추가된다면 더 많은 생성자를 정의해야 하기 때문에 Home
클래스가 복잡해질 수 있다. 또한 이는 생성자의 오버로딩이기 때문에 어떤 생성자를 호출할지는 메소드 시그니처만으로 구분한다. 따라서 다음과 같은 생성자는 동시에 정의가 불가능하다.
public Home(String structure, int door, int window, String roof, String elevator) {
this(structure, door, window, roof, elevator, null);
}
public Home(String structure, int door, int window, String roof, String parkingLot) {
this(structure, door, window, roof, null, parkingLot);
}
//=> 두 생성자의 시그니처 중복
3. 거대한 1개의 생성자
가장 간단한 구현 방법으로는 모든 필드를 매개변수로 받는 거대한 생성자 1개를 정의하는 것이다.
public class Home {
private String structure;
private int door;
private int window;
private String roof;
private String elevator;
private String parkingLot;
public Home(String structure, int door, int window, String roof, String elevator, String parkingLot) {
this.structure = structure;
this.door = door;
this.window = window;
this.roof = roof;
this.elevator = elevator;
this.parkingLot = parkingLot;
}
}
이 경우 클래스와 생성자의 수가 1개로 줄어들지만, 생성자 호출 시 선택적인 필드를 매개변수로 전달하고 싶지 않음에도 불구하고 이를 null 값으로 넘겨야 한다. 또한 필드 수가 더 많아진다면 생성자가 매우 커지고 객체 생성 시 어떤 값을 매개변수로 넘겨야 하는지 헷갈릴 가능성이 높다.
정리하자면 앞의 방식들은 클래스의 필드 수가 많아질 수록 객체 생성 시 코드 가독성이 떨어지고 선택적인 필드를 처리하기 위한 별도의 생성자 코드가 추가되어야 한다는 문제점이 있다. 다음으로 소개할 Builder Pattern을 이용하면 필드가 많아져도 객체를 생성하는 코드의 가독성이 좋으며, 내가 원하는 메소드만을 호출함으로써 별도의 생성자 정의 없이도 원하는 필드만을 설정하여 객체를 생성할 수 있다.
Builder Pattern 구성 요소
가장 기본적인 빌더 패턴은 다음과 같은 요소로 구성된다. 이제 각 구성 요소에 대해 자세히 알아보자.
Director
Builder가 객체 생성 단계(construction steps)를 정의한다면 Director는 해당 building steps를 수행할 순서를 정의한다. 즉 각 단계에 대한 일련의 호출을 뽑아내어 정의한다. Director는 필수적이지는 않으나, Director를 정의하면 코드 재사용성이 증가하고 무엇보다 객체 생성에 대한 디테일을 Client로부터 완전히 숨길 수 있다.
Builder
Concrete Builders의 공통적인 객체 생성 단계(construction steps)를 선언한 Interface를 의미한다.
Concrete Builders
Builder에서 선언한 construction steps의 각기 다른 구현을 제공한다. 추가적인 메소드를 정의하여 Concrete Builder 별로 확장된 필드를 가지는 객체를 생성할 수 있다.
Products
Concrete Builder에 의해 생성된 결과 Object를 의미한다.
Client
Director 또는 Concrete Builder를 통해 Product를 생성한다.
백불이불여일견. 코드로 해당 패턴을 구현해보자.
코드 예제
일반 생성자
public class Bread {
private List<String> ingredients;
private Integer cookingTime;
private Integer cookingTemp;
private List<String> cookingTools; //optional
private LocalDate cookingDate; //optional
private String chef;
public Bread(List<String> ingredients, Integer cookingTime, Integer cookingTemp, String chef) {
this(ingredients, cookingTime, cookingTemp, List.of("hands", "oven"), LocalDate.now(), chef);
}
public Bread(List<String> ingredients, Integer cookingTime, Integer cookingTemp, List<String> cookingTools, String chef) {
this(ingredients, cookingTime, cookingTemp, cookingTools, LocalDate.now(), chef);
}
public Bread(List<String> ingredients, Integer cookingTime, Integer cookingTemp, LocalDate cookingDate, String chef) {
this(ingredients, cookingTime, cookingTemp, List.of("hands", "oven"), cookingDate, chef);
}
public Bread(List<String> ingredients, Integer cookingTime, Integer cookingTemp, List<String> cookingTools, LocalDate cookingDate, String chef) {
this.ingredients = ingredients;
this.cookingTime = cookingTime;
this.cookingTemp = cookingTemp;
this.cookingTools = cookingTools;
this.cookingDate = cookingDate;
this.chef = chef;
}
}
public class Recipe {
private List<String> ingredients;
private Integer cookingTime;
private Integer cookingTemp;
private List<String> cookingTools; //optional
private String cookingLevel; //optional
public Recipe(List<String> ingredients, Integer cookingTime, Integer cookingTemp) {
this(ingredients, cookingTime, cookingTemp, List.of("hands", "oven"), "poor");
}
public Recipe(List<String> ingredients, Integer cookingTime, Integer cookingTemp, List<String> cookingTools) {
this(ingredients, cookingTime, cookingTemp, cookingTools, "poor");
}
public Recipe(List<String> ingredients, Integer cookingTime, Integer cookingTemp, String cookingLevel) {
this(ingredients, cookingTime, cookingTemp, List.of("hands", "oven"), cookingLevel);
}
public Recipe(List<String> ingredients, Integer cookingTime, Integer cookingTemp, List<String> cookingTools, String cookingLevel) {
this.ingredients = ingredients;
this.cookingTime = cookingTime;
this.cookingTemp = cookingTemp;
this.cookingTools = cookingTools;
this.cookingLevel = cookingLevel;
}
}
public class Main {
public static void main(String[] args) {
Bread bread = new Bread(List.of("flour", "salt", "butter"), 90, 150, "Gordon Ramsay");
Recipe recipe = new Recipe(List.of("flour", "salt", "butter"), 90, 150);
}
}
먼저 빌더 패턴을 적용해보기 전 일반 생성자를 통해 객체를 생성해보았다. 앞에서 설명했던 것처럼 이 방식은 문제가 있다. 먼저 생성자의 갯수가 너무 많다는 점이다. 만약 선택적인 필드가 하나더 추가된다면 생성자는 기하급수적으로 늘어날 수 있다. 또한 현재는 optional한 필드들 간 타입이 다르지만 만약 같다면 생성자를 오버로딩하는데 제약이 있다. (메소드 시그니처가 중복되기 때문) 무엇보다 생성자 호출 시 90, 150 같은 숫자가 무엇을 의미하는지 직관적으로 알아차리기가 어렵다.
Builder Pattern
Product
@AllArgsConstructor
public class Bread {
private List<String> ingredients;
private Integer cookingTime;
private Integer cookingTemp;
private List<String> cookingTools;
private LocalDate cookingDate;
private String chef;
}
@AllArgsConstructor
public class Recipe {
private List<String> ingredients;
private Integer cookingTime;
private Integer cookingTemp;
private List<String> cookingTools;
private String cookingLevel;
}
Builder
Bread
, Recipe
의 공통적인 필드인 ingredients
, cookingTime
, cookingTemp
, cookingTools
값을 설정하는 공통의 메소드를 Builder
인터페이스에 선언해주었다.
public interface Builder {
void setIngredients(List<String> ingredients);
void setCookingTime(Integer cookingTime);
void setCookingTemp(Integer cookingTemp);
void setCookingTools(List<String> cookingTools);
}
Conrete Builders
다음 코드를 보면 생성하고자 하는 객체의 클래스인 Bread
에는 setter가 없고 Bread
를 생성하는 BreadBuilder
에만 setter가 정의되어 있는 것을 알 수 있다. setter는 객체의 수정 시점을 추적하는 것을 어렵게 만들어 사용하지 않는 것이 좋다. BreadBuilder
에만 setter를 정의함으로써 생성 시점에만 객체의 필드값이 설정되고 이후로는 객체가 수정되지 않도록 할 수 있다.
public class BreadBuilder implements Builder {
private List<String> ingredients;
private Integer cookingTime;
private Integer cookingTemp;
private List<String> cookingTools = List.of("hands", "oven");
private LocalDate cookingDate = LocalDate.now();
private String chef;
@Override
public void setIngredients(List<String> ingredients) {
this.ingredients = ingredients;
}
@Override
public void setCookingTime(Integer cookingTime) {
this.cookingTime = cookingTime;
}
@Override
public void setCookingTemp(Integer cookingTemp) {
this.cookingTemp = cookingTemp;
}
@Override
public void setCookingTools(List<String> cookingTools) {
this.cookingTools.addAll(cookingTools);
}
public void setCookingDate(LocalDate cookingDate) {
this.cookingDate = cookingDate;
}
public void setChef(String chef) {
this.chef = chef;
}
public Bread getProduct() {
return new Bread(ingredients, cookingTime, cookingTemp, cookingTools, cookingDate, chef);
}
}
setCookingDate
, setChef
와 같은 Builder
Interface에 선언한 메소드 외에도 별도의 메소드 정의가 가능하여 확장된 필드를 가지는 객체를 생성할 수 있다. getProduct
메소드는 해당 객체를 생성하여 반환한다.
public class RecipeBuilder implements Builder {
private List<String> ingredients;
private Integer cookingTime;
private Integer cookingTemp;
private List<String> cookingTools = List.of("hands", "oven");
private String cookingLevel = "poor";
@Override
public void setIngredients(List<String> ingredients) {
this.ingredients = ingredients;
}
@Override
public void setCookingTime(Integer cookingTime) {
this.cookingTime = cookingTime;
}
@Override
public void setCookingTemp(Integer cookingTemp) {
this.cookingTemp = cookingTemp;
}
@Override
public void setCookingTools(List<String> cookingTools) {
this.cookingTools.addAll(cookingTools);
}
public void setCookingLevel(String cookingLevel) {
this.cookingLevel = cookingLevel;
}
public Recipe getProduct() {
return new Recipe(ingredients, cookingTime, cookingTemp, cookingTools, cookingLevel);
}
}
Director
Director
클래스 없이도 Client
가 직접 Builder
의 메소드를 호출하여 객체를 생성할 수 있다. 그러나 아래와 같이 Builder
의 메소드를 대신 호출해주는 Director
클래스를 정의하면 Client
는 Builder
의 자세한 구현 사항을 알지 못해도 원하는 객체를 생성할 수 있다. 즉 객체의 생성이 Director
클래스를 통해 완전히 추상화되는 것이다. 또한 Bread
의 특정 객체(Croissant)를 생성하기 위해 Director
의 bakeCroissant
메소드만 호출하면 되므로 코드 중복 또한 제거할 수 있다.
public class Director {
public Bread bakeCroissant(Builder builder) {
if (builder instanceof BreadBuilder) {
BreadBuilder breadBuilder = (BreadBuilder) builder;
breadBuilder.setIngredients(List.of("flour", "salt", "butter"));
breadBuilder.setCookingTime(90);
breadBuilder.setCookingTemp(150);
breadBuilder.setChef("Gordon Ramsay");
return breadBuilder.getProduct();
}
return null;
}
public Recipe writeCroissant(Builder builder) {
if (builder instanceof RecipeBuilder) {
RecipeBuilder recipeBuilder = (RecipeBuilder) builder;
recipeBuilder.setIngredients(List.of("flour", "salt", "butter"));
recipeBuilder.setCookingTime(90);
recipeBuilder.setCookingTemp(150);
return recipeBuilder.getProduct();
}
return null;
}
}
Client
public class Client {
public static void main(String[] args) {
Director director = new Director();
Bread bread = director.bakeCroissant(new BreadBuilder());
Recipe recipe = director.writeCroissant(new RecipeBuilder());
}
}
물론 빌더 패턴을 사용하면 클래스의 수가 많아져 전체 코드의 복잡성이 증가한다. 그럼에도 불구하고 빌더 패턴의 장점은 다음과 같다.
- 복잡한 객체를 step-by-step으로 생성 가능하며, 선택적인 필드에 대한 별도의 생성자 정의할 필요 없이 기존 코드 재사용이 가능하다.
- 클래스의 필드 추가 시, 일반 생성자의 경우에는 해당 필드가 선택적인지 여부에 따라 1개 이상의 생성자를 추가해야 하는 반면, 빌더 패턴의 경우에는
Builder
에 해당 필드에 대한 메소드 1개만 추가하면 된다. - 복잡한 객체 생성 코드를 가진
Builder
클래스와 비즈니스 로직을 가진Product
클래스를 분리하여 SRP(단일 책임 원칙)에 더 부합하다. - 객체 생성 코드가 더 직관적이고 가독성이 좋다.
위의 Builder Pattern은 Builder
클래스를 Product
클래스 외부에 정의하였지만 Effective Java 스타일의 Builder
는 Product
클래스 내부에 Builder
클래스를 정의한, 말 그대로 좀 더 효율적인 방식을 택한다. 그리고 이러한 Builder
클래스를 자동으로 만들어주는 어노테이션이 있는데 바로 Lombok의 @Builder
이다. 다음 글에서는 Effective Java 스타일의 Builder 패턴과 @Builder
어노테이션에 대해 알아보려고 한다.
끝.
참고
https://refactoring.guru/design-patterns/builder
https://www.geeksforgeeks.org/builder-design-pattern/
https://stackoverflow.com/questions/328496/when-would-you-use-the-builder-pattern
'자바 > 개념' 카테고리의 다른 글
[디자인 패턴] Facade 패턴 (1) | 2023.12.17 |
---|---|
[디자인 패턴] Strategy Pattern과 Template Callback Pattern (0) | 2023.09.26 |
[디자인 패턴] Template Method Pattern (0) | 2023.09.19 |
[개념] FrontController 패턴 (with 자바) (0) | 2023.08.27 |
[개념] Builder Pattern과 자바의 @Builder 어노테이션2 (0) | 2023.02.13 |
- Total
- Today
- Yesterday
- github
- JPA
- 단위 테스트
- servlet filter
- spring
- vscode
- 디자인 패턴
- Front Controller
- junit5
- mockito
- Git
- Linux
- 전략 패턴
- 템플릿 콜백 패턴
- rest api
- Transaction
- Assertions
- Java
- SSE
- QueryDSL
- 모두의 리눅스
- FrontController
- Gitflow
- Spring Security
- facade 패턴
- 서블릿 컨테이너
- ParameterizedTest
- spring boot
- C++
- spring aop
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |