영호
하나의 객체에서 여러 Builder 사용해보기 (feat. Builder 컴파일 과정, lombok) 본문
들어가면서,
우테코 5기 프로젝트를 진행하면서 처음 빌더 패턴을 써봤다. 그 과정에서 하나의 객체에 대해 2가지 버전의 Builder 를 활용할 필요성이 있었는데 겪은 문제점과 해결 방법에 대해서 작성할 예정이다.
문제 상황
회원은 2가지 종류가 있다.
- 임시 회원
- 정식 회원
임시회원은 전화번호만 필요하고, 정식 회원은 현재 OAuth를 활용하기 때문에 OAuthProvider, OAuthId 가 필요하다. 추가적으로 임시회원은 임시 닉네임으로 전화번호 마지막 4자리를 사용하기 때문에 임시회원만 전화번호를 이용해 닉네임을 생성하는 작업이 필요하다.
그래서 내가 원한 것은
내가 원한 기능
- Customer 객체에 회원 종류 별 Builder 메서드를 이용해 각 회원 객체를 생성할 때 개발자의 실수를 줄이고, 회원에 맞는 닉네임을 설정하고 싶었다.
처음에 builderMethodName 이란 옵션을 찾았고, 이를 적용해 아래와 같이 코드를 작성했다. 그러나 내 예상과 다르게 동작해서 공식문서와 컴파일된 코드를 직접 확인 하면서 문제점을 찾아봤다.
@Getter
public class Customer {
private String name;
private String phone;
private String oAuthProvider;
private String oAuthID;
private Type customerType;
@Builder(builderMethodName = "registeredCustomer")
public Customer(String name, String oAuthProvider, String oAuthID) {
this.name = name;
this.oAuthProvider = oAuthProvider;
this.oAuthID = oAuthID;
this.type = Type.REGISTER;
}
@Builder(builderMethodName = "temporaryCustomerBuilder")
public Customer(String phone) {
this.name = "xxxx";
this.phone = phone;
this.type = Type.TEMPORARY;
}
}
문제점
각각의 @Builder 를 통해 회원 인스턴스를 생성해도
Customer.temporaryCustomerBuilder().phone(”xxx”).build()
를 통해 Customer를 만들고 test 를 돌려보니 실패했는데 그 원인은 lombok 의 @Builder 컴파일 과정에 있었다.
간략한 컴파일 과정
- build() 메서드에서 사용할 파라미터를 가진 생성자를 생성한다.
- 예를 들어 phone, name 을 가지는 builder 가 있다면 phone, name 을 파라미터로 가지는 생성자가 생성된다.
- Builder 는 내부적으로 클래스이름+Builder 라는 static class 를 객체 클래스 내부에 생성한다.
- static Builder class 내부에 Builder 메서드의 target 이 되는 파라미터들을 non-static, non-final 로 생성한다.
- static builder 기본 생성자를 생성한다.
- build(), setter 역할을 하는 fieldName() 등의 메서드를 생성한다.
- 더 자세한 내용은 공식문서에 나와있습니다.
그리고 각 builder 를 구분하기 위해 사용한 builderMethodName 옵션은 내부적으로 builderMethodName 에 선언한 이름으로 Builder 객체를 반환해주는 생성자가 생성된다.
public static CustomerBuilder temporaryCustomerBuilder() {
return new CustomerBuilder();
}
나의 경우는 builderMethodName 을 이용해 2개의 Builder 를 생성했기 때문에 컴파일된 코드를 보면 아래와 같다. 동일한 Builder 인스턴스를 생성해 반환해주는 것을 볼 수 있다.
public static CustomerBuilder temporaryCustomerBuilder() {
return new CustomerBuilder();
}
public static CustomerBuilder registeredCustomer() {
return new CustomerBuilder();
}
위에서 잠깐 언급했지만 내부적으로 각 Builder 의 target 에 맞는 public 생성자가 생성된다. 현재 target parameter 가 다른 2개의 builder 가 존재하기 때문에 2개의 Customer 생성자가 생성된다.
그러면 Builder.build() 메서드는 어떤 생성자를 통해 Customer 인스턴스를 생성해줄까?
public Customer(String name, String phone, String oAuthProvider, String oAuthID) {
this.name = name;
this.phone = phone;
this.oAuthProvider = oAuthProvider;
this.oAuthID = oAuthID;
this.Type = Type.REGISTER;
}
public Customer(String phone) {
this.name = "xxxx";
this.phone = phone;
this.Type = Type.TEMPORARY;
}
정답은 먼저 선언한 Builder 에 맞는 생성자를 호출해 인스턴스를 반환한다. 이 경우 먼저 선언한 정식회원 용 builder 에 맞는 생성자를 호출해준다.
public Customer build() {
return new Customer(this.name, this.phone, this.oAuthProvider, this.oAuthID);
}
출력문으로 확인해보기
각각의 Builder 에서 호출을 통해 추가적으로 확인해보자.
@Getter
public class Customer {
private String name;
private String phone;
private String oAuthProvider;
private String oAuthID;
private Type type;
@Builder(builderMethodName = "registeredCustomerBuilder")
public Customer(String name, String phone, String oAuthProvider, String oAuthID) {
log.info("call by registeredCustomerBuilder");
this.name = name;
this.phone = phone;
this.oAuthProvider = oAuthProvider;
this.oAuthID = oAuthID;
this.Type = Type.REGISTER;
}
@Builder(builderMethodName = "temporaryCustomerBuilder")
public Customer(String phone) {
log.info("call by temporaryCustomerBuilder");
this.name = "xxxx";
this.phone = phone;
this.Type = Type.TEMPORARY;
}
}
// main method
Customer.registeredCustomerBuilder().name("one").build();
Customer.temporaryCustomerBuilder().phone("two phone").build();
// call by registeredCustomerBuilder
// call by registeredCustomerBuilder
source code
@Getter
public class Customer {
private String name;
private String phone;
private String oAuthProvider;
private String oAuthID;
private Type type;
@Builder(builderMethodName = "temporaryCustomerBuilder")
public Customer(String phone) {
this.name = "xxxx";
this.phone = phone;
this.Type = Type.TEMPORARY;
}
@Builder(builderMethodName = "registeredCustomerBuilder")
public Customer(String name, String phone, String oAuthProvider, String oAuthID) {
this.name = name;
this.phone = phone;
this.oAuthProvider = oAuthProvider;
this.oAuthID = oAuthID;
this.Type = Type.REGISTER;
}
}
compile code
public class Customer {
private String name;
private String phone;
private String oAuthProvider;
private String oAuthID;
private Type type;
public Customer(String phone) {
this.name = "xxxx";
this.phone = phone;
this.Type = Type.TEMPORARY;
}
public Customer(String name, String phone, String oAuthProvider, String oAuthID) {
this.name = name;
this.phone = phone;
this.oAuthProvider = oAuthProvider;
this.oAuthID = oAuthID;
this.Type = Type.REGISTER
}
public static CustomerBuilder temporaryCustomerBuilder() {
return new CustomerBuilder();
}
public static CustomerBuilder registeredCustomerBuilder() {
return new CustomerBuilder();
}
public static class CustomerBuilder {
private String phone;
private String name;
private String oAuthProvider;
private String oAuthID;
CustomerBuilder() {
}
public CustomerBuilder phone(final String phone) {
this.phone = phone;
return this;
}
public Customer build() {
return new Customer(this.phone);
}
public String toString() {
return "Customer.CustomerBuilder(phone=" + this.phone + ")";
}
public CustomerBuilder name(final String name) {
this.name = name;
return this;
}
public CustomerBuilder oAuthProvider(final String oAuthProvider) {
this.oAuthProvider = oAuthProvider;
return this;
}
public CustomerBuilder oAuthID(final String oAuthID) {
this.oAuthID = oAuthID;
return this;
}
}
}
해결 방법
내가 생각한 해결 방법은 2가지가 있다.
- 모든 필드를 targetParameter로 받는 Builder 하나를 통해 임시회원, 정식회원 인스턴스를 생성하도록 한다.
- 각 Builder 별로 별도의 static builder 클래스를 생성하도록 설정을 변경한다.
2번 방법을 선택했다. 1번 방법을 선택하지 않은 이유는 임시 회원 인스턴스를 생성할 때 정식회원과 별개로 전화번호 뒷자리를 닉네임으로 설정해주는 로직이 필요하다.
그리고 2 종류의 회원이 필요한 정보가 아예 다르기 때문에 이를 분리하여 개발자의 실수를 막고 싶었기 때문에 2번을 선택했다.
Builder 분리하기
builderClassName 이란 옵션이 있다. 이 옵션을 사용하면 내가 작성한 이름으로 static class 가 생성된다.
아래 코드를 보면 2개의 static builder 클래스가 생성된 것을 볼 수 있다. 그리고 각각의 build() 메서드를 보면 각 builder 에 맞는 생성자를 호출해 Customer 인스턴스를 반환해주는 것을 볼 수 있다.
public class Customer {
private String name;
private String phone;
private String oAuthProvider;
private String oAuthID;
public Customer(String name, String phone, String oAuthProvider, String oAuthID) {
this.name = name;
this.phone = phone;
this.oAuthProvider = oAuthProvider;
this.oAuthID = oAuthID;
}
public Customer(String phone) {
this.name = "xxxx";
this.phone = phone;
}
public static RegisteredCustomerBuilder registeredCustomer() {
return new RegisteredCustomerBuilder();
}
public static CustomerBuilder temporaryCustomerBuilder() {
return new CustomerBuilder();
}
public String getName() {
return this.name;
}
public String getPhone() {
return this.phone;
}
public String getOAuthProvider() {
return this.oAuthProvider;
}
public String getOAuthID() {
return this.oAuthID;
}
public static class RegisteredCustomerBuilder {
private String name;
private String phone;
private String oAuthProvider;
private String oAuthID;
RegisteredCustomerBuilder() {
}
public RegisteredCustomerBuilder name(final String name) {
this.name = name;
return this;
}
public RegisteredCustomerBuilder phone(final String phone) {
this.phone = phone;
return this;
}
public RegisteredCustomerBuilder oAuthProvider(final String oAuthProvider) {
this.oAuthProvider = oAuthProvider;
return this;
}
public RegisteredCustomerBuilder oAuthID(final String oAuthID) {
this.oAuthID = oAuthID;
return this;
}
public Customer build() {
return new Customer(this.name, this.phone, this.oAuthProvider, this.oAuthID);
}
}
public static class CustomerBuilder {
private String phone;
CustomerBuilder() {
}
public CustomerBuilder phone(final String phone) {
this.phone = phone;
return this;
}
public Customer build() {
return new Customer(this.phone);
}
}
마무리
빌더를 처음 학습하면서 정리한 내용이라 부족한 부분 댓글로 알려주시면 감사하겠습니다!
'Spring' 카테고리의 다른 글
[Spring] @Transactional 의 REQUIRED, REQUIRES_NEW 트랜잭션 생성 과정 (1) | 2023.10.14 |
---|---|
[Spring] @Transactional 물리 트랜잭션과 논리 트랜잭션 커밋 동작 차이 (2) | 2023.10.14 |
[Spring] test context caching (0) | 2023.05.21 |
ResponseEntity의 created(URI)는 뭘까? (feat.httpCode 201) (0) | 2023.04.24 |
공식문서로 알아보는 IoC container (0) | 2023.04.20 |