작성계기는 지금까지 빌더패턴이 어떤 의미인지 제대로 모른체 사용해왔다는 것이 가장 충격적이었고 너무 Lombok에 의존하여 코드를 작성한거 같아 이번 기회에 빌더패턴에 대해 정리해보고 나중에는 @Builder 어노테이션을 사용한 코드와 @Builder 어노테이션으로 만들어진 빌더 패턴은 어떤지 비교해보면서 빌더 패턴에 대해 공부해보기로 했다.
정리하기전에 해당 게시글을 작성하게 도움이 된 Inpa님에게 감사인사를 보내고 싶다.
빌더 패턴(Builder Pattern)은 복잡한 객체 생성 과정과 표현 방법을 분리하여 다양한 구성의 인스턴스를 만드는 생성 패턴이다. 간단히 이야기하면 개발자가 선택적으로 인스턴스 타입을 지정하고 생성하는 것이 가능하게 만드는 패턴이라고 볼 수 있다. 클래스에서 선택적 매개변수가 많은 상황에서 유용하게 사용이 가능하다.
빌더 패턴은 다음의 과정을 거쳐 지금의 빌더 패턴이 등장했다.
점증적 생성자 패턴(Telescoping Constructor Pattern)은 필수 밀수 매개변수와 함께 선택 매개변수를 1개 2개 받는 형태로 우리가 다양한 매개변수를 입력받아 인스턴스를 생성하고 싶을 때 사용하는 생성자를 오버로딩 하는 방식이다.
class music {
private String name;
private String artist;
private int time;
music(String name, int time){
this.name = name;
this.time = time;
}
music(String name ,String artist){
this.name = name;
this.artist = artist;
}
}
public class constructor {
public static void main(String[] args) {
music music1 = new music("song1", "art1");
music music2 = new music("song2", 180);
}
}
문제점
위의 생성자 패턴을 보완하기위해 만든것이 Setter 메서드를 포함한 자바 빈(Bean) 패턴이다. 매개변수가 없는 생성자로 객체를 생성한 후 Setter 메서드를 이용해 클래스 필드의 초깃값을 설정하는 방식이다. 기존의 생성자 오버로딩으로 나타난 가독성 문제와 Setter메서드를 호출함으로써 유연적으로 객체를 생성할 수 있게 되었다. 하지만 이러한 방식도 문제가 있다.
class Music {
private String name;
private String artist;
private int time;
public Music(){ }
public void setName(String name){
this.name = name;
}
public void setArtist(String artist){
this.artist = artist;
}
public void setTime(int time){
this.time = time;
}
}
public class constructor {
public static void main(String[] args) {
Music music1 = new Music();
music1.setName("music1");
music1.setArtist("artist1");
music1.setTime(170);
Music music2 = new Music();
music2.setName("music2");
music2.setArtist("artist2");
music2.setTime(180);
}
}
일관성(consistency) 문제점
필수 매개변수란 객체가 초기화될때 반드시 설정되어야 한다. 하지만 개발자의 miss로 필수 메서드를 호출하지 않았다면 이 객체는 일관성이 무너진 상태가 된다. 만약 다른곳에서 Music 인스턴스를 사용한다면 런타임 에러가 발생할 수도 있다.
이러한 문제는 객체를 생성하는 부분과 값을 설정하는 부분이 물리적으로 떨어져 있어서 발생한 문제점이다. 이러한 부분은 생성자(Constructor)와 결합하여 어느정도 해결할 수는 있다.
불변성(immutable) 문제점
빌더 패턴은 결국 자바 빈 패턴의 문제를 해결하기 위해 생성되었다고 생각한다. 정리하지면 객체의 일관성과 불변성을 해결하면 된다고 생각한다.
빌더 패턴은 별도의 Builder 클래스를 만들어 메소드를 통해 하나씩 값을 입력받은 후 build()
메서드로 하나의 인스턴스를 생성하여 리턴하는 패턴이다.
이 예시 코드는 가장 기본적인 빌더 패턴예시 코드이다.
Music class
// Music class
// 빌더 대상 클래스
class Music {
private String name;
private String artist;
private int time;
// 생성자 생성
Music(String name, String artist, int time){
this.name = name;
this.artist = artist;
this.time = time;
}
@Override
public String toString() {
return "Music {" +
" name=" + name +
", artist=" + artist +
", time=" + time +
" }";
}
}
Music 클래스는 객체 생성용 클래스이다.
MusicBuilder class
class MusicBuilder {
private String name;
private String artist;
private int time;
public MusicBuilder name(String name){
this.name = name;
return this; // 자기자신을 리턴
}
public MusicBuilder artist(String artist){
this.artist = artist;
return this;
}
public MusicBuilder time(int time){
this.time = time;
return this;
}
public Music build(){
return new Music(name,artist,time); // 생성자 호출
}
}
MusicBuilder 클래스는 Music의 Builder 생성용 클래스이다, setter부분을 보면 MusicBuilder 객체 자신을 리턴하는 return this
부분이 있다. 즉 빌더 객체 자기자신을 리턴함으로써 메서트 호출 후 연속적으로 빌더 메서드들을 체이닝하여 호출 할 수 있게 된다.
예시 :
new MusicBuilder().name("tabun").build();
마지막으로 Music 객체를 만들어주는 build 메서드를 구성해준다. 빌더 클래스의 필드들을 Music 생성자의 인자에 넣어줌으로써 멤버 구성이 완료된 Music 인스턴스를 얻게 된다.
musicpack class
public class musicpack {
public static void main(String[] args) {
Music music = new MusicBuilder()
.name("tabun")
.artist("yoasobi")
.time(190)
.build();
System.out.println(music);
// Music { name=tabun, artist=yoasobi, time=190 }
}
}
구성한 빌더 객체를 실행하면 위의 내용과 같은 출력 결과가 나온다, 그리고 artist
, name
, time
중에 하나가 없어도 결과는 없앤 객체의 결과 값이 null 혹은 0 으로 초기화 된 체로 출력이 된다.
위와 같은 특징만 봐도 장점이 분명한데 빌더 패턴을 사용함으로 써 장점이 생각보다 많았다.
생성자 방식으로 객체를 생성하는 경우 매개변수가 적으면 상관이 없을 수 있는데 매개변수가 많아지면 가독성이 급격히 떨어진다. 반면 빌더 패턴은 직관적으로 어디에 어떤 값이 설정 되었는지 한눈에 파악할 수 있게된다. 생성자 방식과는 다르게 설정 오류와 같은 실수를 방지 할 수 있다.
Music musicon = new Music("idol","yoasobi",180);
Music music = new MusicBuilder()
.name("tabun")
.artist("yoasobi")
.time(190)
.build();
그런데 이 장점은 장점이라고 하기에 약간 애매한 부분이 있는데 요즘 어지간한 IDE는 생성자를 만들고 객체 선언을 하고 매개변수를 입력하려고하면 미리보기가 출력이 된다. 하지만 IDE도 결국 사람이 만들기에 내부에서 어떠한 오류로 인해 미리보기 기능이 꺼지거나 한다면 미리보기가 안될 가능성이 완전히 없는건 아니기에 이렇게 빌더 패턴으로 작성하는 것이 좋을 수도 있겠다.
원래 자바에서는 기본적으로 메서드에 대한 디폴트 매개변수를 지원하지 않는다. 따라서 디폴트 매개변수가 설정된 필드를 설정하는 메서드를 호출하지 않는 방식으로 마치 디폴트 매개변수를 생략하고 호출하는 효과를 간접적으로 구현이 가능하다.
MusicBuilder class
class MusicBuilder {
private String name;
private String artist = "yoasobi";
private int time;
public MusicBuilder name(String name){
this.name = name;
return this; // 자기자신을 리턴
}
public MusicBuilder artist(String artist){
this.artist = artist;
return this;
}
public MusicBuilder time(int time){
this.time = time;
return this;
}
public Music build(){
return new Music(name,artist,time); // 생성자 호출
}
}
musicpack class
public class musicpack {
public static void main(String[] args) {
//Music musicon = new Music("idol","yoasobi",180);
Music music = new MusicBuilder()
.name("tabun")
//.artist("yoasobi")
.time(190)
.build();
System.out.println(music);
// Music { name=tabun, artist=yoasobi, time=190 }
}
}
위와 같은 방법은 생성자에도 적용이 되는데 MusicBuilder
에서 사용한 artist
매개변수에 디폴트 값을 정한 것처럼 빌더 대상인 Music
클래스에도 마찬가지로 같은 방법으로 디폴트 값을 정할 수 있다.
객체 인스턴스는 목적에 따라 초기화 필수 멤버변수가 있고 선택적 멤버 변수가 있을 수 있다. 생성자 패턴은 선택적 멤버변수와 필수 멤버변수 구분을 사실상 하기가 힘들다. 필수만 지정하고 싶으면 생성자 오버로딩을 통해 전체 멤버중에 필수 인자 빼고 전부 null혹은 0으로 입력해야한다. 하지만 빌더 패턴은 생성자로 필수 멤버변수를 받게 하여 객체가 생성되도록 유도하는 것이 가능하다.
Music class
class Music {
private String country; // 필수 멤버변수
private int seq; // 필수 멤버변수
private String name;
private String artist;
private int time;
public Music(String country, int seq, String name, String artist, int time){
this.seq = seq;
this.country = country;
this.name = name;
this.artist = artist;
this.time = time;
}
@Override
public String toString() {
return "Music {" +
" seq=" + seq +
" country=" + country +
" name=" + name +
", artist=" + artist +
", time=" + time +
" }";
}
}
MusicBuilder class
class MusicBuilder {
private String country; // 필수 멤버변수
private int seq; // 필수 멤버변수
private String name;
private String artist = "yoasobi";
private int time;
// 필수 멤버변수는 생성자를 통해 설정한다.
MusicBuilder(int seq, String country){
this.country = country;
this.seq = seq;
}
public MusicBuilder name(String name){
this.name = name;
return this; // 자기자신을 리턴
}
public MusicBuilder artist(String artist){
this.artist = artist;
return this;
}
public MusicBuilder time(int time){
this.time = time;
return this;
}
public Music build(){
return new Music(country,seq,name,artist,time); // 생성자 호출
}
}
musicpack class
public class musicpack {
public static void main(String[] args) {
//Music musicon = new Music("idol","yoasobi",180);
Music music = new MusicBuilder(1,"japen")
.name("tabun")
//.artist("yoasobi")
.time(190)
.build();
System.out.println(music);
// Music { name=tabun, artist=yoasobi, time=190 }
}
}
musicpack
클래스에 작성한 것처럼 필수 멤버변수를 1개 혹은 그이상으로 지정할 수 있다. 하지만 신경써야 하는 부분이 있다. 빌더 패턴 대상 클래스와 빌더 패턴 클래스의 생성자를 맞춰야한다.
// Music class 빌더 대상 클래스 일부
public Music(String country, int seq, String name, String artist, int time){
this.seq = seq;
this.country = country;
this.name = name;
this.artist = artist;
this.time = time;
}
// MusicBuilder 빌더 패턴 구현 클래스 일부
public Music build(){
return new Music(country,seq,name,artist,time); // 생성자 호출
}
객체 생성을 단계별로 구성하거나 구성 단계를 지연하거나 재귀적으로 생성을 처리할 수 있다. 빌더를 재사용 함으로써 객체 생성을 주도적으로 지연 할 수 있다.
import java.util.ArrayList;
import java.util.List;
public class musicpack {
public static void main(String[] args) {
//Music musicon = new Music("idol","yoasobi",180);
List<MusicBuilder> buildList = new ArrayList<>();
buildList.add(
new MusicBuilder("japen")
);
buildList.add(
new MusicBuilder("japen")
.artist("Yoasobi")
.name("tabun")
);
buildList.add(
new MusicBuilder("japen")
.artist("Kana-boon")
.name("Silhouette")
.time(180)
);
for(MusicBuilder list : buildList){
Music music = list.build();
System.out.println(music);
}
// Music { country=japen name=null, artist=yoasobi, time=0 }
// Music { country=japen name=tabun, artist=Yoasobi, time=0 }
// Music { country=japen name=Silhouette, artist=Kana-boon, time=180 }
}
}
위의 특징은 처음에 저것이 어떻게 특징이 되는가 생각해봤는데 객체는 그자체로 일종의 소비로 볼 수 있다. 객체를 처음부터 만들고 값을 검증 및 수정하는 것과 모든 검증을 거치고 최종값을 만들고 객체를 만드는 것과 비교 했을 때 나는 후자를 고를거 같다.
생성자로 부터 멤버변수를 받는 형태이고 검증 로직이 있다면 생성자 메서드마다 복잡하게 구현해야한다. 그렇게 되면 생성자 크기가 비대해지고 코드가 지저분해지는 결과가 나올 수 있다.
// Music 클래스의 생성자
public Music(String country, String name, String artist, int time) {
if(country.equals("")){
throw new IllegalArgumentException(country);
}
else if(time < 0){
throw new IllegalArgumentException();
}
// 이렇게 멤버변수 별로 if문을 추가하는 것은 별로 좋지 않은 방법이라고 생각한다.
this.country = country;
this.name = name;
this.artist = artist;
this.time = time;
}
// MusicBuilder 클래스의 빌더 패턴 일부
MusicBuilder(String country){
if(country.equals("")){
throw new IllegalArgumentException(country);
}
this.country = country;
}
public MusicBuilder name(String name){
this.name = name;
return this; // 자기자신을 리턴
}
public MusicBuilder artist(String artist){
this.artist = artist;
return this;
}
public MusicBuilder time(int time){
if(time < 0){
throw new IllegalArgumentException();
}
this.time = time;
return this;
}
상단의 빌더 패턴 코드처럼 저렇게 작성하면 코드의 가독성이 올라가는 장점을 기대할 수 있겠다.
이 부분은 JPA Entity 설계를 할 때 주의사항으로도 들은 이야기인데 DTO같은 클래스 처럼 분명한 목적이 있는 클래스를 제외하고 클래스 멤버를 초기화 할 때 Setter로 구성하는 것은 별로 좋지 않은 방법이다.
보통 다른 개발자와 협력하는 것에 있어서 중요한 것이 불변 객체이다. 불변 객체는 말그대로 객체 생성 후 변하지 않는 객체를 뜻하는 말인데 대표적으로 final 키워드를 붙인 변수가 바로 불변이다.
그렇다면 그냥 setter가 없는 생성자를 사용하면 되진 않은가? 라고 생각할 수 있는데 지나친 생성자 오버로딩으로 인해 문제가 되었고 결국 빌더 패턴이 나오게 된 것이다.
빌더 패턴은 정리하지면 이렇게 된다.
생성자 없이 어느 객체에 대해 '변경 가능성의 최소화'를 추구하여 불변성을 갖게 해준다.
이렇게 좋아보이는 빌더 패턴도 단점이 분명히 존재한다.
빌더 패턴을 작성하기 위해서는 빌더 대상 클래스에 대해 같은 양의 빌더 패턴 클래스를 만들어 주어야한다. 그렇기에 클래스 구조가 복잡해지고, 선택적 매개변수가 많은 객체를 생성하기 위해서는 먼저 빌더 클래스를 정의해야한다. 하지만 이것은 왠만한 디자인 패턴이 가지고 있는 고질적인 단점이다.
빌더 패턴은 생성자의 멤버변수가 너무 많고 관리가 어렵기에 나왔는데 정작 멤버변수가 적다면 오히려 생성자가 더좋을 수 있겠다. 그렇기에 상황에 맞게 패턴 적용유무를 따져야 한다.
메서드 호출 할 때마다 빌더를 거쳐 인스턴스화 하기 때문에 당연할 이야기 일지도 모른다. 비용자체는 크지는 않지만 성능을 중요시하는 경우에는 생각해봐야 할 것이다.
지금까지 이렇게 작성해보니 내가 Lombok의 @Builder 어노테이션에게 얼마나 신세졌는지 그리고 나의 지식이 생각보다 짧았다는 것을 깨달았다. 앞으로 공부할 것은 산더미처럼 있고 또다시 쌓이게 될 것이다. 내가 모든 CS지식을 알 수는 없을지만 내가 배우기로 정한 지식은 확실히 알고 가겠다는 다짐을 한다.
이제 빌더 패턴에 대해 기본적인 지식을 쌓았으니 Spring의 @Builder 어노테이션과 빌더의 디자인 패턴 2가지를 다루어볼 생각이다.
(항상 감사합니다.)