글 작성자: 자바니또

개요

이번 포스팅은 JPA의 내부 구조와 동작 방식을 알아보고 사용예제를 간단하게 알아보는 포스팅이다. 이 글은 JPA를 사용하면서 DB에 데이터를 넣고 꺼내 봤는데, 어떻게 동작하는지 머리에 그려지지 않는 사람을 대상으로 했다. 

목차

  • JPA란?
  • 구동 방식
  • EntityManagerFactory와 EntityManager
  • 영속성 컨텍스트 : 가상의 객체 DB
  • First-Level Cache : 1차 캐시
  • 쓰기 지연 SQL 저장소
  • 변경 감지(Dirty Checking)
  • 만약 엔티티 식별자 생성 전략이 자동이라면 어떻게 캐시에 넣을까?

JPA 란?

EJB 시절 Java에는 ORM 기술이 이미 있었다. 하지만 굉장히 불편하고 완성도도 떨어졌고, 이에 불만을 가진 개발자가 직접 만들기 시작했다. 여러 개발자가 참여해서 구색을 갖추기 시작한 프로젝트는 반응이 좋았고 이 프로젝트가 바로 Hibernate이다. Java에서는 반성하며 해당 기술을 API로 만들어 Java 의 새로운 ORM 기술 표준으로 만들었고 이것이 JPA이다. 

JPA는 Java Persist API의 약자로서 Java진영의 ORM 기술 표준이다. 이름에서 알 수 있듯이 JPA는 특정 구현체가 아닌 인터페이스이기 때문에 개발자는 어떤 구현체를 사용하던지 같은 방식으로 사용할 수 있고, 구현체를 변경한다 해도 코드를 변경하지 않아도 된다. 구현체로는 대표적으로 Hibernate가 있다.

<출처 : 자바 ORM 표준 JPA 프로그래밍 기본>

JPA는 Java 애플리케이션과 JDBC API 사이에서 동작한다. 개발자는 JDBC를 직접 사용할 때 느꼈던 번거로움을 JPA를 통해 해결할 수 있다. 

 

구동 방식

<출처 : 자바 ORM 표준 JPA 프로그래밍 기본>

  1. Persistence 클래스는 META-INF/persistence.xml 의 Persistence-unit 이름으로 설정 정보를 조회한다.
  2. 조회한 설정 정보를 바탕으로 EntityManagerFactory를 생성한다.
  3. EntityManagerFactory는 이름대로 EntityManager를 생성해주는 기능을 한다.
public class Main {
	public static void main(String[] args) {
    	// 1, 2
    	EntityManagerFactory emf = Persistence.createEntityManagerFactory("Hello");
        
        // 3
        EntityManager em = emf.createEntityManager();

        em.close();
        
        emf.close();
    }
}

EntityManagerFactory & EntityManager

EntityManagerFactory는 설정 정보를 바탕으로 EntityManager를 찍어내는 객체이다. 설정 정보는 DB에 대한 정보이기에 하나의 DB를 사용하는 애플리케이션이라면 로드 시점에 하나만 생성되어야 하며 Thread-safe 하다.

EntityManager는 내부에 영속성 컨텍스트를 가지고 있으며, 영속성 컨텍스트에 접근할 수 있게 해주는 객체이다. 멀티쓰레드 환경에서 안전하지 않으므로 공유되면 안되고, 사용 후에는 close() 해주어야 한다또한 영속성 컨텍스트에 접근하여 데이터를 변경할 땐 항상 트랜잭션 안에서 이루어져야 한다. 

public class Main {
	public static void main(String[] args) {
    	EntityManagerFactory emf = Persistence.createEntityManagerFactory("Hello");
        
        EntityManager em = emf.createEntityManager();
        
        // 트랜잭션 시작
        EntityTransaction tx = em.getTransaction();
        tx.begin();
        
        // Member 엔티티 저장
        em.persist(new Member());
        
        // 트랜잭션 커밋
        tx.commit();
        
        em.close();
        
        emf.close();
    }
}

<출처 : 자바 ORM 표준 JPA 프로그래밍 기본>

요청을 받으면 EntityManager를 하나 생성해준다. 그림을 보면 EntityManager1은 커넥션을 얻지 않았는데, EntityManager는 DB 연결이 꼭 필요한 시점까지 커넥션을 얻지 않는다. 보통은 트랜잭션을 시작할 때 커넥션을 획득한다. 

영속성 컨텍스트 : 가상의 객체 DB

영속성 컨텍스트는 RDBMS와 OOP의 패러다임을 해결하기 위해 나온 것으로써, 애플리케이션에서 객체를 보관하는 가상의 데이터베이스 같은 역할을한다. EntityManager를 통해 엔티티 저장이나 조회를 명령하면 EntityManager는 영속성 컨텍스트에 엔티티를 보관하고 관리한다. 

// 엔티티 생성
Member member = new Member();
member.setId("member1");
member.setName("mingon");

// 엔티티 영속화
em.persist(member);

