본문 바로가기
Spring/이론

JPA 엔티티에서 @NoArgsConstructor(access = AccessLevel.PROTECTED)를 사용하는 이유

by JuNo_12 2025. 6. 16.

들어가며

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)를 사용하는 이유는 다음과 같습니다:

 

기술적 이유

  1. JPA 프록시 생성: 상속받은 프록시 클래스가 부모 생성자에 접근할 수 있음
  2. 리플렉션 지원: JPA가 객체를 생성할 때 필요한 기본 생성자 제공

설계적 이유

  1. 캡슐화 보장: 외부에서 무분별한 객체 생성 방지
  2. 의도 명확화: JPA 전용 생성자임을 명시
  3. 상속 구조 지원: 엔티티 상속 계층에서 올바른 동작 보장