[Spring] JPA (Java Persistence API)와 영속성 컨텍스트 (Persistence Context)

전윤혁·2024년 7월 28일
0

Spring

목록 보기
8/8

JPA (Java Persistence API)

JPA(Java Persistence API)를 자바 플랫폼에서 ORM을 구현하기 위한 표준 API라고 설명했었다. JPA를 Java, Persistence, API로 나누어 해석해보면, Java와 API는 쉽게 이해할 수 있다. 그렇다면 Persistence는 뭘까? 이번 글에서는 JPA의 영속성 컨텍스트에 대해 다뤄보고자 한다.


1. 영속성 컨텍스트 (Persistence Context)

영속성 컨텍스트를 설명하기 전, 먼저 영속성이란 무엇일까? 여기서 영속성이란 "오래 계속되는 성질"을 의미한다. 그렇다면 프로그램에서 영속성을 얻는다는 것은 무엇을 의미할까? 바로 프로그램이 종료되거나 재시작되더라도 유지되는 것을 의미한다. 또한 여기서 컨텍스트란, 범위와 환경 정도로 이해할 수 있다.

정리하면, 영속성 컨텍스트란 엔티티를 영구적으로 저장하는 환경을 의미한다. 애플리케이션과 데이터베이스 사이에서, 영속성 컨텍스트는 가상의 데이터베이스 역할을 한다고 볼 수 있다.


2. Entity, EntityManager, EntityManagerFactory

영속성 컨텍스트에 대해 알아보기 전, Entity, EntityManager, EntityManagerFactory의 의미와 그 관계를 명확히 점검하자.

  • Entity
    Entity는 데이터베이스 테이블에 매핑되는 자바 클래스의 인스턴스로, JPA를 통해 데이터베이스와 상호작용하는 객체이다. 엔티티는 데이터베이스 레코드의 상태를 반영하고, 애플리케이션에서 데이터를 조작하는 기본 단위이다.

  • EntityManager
    EntityManager는 엔티티의 생명 주기를 관리하고 데이터베이스 작업(CRUD)을 수행하는 인터페이스이다. (엔티티의 생명 주기는 다음 내용에서 설명된다.) 여기서 알아야 할 점은, EntityManager가 엔티티를 관리하는 방법이 바로 영속성 컨텍스트라는 것이다.

  • EntityManagerFactory
    EntityManagerFactory는 말 그대로 EntityManager를 만들어내는 공장과 같다. EntityManager는 여러 스레드가 동시에 접근할 경우 동시성 문제가 발생하기 때문에, 상황에 따라 새롭게 만들어줘야 한다. 이 때 이 작업을 수행하고, 데이터베이스 연결 설정을 수행하는 것이 EntityManagerFactory이다.

그림으로 구조를 표현하면 아래와 같다.

Persistence 클래스는 createEntityManagerFactory라는 정적 메서드를 제공하여, 애플리케이션이 사용할 EntityManagerFactory 객체를 생성한다.

Persistence Unit이란 persistence.xml 파일에 정의된 논리적인 이름으로, 특정 데이터 저장소와 관련된 모든 JPA 설정을 포함한다. EntityManagerFactory는 Persistence Unit의 설정을 기반으로 생성된다.

여기서 Persistence Unit이 PersistenceContext를 Create 한다는 표현이 조금 혼동되는데, "여러 영속성 컨텍스트는 동일한 Persistence Unit의 설정을 공유한다." 정도로 이해하면 좋을 것 같다.

영속성 컨텍스트는 Entity를 영구 저장하는 환경이라는 논리적인 개념이다. EntityManager 생성 시점에, 영속성 컨텍스트라는 눈에 보이지 않는 공간이 생기고, 해당 공간은 Persistence Unit의 설정을 공유한다.

J2SE (Java 2 Standard Edition), J2EE (Java 2 Enterprise Edition) 구분

