Entity설계 시 멘토님으로부터 “현업에서는 EntitySetterAllArgsConstructor를 사용하지 않고 기본생성자(NoArgsConstructor)만 작성 후 정적 팩토리 메서드를 사용해 Entity객체를 생성한다”라는 피드백을 받았습니다.

그래서 “팩토리 메서드 내에 기본 생성자를 사용하도록 구현할 테니 기본 생성자를 객체 외부에서 사용하지 못하도록 해야겠지?” 라는 생각으로(뇌를 비우고) accessLevel을 Private로 설정했는데 InstantiationException이 발생했습니다.

Reflection API와 JPA의 Entity관리에 대한 이해 부족으로 생긴 문제였고 정확히 이해하기 위해 학습을 진행했습니다.


1. Reflection API

1-1. Reflection API?

JAVA의 Reflection API는 런타임에 클래스의 정보를 탐색하고 필드, 메서드, 생성자 등에 접근할 수 있는 기능을 제공합니다. 컴파일 시점에 클래스의 구체적인 내용을 알지 못해도 런타임 시점에 클래스의 구조를 파악하고 해당 클래스의 객체를 생성하거나 메서드를 호출할 수 있습니다.

⚙️ 이게 어떻게 가능한가?

자바로 작성된 프로그램은 먼저 Java 컴파일러(javac)에 의해 .java 소스 코드가 바이트코드(.class 파일)로 변환됩니다. 프로그램이 실행될 때, JVM은 이 .class 파일을 읽고, 클래스 로더(ClassLoader)를 통해 메모리에 로드합니다. 이때 클래스의 메타데이터(필드, 메서드, 생성자 정보 등)가 JVM의 메소드 영역(Method Area)에 저장됩니다.(Java 8 이전엔 Method 영역이 맞으나 Java 8 부터는 Metaspace에 저장됨. Heap 메모리가 아닌 Native 메모리를 사용함.)

Reflection API는 이 메소드 영역에 저장된 클래스의 메타데이터에 접근하여, 런타임 시점에 클래스의 구조를 탐색하거나 객체를 생성할 수 있습니다.(동적으로 접근/변경이 가능하다는 뜻) 이를 통해 클래스 이름만으로도 필드, 메서드, 생성자와 같은 대부분의 정보를 가져올 수 있게됩니다.

Spring에서는 DI, Proxy등에서 사용됩니다. 위와 같은 아주 강력한 기능들을 제공하며 이러한 장점들로 보통 프레임워크, 라이브러리 개발에서 사용합니다.

1-2. 그럼 AccessLevel.PRIVATE이 문제될게 없지 않나??

❌ 자바의 보안 모델로 인해 접근이 불가능하다.

위에서 서술했듯 Reflection API는 런타임에 클래스의 정보를 탐색하고 접근하는 기능을 제공합니다. 하지만 Java는 기본적으로 캡슐화(encapsulation)를 지원하기 때문에, 클래스의 private 멤버는 Reflection으로도 바로 접근할 수 없도록 제한되어 있습니다.

Class<?> clazz = User.class;

// private 생성자를 가져옴
Constructor<?> constructor = clazz.getDeclaredConstructor();
System.out.println(constructor); // 정상적으로 출력됨

이처럼 클래스의 private 생성자나 필드 정보는 가져올 수 있다.

Constructor<User> constructor = User.class.getDeclaredConstructor();
constructor.newInstance(); // IllegalAccessException

하지만 이를 호출하거나 값을 수정하려고 할 때는 접근제한(Access Control)이 적용됩니다.

1-3. JPA와 Reflection API

Reflection API는 JPA에서 객체 생성, 필드 값 매핑, Lazy Loading, 콜백 메서드 호출 등 다양한 작업에 사용됩니다. 이를 통해 Setter 메서드 없이도 private 필드에 직접 접근하거나, Proxy 객체를 생성할 수 있습니다.

1) 엔티티 클래스 접근

JPA는 엔티티 클래스의 필드, 메서드, 생성자, 어노테이션 정보를 동적으로 탐색하여 엔티티 클래스를 분석한다.

Class<?> clazz = Class.forName("com.example.User");

2) 객체 생성

Reflection ApI기본 생성자를 사용해 엔티티 객체를 생성합니다. JPA도 Reflection API를 활용하여 엔티티 객체를 생성하므로 엔티티 클래스에는 반드시 기본 생성자가 필요합니다.

