[jpa] 영속성 관리

엔티티란?

간단하게 DB 테이블에 매핑되는 자바 클래스를 얘기한다.
이 클래스의 인스턴스가 결국 RDB의 레코드 하나로 매핑될 수 있다.

영속성 관리

영속성이란 간단하게 영구히 저장되는 성질을 얘기한다.
ORM 이기 떄문에 영구히 저장되는 환경은 당연히 RDB이고, 영구히 저장할 대상은 엔티티이다.
JPA에서는 이 행위를 엔티티 매니저라는 애가 수행한다.

엔티티 매니저

이름 그대로 엔티티를 관리하는 관리자이다.
엔티티와 관련된 모든 작업(삽입, 수정, 삭제 등)을 수행할 수 있다.

엔티티 매니저를 생성하는 플로우는 아래와 같다.

  1. 매타정보 입력
1
2
3
4
5
6
7
8
9
<persistence-unit name="test">
<properties>
<property name="javax.persistence.jdbc.driver" value="org.h2.Driver"/>
<property name="javax.persistence.jdbc.user" value="sa"/>
<property name="javax.persistence.jdbc.password" value=""/>
<property name="javax.persistence.jdbc.url" value="jdbc:h2:~/test"/>
<property name="hibernate.dialect" value="org.hibernate.dialect.H2Dialect" />
</properties>
</persistence-unit>

엔티티 매니저가 사용할 메타정보이다. database 접속 정보 등이 설정되어 있다.
META-INF/persistence.xml에 입력하면 자동으로 스캐닝한다.

  1. 엔티티 매니저 팩토리 등록
    엔티티 매니저는 엔티티 매니저 팩토리를 통해 생성할 수 있다.
1
EntityManagerFactory emf = Persistence.createEntityMangerFactory("test");

앞서 등록한 메타정보를 통해 엔티티 매니저 팩토리를 생성한다.
보다시피 팩토리이므로 어플리케이션 실행 시 한번만 생성해서 공유하도록 하면 된다.(여러 스레드가 접근해도 안전함)
이 시점에 connection pool을 init 한다.

J2SE에서는 엔티티 매니저 팩토리 생성 시 커넥션 풀을 생성하고, J2EE의 경우 컨테이너가 제공하는 데이터 소스를 사용한다.

  1. 엔티티 매니저 생성
    등록한 엔티티 매니저 팩토리에서 생성하면 된다.
1
2
3
4
5
EntityManager em = emf.creteEntityManger();

em.persist(entity); // 등록
em.find(entity); // 조회
em.remove(entity); // 삭제

보다시피 팩토리에서 매번 생성해서 사용하는 구조이며, 생성 시 마다 connection을 하나 준다고 생각하면 된다.
(엔티티 매니저를 생성했다고 바로 커넥션을 얻는 것은 아니고, 정말 필요할 시점에 커넥션을 획득한다)
여기서 생성된 엔티티 매니저는 데이터베이스에 대한 직접적인 하나의 커넥션이므로, 쓰레드간에 절대 공유해서는 안된다.

개발자 입장에서는 엔티티 매니저는 엔티티를 저장하는 가상의 데이터베이스라고 생각하면 된다.

영속성 컨텍스트

용어를 정의하면 엔티티를 영구 저장하는 환경이다.
엔티티 매니저는 작업을 수행할 때 RDB에 바로 접근하지 않고, 이 영속성 컨텍스트를 통해 작업을 수행한다.
즉 어플리케이션과 RDB 사이에 하나 더 있는 영역인데, 이 영역을 통해 얻는 이점은 아래와 같다.(뭐든 중간에 하나 두면 성능 최적화를 할 요소가 많아진다)

  • 1차 캐시
    • 1차 캐시의 키는 식별자 값이다.
    • em.find()를 호출하면 먼저 1차 캐시에서 엔티티를 찾고 만약 없으면 데이터베이스에서 조회한다.
    • 데이터베이스에서 조회후 1차 캐시에 저장한 후에 영속상태의 엔티티를 반환한다.
    • 이후 한 트랜젝션안에서는 엔티티 인스턴스는 1차 캐시에 있으므로 이 엔티티를 조회시 메모리에 있는 1차 캐시에서 바로 불러오므로 성능상의 이점을 누릴 수 있음
  • 동일성 보장
    • 동일성과 동등성 ==, equal
    • 영속성 컨텍스트에서 관리하는 엔티티 인스턴스는 동일성을 보장한다.
  • 트랜잭션을 지원하는 쓰기 지연
  • 변경 감지
  • 지연 로딩

