[Spring] @Builder 어노테이션 - Lombok

Kim Hyen Su·2024년 9월 4일

Spring

목록 보기
12/13
post-thumbnail

참고 포스팅

🌿 Spring


@Builder(toBuilder = boolean) : default false

Builder의 속성 중 'ToBuilder'는 값을 true로 지정하는 경우 기존에 구성한 빌더를 기반으로 새로운 객체를 재구성 할 수 있도록 도와주는 속성입니다.

@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ExampleCode {
  private int status;
  private String code;
  private String message;

  @Builder(toBuilder = true)
  public ExampleCode(int status, String code, String message) {
    this.status = status;
    this.code = code;
    this.message = message;
  }
}

해당 속성은 기존의 Builder로 구성한 객체를 새로 생성하지 않고 재구성하고자 할 때, 사용하면 좋습니다.


    ExampleCode example = ExampleCode.builder()
        .status(200)
        .code("OK")
        .message("성공")
        .build();

    System.out.println(example);

    System.out.println("----------------------------------");

    ExampleCode example2 = example.toBuilder()
        .status(400)
        .code("Bad Request")
        .message("잘못된 입력입니다.")
        .build();

    System.out.println(example2);
  }

@Builder(builderMethodName = String)

기본으로 제공되는 Builder라는 명칭 이외에 다른 이름으로 네이밍할 수 있도록 제공하기 위한 속성입니다.

해당 속성은 상속 관계에서 부모의 Builder와 자식의 Builder 간의 이름이 동일하여 충돌이 발생할 수 있으므로, 이를 각각의 이름을 지정해주기 위해 사용되면 좋습니다.

실제로 다음과 같이 상속관계에 있는 클래스에서 @Builder 어노테이션을 동일하게 사용하게 될 경우, 컴파일 오류가 발생합니다.

이를 방지하려면, builderMethodName으로 명칭을 수정해줘야 합니다.

public class ChildExampleCode extends ExampleCode{
  private int num;

  @Builder(builderMethodName = "childBuilder")
  public ChildExampleCode(int num) {
    this.num = num;
  }
}

@Builder.Default

변수의 앞에 선언되며, 빌더 패턴으로 구성 시 초기값을 지정해줄 수 있도록 도와주는 속성입니다.

테스트를 위해서 기본형이었던 필드를 Integer 타입으로 변경했습니다.
다음과 같이 ChildExampleCode 클래스에서 num 필드를 0으로 초기화 해주겠습니다.

public class ChildExampleCode extends ExampleCode{
  private Integer num = 0;

  @Builder(builderMethodName = "childBuilder")
  public ChildExampleCode(Integer num) {
    this.num = num;
  }
}

하지만, 예상과는 다르게 null이 나오게 됩니다.

Builder 패턴에서 초기값을 지정하기 위해서는 Builder.Default를 사용해줘야 합니다. 이는 @NoArgsConstructor 또는 @RequiredArgsConstructor를 사용할 경우 에러가 발생합니다. 따라서 @Builder 어노테이션을 클래스 위에 붙여준 뒤에 사용해주면 됩니다.

@Builder(builderMethodName = "childBuilder")
@ToString
public class ChildExampleCode extends ExampleCode{
  @Builder.Default
  private Integer num = 0;

  public ChildExampleCode(Integer num) {
    this.num = num;
  }
}

다음과 같이 정상적으로 초기화된 것을 확인할 수 있습니다.

2024.09.07 추가

회원 탈퇴 기능을 구현하던 중 회원의 개인 정보를 마스킹 처리해야 하는 로직을 구현하게 되었습니다.

이때,toBuilder = true 속성을 사용하여 조회한 객체의 값을 수정하도록 구현한 뒤 API 테스트를 진행했습니다. 예상했던 결과는 조회한 Member 객체의 모든 필드값을 가진 상태에서 toBuilder로 수정한 값만 변경될거라고 생각했습니다.

하지만, 예상과는 다르게 회원 엔티티에 id 값이 null로 조회되었습니다. 원인을 찾기위해 검색하던 중 다음의 포스팅을 찾았고, 원인을 알게되었습니다.

참고 포스팅

원인은 빌더는 내부적으로 새로운 객체를 생성한다는 점입니다. toBuilder 속성을 추가하게 될 경우, 객체를 생성한 다음 기존의 객체가 가지고 있던 값으로 초기화 해줍니다. 그리고 새롭게 설정한 값들을 내부적으로 set 해준다는 것을 알게되었습니다.

제가 정의한 @Builder는 생성자 레벨에서 작성되었으며, id(Auto_Increment 되는 값)는 자동 생성되므로 제외한 값들을 매개변수로 받아서 객체를 생성하도록 정의되어 있었습니다.이로 인해 id 값이 null로 설정되었던 것이었습니다.

이를 계기로 Builder가 내부적으로 어떻게 동작하는지에 대해 궁금증이 생겨 다음과 같은 내용을 추가로 작성하게 되었습니다.

Class 레벨 어노테이션

출처 : Lombok Docs
If a class is annotated, then a private constructor is generated with all fields as arguments (as if @AllArgsConstructor(access = AccessLevel.PRIVATE) is present on the class), and it is as if this constructor has been annotated with @Builder instead.

클래스 상단에 사용되는 @Builder 어노테이션은 클래스 내 모든 필드를 매개변수로 받는 Default 제한자의 생성자가 내부적으로 생성되며, 해당 생성자에 @Builder 어노테이션을 붙인 것과 동일하게 동작합니다. 결국, Class 상단에 붙이는 것도 중간 단계를 거쳐 생성자 레벨로 변환되어 동작합니다.

다음은 build 패키지 내 컴파일 된 자바 클래스 파일입니다.

public static class ResponseBuilder {
      private String accessToken;
      private String refreshToken;

