본 포스팅은 JPA 엔티티 생성에 대해 생성자 방식이 아닌 빌더 패턴 적용을 전제로 한다.
@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Comment extends BaseEntity {
@Id
@GeneratedValue
@Column(name = "comment_id")
private Long id;
JPA 엔티티를 설계하려고 한다. JPA 스펙에 따라 리플렉션 기술을 활용하기 위해 기본적으로 제어자가 protected혹은 public으로 선언된 기본 생성자가 필요하다. Lombok을 활용한다면 기본 생성자는 엔티티 클래스에 @NoArgsConstructor를 붙여 만들 수 있을 것이다. access 파라미터에 AccessLevel.PROTECTED를 주어 protected 수준으로 다른 패키지에서 사용하지 못하도록 한다.(기본 생성자를 JPA 스펙 외에 사용할 경우가 없다.)
@Builder를 적용했을 때 엔티티가 어떤 코드가 되는지 동치 코드를 살펴보자.
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // 기본 키
private String firstName;
private String lastName;
private int age;
// JPA 스펙에 따라 매개변수 없는 protected 기본 생성자 추가
protected User() {
// JPA에서 기본 생성자를 요구하므로 추가 (외부에서 호출되지 않도록 protected로 설정)
}
// 빌더 패턴 적용
private User(String firstName, String lastName, int age) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}
// 빌더 클래스
public static class UserBuilder {
private String firstName;
private String lastName;
private int age;
public UserBuilder firstName(String firstName) {
this.firstName = firstName;
return this;
}
public UserBuilder lastName(String lastName) {
this.lastName = lastName;
return this;
}
public UserBuilder age(int age) {
this.age = age;
return this;
}
public User build() {
return new User(firstName, lastName, age);
}
}
// 빌더 메서드
public static UserBuilder builder() {
return new UserBuilder();
}
}
@Builder는 빌더 로직을 만들어준다. 여기서 주목해야할 것은 @AllArgsConstructor :: 전체 필드 생성자 기능에 해당하는 전체 필드에 대한 생성자, 즉 다음의 코드가 private으로 만들어진다.
// 빌더 패턴 적용
private User(String firstName, String lastName, int age) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}
여기서 한가지 문제점은 @NoArgsConstructor를 사용했을 때 @Builder가 만들어주는 전체 필드 생성자가 만들어지지 않는다.
@NoArgsConstructor를 사용하면 기본 생성자가 명시적으로 추가되는데, Lombok은 클래스에 이미 생성자가 존재하면 추가적인 생성자를 생성하지 않는 동작 방식을 따른다. 따라서 @Builder는 필요한 전체 필드를 초기화하는 생성자를 생성하지 않게 되어 빌더 패턴이 정상적으로 동작하지 않을 수 있다. 이를 해결하기 위해 특정 생성자에만 @Builder를 적용하거나, @AllArgsConstructor를 함께 사용해 @Builder가 필요로 하는 전체 필드 생성자를 명시적으로 제공해야 한다.
위의 내용을 정리하면 JPA 엔티티에 @Builder를 적용하기 위해서는 기본 생성자(for JPA 스펙)와 전체 필드 생성자(빌더 스펙)가 필요하다. 기본 생성자를 만들기 위해 @NoArgsConstructor를 사용하면 롬복 규칙에 따라 @Builder가 생성하는 전체 필드 생성자 생성이 중단된다.
그렇기에 보통은 @Builder, @NoArgsConstructor, @AllArgsConstructor :: 전체 필드 생성을 모두 같이 사용한다.
근데 @AllArgsConstructor를 추가함에 따라 또다른 문제가 발생한다.
@AllArgsConstructor는 클래스의 모든 필드를 매개변수로 받는 public 생성자를 생성한다. 하지만 필드 순서에 의존하므로, 필드 순서가 변경되면 치명적인 런타임 버그가 발생할 수 있다.(그렇기에 빌더 패턴을 사용하는 것이다.)@AllArgsConstructor로 인해 JPA에서 직접 호출되지 않아야 하는 전체 필드 생성자가 외부에서 호출 가능(public)해진다. 이는 객체의 일관성을 깨뜨릴 수 있다.@Builder와 @AllArgsConstructor가 생성하는 생성자가 중복될 수 있어 유지보수성과 가독성이 저하된다.@AllArgsConstructor에 의해 발생하는 문제는 public 생성자가 만들어지기에 빌더 패턴으로 생성하는 것 이외에 이 엔티티를 생성자 방식으로 생성또한 가능해진다는 것이다. 빌더 생성과 생성자 생성 두 가지 기능이 모두 활성화 된다.
우리는 분명 생성자 방식에서 탈피하고자 빌더 패턴을 도입했는데 생성자 방식이 살아있게된다. 답은 간단하다.
클래스 레벨에서 @Builder를 사용하는 대신, 특정 생성자에만 @Builder를 적용한다. 이를 통해 불필요한 중복 생성자를 방지하고, 빌더 패턴과 JPA의 기본 생성자 요구 사항을 충족할 수 있다.
권장되는 방법이나 생성자를 개발자가 계속 관리해야한다는 단점이 존재한다.
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String firstName;
private String lastName;
private int age;
// JPA 기본 생성자
protected User() {}
// 생성자에 @Builder 적용
@Builder
public User(String firstName, String lastName, int age) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}
}
@AllArgsConstructor를 유지하면서, 생성자의 접근 수준을 private으로 제한하여 외부에서 직접 호출을 차단한다. 이를 통해 전체 필드 생성자가 외부에서 호출될 위험을 제거하고, 빌더 패턴을 안전하게 사용할 수 있다.
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Getter
@Builder
@Entity
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) // JPA 기본 생성자
@AllArgsConstructor(access = lombok.AccessLevel.PRIVATE) // 전체 필드 생성자를 private으로 제한
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String firstName;
private String lastName;
private int age;
}
Lombok은 반복적인 코드를 줄이고 개발 속도를 높여주는 편리함을 제공하지만, 이러한 편리함 때문에 코드 가독성과 디버깅이 어려워질 수 있는 단점이 있다. Lombok이 자동으로 생성하는 코드는 IDE나 컴파일 시점에만 확인할 수 있어, 코드의 동작을 명확히 이해하려면 Lombok의 내부 동작 방식을 알아야 한다. 또한, 프로젝트의 모든 개발자가 Lombok에 익숙하지 않을 경우, 유지보수 단계에서 의도치 않은 오해나 문제가 발생할 수 있다. 더불어, Lombok에 의존하다 보면 표준 Java 문법을 사용하는 능력이 저하될 가능성도 존재하며, 특정 상황에서 Lombok이 생성하는 코드와 다른 프레임워크(JPA 등)의 요구사항이 충돌하여 예기치 못한 오류가 발생할 수 있다.