Constructor<?> constructor = clazz.getDeclaredConstructor();
constructor.setAccessible(true); // private 생성자에도 접근 가능
Object entity = constructor.newInstance();

3) 필드 값 설정

객체 생성 후 DB의 값과 엔티티 객체를 매핑하기 위해 Refection API를 사용합니다.

Field field = clazz.getDeclaredField("name"); //클래스명으로 정보 가져오기
field.setAccessible(true); // private 필드에도 접근 허용
field.set(entity, "bik_kyun");

이 과정에서 Setter 메서드를 호출하지 않고 필드에 직접 값이 설정됩니다.

4) 메서드 호출

JPA는 특정 상황에서 엔티티 클래스의 메서드를 호출하기 위해 Reflection API를 사용합니다. ex) 콜백 메서드(@PrePersist, @PostLoad)

Method prePersistMethod = clazz.getDeclaredMethod("prePersist");
prePersistMethod.setAccessible(true);
prePersistMethod.invoke(entity);

5) Lazy Loading과 Proxy 객체

(Hibernate)Proxy 객체 : 실제 엔티티 클래스를 상속받은 객체.

JPA는 Lazy Loading을 구현하기 위해 Proxy 객체를 동적으로 생성합니다. 이때 Reflection API를 사용하여 Proxy 객체를 생성하며, 즉시 초기화하지 않고 DB에도 접근하지 않습니다. 실제 데이터가 필요한 시점(호출 시점)이 되면 Proxy 객체는 해당 호출을 가로채 DB 쿼리를 실행하며 필요한 데이터를 로드합니다. 로드가 완료되면 Proxy 객체는 실제 엔티티의 데이터로 초기화된 상태가 됩니다.(호출 시점 전까지는 Proxy 객체로 유지된다.)

//Hibernate 내부
User proxyUser = (User) Proxy.newProxyInstance(
    User.class.getClassLoader(),
    new Class[]{User.class},
    (proxy, method, args) -> {
        System.out.println("프록시 메서드 호출: " + method.getName());
        return null; // 실제 데이터는 Lazy 로딩 시 가져옴
    }
);

2. 결론

@NoArgsConstructor(access = AccessLevel.PROTECTED)

  1. JPA는 Reflection API를 이용해 엔티티 객체를 생성하고 데이터베이스 값을 매핑합니다. 이 과정에서 기본 생성자가 필수적입니다.

  2. Lazy Loading시 생성되는 Proxy 객체는 엔티티(부모클래스)의 자식클래스이며, 이를 생성할 때 기본 생성자가 호출됩니다.

  3. 기본 생성자가 private으로 선언되어 있으면 해당 엔티티를 상속한 Proxy 객체를 만들 수 없게됩니다. 또한, 상속받은 클래스에서 부모 객체의 생성자(super())를 호출할 수 없습니다.

    참고 - 부모클래스와 자식클래스의 생성자 호출 부모 클래스로부터 상속받은 메소드 및 필드는 부모 클래스에 정의된 것이고 부모 클래스의 것이다. 따라서 부모 클래스의 생성자가 호출되어야 자식 클래스에서 사용이 가능하다.

    부모 클래스의 생성자(super())는 자식 클래스의 생성자로 인스턴스를 생성할 때 자동으로 호출된다. 순서 : 부모클래스 호출 -> 자식클래스 생성자 호출

  4. 따라서, 기본 생성자는 JPA 엔티티가 동작하는 데 있어 반드시 필요하며, 실제 엔티티(부모클래스) 또한 public 또는 protected인 기본 생성자가 존재해야 Proxy 객체(자식클래스) 도 기본 생성자를 사용할 수 있기 때문에 실제 엔티티에도 기본 생성자가 필요한 것입니다.

  5. public은 객체 외부에서 기본 생성자 접근이 가능하게 되므로 publicprivate의 타협점인 protected를 사용하여 불필요한 객체 생성을 막습니다.

참고 IntelliJ를 사용하면 public이나 protected로 선언된 기본 생성자가 없는 클래스에 Class 'XXX' should have [public, protected] no-args constructor라는 경고를 볼 수 있지만, 기본 생성자의 접근 제어자에 관련된 예외는 런타임 예외이기 때문에 즉시 로딩을 사용하거나 프록시를 사용할 일이 없다면 관련 예외가 발생하지 않는다.