[JPA/스프링] 퍼시스턴스 컨텍스트와 엔티티 매니저

하이버네이트와 JPA

JPA(Java Persistence API)는 객체와 테이블 간 매핑을 수행하고 데이터베이스를 조작하여 데이터를 영속화하는 ORM 라이브러리의 표준(명세)이다. 하이버네이트는 JPA 표준을 구현한 JPA 구현체이다. JPA 표준은 하이버네이트의 영향을 많이 받았다. 하이버네이트 3.2 버전부터는 JPA 구현을 제공하므로 하이버네이트를 퍼시스턴스 공급자로 사용하면 애플리케이션은 하이버네이트 자체 API를 사용하거나 JPA를 사용할 수 있다.

ORM(객체 관계 매핑)을 위해서는 javax.persistence 패키지 아래에 있는 JPA 표준 애너테이션을 사용한다. JPA 표준 애너테이션은 하이버네이트 자체 애너테이션으로 교체 가능하다.


JPA 도메인 개념

JPA에서 객체와 테이블 간 객체 관계 매핑을 수행하고 데이터베이스를 조작하여 데이터를 영속화하는 역할을 수행하는 주요 인터페이스는 엔티티 매니저(EntityManager)이다. 하이버네이트의 경우 세션(Session)이 그 역할을 수행한다. 엔티티 매니저 또는 세션 인터페이스 구현체는 팩토리 클래스를 통해 얻을 수 있다. 엔티티 매니저는 엔티티 매니저 팩토리(EntityManagerFactory, 세션은 세션 팩토리(SessionFactory)가 구현체를 반환한다. 따라서 엔티티 매니저와 퍼시스턴스 컨텍스트를 통해 엔티티 객체를 관리하고 데이터를 매핑하기 위해서는 엔티티 매니저 팩토리 구성이 필수이다.

퍼시스턴스 컨텍스트(persistence context)는 객체 관계 매핑 대상인 엔티티 객체가 저장 및 관리되는 곳을 말한다. 엔티티 매니저를 통해 엔티티를 저장하거나 조회하는 코드를 실행하면 엔티티 매니저는 영속성 컨텍스트에 엔티티를 보관한 후 관리한다. 엔티티 매니저의 주 역할은 퍼시스턴스 컨텍스트를 관리하는 것이다. 엔티티 매니저가 엔티티를 퍼시스턴스 컨텍스트에 저장하면 퍼시스턴스 컨텍스트는 저장된 엔티티를 관리한다. 하이버네이트의 경우 퍼시스턴스 컨텍스트는 세션에 해당되며 세션에 엔티티가 저장된다. 애플리케이션의 데이터 액세스 레이어와 해당 서비스는 엔티티 매니저 또는 세션 인터페이스를 통해 데이터베이스와 통신하여 엔티티 객체를 데이터베이스에 영속화한다.

JPA에서는 퍼시스턴스 컨텍스트에 직접 접근하여 조작할 수 없으며 엔티티 매니저 인터페이스를 통해서만 접근이 가능하다. 반면 하이버네이트에서는 세션 인터페이스를 사용해 직접 세션을 조작할 수 있다.

기본적으로 엔티티 매니저를 생성할 때 하나의 퍼시스턴스 컨텍스트가 생성된다. 따라서 하나의 엔티티 매니저가 하나의 퍼시스턴스 컨텍스트에 접근할 수 있다. 추가적인 구성을 통해 여러 엔티티 매니저가 동일한 퍼시스턴스 컨텍스트에 접근하도록 만들 수 있다.

퍼시스턴스 유닛(persistence unit)이란 말 그대로 데이터 영속화의 단위이다. 객체 관계 매핑을 위해 정의한 엔티티 객체 집합 및 관련 메타데이터들을 논리적으로 그룹화하여 논리적 단위를 구성하고 서로 구별하기 위한 개념이다. JPA를 사용하기 위해서는 이 퍼시스턴스 유닛 구성도 필수적이다. 퍼시스턴스 유닛을 구성한다는 것은 JPA가 관리할 엔티티 객체 등록, 데이터베이스 접속 정보 설정, JPA 프로퍼티(또는 하이버네이트 프로퍼티) 설정, 트랜잭션 관리 및 캐싱 설정, JPA 제공자 등을 설정하는 것을 의미한다. JPA가 ORM을 수행하기 위한 모든 필수적인 정보가 퍼시스턴스 유닛 구성 시 정의되고 JPA는 이를 기반으로 동작한다고 볼 수 있다. 일반적으로 데이터베이스 하나 당 하나의 퍼시스턴스 유닛을 구성한다. 퍼시스턴스 유닛에는 고유한 이름을 부여해야 한다. 단일 데이터 소스 환경에서 퍼시스턴스 유닛은 JPA 동작을 위한 구성 정보를 갖고 있는 역할을 하지만 멀티 데이터 소스 환경에서는 데이터 소스 별(데이터베이스 별)로 엔티티 객체와 JPA 프로퍼티 정보를 구별하여 관리하기 위한 논리적 단위로써 그 역할을 수행한다.

기본적으로 META-INF/persistence.xml 파일을 생성하고 관련 속성을 정의함으로써 퍼시스턴스 유닛을 구성할 수 있다. 퍼시스턴스 유닛을 구성하고 나면 이를 기반으로 엔티티 매니저 팩토리가 생성된다. 엔티티 매니저 팩토리 객체가 생성되면 JPA 동작을 위한 관련 객체들이 생성되며 JPA 구현체에 따라 데이터베이스 커넥션 풀도 생성된다.

엔티티 매니저 팩토리는 엔티티 매니저를 반환한다. 이 엔티티 매니저를 사용해서 엔티티를 데이터베이스에 영속화할 수 있다. 엔티티 매니저는 애플리케이션 내부적으로 생성된 데이터 소스 객체를 통해 데이터베이스 접속 정보 및 커넥션 리소스 정보를 기반으로 데이터베이스와 통신한다.

자바 환경에서는 엔티티 매니저 팩토리에서 엔티티 매니저 객체를 직접 생성해서 사용한다. 즉, 엔티티 매니저는 애플리케이션에 의해 관리되는(application-managed bean) 객체이다. 스프링이나 J2EE 컨테이너를 사용하면 컨테이너가 엔티티 매니저 빈을 관리하고 관련 의존 객체에 의존성을 주입한다. 이 경우 엔티티 매니저는 컨테이너에 의해 관리되는(container-managed) 객체가 되며, 엔티티 매니저 팩토리에서 엔티티 매니저를 직접 생성해서 사용할 필요 없이 컨테이너가 제공하는 엔티티 매니저를 사용하면 된다.

스프링 프레임워크를 사용하지 않고 JPA를 사용하는 경우 다음과 같은 과정으로 관련 설정을 진행한다.

  1. META-INF/persistence.xml에 퍼시스턴스 유닛을 정의한다.
  2. 퍼시스턴스 유닛명을 사용하여 엔티티 매니저 팩토리를 생성한다.
  3. 엔티티 매니저 팩토리로부터 엔티티 매니저를 생성한다.
  4. 엔티티 매니저로부터 엔티티 트랜잭션을 생성한다.
  5. 트랜잭션을 설정하고 엔티티를 영속화한다.


JPA의 동작을 위해 퍼시스턴스 유닛을 기반으로 생성되어야 하는 객체들은 다음과 같다.

  • 데이터 소스 (DataSource)
  • 엔티티 매니저 팩토리 (EntityManagerFactory)
  • 엔티티 매니저 (EntityManager)
  • 트랜잭션 매니저 (TransactionManager)


위 객체들을 사용하여 JPA를 실제 구현하는 코드는 다음과 같다.

EntityManagerFactory emf =
Persistence.createEntityManagerFactory("퍼시스턴스유닛명");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();

try {
  tx.begin();
  비즈니스 로직 실행
  tx.commit();
} catch (Exception e) {
  tx.rollback();
} finally {
  em.close();
  emf.close();
}


퍼시스턴스 유닛을 구성하기 위해 persistence.xml 파일에 설정하는 프로퍼티 정보들은 다음과 같다. 이러한 정보들은 엔티티 매니저 팩토리 객체의 프로퍼티로 설정된다.

  • 퍼시스턴스 유닛명
    • 엔티티 매니저 팩토리 생성 시 퍼시스턴스 유닛명을 사용한다.
    • 애플리케이션이 여러 데이터베이스에 연결된 경우 두 개 이상의 퍼시스턴스 유닛을 구성할 수 있으며 이 경우 이름으로 구별하여 서로 다른 엔티티 매니저 팩토리 객체를 생성한다.
  • 엔티티(도메인) 객체 스캔 대상 패키지
  • JPA 퍼시스턴스 공급자
  • JPA 프로퍼티 정보
  • 데이터 소스(DataSource) 정보
    • JPA 퍼시스턴스 공급자는 데이터 소스를 통해 데이터베이스에 접근한다.


스프링 프레임워크와 JPA

스프링 프레임워크에서 JPA를 사용하는 순서는 다음과 같다.

  1. 엔티티 매니저 팩토리 구성
  2. 데이터 액세스 레이어의 리포지토리 구현체에 엔티티 매니저 의존성 주입
  3. 엔티티 매니저를 통해 데이터 영속화(쿼리 실행을 통한 데이터베이스 조작) 수행


스프링 애플리케이션에서는 다음 세 가지 방법으로 엔티티 매니저 팩토리를 구성할 수 있다.

  1. LocalEntityManagerFactoryBean 사용
  2. LocalContainerEntityManagerFactoryBean 사용


LocalEntityManagerFactoryBean를 사용하는 첫 번째 방법의 경우 퍼시스턴스 유닛 이름만 프로퍼티로 설정하면 된다. 하지만 데이터 소스를 주입할 수 없어 분산 트랜잭션을 사용할 수 없다.

LocalContainerEntityManagerFactoryBean를 사용하는 두 번째 방법의 경우 데이터 소스 주입이 가능하며 로컬 및 분산 트랜잭션 사용이 가능하다.

위 두 방법 대신 JEE 호환 컨테이너가 제공하는 엔티티 매니저를 사용하여 JPA를 사용할 수도 있다. 스프링은 JNDI 룩업으로 엔터티 매니저를 룩업해 사용할 수 있다.

스프링 애플리케이션에 하이버네이트를 적용하기 위해서는 하이버네이트를 표준 JPA의 퍼시스턴스 공급자(persistence provider)로 사용하면 된다.

스프링 프레임워크에서 하이버네이트와 JPA를 사용하기 위해 LocalContainerEntityManagerFactoryBean를 사용한 구성 클래스 코드는 다음과 같다.

@Configuration
@EnableTransactionManagement
@ComponentScan(basePackages = {"컴포넌트스캔대상패키지"})
public class JpaConfig {
  // 데이터 소스
  @Bean
  public DataSource dataSource() {
    ...
  }

  // 트랜잭션 매니저
  @Bean
  public PlatformTransactionManager transactionManager() {
    return new  JpaTransactionManager(entityManagerFactory());
  }

  // JPA 공급자
  @Bean
  public JpaVendorAdapter jpaVendorAdapter() {
    return new HibernateJpaVendorAdapter();
  }

  // JPA 프로퍼티 정보
  @Bean
  public Properties hibernateProperties() {
    Properties hibernateProp = new Properties();
    hibernateProp.put("hibernate.dialect", "org.hibernate.dialect.H2Dialect");
    hibernateProp.put("hibernate.format_sql", true);
    hibernateProp.put("hibernate.use_sql_comments", true);
    hibernateProp.put("hibernate.show_sql", true);
    hibernateProp.put("hibernate.max_fetch_depth", 3);
    hibernateProp.put("hibernate.jdbc.batch_size", 10);
    hibernateProp.put("hibernate.jdbc.fetch_size", 50);
    return hibernateProp;
  }

  // 엔티티 매니저 팩토리
  @Bean
  public EntityManagerFactory entityManagerFactory() {
    LocalContainerEntityManagerFactoryBean factoryBean = new LocalContainerEntityManagerFactoryBean();
    factoryBean.setPackagesToScan("엔티티스캔대상패키지");
    factoryBean.setDataSource(dataSource());
    factoryBean.setJpaProperties(hibernateProperties());
    factoryBean.setJpaVendorAdapter(jpaVendorAdapter());
    factoryBean.afterPropertiesSet();
    return factoryBean.getNativeEntityManagerFactory();
  }
}


위와 같이 엔티티 매니저를 구성하기 위해 필수적인 빈들을 등록하고 관련 프로퍼티를 설정하였으면 엔티티 매니저 의존성을 주입받은 후 엔티티 매니저를 통해 데이터에 접근하기 위한 쿼리들을 실행할 수 있다. JPA를 사용하여 데이터에 접근하는 데이터 액세스 레이어의 리포지토리 구현체 코드는 다음과 같다.

@Repository
@Transactional
public class MyJpaRepository {
  @PersistenceContext
  private EntityManager em;
  
  @Transactional(readOnly=true)
  @Override
  public List<MyEntity> findAll() {
    ...
  }
  
  @Transactional(readOnly=true)
  @Override
  public MyEntity findById(Long id) {
    ...
  }
  
  @Override
  public MyEntity save(MyEntity myEntity) {
    ...
  }
  
  @Override
  public void delete(MyEntity myEntity) {
    ...
  }


@PersistenceContext 표준 JPA 어노테이션을 사용하여 컨테이너에 빈 등록된 엔티티 매니저의 의존성을 주입할 수 있다. 애플리케이션 내에 여러 퍼시스턴스 유닛이 존재할 때는 어노테이션에 unitName 속성을 추가해 주입 받을 퍼시스턴스 유닛을 명시할 수 있다.


스프링 프레임워크의 멀티 데이터 소스 구성

애플리케이션이 하나의 데이터베이스에만 접근한다면 엔티티 매니저 팩토리도 하나만 필요하다. 두 개 이상의 서로 다른 데이터베이스에 접근하는 경우에는 엔티티 매니저 팩토리도 두 개 이상 필요하다. 즉, 데이터베이스 별로 엔티티 매니저 팩토리가 필요하다. 서로 다른 데이터베이스에 접근한다는 것은 벤더가 다른 데이터베이스에 접근하는 경우 또는 동일 벤더이지만 물리적 스키마가 서로 다른 데이터베이스 인스턴스에 각각 접근하는 경우를 말한다. 데이터베이스 하나 당 하나의 데이터 소스 인스턴스가 필요하므로 여러 데이터베이스를 위한 구성을 멀티 데이터 소스 구성이라고 한다. 데이터 소스 당 퍼시스턴스 유닛이 정의되므로 멀티 데이터 소스 구성에서는 퍼시스턴스 유닛이 여러 개 정의된다.

엔티티 매니저 구현체를 반환하는 엔티티 매니저 팩토리를 컨테이너에 빈 등록할 때는 데이터베이스에 대한 DataSource 빈을 등록한 후 엔티티 매니저의 프로퍼티로 설정하는 과정이 필요하다.

멀티 데이터 소스 구성을 위해 데이터베이스 별로 서로 다르게 구성되는 JPA 관련 컴포넌트들은 다음과 같다.

  • 데이터 소스 (DataSource)
  • 엔티티 매니저 팩토리 (EntityManagerFactory)
  • 엔티티 매니저 (EntityManager)
  • 트랜잭션 매니저 (TransactionManager)


여러 데이터베이스에 걸친 트랜잭션인 글로벌 트랜잭션을 지원하는 경우 데이터베이스 별로 퍼시스턴스 유닛을 정의하고 엔티티 매니저를 생성하여 사용하며, JTA를 사용할 경우 하나의 트랜잭션 매니저인 JtaTransactionManager를 사용한다.

기존 단일 데이터 소스 구성일 경우 LocalContainerEntityManagerFactoryBean를 사용한 엔티티 매니저 팩토리 구성 코드는 다음과 같다.

스프링 프레임워크가 JPA 구현을 위해 제공하는 LocalContainerEntityManagerFactoryBean를 사용하면 엔티티 매니저 팩토리를 원하는 요구사항에 맞게 구성 가능하며, 멀티 데이터 소스를 위한 JPA 구성 시 이를 사용하고 데이터 소스 별로 복제 구성하면 된다. 멀티 데이터 소스 구성일 경우 LocalContainerEntityManagerFactoryBean를 사용한 엔티티 매니저 팩토리 구성 코드는 다음과 같다.


JPA 사용 시 주의점

엔티티 매니저와 엔티티 매니저 팩토리

엔티티 매니저 팩토리를 생성하는 비용은 크다. 따라서 엔티티 매니저 팩토리는 애플리케이션 전체에서 딱 한 번만 생성하고 공유해서 사용해야 한다.

엔티티 매니저는 데이터베이스 커넥션과 밀접한 관계가 있으므로 스레드 간에 공유하거나 재사용하면 안 된다.


JPA에서 엔티티 간 연관관계 설정 시 발생할 수 있는 문제점

  1. 엔티티 연관관계가 양방향 관계이면 순환 참조 발생 및 무한 루프 발생 가능
    • 원인: 객체를 주입 받아 필드에 설정하는 수정자 메서드나, 컬렉션에 원소를 추가하는 메서드와 같은 편의 메서드를 각각의 엔티티에 모두 정의하였고 서로 호출하는 경우 발생
    • 해결 방법: 서로의 편의 메서드를 계속 호출하는 무한 루프에 빠지지 않도록 엔티티를 확인하는 코드를 작성해야 함
      • 객체 참조 전 객체가 이미 참조되어 있는지 확인
      • 컬렉션에 추가하기 전 컬렉션에 이미 추가되어 있는지 확인
  2. 조회 시 엔티티 일대다 또는 다대일 연관관계를 사용할 경우(객체 그래프 탐색 이용) n+1 문제(n+1 query problem) 및 성능 문제 발생 가능
    • 원인
      1. 즉시 로딩이며 페치 조인을 사용하지 않는 JPQL 조회: 일대다 연관관계이고 @OneToMany 필드(연관 엔티티 컬렉션 필드)에 즉시 로딩 설정이 되어 있는 경우 JPQL을 통해 엔티티(1)만 조회(단일 조회 또는 컬렉션 조회) 시 연관 엔티티(n) 컬렉션도 조회하기 위해 SQL 문이 n번 더 수행됨
        • 연관 엔티티의 총 개수 만큼 SQL 문이 추가로 수행됨
      2. 지연 로딩이며 JPQL을 사용하였지만 이후 컬렉션 엔티티를 전체 조회: 1번과 동일한 상황에서 지연 로딩 설정이 되어 있는 경우 JPQL을 통해 엔티티(1)만 조회(단일 조회 또는 컬렉션 조회) 시 연관 엔티티(n) 컬렉션은 조회하지 않으므로 SQL 문은 한 번만 실행되지만, 이후에 get() 메서드로 연관 엔티티 컬렉션의 모든 엔티티를 조회하는 경우 SQL 문이 n번 더 수행됨
    • 해결 방법
      1. JPQL을 사용하지 않고 조회: 일대다 연관관계이고 @OneToMany 필드(컬렉션 필드)에 즉시 로딩 설정이 되어 있는 경우 find() 메서드로 엔티티(1)만 조회(단일 조회 또는 컬렉션 조회) 시 조인을 통해 연관 엔티티(n) 컬렉션도 한 번에 조회하므로 SQL 문은 한 번만 실행됨
      2. JPQL의 페치 조인을 사용하여 조회: 페치 조인을 사용하면 엔티티 조회 시 조인을 이용해서 연관된 엔티티도 한 번에 조회하므로 SQL 문은 한 번만 실행됨
      3. 하이버네이트가 제공하는 해결 방법 사용:


지연 로딩

지연 로딩은 연관 관계에 있는 객체를 실제로 사용(참조)할 때 메모리에 로드함으로써 메모리 리소소를 줄인다. 하이버네이트는 지연 로딩 구현을 위해 프록시 객체를 사용한다. 하이버네이트에서 데이터를 로드하려면 항상 열린 세션(Session)이 필요하다. 세션이란 데이터를 영속화하는 역할을 수행하는 주요 인터페이스이며 JPA에서는 엔티티 매니저(EntityManager)에 해당된다.

지연 로딩 시 프록시 객체로부터 데이터를 조회하기 위해서는 트랜잭션이 열린 상태여야 하며 그렇지 않은 경우 LazyInitializationException 예외가 발생하게 된다. 즉, 트랜잭션이 닫힌 상태에서 프록시 객체로부터 데이터를 조회하면 예외가 발생하게 된다.

위 예외가 발생하는 것을 막기 위해서는 연관관계를 갖는 엔티티를 조회하는 메서드가 트랜잭션 내에서 호출되어야 한다. @Transaction을 사용하여 메서드에 트랜잭션을 설정하는 경우 트랜잭션 전파 레벨은 기본적으로 REQUIRED이므로 진행 중인 트랜잭션이 있으면 현재 메서드를 해당 트랜잭션에서 실행하고, 그렇지 않을 경우 새 트랜잭션을 시작해서 실행하게 된다. 따라서 리포지토리 구현체로부터 엔티티를 조회한 후 연관관계에 있는 엔티티의 데이터를 프록시 객체로부터 조회하는 코드는 @Transaction 메서드 내에 설정된 트랜잭션 내에서 실행되어야 한다.


참고

Comments