em.persist(member) 가 하는 일은 무엇일까? DB에 바로 저장시키는 것이라면 매번 서버와 DB가 네트워킹을 통해 통신하면서 속도가 굉장히 느려질 것이다. 그래서 영속성 컨텍스트는 OOP 세계와 DB 세계 중간에서 OOP 세계의 엔티티(객체)들을 캐싱하다가 필요 시 한번에 DB 세계로 보낸다. 

First-Level Cache : 1차 캐시

이제 영속성 컨텍스트가 객체를 저장해 두는 도구를 살펴보자영속성 컨텍스트 안에는 엔티티의 식별자를 키로하여 저장하는 캐시가 존재한다. 바로 위의 예제 코드를 실행하면 트랜잭션이 커밋되기 전까지 영속성 컨텍스트는 엔티티를 다음과 같이 저장해 둔다. 

member가 persist된 상태

DB에 데이터를 저장하는 것을 영속화(persist) 한다고 표현한다. 하지만 JPA에서의 영속화(persist)는 DB가 아닌 영속성 컨텍스트의 캐시에 엔티티의 레퍼런스를 저장하라는 의미이다. 1차 캐시에 저장되어있는 엔티티의 상태를 '영속 상태'라 한다. 

// 엔티티 생성
Member member = new Member();
memeber.setId("member1");
member.setName("mingon");

// 엔티티 영속화
em.persist(member);

// 엔티티 조회
Memeber findMember = em.find(Member.class, "member1");
Memeber findMember2 = em.find(Member.class, "member2");

find()를 통해 엔티티 조회를 시도할 때, JPA는 해당 식별자를 키로하여 1차 캐시에서 우선 찾아본다. 찾는다면 캐시 값을 반환하여 주고, 없다면 DB로부터 조회하여 1차 캐시에 저장 후 레퍼런스를 반환해준다. 즉, 반복가능한 읽기(REPEATABLE READ)등급의 트랜잭션 격리 수준을 DB가 아닌 애플리케이션 차원에서 제공한다.

Memeber findMember = em.find(Member.class, "member1");

캐싱된 엔티티를 조회

"member1"을 식별자로 하는 엔티티는 persist()를 통해 영속성 컨텍스트에 저장되어 있는 상태이다. 이 상태에서 find()는 영속성 컨텍스트를 통해 바로 엔티티를 얻을 수 있기 때문에 DB에 조회쿼리를 보내지 않는다.

Memeber findMember2 = em.find(Member.class, "member2");

"member2"를 식별자로 하는 엔티티는 영속 상태가 아니다. 이 때 find()는 1차 캐시에 없다는 것을 확인 하고 DB에 조회쿼리를 보내 엔티티를 로드한다. 로드한 엔티티는 바로 영속 상태로 만들고 레퍼런스를 반환한다. 만약 DB에도 없다면 null을 반환한다.

1차 캐시는 같은 트랜잭션 안에서 DB에 접근하지 않고 조회할 수 있도록 하여 성능적인 이득을 얻을 수 있다. 하지만 짧은 트랜잭션에서 사용되기 때문에 극적인 성능이득은 기대하기 어렵다. 1차 캐시의 더 중요한 이점은 동일성(identical)을 보장한다는 것이다. 식별자만 같다면 몇번을 find() 해도 같은 객체가 나온다. 

논리적인 같음(equivalent)이 아닌 물리적인 같음(identical)을 보장함으로써 개발자의 머리를 편안하게 만들어준다. 만약 1차 캐시가 없고 매번 DB에서 데이터를 조회하여 엔티티를 생성한다면 equivalent는 equals()를 재정의 하여 보장할 수 있지만, identical(==)은 그럴 수 없다. 물론 트랜잭션이 다르거나 영속성 컨텍스트를 clear 후 다시 조회하면 같은 식별자라도 다른 다른 객체이다.

쓰기 지연 SQL 저장소

조회는 앞에서 말했던 것처럼 찾는 엔티티가 영속상태가 아니면 바로 SQL을 보낸다. 하지만 객체의 상태를 바꿈으로써 비즈니스 로직을 수행하는 OOP 세계에서 select처럼 바로바로 insert, update, delete를 보낸다면 굉장히 많은 SQL이 여러번 네트워크를 왕복하면서 성능이슈가 발생할 것이다. 그래서 영속성 컨텍스트에는 엔티티를 저장하는 1차 캐시 외에도 SQL을 저장하는 버퍼인 '쓰기 지연 SQL 저장소'가 있다. 

EntityTransaction tx = em.getTransaction();

// 엔티티 매니저는 데이터 쓰기 시 트랜잭션을 시작해야 한다.
tx.begin()

em.persist(memberA);
em.persist(memberB);
// 이때 까지 SQL을 보내지 않는다. 

// 커밋하는 순간, 모아둔 SQL을 한번에 보낸다.
tx.commit();

persist 후 영속성 컨텍스트
트랜잭션 커밋 시 내부 동작

새로운 엔티티를 저장하는 persist()를 호출하면 SQL을 바로 보내지 않고 쓰기 지연 SQL 저장소에 SQL을 저장한다. 그러다가 커밋하는 순간 모아둔 SQL을 한번에 보낸다(flush). persist() 외에도 em.remove(memberA)를 호출한다면 1차 캐시에서 memberA 엔티티를 제거하고 SQL 저장소에 delete 쿼리를 저장한다.