필자가 해당 개념을 공부하면서 가장 헷갈렸던 부분은, "그렇다면 Entity Manager마다 각기 다른 Persistence Context가 생기는 것인가?" 였다. J2SE와 J2EE의 구분을 통해 해당 의문을 해소할 수 있었다.

  • J2SE (Java 2 Standard Edition)

    J2SE 환경에서는 개발자가 직접 EntityManagerFactory를 통해 EntityManager를 생성하고 관리해야 한다. 이에 따라 EntityManager를 생성할 때마다 새로운 Persistence Context가 생성되고, 1:1 관계를 가진다.

  • J2EE (Java 2 Enterprise Edition)

J2EE 환경에서는 컨테이너가 트랜잭션과 영속성 컨텍스트를 관리한다. 이에 따라 Entity Manager가 동일한 트랜잭션 내에서 동작하는 경우 동일한 Persistence Context를 사용하고, N:1 관계를 가지게 된다. Spring Framework의 경우 J2EE의 컨테이너 환경에 해당된다.

persistence.xml 파일은 META-INF 디렉토리에 위치하며, 각 Persistence Unit에 대한 설정을 포함한다.

<persistence xmlns="http://xmlns.jcp.org/xml/ns/persistence" version="2.2">
    <persistence-unit name="myPersistenceUnit">
        <provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>
        <class>com.example.MyEntity</class>
        <properties>
            <property name="javax.persistence.jdbc.url" value="jdbc:mysql://localhost:3306/mydb"/>
            <property name="javax.persistence.jdbc.user" value="root"/>
            <property name="javax.persistence.jdbc.password" value="password"/>
            <property name="javax.persistence.jdbc.driver" value="com.mysql.cj.jdbc.Driver"/>
        </properties>
    </persistence-unit>
</persistence>

각 Persistence Unit은 persistence-unit 태그 내에 정의된다. 위의 예시의 경우, <persistence-unit name="myPersistenceUnit"> 이라는 Persistence Unit을 정의하고 있다.

아래는 실제로 EntityManagerFactory를 생성하는 코드이다.

EntityManagerFactory emf = Persistence.createEntityManagerFactory("myPersistenceUnit");

예시의 과정을 요약하면 다음과 같다.

  1. persistence.xml 파일에 Persistence Unit이 정의된다.

  2. Persistence 클래스의 createEntityManagerFactory 메서드를 호출할 때, Persistence Unit의 이름을 인수로 전달한다.

  3. JPA는 해당 Persistence Unit의 설정을 로드하고, 이를 기반으로 EntityManagerFactory를 생성한다.

EntityManagerFactory의 생성은 비용(데이터베이스 연결 설정 등)이 크기에 하나의 데이터베이스 당 하나만 만들어진다. 공장에서 여러 제품(EntityManager)을 찍어내는 것은 비용이 크지 않지만, 공장을 만드는 것은 비용이 크다는 것으로 이해할 수 있다.


3. Entity의 생명 주기

영속성 컨텍스트를 왜 사용하는지 알아보기 이전에, 엔티티의 생명 주기를 먼저 살펴보자.

