난이도 ⭐️⭐️
작성 날짜 2025.07.12
@Getter
@SuperBuilder
@AllArgsConstructor
public class Member extends BaseTimeEntity { ... }
기존의 도메인 모델의 코드를 보면,
@SuperBuilder
, @AllArgsConstructor
를 이용하고 있다.
SuperBuilder를 쓴 이유는 BaseTimeEntity의 필드까지 접근가능하도록 하기 위해서이다.
그러나 이 방식은 도메인 모델의 생성자를 외부로 노출하는 문제가 있었다.
예를 들어, 멤버의 생성은 createMember()
에서만 진행되어야 한다.
이외의 접근 방식을 이용할 수 없도록 강제하지 못한다면 의도와는 다른 생성이 이루어질 수 있다.
그런데 Builder 어노테이션과는 달리, SuperBuilder는 AccessLevel 설정이 안된다.
그리고 SuperBuilder를 제거하는 것은, Mapper 클래스의 엔티티 - 도메인간 변환을 할 수 없도록 만들기 때문에 불가능하다.
도메인 모델의 생성 구조를 변경해야 한다!
🤔 모델 생성을 create 메서드의 접근으로만 강제할 순 없을까?
고민했던 내용을 의식의 흐름대로 정리해보면...
Builder 어노테이션의 경우 롬복이 컴파일 시점에 단일 클래스에 대해서만 정적 빌더 메서드를 생성한다.
// Parent
public class Parent {
private String name;
public static ParentBuilder builder() {
return new ParentBuilder();
}
public static class ParentBuilder {
private String name;
public ParentBuilder name(String name) {
this.name = name;
return this;
}
public Parent build() {
return new Parent(name);
}
}
}
// Child
public class Child extends Parent {
private int age;
public static ChildBuilder builder() {
return new ChildBuilder();
}
public static class ChildBuilder {
private int age;
public ChildBuilder age(int age) {
this.age = age;
return this;
}
public Child build() {
return new Child(age);
}
}
}
반면에 SuperBuilder는 상속을 고려하여 빌더를 생성한다.
import lombok.Getter;
public class Parent {
private String name;
protected Parent(ParentBuilder<?, ?> b) {
this.name = b.name;
}
public static ParentBuilder<?, ?> builder() {
return new ParentBuilderImpl();
}
public abstract static class ParentBuilder<C extends Parent, B extends ParentBuilder<C, B>> {
private String name;
public B name(String name) {
this.name = name;
return self();
}
protected abstract B self();
public abstract C build();
}
private static final class ParentBuilderImpl extends ParentBuilder<Parent, ParentBuilderImpl> {
protected ParentBuilderImpl self() {
return this;
}
public Parent build() {
return new Parent(this);
}
}
}
public class Child extends Parent {
private int age;
protected Child(ChildBuilder<?, ?> b) {
super(b); // 부모 생성자를 호출하여 부모 필드를 초기화
this.age = b.age;
}
public static ChildBuilder<?, ?> builder() {
return new ChildBuilderImpl();
}
public abstract static class ChildBuilder<C extends Child, B extends ChildBuilder<C, B>>
extends Parent.ParentBuilder<C, B> {
private int age;
public B age(int age) {
this.age = age;
return self();
}
}
private static final class ChildBuilderImpl extends ChildBuilder<Child, ChildBuilderImpl> {
protected ChildBuilderImpl self() {
return this;
}
public Child build() {
return new Child(this);
}
}
}
이 생성 과정을 보면 Lombok이 왜 SuperBuilder에 대한 AccessLevel을 지원하지 않는지 알 수 있다.
ChildBuilder의 선언 부분을 보면 ParentBuilder를 상속하고 있다.
이 상속 덕분에 ChildBuilder는 ParentBuilder의 모든 기능을 물려받는다.
즉, ChildBuilder의 인스턴스는 name() 메서드를 가지고 있다.
ParentBuilder에 정의된 name() 메서드를 보면, 접근 제어자가 public인 것을 알 수 있다.
ChildBuilder는 ParentBuilder를 상속했기 때문에 public 접근 제어자를 가진 name() 메서드를 그대로 물려받는다.
자바의 규칙에 따라, 상속받은 메서드를 오버라이딩할 때 접근 제어자를 부모보다 더 제한적인 protected나 private으로 바꿀 수 없다.
따라서 Child.builder().name(...)을 호출할 때 사용되는 name() 메서드는 ParentBuilder로부터 물려받은 public 메서드이며, 이 규칙 때문에 public으로 유지될 수밖에 없는 것이다.
이렇게 복잡하게 구현되는 이유는 메서드 체이닝 때문인데,
Child child = Child.builder()
.name("홍길동") // 1. ParentBuilder의 메서드
.age(20) // 2. ChildBuilder의 메서드
.build();
.name("홍길동")을 호출하면 ParentBuilder에 정의된 name() 메서드가 실행된다. 이 메서드는 마지막에 self()를 반환한다.
ChildBuilder에서 self()는 ChildBuilder 자기 자신(this)을 반환하도록 구현되어 있다.
결과적으로 .name("홍길동")의 반환 값은 ParentBuilder가 아닌 ChildBuilder가 된다.
따라서 바로 이어서 ChildBuilder에만 있는 .age(20) 메서드를 호출할 수 있다.
롬복이 이러한 이유로 AccessLevel을 막아두었을 것으로 예측(?)한다.
@Builder(builderMethodName = "createMemberBuilder", access = AccessLevel.PRIVATE)
private Member(LocalDateTime createdAt, LocalDateTime modifiedAt, LocalDateTime deletedAt, Long id, Provider provider, String providerId, MemberRole memberRole, MemberState memberState, boolean isAlarmOn, String firebaseToken, String refreshToken, LoveTypeId loveTypeId, float avoidanceRate, float anxietyRate, String nickname, String email, InviteCodeValue inviteCode, LocalDate startLoveDate) {
super(createdAt, modifiedAt, deletedAt);
this.id = id;
this.nickname = nickname;
this.email = email;
this.startLoveDate = startLoveDate;
// ...생략
}
이렇게 하면 만약에 필드가 추가될 때 Mapper 클래스까지 까먹지 않고 수정해야되는 부담이 생기고, 만약 빠지면 필드는 존재하는데 도메인이나 엔티티에서 값이 누락되는 문제가 생긴다. 이걸 강제할 수는 없을까?
제거의 근거는 다음과 같다.
1. 어차피 부모 필드가 세 가지 밖에 안된다.
2. 다형성을 활용하는 것도 아니다.
3. createdAt, modifiedAt은 JPA Auditing의 기능이기 때문에 엔티티의 관점에서 분리하는 것은 의미가 있으나, 도메인 레벨에선 굳이 필요 없다.
@Getter
@Builder(access = AccessLevel.PRIVATE)
public class Member { ... }
상속 없애면 정적 팩토리 메서드를 이용해 요렇게 바꿀 수 있다.
public static Member from(
Long id,
String nickname,
String email,
LocalDate startLoveDate,
LocalDateTime createdAt,
LocalDateTime modifiedAt,
LocalDateTime deletedAt
// ...생략
) {
return Member.builder()
.id(id)
.nickname(nickname)
.email(email)
.startLoveDate(startLoveDate)
.createdAt(createdAt)
.modifiedAt(modifiedAt)
.deletedAt(deletedAt)
// ...생략
.build();
}
상속 제거로 반복이 늘어난다는 단점이 있지만,
이제 두 가지 조건을 만족한다.
결론
빌더가 이렇게 복잡한 구조인지는 몰랐다..
모든걸 다 해결해주는 슈퍼슈퍼빌더가 나왔으면 좋겠다