빌더 (Builder) 패턴
동일한 프로세스를 거쳐 다양한 구성의 인스턴스를 만드는 방법
(복잡한) 객체를 만드는 프로세스를 독립적으로 분리할 수 있다
빌더 패턴 적용 Before
@Getter @Setter @ToString
@NoArgsConstructor @AllArgsConstructor
public class DetailPlan {
private int day;
private String plan;
}
@Getter @Setter @ToString
@AllArgsConstructor @NoArgsConstructor
public class TourPlan {
private String title;
private int nights;
private int days;
private LocalDate startDate;
private String whereToStay;
private List<DetailPlan> plans = new ArrayList<>();
public void addPlan(int day, String plan){
plans.add(new DetailPlan(day, plan));
}
}
public class App {
public static void main(String[] args) {
TourPlan tourPlan = new TourPlan();
tourPlan.setTitle("일본 오사카 여행");
tourPlan.setStartDate(LocalDate.of(2024,11,1));
tourPlan.setNights(1);
tourPlan.setDays(2); // 1박 2일
tourPlan.setWhereToStay("오사카 호텔");
tourPlan.addPlan(0, "체크인 이후 짐풀기");
tourPlan.addPlan(0, "저녁 식사");
tourPlan.addPlan(1, "조식 뷔페에서 식사");
tourPlan.addPlan(1, "유니버셜 스튜디오");
tourPlan.addPlan(1, "온천 힐링");
tourPlan.addPlan(1, "체크아웃");
TourPlan shortPlan = new TourPlan();
shortPlan.setTitle("당일치기 여수 여행");
shortPlan.setStartDate(LocalDate.of(2024, 11, 1));
}
}
여행 계획을 짜는 클래스이다.
여행 계획은 다양하게 구성할 수 있다.
ex) 짧은 당일치기 여행은 숙박 장소가 필요 없다.
ex) 계획이니까 아직 시작 날짜를 못 정했을 수 있다.
현재 코드의 문제점 정리
다양한 객체의 요소를 만드는데 각각의 생성자를 사용하면 복잡하다
TourPlan 객체에는 여러 필드가 존재한다. 각 필드를 초기화하기 위해 다양한 생성자를 만들다 보면 생성자 오버로딩이 발생하게 된다. 필드를 추가할 때마다 새로운 생성자를 추가해야 하므로 관리가 복잡해질 수 있다.
예를 들어, 어떤 생성자는 title과 startDate를 받고, 또 다른 생성자는 title, days, whereToStay를 받는 식으로 생성자 수가 늘어난다.
어떤 생성자를 사용해야 할지 명확하지 않아 가독성이 떨어지고, 실수를 유발할 수 있다.
같은 타입의 필드가 있을 경우 생성자 파라미터 순서 혼동 가능
생성자로 객체를 초기화할 때 같은 데이터 타입의 필드가 여러 개 있을 경우, 파라미터 순서가 혼동될 수 있다.
예를 들어 String id, String name과 같은 필드를 가진 생성자가 있을 때, TourPlan("1234", "Osaka")와 같은 코드를 작성하면 "1234"가 id인지 name인지 명확하지 않다. 컴파일러가 오류를 던지지 않기 때문에 순서 혼동에 의한 버그가 발생할 수 있다.
생성자 인자 순서에 대한 실수가 발생하기 쉽고, 유지보수가 어렵다.
생성자가 아니라 setter로 만들게 되면, 추후에 변경 가능성에 열려 있어 좋지 않다
TourPlan과 같은 객체를 setter 메서드를 통해 생성하는 방식은 객체를 한 번에 완성하는 것이 아니라, 객체를 생성한 후 여러 번의 setter 호출로 필드를 설정한다. 이 경우, 객체가 완전히 초기화되기 전에 사용할 가능성도 있고, 추후 필드 값이 변경될 여지를 남긴다. 즉, setter 메서드를 통해 언제든지 필드를 수정할 수 있기 때문에 객체의 상태가 불안정해질 수 있다.
객체가 불변(immutable)하지 않으며, 필드 값이 예기치 않게 변경될 수 있다.
이는 setter 사용을 피하는 것이 바람직하다는 원칙과 연결된다.
만약 값을 할당하는 시점이 객체의 생성뿐이라면 객체에 잘못된 값이 들어왔을 때 그 지점을 찾기 쉬우므로 유지보수성이 훨씬 높아질 것이다
빌더 패턴 적용 After
public interface TourPlanBuilder {
TourPlanBuilder title(String title);
TourPlanBuilder nightsAndDays(int nights, int days);
TourPlanBuilder startDate(LocalDate localDate);
TourPlanBuilder whereToStay(String whereToStay);
TourPlanBuilder addPlan(int day, String plan);
TourPlan getPlan();
}
public class DefaultTourBuilder implements TourPlanBuilder{
private String title;
private int nights;
private int days;
private LocalDate startDate;
private String whereToStay;
private List<DetailPlan> plans;
@Override
public TourPlanBuilder title(String title) {
this.title = title;
return this;
}
@Override
public TourPlanBuilder nightsAndDays(int nights, int days) {
this.nights = nights;
this.days = days;
return this;
}
@Override
public TourPlanBuilder startDate(LocalDate localDate) {
this.startDate = localDate;
return this;
}
@Override
public TourPlanBuilder whereToStay(String whereToStay) {
this.whereToStay = whereToStay;
return this;
}
@Override
public TourPlanBuilder addPlan(int day, String plan) {
if(this.plans == null){
this.plans = new ArrayList<>();
}
plans.add(new DetailPlan(day, plan));
return this;
}
@Override
public TourPlan getPlan() {
return new TourPlan(title, nights, days, startDate, whereToStay, plans);
}
}
public class App {
public static void main(String[] args) {
TourPlanBuilder builder = new DefaultTourBuilder();
TourPlan plan = builder.title("오사카 여행")
.nightsAndDays(1, 2)
.whereToStay("오사카 호텔")
.startDate(LocalDate.of(2024, 11, 1))
.addPlan(0, "체크인 이후 짐풀기")
.addPlan(0, "저녁 식사")
.addPlan(1, "조식 뷔페에서 식사")
.addPlan(1, "유니버셜 스튜디오")
.addPlan(1, "온천 힐링")
.addPlan(1, "체크아웃")
.getPlan();
}
}
TourPlanBuilder<인터페이스> 구현체 DefaultPlanBuilder를 사용해서 TourPlan 인스턴스를 App에서 만들었다.
DefaultTourPlanBuilder 에서는 각 메소드가 this를 반환하기 때문에
getPlan()을 호출하기 전까지 메소드 체이닝 형태가 가능하다.
메소드 체이닝을 통해서 생성자를 여러 개 만드는 것보다 유지 보수와 유연성 가독성 측면에서 훨씬 낫다.
특히 여러 생성자에서 필드의 순서를 혼동하는 문제 또한 피할 수 있다.
setter 방식과는 다르게 객체의 불변성 또한 유지할 수 있다.
자주 사용되는 형태라면?
예를 들어 여행사의 특정 여행 상품은 반복적으로 사용되는 객체이다.
이런 상품은 Director를 도입해서 반복을 줄일 수 있다.
public class TourDirector {
private final TourPlanBuilder tourPlanBuilder;
public TourDirector(TourPlanBuilder tourPlanBuilder) {
this.tourPlanBuilder = tourPlanBuilder;
}
public TourPlan osakaTrip(){
return tourPlanBuilder.title("오사카 여행")
.nightsAndDays(1, 2)
.whereToStay("오사카 호텔")
.startDate(LocalDate.of(2024, 11, 1))
.addPlan(0, "체크인 이후 짐풀기")
.addPlan(0, "저녁 식사")
.addPlan(1, "조식 뷔페에서 식사")
.addPlan(1, "유니버셜 스튜디오")
.addPlan(1, "온천 힐링")
.addPlan(1, "체크아웃")
.getPlan();
}
public TourPlan seoulTrip(){
return tourPlanBuilder.title("서울 여행")
.nightsAndDays(1, 2)
.whereToStay("서울 호텔")
.startDate(LocalDate.of(2024, 11, 1))
.addPlan(0, "체크인 이후 짐풀기")
.addPlan(0, "저녁 식사")
.addPlan(1, "조식 뷔페에서 식사")
.addPlan(1, "롯데월드")
.addPlan(1, "체크아웃")
.getPlan();
}
}
public class App {
public static void main(String[] args) {
TourDirector tourDirector = new TourDirector(new DefaultTourBuilder());
TourPlan seoulTrip = tourDirector.seoulTrip();
TourPlan osakaTrip = tourDirector.osakaTrip();
System.out.println(seoulTrip);
System.out.println(osakaTrip);
}
}
빌더 (Builder) 패턴 장단점
장점
순차적 객체 생성
복잡한 객체를 단계별로 만들 수 있어, 객체 생성의 가독성이 높아진다.
구체적인 과정 숨김
클라이언트는 객체 생성의 복잡한 과정을 몰라도 된다.
내부적으로 어떻게 객체가 구성되는지는 신경 쓰지 않아도 되기 때문에 SRP(단일 책임 원칙)에 부합한다.
유연한 구성
동일한 빌더를 사용하여, 각기 다른 요구사항에 맞춘 객체를 유연하게 생성할 수 있다.
불완전한 객체 방지
빌더 패턴을 사용하면 모든 필드가 완전히 설정되지 않으면 객체가 생성되지 않도록 할 수 있어, 불완전한 상태의 객체 사용을 방지한다.
단점
빌더의 추가 작업
객체를 생성하기 위한 빌더 클래스를 추가로 작성해야 하므로, 코드가 다소 길어지고 초기 설계에 더 많은 시간이 들 수 있다.
복잡성 증가
빌더 패턴은 코드의 구조를 더 복잡하게 만들 수 있으며, 작은 객체나 간단한 객체 생성에는 오히려 오버헤드가 될 수 있다.
빌더 (Builder) 패턴 적용 예시
StringBuilder & StringBuffer
public class StringBuilderExample {
public static void main(String[] args) {
StringBuilder sb = new StringBuilder();
String result = sb.append("jeu").append("baejeu").toString();
System.out.println(result);
StringBuffer sf = new StringBuffer();
String result2 = sf.append("jeu").append("baejeu").toString();
System.out.println(result2);
}
}
append() 메소드 체이닝을 사용 toString()을 호출하면 최종 String 객체를 만든다.
StringBuffer는 Thread-safe StringBuilder는 Not Thread-safe
StringBuffer의 메소드를 보면 synchronized 키워드가 붙어있다.
Stream Builder
public class StreamExample {
public static void main(String[] args) {
Stream<String> build = Stream.<String>builder().add("baejeu").add("jeu").build();
build.forEach(System.out::println);
}
}
baejeu
jeu
롬복(Lombok) @Builder
@Builder @ToString
public class LombokExample {
private String title;
private int nights;
private int days;
public static void main(String[] args) {
LombokExample trip = LombokExample.builder()
.title("여행")
.nights(2)
.days(3)
.build();
System.out.println(trip);
}
}
// LombokExample(title=여행, nights=2, days=3)
애노테이션 프로세서를 사용하여 추가적인 바이트 코드를 삽입한다.
이를 통해 빌더 패턴을 구현하는 코드를 자동으로 생성한다.
AST(Abstract Syntax Tree)를 조작하는 방식으로 소스 코드에는 명시적으로 보이지 않지만 컴파일된 바이트 코드에는
빌더 클래스와 관련된 코드가 포함된다.
static inner 클래스를 통해서 만든다.
Spring UriComponents
public class SpringExample {
public static void main(String[] args) {
UriComponents uri = UriComponentsBuilder.newInstance()
.scheme("https")
.host("www.naver.com")
.path("java-study1")
.encode()
.build();
System.out.println(uri);
}
}
참고자료
https://www.inflearn.com/course/%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4
'Computer Sience > Desgin Pattern' 카테고리의 다른 글
[Design Pattern] 어댑터 (Adapter) 패턴 (2) | 2024.10.22 |
---|---|
[Design Pattern] 프로토타입 (Prototype) 패턴 (2) | 2024.10.20 |
[Design Pattern] 추상 팩토리(Abstract Factory) 패턴 (0) | 2024.10.18 |
[Design Pattern] 팩토리 메소드(Factory Method) 패턴 (1) | 2024.10.18 |
[Design Pattern] 싱글톤(Singleton) 패턴 (1) | 2024.10.16 |