"생성자 패턴과 빌더 패턴 중 무엇을 선택해야할까?" 에 대한 고민을 정리한 글 입니다.
그간 인스턴스를 생성하는데 생성자를 사용 했습니다.
아래 코드와 같이 빈 생성자를 private로 막고(@NoArgsConstructor)
모든 필드를 파라미터로 받는 생성자를 만들어(@AllArgsConstructor) 생성할 때 필드를 빠뜨리는 휴먼에러를 컴파일러와 IDE를 이용해 막고자 함이었죠.
참고 : 만들면서 배우는 클린 아키텍처. 4장 생성자의 힘
@NoArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor @Getter public class Member { private String id; private String password; private String name; private String address; private Long balance; }
코드를 보고 멘토님께서 이런 코멘트를 주셨습니다.
"생성자의 치명직인 단점을 보완하면 좋겠습니다."
빌더 패턴에서 발생할 수 있는 휴먼 에러를 방지하고자 생성자를 이용했지만 생성자에 어떤 단점이 있는지는 '만.클.아' 에서 읽은 내용 외에 따로 생각해보지 않았습니다.
때문에 생성자 패턴과 빌더 패턴 중 더 나은 선택의 기준을 마련해보고자 합니다.
매개변수가 늘어날수록 가독성이 떨어진다.
가독성이 떨어지지만 파라미터의 개수와 타입이 제대로 입력되었는지 컴파일러가 체크해준다면 더 메리트가 있지 않을까요?
글쓴이는 그렇게 생각했지만 가독성이 떨어짐으로써 한 가지 치명적인 문제가 발생할 수 있었습니다.
아래 코드를 볼까요?
public class SampleConstructor { public void test() { Member cm = new Member( "testPassword", "testId", "testName", null, 0L ); } }
문제점을 파악하셨나요?
같은 타입의 파라미터가 연속으로 존재하면 뒤바뀌어도 컴파일러에서 체크하지 못합니다.
필드가 더 늘어날 수록, 협업 환경에서 다른 사람이 만든 생성자를 이용할 수록 생성자는 생성할 때 파라미터로 값을 입력할 뿐이기 때문에 가독성이 떨어져 휴먼 에러가 발생할 수 있습니다.
이 문제는 점층적 생성자 패턴 을 쓰더라도, 정적 팩토리 메소드 를 써도 마찬가지로 발생할 수 있습니다.
이 블럭은 정적 팩토리 메소드에 대해 알고 있다면 스킵하셔도 무방합니다.
정적 팩토리 메소드(static factory method)는 생성자의 어떤 점을 보완할 수 있을까요?
1. 생성자가 이름을 가져 무엇을 생성하는지 알 수 있다.public class Member { private Member() {} public Member withId(String id) { Member member = new Member(); member.id = id; return member; } public Member loginOf(String id, String password) { Member member = new Member(); member.id = id; member.password = password; return member; } private String id; private String password; private String name; private String address; private Long balance; }
위의 코드에서 withId(), loginOf() 이름을 사용하면 반환될 인스턴스가 어떤 역할을 수행할지 new Member() 와 같이 생성자를 사용하는 것 보다 개발하는 입장에서 인지하며 인스턴스를 생성할 수 있습니다.
2. 반환 타입의 하위 타입 클래스를 반환할 수 있다.
반환 타입의 하위 클래스를 반환해 상속의 강력함을 이용할 수 도 있습니다.
이러한 구조를 가진 프로그램(인터페이스 기반 프레임워크)를 만든다면 개발할 때에도 인터페이스 기반으로 사용법만 알면 되기에 무분별하게 새로운 클래스를 만드는 것을 막을 수 있습니다.3. 정적 팩토리 메서드를 작성할 시점에는 반환할 클래스가 존재하지 않아도 된다.
JDBC 처럼 기능 명세(ex. getConnection())를 가진 인터페이스를 기반으로 클래스를 구현한다면 다양한 타입을 반환하는 클래스를 추후에 유연하게 확장할 수 있습니다.🤔 하지만 이 장점들이 위에서 발생한 '가독성' 으로 인한 문제를 해결할 수 있을까요?
아닙니다. 여전히 String id, String password 처럼 같은 타입의 파라미터가 연속으로 존재하기 때문에 파라미터를 뒤바꾸어 입력했을 때 컴파일러가 확인하지 못하는 문제는 존재합니다.
그럼 프로젝트에서 '상속의 강력함'을 사용하면 좋을까요?
답은 "상황마다 다르다" 입니다.(이 부분 답변 코멘트 필요)
자바의 Collections, JDBC와 같은 상속으로 재사용성을 높인 프레임워크를 만든다면 이는 엄청난 장점으로 사용하지 않을 이유가 없습니다.
하지만 타임딜 프로젝트 처럼 규모가 작은 프로젝트에서 Member, LoginMember 와 같이 목적에 맞는 새로운 클래스를 만들더라도 오버헤드가 크게 발생하지 않습니다.
오히려 해당 클래스 이름이 명확하기 때문에 그 역할이 명확해 보입니다.
new LoginMember();와 같이 말이죠.
😮 그럼 빌더 패턴로 위의 문제를 해결할 수 있나요?
먼저 Member를 빌더 패턴으로 리팩토링 해보겠습니다.
public class Member { //필수 필드 private String id; //옵션 필드 private String password; private String name; private String address; private Long balance; /**생성자는 private로 외부에서 인스턴스화를 막음 오직 빌더를 통해서만 인스턴스 생성 허용**/ private Member(MemberBuilder builder) { this.id = builder.id; this.password = builder.password; this.name = builder.name; this.address = builder.address; this.balance = builder.balance; } /**빌더 클래스는 Static Nested Class로 선언하며, 관례적으로 생성할 <클래스Builder>로 네이밍 함**/ public static class MemberBuilder { //필수 필드 private String id; //옵션 필드 private String password; private String name; private String address; private Long balance; //생성자는 필수 필드를 정의함 public MemberBuilder(String id) { this.id = id; } /**옵션 필드는 메서드로 정의함. 옵션 필드의 반환값은 빌더 인스턴스 자신으로 메서드 체이닝을 활용할 수 있음**/ public MemberBuilder setPassword(String password) { this.password = password; return this; } public MemberBuilder setName(String name) { this.name = name; return this; } public MemberBuilder setAddresss(String address) { this.address = address; return this; } public MemberBuilder SetBalance(Long balance) { this.balance = balance; return this; } // //빌더 클래스에 있는 build() 메서드를 통해서만 클래스를 생성함. public Member build() { return new Member(this); } } }
생성자 패턴과는 달리 Member 인스턴스를 얻으려면 MemberBuilder를 통해서만 얻을 수 있습니다.
이제 직접 인스턴스를 생성하는 코드를 볼까요?
public class SampleBuilder { public void test() { Member bm = Member.MemberBuilder("testId") .setPassword("testPassword") .setName("testName") //.setAddress(null) //.setBalance(0L) .build(); } }
생성자와 달리 옵션으로 설정한 필드는 set<필드이름> 으로 되어있어 가독성이 향상되었습니다.
빌더 패턴을 적용하니 아래의 내용이 더 좋아졌네요.
- 필드에 무엇을 주입하고 있는지 명확함
- 빌드 패턴에서는 필수 필드만 초기화 해주면 됨
(생성자는 null, 0L 등의 값으로 초기화 필요)- 생성자와 마찬가지로 필수 필드에 대해서는 컴파일러로 체크 가능
생성자의 문제였던 연속되는 같은 타입의 필드는 필수 필드와 옵션 필드를 잘 나누거나 setPassword()와 같이 메서드의 이름으로 정확히 명시해주면 휴먼에러가 줄어들 것 입니다.
그리고 생성자의 장점이었던 컴파일러 체크도 경우에따라 이용할 수 있습니다.
😱 근데, 빌더 패턴을 사용하려면 클래스마다 정의해야 하나요?
아닙니다.
롬복에서 지원하는 @Builder 어노테이션으로 간편하게 만들 수 있습니다.
이제 롬복으로 리팩토링 해볼까요?
@Builder public class Member { private String id; private String password; private String name; private String address; private Long balance; }
짜잔~ 간단하죠? 아! 한 가지가 빠졌네요. 필수 필드를 지정해보겠습니다.
@Builder(builderMethodName = "innerBuilder") public class Member { //필수 필드 private String id; //옵션 필드 private String password; private String name; private String address; private Long balance; /** 기존의 builder() 메서드를 아래 메서드의 이름을 innerBuilder로 변경 한 것 public static Member.MemberBuilder builder() { return new Member.MemberBuilder(); } *// public static MemberBuilder builder(String id) { return innerBuilder.id(id); } }
간단해졌죠? 하지만 어디에나 맞는 코드란 것은 없습니다.
때로는 생성자가 적합할 때도 있고, 빌더 패턴 혹은 다른 적절한 패턴을 사용해야할 때가 있습니다.
자신이 속한 팀 혹은 프로젝트의 상황에 맞는 적절한 전략을 선택하는 것이 그 상황에서는 최선이라고 생각하면 됩니다.
질문 : 빌더 패턴을 사용하면 auto increment같은 것을 사용할 수 없음 생성자에 빌더 어노테이션을 달아야 함 이 경우 생성자는 포기해야하나?
출처
- Effective Java 3/E
- 만들면서 배우는 클린 아키텍처
- https://hothoony.tistory.com/1295
- https://cheese10yun.github.io/lombok/