엔티티 매니저가 생성될 때 하나 생성된다.
보다시피 1차 캐시 + 부가적인 기능들을 묶어서 영속성 컨텍스트라고 부른다. 레이어로 딱 나뉘어져 있는 것은 아니다.

궁금해서 em.persist의 소스를 조금 따라가 보다보니… 1차 캐시에 대한 내용을 약간 확인할 수 있었다.

1
2
3
4
5
6
7
8
private Map<EntityKey, Object> entitiesByKey; // 1차 캐시(!)  

// ...
@Override
public void addEntity(EntityKey key, Object entity) {
entitiesByKey.put( key, entity ); // 여기!
getBatchFetchQueue().removeBatchLoadableEntityKey( key );
}

보다시피 SaveOrUpdateEventListener 에서 엔티티들을 Map에 저장하고 있다.
key, object의 형태로 저장함을 볼 수 있다. (key는 hashCode와 persister(?) 등으로 조합된 클래스이다)
엔티티 키를 만드는 행위를 간단히 보면 아래와 같다.

1
final EntityKey key = source.generateEntityKey( event.getRequestedId(), persister );

(엔티티의 식별자값(primary key)을 통해 만들고 있다. 엔티티 매니저에 의해 관리되러면 식별자값은 필수이다!!)
모든 행위들에 대해 이런식으로 저장하고, 트랜잭션이 끝나는 시점에 이런 정보들을 종합하여 최종 SQL을 날린다고 보면 된다.(이 행위를 flush라고 한다)

의문. PK가 아닌 다른 조건으로 조회했을때는 어떻게 되는건가?
영속성 컨텍스트는 1차 캐시의 역할을 하므로, 이미 조회해온 엔티티에 대해서는 추가 조회를 하지 않고 1차 캐시에 있는 엔티티를 돌려준다.
근데 만약… 조회의 조건을 바꿔서 검색했을때는 어떻게 되는걸까?
예를 들어 findByUserNameContaining으로 조회하면 해당 엔티티들이 전부 1차 캐시에 저장될 것이다.
이후에 다른 조건, 예를 들면 findByUserNickNameContaining으로 조회했을 경우 분명 첫번째 조회 결과와 두번째 조회 결과는 겹치는 부분이 있을 것이다.
이 부분에 대해서 그냥 재조회를 하는건지, 아니면 겹치는 부분은 1차 캐시의 엔티티를 반환해주는지 궁금하다.
후자의 경우가 더 비효율적일것 같은데…(리스트끼리 서로 돌면서 여부를 체크해야하기 때문에)

엔티티 생명주기(상태)

위에서 언급한 영속성 컨텍스트에 저장하는 행위에도 결국 일종의 룰이 필요할 것이다.
여기서 등장하는 것이 엔티티 상태 이다.
엔티티 매니저는 엔티티들의 상태를 통해 여러가지 작업들을 수행한다.
이를 엔티티 생명주기라고 한다.

비영속

영속성 컨텍스트와 전혀 관계없는 상태이다.
그냥 엔티티를 생성하면 비영속 상태이다.

1
Member member = new Member(); // 비영속 상태!

영속

엔티티 매니저를 통해 엔티티를 영속성 컨텍스트에 저장한 상태를 말한다.
(간단하게 말하면 위의 Map에 저장된 상태)
영속상태로 전환하는 법은 간단하다.
em.persistem.find를 통해 엔티티를 저장하거나 조회하기만 하면 영속 상태가 된다.
영속성 컨텍스트에 저장되고, 엔티티 매니저에 의해 관리된다는 뜻!

준영속

조금 특별한 상태이다.
영속상태였다가 비영속상태로 변환된 엔티티를 준영속 상태라고 한다.
em.detach 메서드를 통해 전환할 수 있으며, 전환 시 1차 캐시, 쓰기지연 저장소에 저장된 정보들이 모두 삭제된다.
결과적으로 영속상태가 아니개 되는 것이므로 영속성 컨텍스트에서 제공하는 모든 기능을 사용할 수 없다.

1
2
3
4
5
em.persist(member); // 영속 상태

member = em.detach(member); // 준영속 상태

member.setName("changed name"); // update 발생하지 않음

em.detach외에도
em.clear를 통해 영속성 컨텍스트 내의 모든 엔티티를 지워버림으로써 준영속 상태로 만들 수 있고,
em.close를 통해 영속성 컨텍스트를 종료해버림으로써 준영속 상태로 만들 수 있다.

