JPA 엔티티에 protected 기본 생성자가 필요한 이유

반응형

JPA 엔티티에서 다음 annotation 조합을 자주 본다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
    // ...
}

왜 매개변수 없는 생성자가 필요하고, 왜 public이 아니라 protected로 둘까? 두 선택의 근거는 서로 다르다.

  • 기본 생성자는 Jakarta Persistence 명세가 entity class에 요구한다.
  • protected 접근 수준은 명세를 만족하면서 application code의 무의미한 생성을 줄이려는 설계 선택이다.

@NoArgsConstructor는 Lombok이 이 생성자를 대신 작성하게 하는 도구일 뿐이다.

JPA 명세가 요구하는 생성자

Jakarta Persistence의 entity class는 매개변수 없는 public 또는 protected 생성자를 가져야 한다. application이 직접 호출하지 않더라도 persistence provider가 database에서 읽은 값을 entity instance로 복원할 때 사용할 수 있어야 하기 때문이다.

직접 작성하면 다음과 같다.

@Entity
public class Order {

    @Id
    @GeneratedValue
    private Long id;

    private String orderNumber;

    protected Order() {
    }
}

Java가 자동으로 만들어 주는 기본 생성자를 기대하면 쉽게 놓친다. class에 다른 생성자를 하나라도 선언하면 compiler는 매개변수 없는 생성자를 자동 생성하지 않는다.

@Entity
public class Order {

    @Id
    private Long id;

    private String orderNumber;

    public Order(String orderNumber) {
        this.orderNumber = orderNumber;
    }
}

이 class에는 자동 기본 생성자가 없다. JPA 요구사항을 만족하려면 별도로 선언해야 한다.

왜 public보다 protected를 선택할까

JPA는 publicprotected를 모두 허용한다. 둘 중 protected를 고르는 이유는 entity가 불완전한 상태로 생성되는 경로를 application code에 넓게 열지 않기 위해서다.

Order order = new Order(); // public이면 어디에서나 가능

orderNumber처럼 생성 시 반드시 필요한 값이 있는데도 위 코드가 허용되면 domain invariant를 놓치기 쉽다. 기본 생성자를 protected로 내리고 의미 있는 생성 경로를 따로 제공할 수 있다.

@Entity
public class Order {

    @Id
    @GeneratedValue
    private Long id;

    @Column(nullable = false, unique = true)
    private String orderNumber;

    protected Order() {
    }

    public Order(String orderNumber) {
        if (orderNumber == null || orderNumber.isBlank()) {
            throw new IllegalArgumentException("orderNumber는 비어 있을 수 없습니다.");
        }
        this.orderNumber = orderNumber;
    }
}

이 방식은 JPA가 쓸 통로와 application이 쓸 통로를 구분한다. 다만 protected 하나만으로 모든 invariant가 보장되는 것은 아니다. reflection을 막는 보안 장치도 아니며, setter나 field 변경 경로가 열려 있다면 상태는 여전히 깨질 수 있다.

Lombok으로 같은 코드를 줄이기

Lombok을 사용하면 직접 작성한 protected Order() {}를 다음처럼 대체할 수 있다.

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id
    @GeneratedValue
    private Long id;

    private String orderNumber;

    public Order(String orderNumber) {
        if (orderNumber == null || orderNumber.isBlank()) {
            throw new IllegalArgumentException("orderNumber는 비어 있을 수 없습니다.");
        }
        this.orderNumber = orderNumber;
    }
}

Lombok이 생성하는 개념적인 코드는 다음과 같다.

protected Order() {
}

access를 생략하면 @NoArgsConstructor의 기본 접근 수준은 public이다. JPA entity에서 의도적으로 protected를 원한다면 명시하는 편이 코드 리뷰에서 의미가 잘 드러난다.

force = true는 편의 옵션이 아니다

final field가 초기화되지 않은 class에는 일반적인 no-args constructor를 만들 수 없다.

@NoArgsConstructor
class Member {
    private final String name; // compile error
}

Lombok의 force = true를 사용하면 생성자를 만들면서 초기화되지 않은 final field에 0, false, null 같은 기본값을 넣는다.

@NoArgsConstructor(force = true, access = AccessLevel.PROTECTED)
class Member {
    private final String name;
}