위의 그림은 엔티티의 생명 주기를 보여주고 있다. 하나씩 살펴보자.

  • New/Transient (비영속)

    Entity entity = new Entity();

    엔티티 객체가 처음 생성되었지만 아직 데이터베이스에 저장되지 않은 상태이다. 영속성 컨텍스트와 연결되어 있지 않고, 데이터베이스에 레코드로 존재하지 않는다.

  • Managed (영속)

    @Autowired
    private EntityManager em;
    
    em.persist(entity);

    persist 메소드를 호출하면 엔티티가 영속성 컨텍스트에 연결되어 관리 상태가 된다. 이 시점에서 엔티티는 데이터베이스와 동기화되며, 데이터베이스에 저장될 준비가 된 상태이다.

  • Detached (준영속)

    em.detach(entity);
    em.clear();
    em.close();
    em.merge(entity);

    detach 메소드를 통해 엔티티를 영속성 컨텍스트에서 분리하거나, clear, close 메소드를 통해 영속성 컨텍스트가 비워지거나 종료되면 준영속 상태가 된다. 준영속 상태의 엔티티는 merge 메서드를 호출하여 다시 관리 상태로 전환될 수 있다.

  • Removed (제거)

    em.remove(entity);

    remove 메소드를 호출하면 엔티티는 영속성 컨텍스트에서 "제거된" 상태로 표시된다. 정확히 말하면, Removed 상태는 영속성 컨텍스트에 엔티티가 삭제될거라고 기록된 상태이며, 이후 flush 메소드를 통해 영속성 컨텍스트와 데이터베이스에서 삭제된다.

  • Managed - DB (영속 - DB 반영)

    em.flush();
    em.find(Entity.class, entity.getId());;

    flush 메소드는 영속성 컨텍스트에 있는 모든 변경사항을 데이터베이스에 반영한다. find 메소드는 데이터베이스에서 엔티티를 조회하여 영속성 컨텍스트에 다시 관리 상태로 가져온다.

  • Removed - DB (제거 - DB 반영)

    em.remove(entity);
    em.flush();

    remove 메서드를 호출한 후 flush 메서드를 호출하면 엔티티가 데이터베이스에서 실제로 삭제된다. 이는 영속성 컨텍스트에서 제거된 엔티티가 데이터베이스에서도 제거되었음을 의미한다.


4. 영속성 컨텍스트의 장점

그렇다면 영속성 컨텍스트는 왜 사용하는걸까? 아래의 특징들을 통해 영속성 컨텍스트의 장점을 이해해보자.

  • 1차 캐시
  • 동일성 (identity) 보장
  • 쓰기 지연 (트랜잭션 지원)
  • 변경 감지 (Dirty Checking)
  • 지연 로딩 (Lazy Loading)

1) 1차 캐시

영속성 컨텍스트는 1차 캐시를 통해 데이터베이스에서 조회한 엔티티를 메모리에 캐싱한다. 이로 인해 동일한 트랜잭션 내에서 동일한 엔티티를 여러 번 조회해도 데이터베이스에 불필요한 쿼리를 보내지 않는다.

이것이 유용한 이유는, DB에 연결하여 직접 정보를 가져오는 작업의 비용이 높기 때문이다. 따라서 영속성 컨텍스트는 DB에 있는 정보를 들고 있는 것이다. 조회의 흐름은 아래와 같다.

  1. 1차 캐시에서 엔티티를 찾는다.
    (1차 캐시에 엔티티가 있는 경우 즉시 조회하여 반환한다.)
  2. 1차 캐시에 엔티티가 없는 경우 DB에서 조회한다.
  3. 조회한 데이터로 엔티티를 생성해 1차 캐시에 저장한다. (엔티티를 영속 상태로 만든다.)
  4. 조회한 엔티티를 반환한다.

1차 캐시는 트랜잭션 범위 내에서만 사용되는 캐시이다. 즉, 트랜잭션이 시작될 때 생성되고 트랜잭션이 종료되면 사라진다. 이 캐시는 스레드끼리 공유되지 않으며, 각 트랜잭션마다 독립적으로 존재한다.

2) 동일성 (identity) 보장

영속 엔티티는 동일성(identity)이 보장된다. 즉, 같은 엔티티를 여러 번 호출하더라도 같은 주소에 있는 객체가 반환된다. 아래의 간단한 예시를 살펴보자.

User user1 = em.find(User.class, "user1");
User user2 = em.find(User.class, "user2);

if (user1 == user2) {
    System.out.println("동일한 객체입니다.");
} else {
    System.out.println("다른 객체입니다.");
}

위의 예시에서 user1 == user2는 true가 되며, "동일한 객체입니다."가 출력된다.

동일성(Identity) vs 동등성(Equality)

동일성은 두 객체가 메모리상에서 같은 주소를 가리키는지를 의미하며, == 연산자로 비교할 수 있다.
동등성은 두 객체의 내용이 같은지를 의미하며, equals() 메소드로 비교할 수 있다.

동일성의 경우 같은 인스턴스를 가리키는 것이고, 동등성의 경우 다른 인스턴스일 수 있지만, 그 안의 데이터는 동일하다는 것이다.

3) 쓰기 지연 (트랜잭션 지원)

