[Spring] JPA 엔티티에 왜 기본 생성자가 필수일까?
안녕하세요. 글 쓰는 개발자 stark입니다!
오늘은 JPA를 사용하면서 "왜 엔티티에 기본 생성자가 필수적일까? 하는 궁금증을 해결하기 위해 공부한 내용을 정리해보려고 합니다.
어느 날, 제가 작성한 코드를 리뷰하며 피드백하던 중 엔티티 클래스가 너무 지저분하게 작성된 것이 아닌지 생각해 보게 되었습니다. 그래서 어떻게 해야 코드가 깔끔해질지 고민하며 매개변수가 없는 기본 생성자를 제거해 보았습니다.
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor // 이 코드를 제거했습니다.
@Getter
@Entity
@Table(name = "member")
public class MemberEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // PK
@Column(unique = true, nullable = false)
private String email; // 이메일
// 추가 필드
}
그런데 위의 코드에 있는 기본 생성자(@NoArgsConstructor)를 없앴더니 IntelliJ에서 컴파일 오류 메시지가 발생했습니다.
'Class 'MemberEntity' should have [public, protected] no-arg constructor'
이게 무슨 문제일까?
무슨 문제일지 조금 생각을 해봤습니다. 기본 생성자는 매개변수가 없는 생성자로, 클래스에 생성자를 명시적으로 정의하지 않을 때 자바 컴파일러가 자동으로 추가해 줍니다. 그런데 만약 클래스에 커스텀 생성자를 하나라도 작성하게 되면, 자바 컴파일러는 더 이상 기본 생성자를 자동으로 추가하지 않습니다. 이 경우 기본 생성자가 필요하다면, 명시적으로 작성해야 합니다. 이런 자바의 기본 동작원리에 따르면 개발자가 어떤 생성자를 작성하지 않아도 클래스에서는 컴파일 오류가 발생하지 않습니다.
저는 엔티티 클래스에서 Lombok을 사용하여 @AllArgsConstructor와 @NoArgsConstructor를 같이 사용 중이었습니다. 잘 생각해 보면 이 경우 @NoArgsConstructor(기본 생성자)를 지워도 클래스에서는 컴파일 문제가 발생하지 않았어야 합니다.
왜냐하면 커스텀 생성자를 하나(@AllArgsConstructor) 작성했기 때문에 '기본 생성자'가 없다고 해도 문제가 없어야 하기 때문입니다.
근데 엔티티 클래스에서는 'should have [public, protected] no-arg constructor' 이렇게 기본 생성자가 필요하다는 컴파일 에러 메시지가 나왔다는 것은 분명히 JPA와 엔티티의 동작 방식에 무언가 제약이 있는 것이 아닐지 생각해 보게 되었습니다. 그래서 저는 왜 엔티티에서 기본 생성자가 없으면 이런 컴파일 오류가 발생하는 건지 알아보기 시작했고 그 이유를 알아냈습니다. 지금부터 JPA 엔티티에서 왜 기본 생성자가 필요한지 알아봅시다.
JPA와 엔티티 클래스의 역할
JPA(Java Persistence API)는 자바 애플리케이션에서 객체와 관계형 데이터베이스 사이의 데이터를 매핑해 주는 ORM(Object-Relational Mapping) 기술입니다. 너무 감사한 분이 있어서 잠깐 샤라웃 하고 넘어가겠습니다. 바로 제가 개발자로서의 성장을 할 수 있도록 큰 도움을 주신 영한쌤인데요 JPA를 한국에 보급하는데 정말 큰 영향력을 행사해 주셨습니다.
다시 본론으로 돌아와서 JPA의 가장 중요한 개념 중 하나가 엔티티(Entity)입니다. 엔티티는 데이터베이스의 테이블과 매핑되는 자바 클래스입니다. 예를 들어, User라는 테이블에 해당하는 엔티티는 자바에서 다음과 같은 형태로 정의될 수 있습니다.
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String email;
// 기본 생성자와 커스텀 생성자
protected User() {}
public User(String username, String email) {
this.username = username;
this.email = email;
}
}
위 코드에서 @Entity는 해당 클래스가 엔티티임을 JPA에 알려주고, @Id는 id 필드가 기본 키(pk)라는 것을 나타냅니다. JPA는 이렇게 정의된 엔티티 클래스를 사용하여 데이터베이스 테이블과 상호작용합니다. 애플리케이션이 실행되는 동안 JPA는 SQL 쿼리를 자동으로 생성하여 데이터베이스에 접근하고, 결과를 엔티티 객체로 변환합니다. 각 필드는 데이터베이스의 컬럼과 매핑되며, 이 모든 과정이 ORM의 장점을 극대화하는 것입니다.
[Java 객체] <---> [JPA] <---> [데이터베이스 테이블]
왜 JPA에서 기본 생성자가 필수일까?
JPA를 사용할 때 엔티티 클래스에는 기본 생성자(no-args constructor)가 필수입니다. 이는 JPA가 데이터베이스에서 데이터를 조회하여 엔티티 객체를 생성할 때 리플렉션(Reflection)이라는 기술을 사용하기 때문입니다.
리플렉션은 자바 런타임에서 클래스의 구조를 검사하고 동적으로 객체를 생성할 수 있게 해 줍니다. 이 과정에서 JPA는 엔티티의 기본 생성자를 호출하여 객체를 인스턴스화하고, 이후 데이터베이스에서 조회한 값을 엔티티 필드에 매핑합니다. 기본 생성자가 없으면 JPA는 객체를 생성할 수 없어 오류가 발생합니다.
예를 들어, 기본 생성자가 없는 엔티티를 정의하면 다음과 같은 문제가 발생할 수 있습니다.
@Entity
public class User {
private Long id;
private String username;
private String email;
// 기본 생성자가 없음
public User(String username, String email) {
this.username = username;
this.email = email;
}
}
위의 User 엔티티에는 기본 생성자가 없기 때문에 JPA는 getDeclaredConstructor().newInstance() 메서드를 통해 객체를 생성할 수 없습니다. 결과적으로 애플리케이션이 실행되는 즉시 오류가 발생하게 됩니다. 따라서 기본 생성자는 반드시 필요하며, 일반적으로 protected로 선언하여 외부에서 불필요한 객체 생성을 방지하면서도 JPA가 리플렉션을 통해 접근할 수 있도록 설정하는 것이 좋습니다.
또한, JPA는 지연 로딩(Lazy Loading)이라는 기능을 지원합니다. 지연 로딩은 데이터베이스에서 실제 데이터를 가져오기 전에 프록시 객체를 먼저 반환하여 성능을 최적화하는 방식입니다. 이때 프록시 객체를 생성할 때도 기본 생성자가 필수적으로 필요합니다. 프록시 객체는 나중에 실제 데이터를 필요로 할 때 비로소 실제 엔티티 객체로 초기화됩니다.
결론적으로, 기본 생성자는 JPA의 객체 인스턴스화 및 지연 로딩 메커니즘에서 중요한 역할을 합니다.
이제부터는 프록시 객체와 기본 생성자의 관계성을 자세히 알아봅시다.
프록시 객체와 기본 생성자
JPA에서 기본 생성자가 중요한 또 다른 이유는 바로 프록시 객체와 관련이 있습니다. JPA는 성능 최적화를 위해 지연 로딩(Lazy Loading)이라는 기술을 지원하는데, 이는 데이터가 실제로 필요할 때만 데이터베이스에서 해당 데이터를 로드하는 방식입니다. 예를 들어, 데이터베이스에서 엔티티를 조회할 때 모든 연관된 엔티티 데이터를 즉시 가져오는 대신, 필요한 시점에 데이터를 가져오도록 설계할 수 있습니다.
이때, JPA는 실제 엔티티 객체 대신에 프록시 객체를 먼저 반환합니다. 프록시 객체는 엔티티를 상속받아 생성된 '가짜 객체'로, 실제 데이터베이스 호출을 지연시킬 수 있습니다. 프록시 객체는 기본적으로 비어 있는 상태로 생성되며, 데이터 접근이 이루어질 때 비로소 데이터베이스에서 필요한 데이터를 가져와 초기화됩니다.
이 과정에서 기본 생성자가 필수적입니다. 프록시 객체는 기본 생성자를 통해 먼저 빈 객체를 생성한 후, 실제로 데이터가 필요할 때 해당 필드를 채워 넣기 때문입니다. 기본 생성자가 없으면 프록시 객체를 만들 수 없기 때문에, JPA는 엔티티 클래스에 반드시 기본 생성자가 있어야 한다는 규칙을 가지고 있습니다.
[엔티티 요청] --> [프록시 객체 생성] --> [실제 데이터 접근?]
|
+--> [Yes] --> [데이터베이스 조회 및 초기화]
|
+--> [No] --> [프록시 객체 반환]
예를 들어, 지연 로딩을 설정한 연관 관계에서 다음과 같은 엔티티 설계를 생각해 볼 수 있습니다.
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
// 기본 생성자
protected Order() {}
}
위 Order 엔티티에서 customer 필드는 지연 로딩(Lazy)이 설정되어 있습니다. 이렇게 설정하면 JPA는 Order 객체를 조회할 때 Customer 객체를 바로 로딩하지 않고, 대신 프록시 객체를 생성하여 나중에 필요할 때 Customer 데이터를 가져옵니다. 이 과정에서 기본 생성자가 없으면 Customer에 대한 프록시 객체를 생성할 수 없기 때문에 엔티티 클래스에는 반드시 기본 생성자가 필요합니다.
이렇게 엔티티에 선언된 기본 생성자는 프록시 객체를 생성하는데도 사용된다는 것을 알게 되었습니다. 근데 생성자가 public으로 오픈되어 있다면 어디서든 이 엔티티를 생성할 수 있다는 위험이 존재합니다. 우리는 이것을 어떻게 해결해 나가야 할까요? 지금부터는 기본 생성자의 설계와 접근 제어를 알아봅시다.
기본 생성자의 설계와 접근 제어
기본 생성자는 JPA에서 필수적으로 요구되지만, 이 기본 생성자를 어떻게 설계할지는 개발자가 결정할 수 있습니다. 가장 많이 사용되는 방식은 protected 접근 제어자로 기본 생성자를 정의하는 것입니다. 이는 외부에서 불필요하게 객체가 생성되는 것을 막으면서도 JPA가 리플렉션을 통해 객체를 인스턴스화할 수 있도록 하기 위함입니다.
기본 생성자를 protected로 선언하는 이유는 다음과 같습니다.
- 외부에서 객체를 생성하는 것을 방지
- 엔티티 객체는 주로 JPA를 통해 관리되며, 외부에서 직접적으로 인스턴스화(new로 생성)할 필요가 없는 경우가 많습니다. 기본 생성자를 protected로 선언하면 외부 코드에서 직접 객체를 생성하지 못하게 하여, 객체의 불필요한 생성을 방지할 수 있습니다.
- 엔티티 객체는 주로 JPA를 통해 관리되며, 외부에서 직접적으로 인스턴스화(new로 생성)할 필요가 없는 경우가 많습니다. 기본 생성자를 protected로 선언하면 외부 코드에서 직접 객체를 생성하지 못하게 하여, 객체의 불필요한 생성을 방지할 수 있습니다.
- JPA 리플렉션 접근을 허용
- JPA는 리플렉션을 사용하여 엔티티의 기본 생성자를 호출합니다. 리플렉션은 protected로 선언된 생성자에도 접근할 수 있으므로, 기본 생성자를 protected로 선언해도 JPA가 이를 호출하는 데에는 전혀 문제가 없습니다.
- JPA는 리플렉션을 사용하여 엔티티의 기본 생성자를 호출합니다. 리플렉션은 protected로 선언된 생성자에도 접근할 수 있으므로, 기본 생성자를 protected로 선언해도 JPA가 이를 호출하는 데에는 전혀 문제가 없습니다.
예를 들어, 다음과 같은 엔티티 설계를 볼 수 있습니다.
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
// JPA에서 리플렉션을 통해 객체 생성에 사용되는 기본 생성자
protected Product() {}
public Product(String name, double price) {
this.name = name;
this.price = price;
}
}
위 코드에서 protected로 선언된 기본 생성자는 외부에서 객체를 생성하는 것을 방지하면서도, JPA가 리플렉션을 통해 이를 사용할 수 있게 해 줍니다. 이를 통해 엔티티의 무결성을 유지하면서도 JPA와의 호환성을 보장할 수 있습니다.
이것들을 공부하고 나니 JPA는 내부적으로 어떻게 기본 생성자를 생성하고 리플랙션을 사용해서 엔티티 객체를 생성하는지 이런 것들이 너무 궁금했습니다. 그래서 Hibernate의 내부 동작 코드를 조금 살펴보았습니다. 근데 Hibernate의 경우 6 버전이 되면서 상당히 많은 변화점이 있었기에 지금부터 설명하는 내용은 구버전 Hibernate의 구현방식과는 조금 다르다는 것을 주의해 주세요.
Hibernate6에서 객체 인스턴스화 방식의 변화
Hibernate 6는 이전 버전과 비교했을 때 객체 인스턴스화 및 속성 접근 방식에 큰 변화가 있었습니다. 이전에는 리플렉션 기반으로 엔티티를 생성하고 속성에 접근했기 때문에, 대규모 애플리케이션에서는 성능 문제가 발생할 가능성이 컸습니다. 이러한 문제를 해결하기 위해 Hibernate 6는 객체 인스턴스화 및 속성 접근에 있어 성능을 크게 개선한 다양한 전략을 도입했습니다.
지금부터 이 변화의 핵심 요소인 EntityRepresentationStrategy, EntityInstantiator, ProxyFactory 등과 같은 주요 인터페이스들이 어떻게 작동하는지 살펴보고, 이들이 어떤 방식으로 성능을 최적화하는지에 대해 알아봅시다.
먼저 지금부터 확인할 EntityRepresentationStrategy 인터페이스의 상속 구조를 확인해 봅시다.
// 최상위 인터페이스: 기본적인 표현 전략 정의
[ManagedTypeRepresentationStrategy]
|
+----> RepresentationMode getMode()
| // 현재 표현 모드를 반환 (예: POJO, 동적 맵 등)
|
+----> ReflectionOptimizer getReflectionOptimizer()
| // 리플렉션 최적화를 위한 객체 반환. 객체 생성 및 속성 접근 최적화에 사용
|
+----> JavaType<?> getMappedJavaType()
| // 매핑된 Java 타입 반환. 동적 맵 모델의 경우 java.util.Map의 JavaType 반환
|
+----> PropertyAccess resolvePropertyAccess(Property bootAttributeDescriptor)
| // 지정된 속성에 대한 접근자(accessor) 객체 생성. 속성 값 읽기/쓰기에 사용
|
| // EntityRepresentationStrategy는 ManagedTypeRepresentationStrategy를 상속
v
[EntityRepresentationStrategy extends ManagedTypeRepresentationStrategy]
|
+----> EntityInstantiator getInstantiator() // 엔티티 인스턴스 생성 담당
+----> ProxyFactory getProxyFactory() // 프록시 객체 생성 담당
+----> JavaType<?> getProxyJavaType() // 프록시의 Java 타입 반환
+----> JavaType<?> getLoadJavaType() // 로드 시 사용될 Java 타입 반환
+----> visitEntityNameResolvers() // 엔티티 이름 해석기 방문
|
| // ReflectionOptimizer는 ManagedTypeRepresentationStrategy에서 반환됨
| // 별도의 상속 관계는 아니지만, 밀접한 관련이 있음
v
[ReflectionOptimizer] // 리플렉션 최적화 담당
|
+----> InstantiationOptimizer // 객체 생성 최적화
|
+----> AccessOptimizer // 속성 접근 최적화
1. EntityRepresentationStrategy 인터페이스
먼저, EntityRepresentationStrategy 인터페이스를 살펴봅시다. 이 인터페이스는 엔티티의 표현 방식과 인스턴스화를 처리하는 전략을 정의하는 인터페이스입니다. 이 인터페이스는 엔티티 객체를 생성하는 EntityInstantiator와 지연 로딩을 위한 프록시 객체를 생성하는 ProxyFactory를 제공합니다.
이 인터페이스는 아래의 두 가지 주요 메서드를 가지고 있습니다.
- getInstantiator(): 엔티티 인스턴스를 생성하는 EntityInstantiator 객체를 반환합니다.
- getProxyFactory(): 지연 로딩을 위한 프록시 객체를 생성하는 ProxyFactory를 반환합니다.
package org.hibernate.metamodel.spi;
import java.util.function.Consumer;
import org.hibernate.EntityNameResolver;
import org.hibernate.proxy.ProxyFactory;
import org.hibernate.type.descriptor.java.JavaType;
public interface EntityRepresentationStrategy extends ManagedTypeRepresentationStrategy {
// 엔티티 인스턴스를 생성하는 메서드
EntityInstantiator getInstantiator();
// 프록시 객체를 생성하는 메서드
ProxyFactory getProxyFactory();
default boolean isLifecycleImplementor() {
return false;
}
default boolean isBytecodeEnhanced() {
return false;
}
JavaType<?> getProxyJavaType();
default JavaType<?> getLoadJavaType() {
return getMappedJavaType();
}
default void visitEntityNameResolvers(Consumer<EntityNameResolver> consumer) {
// 기본적으로 아무것도 하지 않음
}
}
이제 EntityRepresentationStrategy 내부에 선언된 인터페이스들을 살펴봅시다.
2. EntityInstantiator 인터페이스 : 최적화된 엔티티 생성
EntityInstantiator 인터페이스는 Hibernate 6에서 객체 생성 방식을 최적화하기 위해 도입되었습니다. 기존의 리플렉션을 사용하는 객체 생성 방식은 성능상의 문제를 야기할 수 있습니다. EntityInstantiator는 이를 대체하여 더 빠르고 효율적인 객체 생성을 지원합니다.
다음은 EntityInstantiator의 주요 메서드입니다.
- instantiate(): SessionFactoryImplementor를 이용하여 엔티티 인스턴스를 생성합니다.
- canBeInstantiated(): 인스턴스화가 가능한지 여부를 반환합니다.
package org.hibernate.metamodel.spi;
import org.hibernate.engine.spi.SessionFactoryImplementor;
public interface EntityInstantiator extends Instantiator {
// 엔티티 인스턴스 생성
Object instantiate(SessionFactoryImplementor sessionFactory);
// 인스턴스화 가능한지 여부를 반환
default boolean canBeInstantiated() {
return true;
}
}
3. ProxyFactory 인터페이스 : 지연 로딩 최적화
ProxyFactory 인터페이스는 엔티티의 지연 로딩을 처리하기 위해 사용됩니다. 이 인터페이스는 Hibernate에서 엔티티를 프록시로 생성하여 실제 데이터가 필요할 때만 데이터베이스에 접근하게 합니다. 이를 통해 애플리케이션의 성능을 크게 향상시킬 수 있습니다.
ProxyFactory의 주요 메서드들은 다음과 같습니다.
- postInstantiate(): 프록시 팩토리를 초기화합니다.
- getProxy(): 프록시 객체를 생성하여 지연 로딩을 처리합니다.
package org.hibernate.proxy;
import java.lang.reflect.Method;
import java.util.Set;
import org.hibernate.HibernateException;
import org.hibernate.engine.spi.SharedSessionContractImplementor;
import org.hibernate.type.CompositeType;
/**
* Contract for runtime, proxy-based lazy initialization proxies.
*/
public interface ProxyFactory {
// 프록시 팩토리 초기화
void postInstantiate(String entityName,
Class<?> persistentClass,
Set<Class<?>> interfaces,
Method getIdentifierMethod,
Method setIdentifierMethod,
CompositeType componentIdType) throws HibernateException;
// 프록시 객체 생성
HibernateProxy getProxy(Object id, SharedSessionContractImplementor session) throws HibernateException;
}
이것들을 공부하고 나니 EntityRepresentationStrategy의 부모 객체인 ManagedTypeRepresentationStrategy가 눈에 띄었습니다. 이 인터페이스는 대체 무슨 역할을 하는지 알아봅시다.
ManagedTypeRepresentationStrategy 인터페이스 : 엔티티와 임베디드 객체의 표현
부모 인터페이스인 ManagedTypeRepresentationStrategy는 기본적으로 엔티티나 임베디드 객체가 어떻게 표현되고 처리될지를 정의합니다. 이 전략은 특히 속성에 접근하는 방식을 결정하며, 리플렉션을 통한 성능 저하를 최적화하기 위한 다양한 메커니즘을 제공합니다.
이러한 역할이 중요한 이유는 JPA나 Hibernate에서 다루는 엔티티들은 데이터베이스와의 상호작용에서 많은 필드나 속성들이 연관되어 있어, 성능을 효율적으로 관리해야 하기 때문입니다.
ManagedTypeRepresentationStrategy 코드의 주요 메서드는 다음과 같습니다.
package org.hibernate.metamodel.spi;
import org.hibernate.bytecode.spi.ReflectionOptimizer;
import org.hibernate.mapping.Property;
import org.hibernate.metamodel.RepresentationMode;
import org.hibernate.property.access.spi.PropertyAccess;
import org.hibernate.type.descriptor.java.JavaType;
public interface ManagedTypeRepresentationStrategy {
// 엔티티의 표현 모드 반환
RepresentationMode getMode();
// 리플렉션 최적화를 위한 옵티마이저 반환
ReflectionOptimizer getReflectionOptimizer();
// 엔티티의 Java 타입 반환
JavaType<?> getMappedJavaType();
// 속성 접근자 생성
PropertyAccess resolvePropertyAccess(Property bootAttributeDescriptor);
}
- getMode(): 엔티티의 표현 모드를 반환합니다. 이 모드는 엔티티나 임베디드 객체가 어떻게 사용될지를 결정합니다.
- getReflectionOptimizer(): 리플렉션을 최적화하는 역할을 합니다. 엔티티를 리플렉션으로 생성하고 접근할 때 발생하는 성능 문제를 줄이기 위해 ReflectionOptimizer를 반환합니다.
- resolvePropertyAccess(): 속성에 접근할 수 있는 적절한 접근 방식을 결정하고, 이에 맞는 PropertyAccess 객체를 반환합니다. 이 메서드는 속성에 어떻게 접근하고, 값을 가져오고 설정할지를 결정하는 데 중요한 역할을 합니다.
Hibernate에서 엔티티의 속성 접근과 관련된 복잡한 연산들은 최적화가 필수적입니다. 왜냐하면 속성 접근 최적화는 객체 생성만큼이나 성능에 중요한 영향을 미치기 때문입니다. 만약 ReflectionOptimizer와 같은 구성 요소가 없으면, 대량의 엔티티를 다룰 때 성능 문제가 발생할 수 있습니다. Hibernate의 최적화를 조금이라도 더 이해해 보기 위해 이 부모 인터페이스 내부에서 사용되는 ReflectionOptimizer를 조금 더 알아봅시다.
Hibernate6의 리플렉션 최적화와 속성 접근 최적화
Hibernate는 객체 생성과 속성 접근의 성능을 최적화하기 위해 ReflectionOptimizer와 같은 인터페이스를 제공하여 리플렉션을 통한 성능 저하를 방지합니다. 이 기능은 대량의 엔티티 인스턴스가 생성될 때 리플렉션을 통해 발생할 수 있는 비용을 줄이기 위한 중요한 최적화 전략입니다.
여기서 중요한 점이 있습니다. Hibernate는 바이트코드 향상을 사용하여 성능 최적화를 더욱 극대화합니다. 이는 런타임에 리플렉션 대신 바이트코드를 수정하여 객체 생성과 속성 접근을 빠르게 처리할 수 있도록 하는 기술입니다.
Hibernate의 리플렉션 최적화 : ReflectionOptimizer
Hibernate에서는 리플렉션을 최적화하기 위해 ReflectionOptimizer라는 인터페이스를 제공합니다. 이 인터페이스는 엔티티 객체 생성과 속성 접근을 최적화하여 리플렉션에 의해 발생할 수 있는 성능 문제를 줄이고, 더 빠르게 엔티티를 생성하고 속성에 접근할 수 있게 해 줍니다.
package org.hibernate.bytecode.spi;
public interface ReflectionOptimizer {
// 엔티티 생성 최적화
InstantiationOptimizer getInstantiationOptimizer();
// 속성 접근 최적화
AccessOptimizer getAccessOptimizer();
interface InstantiationOptimizer {
Object newInstance();
}
interface AccessOptimizer {
String[] getPropertyNames();
Object[] getPropertyValues(Object object);
void setPropertyValues(Object object, Object[] values);
}
}
이 인터페이스를 살펴보면 엔티티 속성의 이름을 가져오고, 속성 값을 더 빠르게 읽고 쓰는 방법을 제공합니다. 내부에 선언된 InstantiationOptimizer 인터페이스는 newInstance() 메서드를 통해 엔티티 생성에 사용되며, AccessOptimizer는 속성에 접근하는 방법을 최적화하여 성능을 향상시킵니다.
핵심인 InstantiationOptimizer 인터페이스의 역할을 조금 더 알아봅시다.
InstantiationOptimizer는 Hibernate에서 엔티티 객체를 빠르게 생성할 수 있도록 돕는 최적화된 메커니즘입니다. 이 인터페이스는 객체를 리플렉션을 사용하지 않고 생성하는 역할을 하며, 성능 개선을 목표로 하고 있습니다.
InstantiationOptimizer 내부에 선언된 newInstance() 메서드는 객체 인스턴스화의 기능을 수행합니다. 중요한 점은, 기존의 리플렉션 방식처럼 Class.newInstance() 메서드를 호출하는 것이 아니라, 바이트코드 향상이나 다른 최적화된 방법을 사용해 객체를 생성하는 방식으로 더 빠르게 작동한다는 점입니다.
interface InstantiationOptimizer {
Object newInstance();
}
위의 newInstance() 메서드는 호출 시 엔티티 클래스의 새로운 인스턴스를 반환합니다. Hibernate가 이 메서드를 통해 객체를 생성할 때, 직접적인 생성자 호출 또는 바이트코드를 사용하여 리플렉션 오버헤드를 제거하게 됩니다.
근데 리플렉션과 바이트코드 향상 방식의 차이점이 뭘까요?
리플렉션을 사용하는 기존 방식에서는 Constructor.newInstance()와 같은 메서드를 통해 객체가 생성됩니다. 이 메서드는 런타임에 클래스의 메타데이터를 검색하여 동적으로 생성자에 접근하는 방식입니다. 이 과정은 상대적으로 무겁고, 성능에 영향을 미칠 수 있습니다. 특히, 애플리케이션이 커지고, 객체 생성이 빈번하게 발생할수록 성능 저하가 두드러질 수 있습니다.
반면에 InstantiationOptimizer.newInstance()는 이러한 리플렉션을 우회하여 바이트코드 수준에서 생성자 호출을 최적화함으로써 객체 생성 시간을 단축시킵니다. 이 방식은 클래스의 생성자를 미리 알고 있거나, 바이트코드 향상을 통해 미리 최적화된 코드를 사용하여 직접적으로 객체를 생성하는 방법입니다. 리플렉션의 런타임 메타데이터 탐색 비용이 제거되므로 성능이 훨씬 더 빠릅니다.
실제로는 어떻게 사용하고 있을까요?
이 내용은 EntityRepresentationStrategy 인터페이스를 구현하는 클래스에서 확인할 수 있습니다. 예를 들어, EntityRepresentationStrategyPojoStandard 클래스가 이를 구현합니다.
package org.hibernate.metamodel.internal;
public class EntityRepresentationStrategyPojoStandard implements EntityRepresentationStrategy {
private static final CoreMessageLogger LOG = CoreLogging.messageLogger( EntityRepresentationStrategyPojoStandard.class );
private final JavaType<?> mappedJtd;
private final JavaType<?> proxyJtd;
private final boolean isBytecodeEnhanced;
private final boolean lifecycleImplementor;
private final ReflectionOptimizer reflectionOptimizer;
private final ProxyFactory proxyFactory;
private final EntityInstantiator instantiator;
private final StrategySelector strategySelector;
private final String identifierPropertyName;
private final PropertyAccess identifierPropertyAccess;
private final Map<String, PropertyAccess> propertyAccessMap;
private final EmbeddableRepresentationStrategyPojo mapsIdRepresentationStrategy;
// 코드 존재
}
바이트코드 향상(Bytecode Enhancement)은 이렇게 사용됩니다.
이 클래스에 선언된 isBytecodeEnhanced 플래그가 true로 설정되면, Hibernate는 바이트코드를 직접 수정하여 객체 생성을 최적화합니다. 이 경우, ByteBuddy와 같은 바이트코드 조작 라이브러리를 사용해 런타임에 바이트코드를 조작하여, 리플렉션을 거치지 않고 생성자를 직접 호출합니다. 이를 통해 객체를 생성하는 성능이 크게 향상됩니다.
메서드 예시
EntityRepresentationStrategyPojoStandard 클래스 내부에는 다음과 같은 determineInstantiator() 메서드가 존재합니다. 이 메서드는 ReflectionOptimizer를 사용하여 객체를 최적화된 방식으로 생성합니다.
private EntityInstantiator determineInstantiator(PersistentClass bootDescriptor, EntityMetamodel entityMetamodel) {
if ( reflectionOptimizer != null && reflectionOptimizer.getInstantiationOptimizer() != null ) {
final InstantiationOptimizer instantiationOptimizer = reflectionOptimizer.getInstantiationOptimizer();
return new EntityInstantiatorPojoOptimized(
entityMetamodel,
bootDescriptor,
mappedJtd,
instantiationOptimizer
);
}
return new EntityInstantiatorPojoStandard( entityMetamodel, bootDescriptor, mappedJtd );
}
위 코드는 reflectionOptimizer가 존재하고, InstantiationOptimizer가 있을 경우 리플렉션 없이 객체를 생성하는 최적화된 경로를 선택합니다. 만약 해당 최적화 도구가 없을 경우, 기본 방식(리플렉션을 최적화가 적용되지 않은 방식)인 EntityInstantiatorPojoStandard를 사용하여 객체를 생성합니다.
마지막으로 속성 접근 최적화를 위한 PropertyAccess를 알아봅시다.
Hibernate는 엔티티 속성 접근의 성능을 최적화하기 위해 PropertyAccess 인터페이스를 제공합니다. 이 인터페이스는 엔티티의 속성 값을 빠르게 읽고 쓰는 데 필요한 Getter와 Setter를 정의하여 속성 접근을 더욱 효율적으로 만듭니다.
package org.hibernate.property.access.spi;
import org.hibernate.metamodel.spi.ManagedTypeRepresentationStrategy;
import org.checkerframework.checker.nullness.qual.Nullable;
public interface PropertyAccess {
PropertyAccessStrategy getPropertyAccessStrategy();
// 속성 값을 가져오는 Getter 메서드
Getter getGetter();
// 속성 값을 설정하는 Setter 메서드
@Nullable Setter getSetter();
}
마무리하며
저는 어리석게도 이전 버전의 hibernate내용들을 살피느라 이 글의 원고 내용을 3번은 갈아엎은 것 같습니다. 또한 내용이 워낙 복잡하다 보니 어떻게 해야 읽으면서 이해가 될지 굉장히 많은 고민을 하고 수정을 하게 되었습니다.
그래도 저는 이렇게 글을 적으며 배워가는 것에서 즐거움을 느낍니다. 요즘은 개발을 하면서 피곤함이 쌓여 지친다는 생각이 들 때도 있지만 이렇게 멋진 선배님들이 구현한 코드를 보다 보면 나도 언젠간 이렇게 후배가 볼 수 있는 코드를 만들고 싶다는 생각이 들면서 힘이 납니다. 언젠가 제게도 그런 날이 오길 바라며 오늘 글의 작성을 마치도록 하겠습니다.
긴 글 읽어주셔서 감사합니다. 추가적인 궁금증이나 소통이 필요하시다면 '링크드인'을 통해 함께 소통하며 얘기했으면 합니다.
참고자료