김대용
JPA

JPA 엔티티 라이프사이클에 대해 탐구해보자

--------------------

영속성 컨텍스트가 무엇인지에 대한 이해가 필요하다. 여기서 먼저 영속성 컨텍스트의 이해를 하고 오면 좋다.

JPA의 명세 내용을 기준으로 작성했고, 하이버네이트의 구현을 바탕으로 설명된 내용이 있으므로 참고하길 바란다.

엔티티의 생명주기

엔티티는 영속성 컨텍스트에 의해 생명주기가 관리된다. 생명주기는 4가지 상태가 존재한다.[1]

  • 비영속 (new/transient) : 영속성 컨텍스트가 모르는 상태
  • 영속 (persist) : 영속성 컨텍스트에 저장되고, 기억되고 있는 상태
  • 준영속 (detached) : 영속 상태였다가 영속성 컨텍스트가 더 이상 기억하지 않는 상태
  • 삭제 (removed) : 영속성 컨텍스트에 엔티티가 삭제될거라고 기록한 상태. 즉, 삭제되는걸 기다리는 상태

즉, 영속성 컨텍스트가 기억하고 있는지 없는지로 나눌 수 있다.

  • 기억 있음 : 영속, 삭제
  • 기억 없음 : 비영속, 준영속

엔티티들의 생명주기를 관리하기 위해서는 엔티티 매니저가 제공하는 연산(메서드)을 사용한다. 이 그림으로 알 수 있듯이, 영속성 컨텍스트와 관련 있는 영속, 삭제 상태는 플러시를 거쳐 데이터베이스와 동기화가 되는 대상이다.

엔티티 생명주기엔티티 생명주기

JPA로 엔티티를 조회, 추가, 수정, 삭제를 해보며 각 상태에 관해 이해를 해보자.

엔티티 저장하기

아래와 같은 엔티티 클래스가 있다고 가정하자.

@Entity
@Table(name = "MEMBER")
public class Member {
    @Id
    @Column(name = "ID")
    private String id;
    private String name;
    private int age;
    
    // Getters and Setters...
}

엔티티를 저장하기 위해선 엔티티 인스턴스를 만들어야한다. 아래처럼 갓 만들어진 인스턴스는 전혀 영속성 컨텍스트가 모른다. 이 상태를 비영속이라고 한다.

Member member = new Member("1", "김대용", 23);

비영속 상태의 엔티티비영속 상태의 엔티티

이를 저장(영속)하기 위해선 영속성 컨텍스트에 1차 캐시에 엔티티 인스턴스를 저장해야한다. 이는 엔티티 매니저의 persist() 메서드를 통해 저장할 수 있다. 해당 메서드가 호출된 후 엔티티는 영속 상태가 된다. 영속성 컨텍스트의 1차 캐시는 @Id로 매핑한 식별자를 키로, 엔티티 인스턴스를 값으로 가지는 Map이다.

EntityManager em = entityManagerFactory.createEntityManager();
// 트랜잭션 생략
em.persist(member);

영속성 컨텍스트에 저장된 엔티티영속성 컨텍스트에 저장된 엔티티

트랜잭션 커밋과 플러시

엔티티 매니저를 통해 엔티티 저장, 수정, 삭제를 할 땐 트랜잭션을 열어야한다. 작업이 완료된 후에는 커밋을 통해 트랜잭션을 반영한다.

Transaction tx = em.getTransaction();
tx.begin();   // 트랜잭션 시작
 
// 엔티티 저장, 수정, 삭제 작업들
em.persist(member1);
em.persist(member2);
em.persist(member3);
 
tx.commit();  // 트랜잭션 커밋

이전 글에서도 언급했듯이, 이때 영속성 컨텍스트는 엔티티 영속 요청이 일어날 때마다 데이터베이스에 SQL을 날리지 않는다. 대신 엔티티의 상태 변경 사항을 내부에 저장하고, commit()이 호출될 때 이를 한꺼번에 SQL로 변환해 데이터베이스에 날린다. 이 절차를 플러시(flush)라고 한다. 이는 쓰기 지연(write-behind) 전략이라고 하고, 이 때 영속성 컨텍스트는 엔티티의 상태 변경사항을 저장하는 트랜잭션 내 쓰기 지연 캐시로써 동작한다.

엔티티 매니저를 통한 플러시 과정엔티티 매니저를 통한 플러시 과정

엔티티 조회하기

엔티티를 조회하기 위해선 영속성 컨텍스트는 우선 1차 캐시에 해당 엔티티가 있는지 확인한다. 있으면 그 엔티티를 돌려주고, 없으면 데이터베이스에서 가져온다. 이때 엔티티는 영속 상태다.

영속성 컨텍스트에 저장된 엔티티 조회 (1차 캐시)

방금전에 영속성 컨텍스트에 저장한(1차 캐시에 저장된) 엔티티를 조회해보자. 엔티티 매니저의 find() 메서드로 조회할 수 있다. 영속성 컨텍스트가 기억하고 있는 엔티티라서 데이터베이스를 거치지 않고 캐싱된 엔티티를 돌려준다.

