JPA 동작 과정

영속성 컨텍스트

JPA의 내부 동작을 이해하기 위해서는 가장 먼저 영속성 컨텍스트가 무엇인지에 대해 알아야한다.

영속성 컨텍스트는 객체를 관리하고 최종적으로 쿼리를 생성해서 DB에 전달하는 논리 영역이며, EntityManager가 작업을 처리하는 동안 사용되는 메모리 영역이다.

EntityManager는 JPA 스펙의 EntityManagerFactory를 통해 생성할 수 있고, 생성된 EntityManager를 통해서 DB에 저장할 객체인 엔티티를 관리할 수 있다.

이때 EntityManger는 영속성 컨텍스트에 올라간 엔티티만 관리하기 때문에 관리할 엔티티를 꼭 영속해주어야하며, 영속 방법은 perist() 메서드를 호출하는 방법이 있으며, find()를 통해 조회된 엔티티도 1차 캐시에 저장되여 영속된다.

영속성 컨텍스트는 메모리 영역이기 때문에 실제 DB에 저장된 것은 아니며, JPA의 관리 대상이 되었다고 생각하면 된다.

EntityManaverFactory를 싱글톤으로 하나만 구현해서 애플리케이션 전체에서 공유하고, EntityManager는 각각의 트랜잭션에서 작업을 하기 때문에 절대로 공유하면 안된다.

EntityManager는 사용 후 반드시 close()로 객체를 반환해주어야 메모리 누수를 방지할 수 있다.

그 이유는 GC가 수거하기 위해서는 대상 객체에 대한 모든 참조 끊겨야하지만, EntityManager는 여러 참조가 존재할 수 있기 때문이다.

 

 

엔티티의 생명주기

엔티티는 4가지의 상태를 가진다.

  • 비영속 상태: 1차 캐시에 올라가기 전의 객체 상태
  • 영속 상태: 1차 캐시에 등록된 상태
  • 준영속 상태: 1차 캐시 등록되었다가 다시 빼낸 상태 - 관리 대상에서 제외된 상태
  • 삭제: 삭제된 상태

 

 

 

1차 캐시

영속성 컨텍스트 내부에는 1차 캐시와 쓰기 지연 SQL 저장소가 존재하는데 1차 캐시는 DB에 접근하기 전에 데이터를 저장해두는 공간으로 영속된 엔티티가 저장되어있는 공간이며, 자바의 컬렉션이다.

JPA가 다른 Mapper 방식과 다른 점이 바로 데이터를 DB에 바로 저장하지 않고 1차 캐시라는 중간 관리 영역이 존재한다는 것인데, 바로 데이터를 저장하지 않는 이유는 다음과 같은 장점을 누릴 수 있기 때문이다.

 

1차 캐시의 장점

캐싱을 통해 성능을 높일 수 있다.

  • 데이터를 조회할 때 1차 캐시에 저장된 객체를 먼저 찾기 때문에 불필요한 SQL을 줄일 수 있다.
    • 아래 그림을 보면 3번의 em.find()를 호출했을 때 DB가 아닌 1차 캐시에서 식별자인 ID로 값을 조회하는 것을 볼 수 있다. 만약 아이디가 1이 아닌 2였다면 1차 캐시 조회 이후 DB에서 2번에 해당하는 멤버를 조회해서 1차 캐시에 저장했을 것이다.
    • 성능 개선에 도움이 되는 것은 사실이나, 실제로 이미 영속된 엔티티를 조회할 일이 많지는 않다. 
  • 1차 캐시를 통해 받아온 엔티티는 컬렉션에서 조회한 객체이기 때문에 몇 번을 조회하든 같은 메모리 주소를 가진 동일한 객체이므로 영속 엔티티의 동일성을 보장해준다.
    • SQL을 통해 DB에서 직접 조회할 경우 DB로부터 받아온 값을 담은 객체는 새로운 인스턴스이기 때문에 동일성 비교(==)시 다른 메모리 주소를 가진 다른 객체이다. (동등성 비교인 equals()에서는 같은 값이 맞다.)

