일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- factory
- OAuth 2.0
- Volatile
- 일급 객체
- Google OAuth
- java
- Spring
- synchronized
- Dependency Injection
- spring security
- 일급 컬렉션
- middleware
- nestjs
- builder
- lombok
- Today
- Total
HJW's IT Blog
Builder Pattern 에 대한 고찰 본문
들어가며
빌더 패턴은 왜 써야 할까? 이 질문은 빌더 패턴을 공부하는 과정에서 끊임없이 던졌던 핵심 의문이었다. 개발자로서 중요한 덕목 중 하나는 무언가를 선택하고 사용할 때 그에 대한 명확한 “이유”를 가져야 한다는 것이다. 단순히 “빌더 패턴이 좋다고 하니까”라는 이유로 사용한다면, 나중에 누군가 “빌더 패턴을 왜 사용하셨나요?”라고 물었을 때 설득력 있는 대답을 하기 어려울 것이다.
0. 빌더 패턴의 배경
1.1 점층적 생성자 패턴의 한계
점층적 생성자 패턴은 객체 생성을 위한 전통적인 방식이다. 이 패턴의 구조는 다음과 같다.
public class Pizza {
private final String dough; // 필수
private final String sauce; // 필수
private final String topping; // 선택
private final boolean cheese; // 선택
private final boolean olive; // 선택
private final boolean pepper; // 선택
// 필수 매개변수만 받는 생성자
public Pizza(String dough, String sauce) {
this(dough, sauce, null);
}
// 토핑 추가
public Pizza(String dough, String sauce, String topping) {
this(dough, sauce, topping, false);
}
// 치즈 추가
public Pizza(String dough, String sauce, String topping, boolean cheese) {
this(dough, sauce, topping, cheese, false);
}
// 올리브 추가
public Pizza(String dough, String sauce, String topping, boolean cheese, boolean olive) {
this(dough, sauce, topping, cheese, olive, false);
}
// 모든 매개변수를 받는 생성자
public Pizza(String dough, String sauce, String topping, boolean cheese, boolean olive, boolean pepper) {
this.dough = dough;
this.sauce = sauce;
this.topping = topping;
this.cheese = cheese;
this.olive = olive;
this.pepper = pepper;
}
}
이러한 방식의 문제점은 크게 3가지이다.
- 생성자 폭팔 : 선택적 필드가 늘어날 수록 생성자의 수가 기하급수적으로 증가
- 가독성 저하 : 필드의 의미를 직관적으로 알기 어려움, 필드의 순서를 혼동하기 쉬움, boolean 과 같은 값들은 문맥상 파악이 어려움
- 유지보수의 어려움
1.2 Java Beans 패턴의 문제
JavaBeans 패턴은 필드를 private 으로 선언한 뒤 getter setter 를 통해 조작하는 방식이다. 이 방법 또한 크게 3가지의 문제점이 있는데,
- 객체의 일관성 문제 : 객체 생성이 여러 단계로 분리되며, 생성 과정 중간에 객체가 불완전한 상태로 사용될 수 있다.
Pizza pizza = new Pizza();
pizza.setDough("thin");
// 여기서 다른 코드가 실행될 수 있음
pizza.setSauce("tomato");
// 객체가 완전히 생성되기 전에 사용될 위험
- 불변성 보장 불가 : setter 메소드로 변경이 가능하며 스레드 안정성을 확보하기 어렵다.
- 필수 매개변수의 검증이 어렵다. Runtime 에 NullPointerException 이 발생할 수 있다.
이러한 이유로 빌더 패턴은 모습을 드러냈다.
1. 빌더 패턴이란?
빌더 패턴은 객체의 생성 과정을 복잡하게 처리해야 할 때 유용한 디자인 패턴이다. 복잡한 객체의 생성 과정을 여러 단계로 캡슐화 하여 객체를 유연하고 가독성 있게 생성할 수 있도록 도와준다. 빌더 패턴의 핵심은 >> 가독성 과 유연성<< 이다.
객체에 수십 개의 필드가 있다고 상상해 보자. 이러한 객체를 생성하는 방식으로 모든 필드를 생성자에 넣는다면, 생성자 호출부는 매우 난잡해질 것이다. 또한, 필드 중 일부는 필수적이고 나머지는 선택적이라면 더 복잡해진다. 빌더 패턴은 이러한 문제를 해결한다.
빌더 패턴을 사용하지 않는 상황을 우선 생각해 보자. 위와 같은 객체를 생성하는데에는 일반적으로 두가지 방법이 있다.
- 모든 필드를 생성자에서 포함 시키는 방법
- 객체생성 후 필드의 초기화를 각 setter 에 미루는 방법
첫 방법은 생성자의 가독성, 유지보수성이 떨어질 것이고, 두번째 방법은 객체의 불변성이 보장되지 않는다.
1.1 빌더 패턴 예시
public class Channel {
private final String UUID;
private final String ServerUUID;
private final String CategoryUUID;
private String channelName;
private int maxNumberOfPeople;
private String tag;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Boolean isPrivate;
private final ChannelBehavior behavior;
private Channel(ChannelBuilder builder) {
this.UUID = UuidGenerator.generateUUID();
this.ServerUUID = builder.serverUUID;
this.CategoryUUID = builder.categoryUUID;
this.channelName = builder.channelName;
this.maxNumberOfPeople = builder.maxNumberOfPeople;
this.isPrivate = builder.isPrivate;
this.tag = Optional.ofNullable(builder.tag).orElse(ChannelConstant.BASIC_TAG);
this.behavior = builder.behavior;
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
public static class ChannelBuilder {
private final String serverUUID;
private final String categoryUUID;
private final String channelName;
private int maxNumberOfPeople = 50;
private Boolean isPrivate = false;
private String tag;
private ChannelBehavior behavior;
public ChannelBuilder(String serverUUID, String categoryUUID, String channelName, ChannelBehavior channelBehavior) throws ChannelValidationException {
if(serverUUID == null || categoryUUID == null || channelName == null || channelBehavior == null){
throw new ChannelValidationException();
}
this.serverUUID = serverUUID;
this.categoryUUID = categoryUUID;
this.channelName = channelName;
this.behavior = channelBehavior;
}
public ChannelBuilder maxNumberOfPeople(int maxNumberOfPeople) {
this.maxNumberOfPeople = maxNumberOfPeople;
return this;
}
public ChannelBuilder isPrivate(Boolean isPrivate) {
this.isPrivate = isPrivate;
return this;
}
public ChannelBuilder tag(String tag) {
this.tag = tag;
return this;
}
public Channel build() {
return new Channel(this);
}
}
위 코드는 빌더 패턴의 예시이다. 빌더 패턴은 별도의 Builder 클래스를 만들어 메소드를 통해 점층적으로 필드값을 입력받을 수 있도록 하여, 최종 build() 메서드를 통해 인스턴스를 생성하게 된다.
빌더 패턴의 각 메소드를 보면, 모두 this 를 반환하고 있다. 이로 인해, 빌더 객체가 자기 자신을 반환하여 연속적으로 빌더의 메서드를 사용할 수 있는 것이다(메서드 체이닝). 결과적으로, 객체 생성 코드가 깔끔해지고, 읽기 쉬워진다.
객체 생성 코드를 한번 보자
단순 생성자
Channel channel = new Channel(
"random-uuid",
"random-uuid",
"General",
100,
true,
"discussion",
new DefaultChannelBehavior()
);
빌더 패턴
Channel channel = new Channel.ChannelBuilder("server-123", "category-456", "General", new DefaultChannelBehavior())
.maxNumberOfPeople(100)
.isPrivate(true)
.tag("discussion")
.build();
한눈에 보아도 빌더 패턴을 사용하는 경우 객체 생성 코드가 훨씬 더 간결하고 직관적임을 볼 수 있다. 또한 단순히 생성자를 사용한다면, 필수 필드가 아닌 속성도 포함하여 생성해야 한다는 점이 있다. 하지만 빌더 패턴의 경우, 필수 필드와 선택적 필드가 명확하게 구분되기 때문에 다음과 같이도 생성이 가능해 진다.
Channel channel = new Channel.ChannelBuilder("server-123", "category-456", "General", new DefaultChannelBehavior())
1.3 빌더 패턴의 단점
다른 패턴들과 마찬가지로 빌더 패턴이 장점만 있는것은 아니다. 우선 코드의 복잡성이 증가한다. 당연한 이야기지만, 빌더 클래스와 관련 메소드 들이 작성되어야 하기에 초기 구현 비용이 높아진다. 초기 구현 비용이 높아지는 만큼 유지보수 비용의 증가 또한 필연적이다. 당연한 말이지만, 필드가 하나 추가될 때 마다 빌더 객체의 메서드가 추가 되어야 하기 때문이다.
또한 코드뿐만 아니라, 빌더 패턴은 실제로 객체를 추가로 생성하기 때문에 메모리의 사용량이 증가하게 된다.
그렇기에 모든 상황에서 적합한 패턴이 아닌 복잡한 객체의 생성이 필요한 경우 적합하다는 것을 이해해야 한다.
2. Lombok 의 @Builder 어노테이션
Lombok 라이브러리의 @Builder
어노테이션은 빌더를 한층 더 간단히 구현할 수 있도록 도와준다.
위의 빌더 패턴을 Lombok 을 사용하면 다음과 같이 간소화 할 수 있다.
@Builder
public class Channel {
private final String UUID;
private final String ServerUUID;
private final String CategoryUUID;
private String channelName;
private int maxNumberOfPeople;
private String tag;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Boolean isPrivate;
private final ChannelBehavior behavior;
private Channel(ChannelBuilder builder) {
this.UUID = UuidGenerator.generateUUID();
this.ServerUUID = builder.serverUUID;
this.CategoryUUID = builder.categoryUUID;
this.channelName = builder.channelName;
this.maxNumberOfPeople = builder.maxNumberOfPeople;
this.isPrivate = builder.isPrivate;
this.tag = Optional.ofNullable(builder.tag).orElse(ChannelConstant.BASIC_TAG);
this.behavior = builder.behavior;
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
Channel channel = Channel.builder().
.serverUUID("randomID")
.channelName("randomName) //...
그렇다면 이전에 언급했던 필수와 선택 필드의 장점은 사라진것일까? 아니다. 다음과 같이 기본 필드를 설정할 수 있다.
@Builder.Required
private final String channelName;
@Builder.Default
private int maxNumberOfPeople = 50;
혹은 생성자에만 @Builder 를 적용하여 필수 필드를 강제할 수 있다.
@Builder
private Channel(String serverUUID, String categoryUUID, String channelName) {
this.UUID = UuidGenerator.generateUUID();
this.ServerUUID = builder.serverUUID;
this.CategoryUUID = builder.categoryUUID;
this.channelName = builder.channelName;
}
3. Builder 와 Factory
빌더 패턴은 팩토리 패턴과도 조합될 수 있다.
public class ChannelFactory {
private final UserService userService;
private final MessageServiceV2 messageServiceV2;
public ChannelFactory(UserService userService, MessageServiceV2 messageServiceV2) {
this.userService = userService;
this.messageServiceV2 = messageServiceV2;
}
public Channel createChatChannel(
String serverUUID,
String categoryUUID,
String channelName,
int maxNumberOfPeople
) throws ChannelValidationException {
return new Channel.ChannelBuilder(serverUUID, categoryUUID, channelName, new ChatBehaviorV2(userService, messageServiceV2))
.maxNumberOfPeople(maxNumberOfPeople)
.isPrivate(true).build();
}
public Channel createVoiceChannel(
String serverUUID,
String categoryUUID,
String channelName,
boolean isPrivate) throws ChannelValidationException {
return new Channel.ChannelBuilder(serverUUID, categoryUUID, channelName, new VoiceBehavior())
.isPrivate(isPrivate)
.build();
}
}
이렇게 함으로써 얻을 수 있는 이점은 무엇일까?
- 우선 관심사의 분리가 가능하다 → 팩토리는 무엇을 만들지에 집중할 수 있고, 빌더는 어떻게 만들지에 집중할 수 있다. 위 예시에서, 팩토리는 ChatChannel 을 만들지 VoiceChannel 을 만들지를 결정하고 빌더는 세부적인 속성에 대한 설정을 담당한다.
- 객체 생성의 추상화가 가능하다 → 팩토리는 클라이언트로 부터 객체 생성 로직을 숨긴다.
// 클라이언트 코드
Channel chatChannel = channelFactory.createChatChannel(
"server-1",
"category-1",
"일반채팅",
100
);
→ 이것만 보고 과연 클라이언트는 내부적으로 어떤 ChatBehavior 가 사용되는지, 어떤 서비스가 주입되는지 알 수 있을까?
이러한 조합을 통해 복잡한 객체 생성 과정을 체계적으로 관리하면서도, 클라이언트 코드는 간단하고 명확하게 유지할 수 있다.
결론
빌더 패턴은 복잡한 객체를 가독성 있고 유연하게 생성할 수 있는 강력한 도구이다. 특히 필수 필드와 선택 필드가 혼재된 객체를 다룰 때 진가를 발휘한다. 다만 모든 상황에서 빌더 패턴이 최선의 선택은 아니며, 다음과 같은 상황에서 사용을 고려해볼 수 있다:
- 객체의 생성 과정이 복잡하고 여러 선택적 매개변수가 존재할 때
- 불변성을 보장하면서도 유연한 객체 생성이 필요할 때
- 코드의 가독성과 유지보수성이 중요한 프로젝트에서
Lombok의 @Builder 어노테이션을 활용하면 보일러플레이트 코드를 줄이면서도 빌더 패턴의 장점을 취할 수 있다. 또한 팩토리 패턴과의 조합을 통해 객체 생성의 복잡성을 더욱 효과적으로 관리할 수 있다.
결국 빌더 패턴의 채택은 프로젝트의 복잡성, 유지보수 요구사항, 그리고 팀의 개발 문화를 종합적으로 고려하여 결정해야 한다. 단순히 트렌드를 따르는 것이 아닌, 실제 문제 해결에 도움이 되는지를 기준으로 판단해야 할 것이다.
'개발 개념' 카테고리의 다른 글
3 Layered vs Clean Architecture (0) | 2025.01.31 |
---|---|
PRG Pattern 은 왜 사용할까? (0) | 2025.01.16 |
SOLID 와 TradeOff (0) | 2025.01.09 |
상속보단 합성을 사용하자 (0) | 2025.01.06 |
단일 책임 원칙, 얼마나 쪼개야 ‘적당함’일까? (1) | 2025.01.03 |