
JPA로 엔티티를 작성하다 보면 @NoArgsConstructor(access = AccessLevel.PROTECTED)를 거의 관례처럼 쓰게 된다. 처음엔 "JPA가 기본 생성자를 필요로 한다더라" 정도로만 알고 넣었는데, 실제로 엔티티가 어떻게 생성되고 관리되는지 파보니까 이 패턴이 단순한 형식이 아니더라. 왜 기본 생성자가 필요한지, 그리고 왜 protected로 막아두는 게 좋은지 정리해봤다.
평소에 우리는 엔티티를 만들 때 builder()나 create() 같은 팩토리 메서드를 쓴다.
OrderItem item = OrderItem.create(order, productId, quantity);
그런데 JPA는 이런 메서드를 호출하지 않는다. 대신 리플렉션으로 엔티티 객체를 직접 new 한다.
1) DB 조회할 때
findById(), JPQL, QueryDSL로 엔티티를 조회하면 JPA는 DB 데이터를 바탕으로 객체를 직접 만들고 필드를 채워 넣는다.
OrderItem item = new OrderItem(); // 리플렉션으로
item.setId(...)
item.setProductId(...)
item.setQuantity(...)
기본 생성자가 없으면 이 과정 자체가 불가능해서 예외가 터진다.
2) 지연 로딩(Lazy Loading) 프록시 만들 때
예를 들어 OrderItem의 order 필드가 LAZY 로딩이면:
@ManyToOne(fetch = FetchType.LAZY)
private Order order;
JPA는 실제 Order 대신 "프록시 객체"를 만들어두고, 필요할 때 DB를 조회한다. 이때도 기본 생성자가 없으면 프록시 자체를 만들 수 없다.
3) 변경 감지(Dirty Checking)용 스냅샷 만들 때
JPA는 엔티티가 변경됐는지 체크하려고 처음 조회한 시점의 상태를 "스냅샷"으로 저장한다. 이 과정에서도 객체 초기화가 필요해서 기본 생성자를 사용한다.
결론: 기본 생성자는 JPA가 엔티티를 내부적으로 조립하려면 반드시 필요하다.
기본 생성자를 public으로 두면 이렇게 쓸 수 있다:
OrderItem item = new OrderItem(); // 가능해짐
근데 OrderItem은 다음 정보 없이는 아무 의미가 없다:
즉, 엔티티가 "불완전한 상태"로 만들어지는 걸 허용해버리는 거다. 이런 엔티티가 서비스 레이어까지 흘러들어가면 도메인 규칙이 깨지고, 나중에 디버깅하기도 어려운 버그가 된다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
이렇게 하면 두 가지를 동시에 만족시킬 수 있다:
1) JPA는 여전히 사용 가능
리플렉션은 private 빼고는 다 호출할 수 있어서 protected도 문제없다.
2) 개발자가 실수로 new 못 함
new OrderItem()은 같은 패키지나 상속 관계가 아니면 호출할 수 없으니까 잘못된 생성을 원천 차단할 수 있다.
OrderItem은 create() 팩토리 메서드로만 생성하게 되어 있다:
public static OrderItem create(Order order, UUID productId, Integer quantity) { ... }
이 방식은 "OrderItem은 반드시 order, productId, quantity가 있어야 한다"는 도메인 규칙을 코드로 보장한다.
만약 기본 생성자가 public이면 규칙을 무시하고 잘못된 생성이 가능해진다. 이미 문제가 있는 엔티티가 시스템 안으로 들어온 상태가 되는 거다. protected로 막아두면 이 규칙이 자연스럽게 유지된다.
도메인 주도 설계(DDD)에서는 "엔티티는 항상 유효한 상태로 존재해야 한다"는 원칙이 있다. protected 기본 생성자 + 팩토리 메서드 조합은 엔티티의 생성 방식을 통제하는 설계다. 그래서 실무에서 JPA + DDD 스타일 쓰는 팀들은 거의 이 패턴을 따른다고 한다.
protected 기본 생성자는 단순한 추가 옵션이 아니라, JPA의 내부 동작과 엔티티의 안전한 생성을 동시에 만족시키는 핵심 설계다.