비영속 상태와 별 다를것 없지만 하나 확실한 것은, 실존하는 데이터라는게 증명이 된다는 것이다.
(영속성 컨텍스트에 들어갔었으면 등록되거나, 조회되어진 데이터이므로)

실제로 개발자가 준영속 상태를 활용할 경우는 거의 없다.

행위

조회

em.find를 통해 엔티티를 조회해올 수 있다.
바로 데이터베이스에서 조회해오는 것은 아니고, 영속성 컨텍스트를 거쳐서 조회한다.
처음 em.find를 통해 오브젝트를 찾으면 먼저 영속성 컨텍스트에 해당 오브젝트(key로 조회)가 있는지 찾고
있으면 db로 가지 않고 그 오브젝트를 바로 리턴하고, 없으면 db에서 조회해온 뒤 영속성 컨텍스트에 저장하고 그 오브젝트를 리턴한다.
어플리케이션 레벨에서 캐싱이 가능하단 뜻이다!

1
2
3
4
Member member1 = em.find(Member.class, "joont92");
Member member2 = em.find(Member.class, "joont92");

assertSame(member1, member2); // success

하이버네이트와 같은 ORM 프레임워크를 사용하지 않았다면
동일한 레코드임에도 불구하고 쿼리를 두번 날리는 결과가 발생했었을 것이다.

등록

em.persist를 통해 엔티티를 데이터베이스에 등록할 수 있다.
(정확히 얘기하면 persist는 해당 엔티티를 영속성 컨텍스트에 등록하라는 의미이다. 뒤에 나올 @GeneratedValue의 특징 떄문에 persist == 저장 이라고 착각할 수 있는데, 이는 틀렸다)
해당 메서드를 실행함과 동시에 데이터베이스에 바로 저장하는 것은 아니고, 먼저 영속성 컨텍스트에 저장한다.
근데 여기서 단순히 영속성 컨텍스트에 저장하는 작업만을 하는것은 아니고,
쓰기지연 SQL 저장소라는 곳에 insert 쿼리를 등록하는 작업까지 동시에 진행한다.
그리고 마지막에 flush가 일어나면 여기에 저장된 SQL을 데이터베이스로 발사!하는 것이다.
이런식으로 쿼리를 바로 날리지 않고 쓰기지연을 수행하는 이유는, 네트워크 통신 횟수를 줄여 이득을 취하기 위함이다.

추후에 나오겠지만 auto_generate key를 사용하는 데이터베이스는 이 flow대로 진행되지 않는다.
key 값을 얻어오기 위해 persist와 동시에 insert 쿼리를 실행해버린다.

어떻게 쓰기 지연이 가능할까?

  1. 데이터베이스에 트랜잭션이라는 개념이 있기 때문이다.
    데이터베이스에 DML을 아무리 날려도 commit을 하지 않으면 적용되지 않는다는 특징을 이용하여 쓰기 지연을 가능하게 할 수 있다.
  2. 데이터베이스에 직접 날리지 않고 쿼리를 메모리에 저장해두는 방식으로 가능하다.

수정

수정은 딱히 메서드가 존재하지 않는다.
이는 JPA에 변경감지라는 특징이 있기 때문이다.
엔티티가 처음으로 영속상태에 들어갈 경우, Map에 저장만 하는 것이 아니라 초기 엔티티의 스냅샷이라는 것을 찍어둔다.
그리고 마지막 flush가 일어날 때 영속성 컨텍스트에 저장된 엔티티의 속성 값들과 엔티티 스냅샷의 속성 값들을 비교한다.

메서드 실행 시점에 쿼리를 쌓는 다른 메서드들과는 달리, 엔티티 매니저가 flush될 때 쿼리를 생성한다. 즉, 시점이 다르다.
deep 탐색까진 하지 않는다(list의 member들까지 탐색하지는 않음)

그리고 변경이 일어났을 경우 update 메서드를 생성하여 이를 데이터베이스에 발사한다.(변경 감지)
(이러한 로직이므로 따로 dirty check가 필요없다)

생성되는 update문의 형태
실제로 update를 발생시켜보면 알겠지만, 업데이트가 발생하는 특정 속성에 대해서만 업데이트 하는 것이 아니라
전체 오브젝트에 대해 업데이트를 실행하는 쿼리가 생성된다.
이렇게 하면 매번 사용하는 수정 쿼리가 같다는 점을 이용한 것이고, 속성 하나하나에 대해 쿼리를 다 생성해놓지 않아도 된다는 장점이 있다(진짜 장점인가)
JPA가 로딩 시점에 업데이트 쿼리를 미리 생성해둔다.