쓰기 지연 SQL 저장소와 함께 사용되어 쿼리에 대한 버퍼 기능을 제공한다.

  • 1차 캐시는 컬렉션이기 때문에 객체를 조회하고, 저장하고, 삭제하고, 업데이트하는 행위에 쿼리가 필요하지 않다. 따라서 객체 상태로 사용하다가, 실제 쿼리를 실행하는 시점에 최적화된 쿼리를 실행한다.
    • 저장 -> 조회 -> 업데이트 -> 삭제가 한 트랜잭션 내에서 순서대로 일어났을 때 JPA와 Mapper의 동작은 다음과 같다.
      • Mapper: insert, select, update, delete 총 4번의 쿼리가 발생한다.
      • JPA: perisist(), find(), set(), remove() 총 4번의 메서드가 실행되며, 쿼리는 발생하지 않는다.
    • 3명의 멤버를 저장할 경우는 다음과 같다.
      • Mapper: 3번의 INSERT 쿼리 발생
        • INSERT INTO member(name) VALUES("멤버1");
        • INSERT INTO member(name) VALUES("멤버1");
        • INSERT INTO member(name) VALUES("멤버1");
      • JPA: 1번의 INSERT 쿼리에 3개의 데이터 추가
        • INSERT INTO member(name) VALUES("멤버1"),("멤버2"),("멤버3");

더티 체킹이라는 기능을 통해 엔티티의 변경을 감지해서 em.find()와 같은 메서드를 호출하지 않아도 자동으로 변경사항을 반영하는 쿼리가 생성된다.

  • 엔티티가 1차 캐시에 저장됐을 때의 상태를 스냅샷으로 저장한 후 스냅샷과 현재의 엔티티를 비교하여 변경된 내용에 대한 쿼리를 생성한다.
  • 아래와 같은 코드가 있을 때 따로 업데이트 메서드를 호출하지 않았음에도 자동으로 업데이트 쿼리가 발생한다.
Member member = em.find(Member.class, 1L);
member.setName("member");

/* 실행된 쿼리
    SELECT * FROM member WHERE id = 1; 
    UPDATE member SET name = "member" WHERE id = 1;
*/

 

영속성 컨텍스트의 동작 과정

 

 

 

쓰기 지연 SQL 저장소

쓰기 지연 SQL 저장소는 쿼리 실행 시점까지 최적화된 쿼리를 생성해서 가지고 있다가 모든 쿼리를 한번에 DB에 보내는 역할을 한다.

쿼리는 em.flush()가 호출됐을 때 DB에 전달된다. 이때 주의 사항은 flush()는 쿼리를 실행하는 역할이고, 실제 DB에 쿼리 결과를 반영하는 것은 트랜잭션의 커밋 시점이다.

프로덕션 코드에서 사용할 일은 없지만, JPA의 테스트 코드를 작성할 때는 flush()를 강제로 호출해서 쿼리를 원하는 시점에서 강제 실행 시킬 수 있다.

 

 

 

 

트랜잭션

JPA는 쓰기 지연 SQL 저장소에 의해 한번에 쿼리를 보낸다. 이때 트랜잭션이라는 작업 단위를 적용하지 않으면 다음과 같은 문제가 발생할 수 있다.

  • 플러시 시점에 생성된 쿼리가 1개의 INSERT 문과 1개의 UPDATE 문이라고 할 때 INSERT가 진행 된 후 UPDATE문에서 에러가 발생하면 INSERT는 반영되고, UPDATE는 반영이 안된다. 
  • 즉, ACID 원칙을 지키지 못하기 때문에 영속성 컨텍스트의 작업은 꼭 트랜잭션 내에서 이루어져야한다.

트랜잭션이 커밋된 후에는 1차 캐시가 초기화된다.

커밋과 상관없이 1차 캐시를 비우고 싶다면, em.clear()를 통해 모든 엔티티를 detach 시킬 수 있다.

1차 캐시가 비워진다고 영속성 컨텍스트가 사라지는 것은 아니다. 영속성 컨텍스트는 EntityManager가 작업하는 논리 영역이기 때문에 EntityManager가 반환될 때 같이 반환된다고 생각하면 된다.

 

코드 예시

public class Main {
    public static void main(String[] args){
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();

        EntityTransaction tx = em.getTransaction();
        tx.begin();

        try{
           	/*
            	실제 코드
            */
          	
            tx.commit();
        }catch (Exception e){
            tx.rollback();
            e.printStackTrace();
        }finally {
            em.close();
        }
    }
}