생성 직후 namenull이다. “불변이어야 한다”는 뜻으로 final을 붙였는데 임시로 잘못된 상태를 허용하는 셈이다. provider와 mapping 전략 때문에 정말 필요한지 먼저 확인하고, compile error를 지우기 위한 기본 선택으로 쓰지 않는 것이 좋다.

특히 다음을 점검해야 한다.

  • entity가 생성 직후에도 유효해야 하는가
  • provider가 field access와 property access 중 무엇을 사용하는가
  • final field와 proxy·enhancement에 제약이 없는가
  • force = true로 만들어진 null 상태가 method 호출 전에 노출될 수 있는가

생성자 annotation을 겹쳐 쓸 때

@NoArgsConstructor, @AllArgsConstructor, @RequiredArgsConstructor, @Builder를 한꺼번에 붙이면 생성 경로가 의도보다 많아질 수 있다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Order {
    // ...
}

이 코드는 편해 보이지만 id, 연관관계, 내부 상태까지 외부 builder에 열릴 수 있다. JPA entity는 persistence mapping class인 동시에 domain state를 담기 때문에 생성 API를 명시적으로 설계하는 편이 안전하다.

필요한 값만 받는 constructor 또는 정적 factory를 제공할 수 있다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id
    @GeneratedValue
    private Long id;

    private String orderNumber;
    private OrderStatus status;

    private Order(String orderNumber, OrderStatus status) {
        this.orderNumber = orderNumber;
        this.status = status;
    }

    public static Order place(String orderNumber) {
        if (orderNumber == null || orderNumber.isBlank()) {
            throw new IllegalArgumentException("orderNumber는 비어 있을 수 없습니다.");
        }
        return new Order(orderNumber, OrderStatus.CREATED);
    }
}

여기서도 JPA용 no-args constructor는 남아 있다. place는 application이 지켜야 할 생성 규칙을 표현한다.

Entity와 DTO의 기준은 다르다

JPA가 요구하는 기본 생성자는 entity class에 관한 규칙이다. 모든 DTO에 @NoArgsConstructor(access = PROTECTED)를 붙여야 한다는 뜻은 아니다.

JSON library, form binding, mapper가 생성자를 어떻게 사용하는지는 별도의 계약이다. Java record나 명시적인 constructor로 충분한 DTO도 많다. annotation을 습관적으로 복사하기보다 어떤 framework가 왜 생성자를 필요로 하는지 구분해야 한다.

Java 생성자와 초기화 순서 자체가 헷갈린다면 Java 생성자와 초기화 순서를 먼저 보면 좋다. JPA 학습 범위는 JPA 학습 로드맵에 이어 정리해 두었다.

테스트로 생성 경로를 확인한다

설계 의도는 간단한 test로 고정할 수 있다.

class OrderTest {

    @Test
    void 주문번호가_없으면_생성할_수_없다() {
        assertThatThrownBy(() -> Order.place(" "))
            .isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void 주문을_생성하면_CREATED_상태다() {
        Order order = Order.place("ORDER-2026-001");

        assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED);
    }
}

그리고 persistence test에서는 실제 저장과 조회가 되는지 확인한다. annotation만 보고 provider 동작을 추측하는 것보다 사용 중인 JPA provider와 enhancement·proxy 설정에서 검증하는 편이 낫다.

정리

@NoArgsConstructor(access = AccessLevel.PROTECTED)를 한 덩어리의 관용구로 외울 필요는 없다.

  1. JPA entity에는 public 또는 protected no-args constructor가 필요하다.
  2. protected는 JPA 요구를 만족하면서 application의 빈 객체 생성을 줄이는 선택이다.
  3. Lombok은 해당 constructor를 생성해 주지만 설계 판단까지 대신하지 않는다.
  4. force = truefinal field에 기본값을 넣으므로 잘못된 임시 상태를 만들 수 있다.
  5. 의미 있는 생성자나 factory에서 domain invariant를 지키고 persistence test로 확인한다.

짧은 annotation 한 줄에도 framework 계약과 domain 설계가 함께 들어 있다. 이유를 분리해 이해하면 필요 없는 annotation을 줄이고 entity의 생성 경로도 더 명확하게 만들 수 있다.

참고 자료

반응형
KEEP READING
카테고리 전체 보기 →

댓글