Member result = em.find(Member.class, "1");

DB를 거치지 않고도 영속성 컨텍스트에 저장된 엔티티를 돌려준다DB를 거치지 않고도 영속성 컨텍스트에 저장된 엔티티를 돌려준다

캐싱된 엔티티를 받아오는 덕분에 두 엔티티 변수가 같은 주소를 바라본다. 즉, 동일성을 지닌다.

member == result;   // true

영속성 컨텍스트가 모르는 엔티티 조회

영속성 컨텍스트가 모르는 엔티티면 그제서야 데이터베이스에서 데이터를 불러온다. 이때 DB에서 불러온 엔티티를 영속성 컨텍스트에 캐싱한다.

// 영속성 컨텍스트가 모르는 엔티티 ID
Member unknownEntityResolveResult = em.find(Member.class, "2");

영속성 컨텍스트가 모르는 엔티티는 DB에서 가져오고 캐싱한다영속성 컨텍스트가 모르는 엔티티는 DB에서 가져오고 캐싱한다

엔티티 수정하기

엔티티를 수정하기 위해서는 별 다른 호출이 필요없다. 그냥 영속 상태의 엔티티를 수정하면 끝이다.

Transaction tx = em.getTransaction();
tx.begin();   // 트랜잭션 시작
 
Member member = em.find(Member.class, "1");
member.setName("김머용");
member.setAge(24);
 
tx.commit();  // 트랜잭션 커밋

영속성 컨텍스트는 1차 캐시에 엔티티를 보관할 때(영속되거나 조회 시), 엔티티를 복사해 스냅샷도 같이 보관한다. 그래서 스냅샷과 비교해 달라진 점이 있는지 알 수 있다. 이를 더티 체킹(dirty checking)라고 한다. 이는 커밋 또는 플러시 시점에 일어난다.

엔티티와 스냅샷을 비교해 쓰기 지연 캐시에 작업을 추가한다.엔티티와 스냅샷을 비교해 쓰기 지연 캐시에 작업을 추가한다.

정리하자면, 트랜잭션 커밋 또는 플러시 요청이 일어나면 1차 캐시에 있는 엔티티와 스냅샷을 비교한다. 만약 달라진 점이 있다면 쓰기 지연 캐시에 엔티티 수정이 일어났음을 기록한다. 이후 쓰기 지연 캐시에 기록된 변경 사항을 SQL로 변환해 데이터베이스로 전달하고 commit을 한다.

엔티티 삭제하기

엔티티를 삭제하기 위해서는 remove() 메서드를 사용한다.

Transaction tx = em.getTransaction();
tx.begin();   // 트랜잭션 시작
 
Member member = em.find(Member.class, "1");
em.remove(member);
 
tx.commit();  // 트랜잭션 커밋

영속성 컨텍스트는 1차 캐시에 엔티티가 삭제됐다고 표기하고, 쓰기 지연 캐시에 엔티티 삭제가 일어났음을 기록한다. 삭제가 됐다고 기록하는거지 엔티티를 1차 캐시에서 삭제하는게 아니다. 이때 엔티티는 삭제 상태다.

1차 캐시에 엔티티가 삭제됐다고 표기하고, 쓰기 지연 캐시에 삭제 기록을 남겨둔다.1차 캐시에 엔티티가 삭제됐다고 표기하고, 쓰기 지연 캐시에 삭제 기록을 남겨둔다.

마찬가지로 트랜잭션 커밋 또는 플러시 요청이 일어나면 쓰기 지연 캐시에 기록된 삭제 기록을 바탕으로 DELETE SQL이 만들어져 데이터베이스에 전달한다.

흔히들 remove는 영속성 컨텍스트에서 엔티티를 삭제한다고 말한다.

준영속 상태

트랜잭션이 끝나고나면 영속 상태의 엔티티는 준영속 상태로 바뀐다. 즉, 영속성 컨텍스트에서 관리되던 영속 상태의 엔티티가 더이상 관리되지 않는 상태다.

준영속 상태로 바뀌는 경우는 총 네가지가 있다.

  1. tx.commit() / tx.rollback() : 트랜잭션 범위 영속성 컨텍스트에서 트랜잭션이 커밋되거나 롤백됄 때
  2. em.close() : 엔티티 매니저를 닫을 때. (사실 이때 예외사항이 있다. 추후 설명)
  3. em.detach() : 엔티티 매니저로 특정 엔티티를 준영속 상태로 만들 때
  4. em.clear() : 엔티티 매니저로 영속성 컨텍스트를 초기화시킬 때
  5. 엔티티가 깊은 복사됐거나 직렬화 됐을 때 : 당연히 영속성 컨텍스트가 가지고 있는 엔티티와는 다르니깐.

준영속 상태는 식별자 값을 가지고 있는 비영속 상태와 마찬가지다. 1차 캐시, 쓰기 지연, 더티 캐싱, 지연 로딩 등 영속성 컨텍스트가 제공하는 기능을 사용할 수 없다.