변경 감지(Dirty Checking)

persist()와 remove()를 통해 insert, delete 쿼리는 sql 저장소에 저장된다. update 쿼리는 어떻게 저장될까? 몇가지 방법이 있지만 그 중에 가장 많이 사용되고 권장하는 방식은 Dirty Checking이다.

tx.begin();

// 엔티티 조회
Member memberA = em.find(Member.class, "memberA");

// 영속상태 엔티티 수정
memberA.setUsername("mingon");
memberA.setAge("27");

// 여기에 em.update(memberA) 같은 것이 있어야 할 것 같은데..?

tx.commit();

 

위의 코드는 엔티티를 조회하고 엔티티의 상태를 수정했다. 결과 부터 말하자면 이 코드는 커밋 시점에 Update SQL 이 보내지게 된다. 영속성 컨텍스트에 엔티티의 변화를 알려주기 위해 em.update() 같은 것을 호출해야 할 것 같은데 어떠한 추가적인 행위 없이 수정 쿼리가 나간다. 어떻게 가능할까?

비밀은 스냅샷에 있다. 사실 엔티티를 영속시킬 때 JPA는 원본 데이터를 스냅샷으로 만들어 같이 저장한다. 그리고 커밋이나 그 밖에 flush() 하라는 명령이 오면 캐시의 엔트리들을 하나하나 검사하여 변경되었다면 Update 쿼리를 SQL 저장소에 생성하고 바로 저장소에 있는 SQL 들을 DB로 보낸다.

Dirty Checking 은 개발자가 아닌 JPA가 주도하여 이루어진다. 때문에 개발자는 영속화에 신경쓰지 않고 객체 지향적인 코딩이 가능하다. Dirty Checking이 많이 쓰이고 권장되는 이유가 이것이다.

Flush

'flush'란 영속성 컨텍스트의 변경내용들을 SQL로 모아둔 쓰기 지연 SQL 저장소의 SQL(등록, 수정, 삭제)들을 DB에 보내는 것을 말한다. 플러시를 발생시키는 방법은 다음과 같다.

  • 트랜잭션 커밋 : JPA에 의해 플러시 자동 호출
  • em.flush() : 플러시 직접 호출 
  • JPQL 실행 전 : 플러시 자동 호출.

* JPQL 실행 전에 플러시가 호출되는 이유는 앞에서 사용한 persist와 remove, find 등은 1차 캐시를 우선하여 찾기 때문에 JPA가 플러시 시점을 미룰 수 있다. 하지만 개발자가 작성하는 JPQL은 JPA가 어떤 SQL이 만들어질지 모르기 때문에 1차 캐시를 이용할 수 없다. 그래서 JPA는 JPQL 실행 전 1차 캐시를 flush하여 DB에 반영하고, JPQL을 SQL로 변환하여  DB에 날린다. 즉, JPA는 JPQL은 1차 캐시가 아닌 DB로부터 정확한 데이터를 받도록 환경을 조성한다.

만약 엔티티 식별자 생성 전략이 자동이라면 어떻게 캐시에 넣을까?

Spring 으로 JPA를 사용해본 사람이라면 엔티티의 식별자(id)를 예제 처럼 직접 넣는 경우는 별로 없을 것이다. 대부분 @GeneratedValue() 를 통해 GenerationType.AUTO  GenerationType.IDENTITY 로 자동으로 생성되게 할 것이다. 이 경우 persist()호출 시 엔티티의 id는 null인데 어떻게 키로 사용하여 저장될까? 

GenerationType.AUTO DB 시퀀스의 value를 사용하여 id를 생성한다. 동작 순서는 다음과 같다.

  1. em.persist() 호출
  2. JPA는 엔티티의 식별자 생성전략이 AUTO인 것을 확인한다.
  3. sequance로부터 nextValue를 가져오는 SQL을 SQL 저장소에 넣고 바로 flush 한다.
  4. 가져온 nextValue를 엔티티의 식별자에게 주입하고, 엔티티는 1차 캐시에 Insert SQL은 쓰기 지연 SQL 저장소에 저장한다. 
  5. 트랜잭션이 커밋되면 flush를 호출하여 Insert SQL을 보낸다.

GenerationType.IDENTITY INSERT 문이 실행 될 때 DB로부터 id가 생성이 된다. 동작 순서는 다음과 같다. 

  1. em.persist() 호출
  2. JPA는 엔티티의 식별자 생성전략이 IDENTITY인 것을 확인한다.
  3. Insert SQL을 SQL 저장소에 넣고 바로 flush 하여 삽입하고, 생성된 식별자 값을 가져온다.
  4. 가져온 식별자 값을 엔티티의 식별자에게 주입하고, 1차 캐시에 저장한다.
  5. 트랜잭션이 커밋되어도 SQL을 날리지 않는다.

Reference

  • 자바 ORM 표준 JPA 프로그래밍 기본(저자 : 김영한)