영호

하나의 객체에서 여러 Builder 사용해보기 (feat. Builder 컴파일 과정, lombok) 본문

Spring

하나의 객체에서 여러 Builder 사용해보기 (feat. Builder 컴파일 과정, lombok)

0h0 2023. 9. 7. 18:16

들어가면서,

우테코 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가지가 있다.

  1. 모든 필드를 targetParameter로 받는 Builder 하나를 통해 임시회원, 정식회원 인스턴스를 생성하도록 한다.
  2. 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);

  }
}

마무리

빌더를 처음 학습하면서 정리한 내용이라 부족한 부분 댓글로 알려주시면 감사하겠습니다!

Comments