하지만 필드의 내용이 너무 많을 경우 매번 이런식으로 풀 업데이트 쿼리를 날리는 것은 비효율적이다.
기본적으로 모든 컬럼을 다 보내는것 자체가 데이터 전송량 낭비이기 때문이다.
이때는 아래와 같이 @DynamicUpdate 어노테이션을 사용하면 수정된 데이터에 대해서만 update를 실행하는 쿼리를 생성한다.

1
2
3
4
5
6
@Entity
@Table(name = "MEMBER")
@org.hibernate.annotations.DynamicUpdate
class Member{

}

상황에 따라 다르지만 필드가 30개가 넘어가면 위와 같이 @DynamicUpdate를 쓰는 것이 좋다고 한다.
그리고 그 이전에, 30개가 넘어가는 테이블이면 정규화가 제대로 되지 않는다는 고민이 선행되어야 할 것이다.

삭제

엔티티를 삭제하려면 먼저 삭제 대상 엔티티를 조회해야 한다.

1
2
Member member = em.find(Member.class, "joont92");
em.remove(member);

em.detach + delete 쿼리라고 보면 된다.
메서드를 실행하면 해당 엔티티는 영속성 컨텍스트에서 detach 된다(그리고 delete 쿼리를 쓰기지연 SQL 저장소에 저장?)

병합

준영속 상태인 엔티티를 다시 영속상태로 만드는 행위를 말한다.
em.merge 메서드를 사용한다.

1
Member foundMember = em.merge(member);

member는 영속성 상태에서 제거된 준영속 상태이므로 식별자를 가지고 있다.
그러므로 위 메서드 실행 시, 해당 식별자로 조회쿼리가 날라가서 해당 엔티티가 영속화될 수 있는지(실제 존재하는지) 체크하게 된다.
있다면 영속성 컨텍스트에 저장하고, 새로운 엔티티를 리턴한다.
즉, foundMember와 member는 같지 않다.

기존 JPA 명세에 따르면, 전달받은 식별자로 해당 엔티티를 찾을 수 없을 경우(식별자가 전달되지 않은 경우도 마찬가지) IllegalArgumentException이 발생하게 된다.
하지만 hibernate 에서는 이럴 경우 그냥 새로 저장해주고, 영속성 컨텍스트에 등록하여 리턴해준다.(@GeneratedValue일 경우 식별자 generate, 아닐 경우 전달받은 식별자로 등록한다)

flush

영속성 컨텍스트의 변경 내용을 데이터베이스에 반영하는 행위이다.(커밋되는 것은 아니다)
쓰기지연 SQL에 저장된 SQL들을 발사!하고,
위에서 언급했듯이 영속성 컨텍스트에 들어있는 오브젝트와 스냅샷을 비교하여 업데이트 쿼리를 생성한 뒤, 업데이트 쿼리를 생성하여 발사!한다.

flush를 발생시키는 방법은 3가지 정도가 있다.

  1. em.flush() 메서드를 직접 호출

거의 사용할 일이 없다

  1. 트랜잭션 커밋 시 플러시 자동 호출

flush 하지 않고 commit 할 경우, SQL이 하나도 실행되지 않은 상태이기 떄문에 아무런 일도 일어나지 않는다.
JPA에서는 이런 상황을 방지하기 위해 commit시 flush를 자동으로 호출한다.

  1. JPQL 실행 시 플러시 자동 호출

JPQL은 호출시에 SQL로 변환되어 데이터베이스에서 조회해오는데, 이럴려면 레코드들이 이미 데이터베이스에 저장되어 있어야 한다.
persist와 JQPL 호출 작업을 한 트랜잭션 내에서 하는 행위를 방지하기 위해 위와 같이 처리한 듯 하다.

플러시 모드를 변경하려면 javax.persistence.FlushModeType을 사용하면 된다.

  • FlushModeType.AUTO : 커밋이나 쿼리를 실행할 때 플러시(default)
  • FlushModeType.COMMIT : 커밋할 때만 플러시

flush를 한다고 해서 영속성 컨텍스트에서 엔티티가 지워지는 것은 아니다!!
(이걸 신경쓸 일이 있곘느냐만…)