      ResponseBuilder() {
      }

      public ResponseBuilder accessToken(final String accessToken) {
        this.accessToken = accessToken;
        return this;
      }

      public ResponseBuilder refreshToken(final String refreshToken) {
        this.refreshToken = refreshToken;
        return this;
      }

      public Response build() {
        return new Response(this.accessToken, this.refreshToken);
      }

      public String toString() {
        return "MemberJoinDto.Response.ResponseBuilder(accessToken=" + this.accessToken + ", refreshToken=" + this.refreshToken + ")";
      }
    }

해당 클래스는 회원가입 시 정상 응답을 위한 응답 클래스의 컴파일 된 후 내부 코드 입니다. @Builder 에 의해서 내부적으로 ResponseBuilder 라는 내부 클래스가 생성되었고, Response 클래스 내부에 builder() 라는 메서드를 통해 해당 builder 클래스를 생성하도록 동작합니다. 이 때, builder 클래스는 response 클래스와 동일한 필드를 가지며, NoArgsConstructor가 정의되어 있습니다.

즉, builder() 메서드를 호출하게 되면, NoArgsConstructor에 의해서 ResponseBuilder라는 빈 객체가 생성되고, 필드명과 동일한 메서드를 통해 초기화 됩니다. 그리고 마지막 build() 메서드를 통해서 본 객체인 Response 객체가 Builder 클래스 내 필드를 기반으로 초기화되어 생성됩니다.

출처 : Lombok Docs
Note that this constructor is only generated if you haven't written any constructors and also haven't added any explicit @XArgsConstructor annotations. In those cases, lombok will assume an all-args constructor is present and generate code that uses it; this means you'd get a compiler error if this constructor is not present.

위 내용은 생성자가 클래스 레벨에서 요구하는 모든 필드를 주입하기 위한 생성자가 아니더라도 Lombok에서는 그 생성자가 AllArgsConstructor 로 인식하고 빌더 클래스 내 사용하기 때문에 컴파일 오류가 발생하게 됩니다.

  @Schema(name = "MemberJoin_Response", description = "회원가입 응답 DTO")
  @Getter
  @Builder
  public static class Response {

    @Schema(description = "액세스토큰")
    private String accessToken;
    @Schema(description = "리프레쉬토큰")
    private String refreshToken;

    public Response(String accessToken) {
      this.accessToken = accessToken;
      this.refreshToken = "123";
    }
  }

이를 위해서 Builder를 class 레벨에서 사용 시에는 항시 주의하여 사용되어야 합니다.

그렇다면, 위처럼 특정 필드만 빌더로 생성하도록 구현할 수는 없을까요? 아닙니다.

생성자 레벨 어노테이션

클래스 레벨과는 다르게 생성자 레벨에서 직접 @Builder 어노테이션 적용 시 빌더로 설정하도록 제공하는 항목 역시 직접 선택하여 제공할 수 있습니다. 예를 들어, 기본키와 같이 자동 생성 숫자로 지정되는 경우, DB 내에서 생성되므로, DB까지는 Null로 전달해줘야 합니다. 또한, 생성된 회원을 받아온 뒤, 의도치 않게 id값을 다르게 입력하여 생성하는 경우도 있을 수 있습니다. 이를 미연에 방지하기 위해서 생성자 레벨 어노테이션을 사용할 수 있습니다.

다음은 회원 엔티티 생성자 레벨 어노테이션입니다.

@Builder
  public Member(
      SnsType snsType,
      String snsId,
      String email,
      String profile,
      String nickname,
      String address,
      String gender,
      Integer age,
      String phone,
      LocalDateTime deletedAt
  ) {
    LocalDateTime now = LocalDateTime.now();

    this.snsType = snsType;
    this.snsId = snsId;
    this.email = email;
    this.profile = profile;
    this.nickname = nickname;
    this.address = address;
    this.phone = phone;
    this.gender = gender;
    this.age = age;
    this.createdId = "SIGNUP";
    this.createdAt = now;
    this.updatedId = "SIGNUP";
    this.updatedAt = now;
    this.deletedAt = deletedAt;
    this.role = Role.MEMBER;
  }

위처럼 필요한 필드만 빌더 패턴으로 초기화 할 수 있도록 생성자 레벨에서 정의할 수 있습니다.

toBuilder = true 속성

그렇다면, 제일 궁금했던 toBuilder 속성을 true로 설정해주면, 어떻게 컴파일 될지에 대해서 확인해보겠습니다.

위에 정의한 회원 엔티티에 toBuilder 속성을 추가해보겠습니다.

@Builder(toBuilder = true)
public class Member{
	...
}

컴파일한 뒤 toBuilder() 메서드를 확인해보겠습니다.

public MemberBuilder toBuilder() {
    return (new MemberBuilder()).snsType(this.snsType).snsId(this.snsId).email(this.email).profile(this.profile).nickname(this.nickname).address(this.address).gender(this.gender).age(this.age).phone(this.phone).deletedAt(this.deletedAt);
  }

예상했던 대로, MemberBuilder를 새롭게 생성한 뒤, 기존 객체 필드 내 담겨있던 값들로 초기화 해줍니다.

이 때, 생성자 식에서 파라미터로 전달 받지 않는 필드는 초기화 되지 않으므로 'null' 이라는 값이 들어가게 되는 것입니다.

public Member build() {
      return new Member(this.snsType, this.snsId, this.email, this.profile, this.nickname, this.address, this.gender, this.age, this.phone, this.deletedAt);
    }

그 다음으로, MemberBuilder 내 build() 메서드를 호출하여, 필요한 값들로 set 해준 뒤 Member 객체를 생성해주는 것입니다.

profile
백엔드 서버 엔지니어

0개의 댓글