영속성 컨텍스트는 엔티티의 변경 사항을 즉시 데이터베이스에 반영하지 않고, 트랜잭션이 커밋될 때까지 지연시킨다.

영속성 컨텍스트를 살펴보면, 1차 캐시 이외에도 쓰기 지연 SQL 저장소가 존재한다. 쓰기 지연 SQL 저장소는 엔티티의 변경 사항을 즉시 데이터베이스에 반영하지 않고, SQL문을 저장해 두는 공간이다.

여러 번의 데이터 변경이 일어나는 경우, 데이터베이스 접근을 줄이고, 필요한 시점에 한 번에 모든 변경을 처리함으로써 성능을 최적화할 수 있다.

또한, 트랜잭션 내에서 변경 사항을 일시적으로 저장하고, 트랜잭션이 성공적으로 완료되었을 때만 변경 사항을 데이터베이스에 반영할 수 있다.

4) 변경 감지 (Dirty Checking)

변경 감지(Dirty Checking)란, 영속성 컨텍스트가 관리하는 엔티티의 변경 사항을 자동으로 감지하여 트랜잭션이 커밋될 때 데이터베이스에 반영하는 것이다. 쉽게 말하면, 엔티티와 데이터베이스 간 일관성을 지키기 위해 동기화하는 것이다.

변경 감지가 일어나는 과정은 아래와 같다.

  1. 트랜잭션을 커밋하면, 엔티티 매니저 내부에서 자동으로 flush가 호출된다.
  2. flush 과정에서 영속성 컨텍스트의 모든 엔티티와 해당 엔티티의 스냅샷을 비교하여 변경된 엔티티를 찾는다. (여기서 스냅샷이란, 영속성 컨텍스트에 저장된 순간의 엔티티이다.)
  3. 변경된 엔티티가 있으면, 해당 엔티티의 변경 사항을 반영하는 수정 쿼리를 생성하여, 쓰기 지연 SQL 저장소에 저장한다.
  4. 쓰기 지연 SQL 저장소에 저장된 쿼리를 데이터베이스에 전송하여 실제로 flush를 수행한다.
  5. 마지막으로, 데이터베이스 트랜잭션을 커밋하여 모든 변경 사항을 확정하고, 영속성 컨텍스트의 상태를 최신 상태로 갱신한다.

flushcommit의 차이?

flush는 영속성 컨텍스트의 변경 사항을 데이터베이스에 전송하는 역할을 하며, 트랜잭션이 여전히 열려 있으므로 이후 롤백이 가능하다. 반면, commit은 내부적으로 flush를 수행한 후 데이터베이스 트랜잭션을 종료하고 모든 변경 사항을 확정하므로, 한 번 커밋된 변경 사항은 롤백할 수 없다.

5) 지연 로딩 (Lazy Loading)

지연 로딩(Lazy Loading)은 엔티티의 연관된 데이터를 실제로 필요할 때까지 로딩하지 않는 전략이다.

예시에서 member 엔티티의 team 연관 데이터는 즉시 로딩하고, orders 연관 데이터는 지연 로딩하고 있다.

이를 통해 초기 로딩 시 불필요한 데이터베이스 접근을 최소화하고, 메모리 사용을 최적화할 수 있다. JPA에서는 fetch = FetchType.LAZY 설정을 통해 지연 로딩을 구현할 수 있다.

지연 로딩은 JPA N+1 문제를 피하기 위해 기본적으로 사용된다.

JPA N+1 문제에 대한 글
https://velog.io/@airoca/Spring-JPA-N1-%EB%AC%B8%EC%A0%9C%EC%99%80-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95

profile
전공/개발 지식 정리

0개의 댓글