JPA 구현체간 이식성을 위해 준영속 상태로 만들기 전 플러시를 하자.

detach()로 준영속 상태 전환

엔티티 매니저가 제공하는 detach() 메서드를 이용해 준영속 상태로 전환할 수 있다. 엔티티가 준영속 상태로 바뀔 때, 영속성 컨텍스트의 1차 캐시에서 삭제된다. 따라서 준영속 상태로 바뀐 엔티티는 영속성 컨텍스트에 의해 관리되지 않으므로 데이터베이스와 연동이 되지 않는다.

Transaction tx = em.getTransaction();
tx.begin();   // 트랜잭션 시작
 
em.persist(member);
em.detach(member);
 
tx.commit();  // 트랜잭션 커밋

아까 remove()랑 헷갈리면 안된다! remove()는 데이터베이스에서 엔티티를 삭제하기 위해 영속성 컨텍스트가 해당 엔티티를 기억하고 있다. 대신, 이 엔티티가 삭제되어야한다고 기록을 해둔다. 반면 detach()는 1차 캐시에서 완전히 없애버린다. 1차 캐시에서 엔티티를 삭제한다.1차 캐시에서 엔티티를 삭제한다.

Hibernate에서 detach() 동작이 예상과는 살짝 다르게 동작하더라.

clear()로 준영속 상태 전환

detach()가 단일 엔티티를 영속성 컨텍스트에서 삭제하는거라면 clear()는 모든 영속 상태의 엔티티를 삭제해 준영속 상태로 만든다.

Member member1 = em.find(Member.class, "1");
Member member2 = new Member(Member.class, "2");
em.clear();

1차 캐시에서 모든 엔티티를 삭제한다.1차 캐시에서 모든 엔티티를 삭제한다.

close()로 준영속 상태 전환

close()는 어플리케이션 관리형 엔티티 매니저를 종료하는 메서드다. 어플리케이션 관리형 엔티티 매니저는 엔티티 매니저 팩토리를 통해 직접 만든 엔티티 매니저를 말한다. 이 메서드는 엔티티 매니저를 종료함으로써 영속성 컨텍스트도 같이 종료된다. 이때 영속 상태의 엔티티들은 모두 준영속 상태로 전환된다. 영속성 컨텍스트가 종료된다.영속성 컨텍스트가 종료된다.

그러나 예외사항이 하나 있다. 만약 해당 엔티티 매니저에 트랜잭션이 실행중이라면, 엔티티 매니저가 종료되더라도 트랜잭션이 끝날때까지 영속성 컨텍스트는 종료되지 않는다.[2] 영속성 컨텍스트가 종료되지 않았으므로 지연 로딩 기능이 동작하고, 쓰기 지연 캐시가 정상적으로 데이터베이스에 반영된다.

tx.begin();   // 트랜잭션 시작
Member member = new Member("1", "김대용", 23);
em.persist(member);
em.close();
tx.commit();  // 트랜잭션 커밋

merge()로 다시 영속 상태 전환

준영속 상태의 엔티티를 영속 상태로 전환하기 위해서는 merge() 메서드를 사용한다. 이 메서드의 동작방식을 수도코드로 살펴보자.

// merge 동작 방식 설명용 수도코드
function merge(준영속엔티티) {
  // 1. DB 또는 1차 캐시에서 준영속엔티티의 식별자를 이용해 엔티티를 불러온다.
  var 새엔티티 = find(준영속엔티티.id)
 
  // 2. 새엔티티의 내용을 준영속엔티티의 내용으로 바꾼다.
  새엔티티.name = 준영속엔티티.name
  새엔티티.age = 준영속엔티티.age
 
  // 3. 새엔티티를 돌려준다.
  return 새엔티티
}

merge()가 실행되고나면 넘겨줬던 준영속 엔티티를 그대로 돌려주지 않고, find()로 불러온 새 엔티티를 돌려준다. 이때 새 엔티티의 내용은 준영속 엔티티의 내용으로 바뀐 상태이다. 즉, 메서드에 전달한 엔티티와 돌려받은 엔티티는 서로 다르다. 따라서 준영속 엔티티를 참조하던 변수를 영속 엔티티를 참조하도록 바꾸는게 안전하다.

member = em.merge(member);

엔티티를 병합할 때 find()해 불러온 영속 상태 엔티티의 내용을 준영속 엔티티의 내용으로 바꿨다. 당연히 1차 캐시에 스냅샷해둔 내용과 달라졌기 때문에, flush()가 실행되면 더티 체킹이 일어나 변경된 내용을 데이터베이스에 반영한다.

비영속을 merge()하면 어떻게 될까?

병합은 준영속, 비영속을 상관하지 않는다. 식별자 값이 있으면(준영속) 엔티티를 조회해 병합하고, 없다면(비영속) 새로운 엔티티를 만들어 병합한다. 따라서 병합은 save or update 기능을 수행한다.

그러나 난 가능하다면 식별자 값이 있는지 직접 검사해 있다면 merge() 없다면 persist()를 호출하도록 하는게 좋다 생각한다.

참조

--------------------