들어가며
JPA 엔티티 클래스를 작성하다 보면 다음과 같은 코드를 자주 접하게 됩니다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
// 필드들...
}
왜 AccessLevel.PROTECTED를 사용하는 걸까요? PUBLIC이나 PRIVATE는 안 될까요? 이번 글에서는 JPA의 프록시 메커니즘과 객체지향 설계 원칙을 통해 그 이유를 알아보겠습니다.
JPA가 기본 생성자를 요구하는 이유
1. 프록시 객체 생성
JPA는 지연 로딩(Lazy Loading)을 위해 프록시 객체를 생성합니다. 프록시는 실제 엔티티를 상속받은 가짜 객체로, 필요할 때만 실제 데이터를 로딩합니다.
// 실제 엔티티
@Entity
public class User {
@Id
private Long id;
private String name;
// JPA가 프록시를 만들 때 필요
protected User() {}
public User(String name) {
this.name = name;
}
}
// JPA가 런타임에 생성하는 프록시 (개념적 표현)
public class User$HibernateProxy extends User {
// 부모의 기본 생성자를 호출해야 함
public User$HibernateProxy() {
super(); // User의 기본 생성자 호출
}
@Override
public String getName() {
// 실제 데이터가 필요할 때 로딩
if (!isLoaded()) {
loadRealEntity();
}
return super.getName();
}
}
2. 리플렉션을 통한 객체 생성
JPA 구현체(Hibernate 등)는 데이터베이스에서 조회한 결과를 객체로 변환할 때 리플렉션을 사용합니다.
// JPA 내부에서 일어나는 일 (개념적 표현)
public class EntityInstantiator {
public <T> T createInstance(Class<T> entityClass) {
try {
// 기본 생성자를 찾아서 객체 생성
Constructor<T> constructor = entityClass.getDeclaredConstructor();
return constructor.newInstance();
} catch (Exception e) {
throw new RuntimeException("기본 생성자가 없습니다!");
}
}
}
접근 레벨별 차이점 분석
1. PUBLIC 접근 레벨의 문제점
@Entity
@NoArgsConstructor // 기본값: PUBLIC
public class User {
private String name;
private String email;
public User(String name, String email) {
this.name = name;
this.email = email;
}
}
// 사용 코드에서
public class UserService {
public void someMethod() {
// 의도하지 않은 객체 생성이 가능
User user = new User(); // 이름과 이메일이 없는 빈 객체
// 이런 객체는 비즈니스 로직에서 문제를 일으킬 수 있음
user.getName().length(); // NullPointerException 발생 가능
}
}
PUBLIC 생성자는 어디서든 호출할 수 있어 불완전한 상태의 객체가 생성될 위험이 있습니다.
2. PRIVATE 접근 레벨의 문제점
@Entity
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class User {
private String name;
// private 생성자
private User() {}
}
// JPA가 프록시를 생성하려고 할 때
public class User$HibernateProxy extends User {
public User$HibernateProxy() {
// 컴파일 에러! private 생성자는 자식 클래스에서 접근 불가
super();
}
}
PRIVATE 생성자는 상속받은 프록시 클래스에서 접근할 수 없어 JPA가 제대로 작동하지 않습니다.
3. PROTECTED 접근 레벨의 장점
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
private String name;
private String email;
// JPA와 자식 클래스에서만 접근 가능
protected User() {}
// 비즈니스 로직에서는 이 생성자를 사용
public User(String name, String email) {
this.name = name;
this.email = email;
}
}
// 같은 패키지나 다른 패키지에서
public class UserService {
public void someMethod() {
// 컴파일 에러! protected 생성자는 접근 불가
User user = new User();
// 올바른 사용법
User user = new User("홍길동", "hong@example.com");
}
}
// JPA 프록시는 정상 작동
public class User$HibernateProxy extends User {
public User$HibernateProxy() {
// 정상 작동! protected 생성자는 자식 클래스에서 접근 가능
super();
}
}
PROTECTED는 JPA의 요구사항을 만족하면서도 외부에서의 무분별한 사용을 방지합니다.
실제 상속 관계에서의 동작
엔티티 상속 구조 예시
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public abstract class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
// JPA와 자식 클래스를 위한 protected 생성자
protected BaseEntity() {}
}
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends BaseEntity {
private String name;
private String email;
// 부모의 protected 생성자를 호출할 수 있음
protected User() {
super();
}
public User(String name, String email) {
super();
this.name = name;
this.email = email;
}
}
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Admin extends User {
private String role;
protected Admin() {
super();
}
public Admin(String name, String email, String role) {
super(name, email);
this.role = role;
}
}
프록시 생성 과정
// Hibernate가 User 프록시를 생성할 때
public class User$HibernateProxy extends User {
private boolean initialized = false;
private User realEntity;
// 부모의 protected 생성자를 정상적으로 호출
public User$HibernateProxy() {
super(); // User의 protected 생성자 호출
}
@Override
public String getName() {
if (!initialized) {
// 실제 엔티티를 로딩
this.realEntity = loadFromDatabase();
this.initialized = true;
}
return realEntity.getName();
}
}
객체지향 설계 관점에서의 이점
1. 캡슐화 원칙 준수
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
private String orderNumber;
private BigDecimal amount;
private OrderStatus status;
protected Order() {}
// 정적 팩토리 메서드로 객체 생성 제어
public static Order create(String orderNumber, BigDecimal amount) {
Order order = new Order();
order.orderNumber = orderNumber;
order.amount = amount;
order.status = OrderStatus.PENDING;
order.validate(); // 생성 시 검증 로직 실행
return order;
}
private void validate() {
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("주문 금액은 0보다 커야 합니다");
}
}
}
// 사용 예시
public class OrderService {
public Order createOrder(String orderNumber, BigDecimal amount) {
// 컴파일 에러! protected 생성자는 직접 사용 불가
// Order order = new Order();
// 정적 팩토리 메서드를 통해서만 생성 가능
return Order.create(orderNumber, amount);
}
}
2. 불변 객체 설계 지원
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product {
private final String name;
private final BigDecimal price;
private final Category category;
protected Product() {
// JPA를 위한 기본 생성자
// final 필드는 기본값으로 초기화
this.name = null;
this.price = null;
this.category = null;
}
public Product(String name, BigDecimal price, Category category) {
this.name = Objects.requireNonNull(name);
this.price = Objects.requireNonNull(price);
this.category = Objects.requireNonNull(category);
}
// getter 메서드들...
// setter 메서드는 제공하지 않음 (불변 객체)
}
정리
@NoArgsConstructor(access = AccessLevel.PROTECTED)를 사용하는 이유는 다음과 같습니다:
기술적 이유
- JPA 프록시 생성: 상속받은 프록시 클래스가 부모 생성자에 접근할 수 있음
- 리플렉션 지원: JPA가 객체를 생성할 때 필요한 기본 생성자 제공
설계적 이유
- 캡슐화 보장: 외부에서 무분별한 객체 생성 방지
- 의도 명확화: JPA 전용 생성자임을 명시
- 상속 구조 지원: 엔티티 상속 계층에서 올바른 동작 보장
'Spring > 이론' 카테고리의 다른 글
| Spring Boot 검증 처리 아키텍처 전략: 컨트롤러에서 BindingResult를 직접 처리하지 말아야 하는 이유 (0) | 2025.09.16 |
|---|---|
| 데이터베이스 인덱스의 구조와 특징 (0) | 2025.06.18 |
| Static 메서드, 언제 사용하고 언제 피해야 할까? (0) | 2025.06.16 |
| Spring Bean 생명주기 - 실무에서 알아야 할 핵심만 (0) | 2025.06.05 |
| Spring API 예외 처리 시스템 정리 (0) | 2025.06.05 |