본문 바로가기

Dev Book Review/자바 ORM 표준 JPA 프로그래밍

Chapter3. 영속성 관리

JPA가 제공하는 기능은 크게 1) 엔티티와 테이블을 매핑하는 설계 부분과 2) 매핑한 엔티티를 실제 사용하는 부분으로 나눌 수 있다.

본 포스팅에서 설명하는 내용은 매핑한 엔티티를 엔티티 매니저영속성 컨테스트를 통해 사용하는 동적인 부분에 해당한다.

 

1. EntityManagerFactory와 EntityManager

EntityManagerFactory는 이름 그대로 EntityManager를 만드는 공장이다. EntityManagerFactory를 만들기 위한 비용은 상당히 크기 때문에 일반적으로 1개를 만들어서 애플리케이션 전체에서 공유하도록 설계된다. 이후 EntityManagerFactory를 통해 다수의 EntityManager를 만들어서 사용한다

EntityManagerFactory와 EntityManager

앞서 설명한 거처럼 EntityManagerFactory는 여러 스레드가 동시에 접근해도 안전하기 때문에 서로 다른 스레드 간에 공유해도 되지만, EntityManager는 여러 스레드가 동시에 접근하면 동시성 문제가 발생하므로 스레드 간에 절대 공유하면 안된다.

 

또한 EntityManager는 데이터베이스 연결이 꼭 필요한 시점까지 커넥션을 얻지 않는다. 위 그림을 통해 예를 들면 EntityManager2는 현재 커넥션을 사용중이다. 보통 트랜잭션을 시작할 때 커넥션을 획득한다.

2. 영속성 컨텍스트

JPA를 이해하는 데 가장 중요한 개념 중 하나는 영속성 컨텍스트이다.

EntityManager는 영속성 컨텍스트를 통하여 Entity를 보관하고 관리한다고 생각하는 게 편하다. 하지만 영속성 컨텍스트는 논리적인 개념에 가깝고 눈에 보이지도 않는다. 또한 여러 EntityManager가 하나의 영속성 컨텍스트를 공유할 수도 있지만, 지금부터 이어질 설명은 하나의 EntityManager에 하나의 영속성 컨텍스트(1:1)가 만들어진다고 가정한다. (J2EE, 스프링 프레임워크와 같은 컨테이너 환경에서는 N:1로 설계되기도 한다)

3. Entity의 생명주기

Entity에는 4가지 상태가 존재한다.

Entity 생명주기

  • 비영속 : 영속성 컨텍스트와 전혀 관계가 없는 상태
  • 영속 : 영속성 컨텍스트에 저장된 상태
  • 준영속 : 영속성 컨텍스트에 저장되었다가 분리된 상태
  • 삭제 : 삭제된 상태
더보기

1. 비영속

Member member = new Member();
member.setId("member1");
member.setUsername("회원1");

Entity 객체를 생성만 했다. 이땐 순수 객체 상태이며 아직 저장하지 않았기 때문에 영속성 컨텍스트나 DB와는 전혀 관련 없다. 이러한 상태를 비영속 상태라고 한다.

 

2. 영속

em.persist(member)

EntityManager를 통해 Entity를 영속성 컨텍스트에 저장하였다. 이렇게 영속성 컨텍스트가 관리하는 Entity를 영속 상태에 있다고 한다. 또한 em.find()JPQL을 사용해서 조회한 Entity도 영속성 컨텍스트가 관리하는 영속 상태가 된다.

 

3. 준영속

em.detach(member);

영속성 컨텍스트가 관리하던 Entity를 더 이상 관리하지 않는다면 준영속 상태가 된다. 특정 Entity를 준영속 상태로 만들기 위해서는 em.detach()를 호출하면 된다. 또한 em.close()를 호출해서 영속성 컨텍스트를 닫거나, em.clear()를 호출하여 영속성 컨텍스트를 초기화해도 해당 영속성 컨텍스트가 관리하던 Entity는 준영속 상태가 된다.

 

4. 삭제

em.remove(member);

Entity를 영속성 컨텍스트와 데이터베이스에서 삭제한다.

4. 영속성 컨텍스트의 특징

지금까지의 내용을 살펴보면서 도대체 왜 영속성 컨텍스트를 사용하는지 의문이 들 수 있다. 다음과 같은 장점이 있기 때문이다!

 

1) 1차 캐시

영속성 컨텍스트는 내부에 캐시를 가지고 있는데 이를 1차 캐시라고 한다. 영속 상태의 Entity는 모두 여기에 저장된다.

Member member = new Member();
member.setId("member1");
member.setUsername("회원1");

em.persist(member);

영속성 컨텍스트 1차 캐시

위와 같은 코드를 실행한다면 1차 캐시에 회원 엔티티를 저장한다. (DB에는 아직 저장되지 않는다)

이때 1차 캐시의 키는 @Id로 매핑한 식별자 값(테이블의 기본 키와 매핑)이다. 따라서 영속 상태의 Entity는 반드시 식별자 값을 가지고 있어야 한다. 그렇지 않다면 예외가 발생한다. 또한 영속성 컨텍스트에 데이터를 저장하고 조회하는 모든 기준은 DB의 기본 키 값이다.

 

그렇다면 Entity을 조회하는 과정은 어떻게 이뤄질까? 다음과 같은 2가지 경우로 나눌 수 있다.

  • 1차 캐시에 Entity가 있을 때
  • 1차 캐시에 Entity가 없을 때
Member member = em.find(Member.class, "member1");

1차캐시 조회(좌) - 데이터베이스 조회(우)

em.find()를 통해 엔티티 클래스 타입조회할 엔티티의 식별자 값을 전달한다. 

먼저 전달된 값을 통해 1차 캐시에서 해당 Entity를 찾는다. 만약 찾는 Entity가 있다면 데이터베이스를 조회할 필요 없이 메모리에 있는 1차 캐시에서 Entity를 조회한다. 그렇지 않다면 EntityManager는 데이터베이스를 조회하여 Entity를 생성한다. 그리고 1차 캐시에 저장한 후에 영속 상태의 Entity를 반환하게 된다.

 

따라서 영속성 컨텍스트 내부의 1차 캐시를 통해 성능상 이점을 누릴 수 있다는 장점이 있다.

 

2) 동일성 보장

Member a = em.find(Member.class, "member1");
Member b = em.find(Member.class, "member1");

위와 같은 코드를 계속해서 실행하여도 영속성 컨텍스트는 항상 1차 캐시에 있는 같은 Entity 인스턴스를 반환한다. 

 

3) 트랜잭션을 지원하는 쓰기 지연

EntityManager는 트랜잭션을 커밋하기 직전까지 데이터베이스에 Entity를 저장하지 않고 쓰기 지연 SQL 저장소라고 하는 내부 쿼리 저장소에 INSERT SQL을 차곡차곡 모아둔다. 그리고 트랜잭션을 커밋할 때가 되서야 모아둔 쿼리를 한꺼번에 데이터베이스에 날린다. 

 

EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();
transaction.begin(); //[트랜잭션] 시작

em.persist(memberA);
em.persist(memberB);

transaction.commit(); //[트랜잭션] 커밋

em.persist(memberA)와 em.persist(memberB)를 실행하면 1차 캐시에 회원 엔티티를 저장함과 동시에 INSERT SQL을 생성하여 쓰기 지연 SQL 저장소에 모아둔다. 이때 아직 데이터베이스에는 회원 엔티티가 저장되지 않았다. 이후 트랜잭션을 커밋하면 쓰기 지연 SQL 저장소에 모아둔 쿼리를 한꺼번에 데이터베이스로 날리면서 실제 데이터베이스 트랜잭션을 커밋한다.

 

Member memberA = em.find(Memer.class, "memberA");
em.remove(memberA);

마찬가지로 Entity를 삭제할 때도 em.remove()를 통하여 DELETE 쿼리를 쓰기 지연 SQL 저장소에 모아둔다. 이후 트랜잭션을 커밋하면 앞서 저장된 쿼리들과 함께 실제 데이터베이스에 전달된다. 다만 이때 em.remove()를 호출하는 순간 영속성 컨텍스트에서는 바로 제거된다.

 

이러한 쓰기 지연이 가능한 이유는 트랜잭션 범위 안에서 실행되기 때문이다.

transaction.begin();

save(A);
save(B);
save(C);

transaction.commit();

예를 들어 위 코드를 실행했을 때 A, B, C 엔티티에 대한 INSERT 쿼리를 날리더라도 결국 트랜잭션을 커밋하지 않는다면 아무 소용이 없다. 만약 트랜잭션이 롤백된다면 앞서 날린 INSERT 쿼리들도 모두 취소된다. 결론적으로 어쨌든 트랜잭션 커밋 직전에만 SQL 쿼리를 날리게 된다면 다 똑같다는 의미이다. 이를 활용하여 SQL 쿼리들을 한꺼번에 모아서 전달한다면 성능을 최적화할 수 있다는 장점이 있다.

 

4) 변경 감지

영속 상태 Entity의 데이터를 변경하면 EntityManager는 변경사항을 알아차리고 데이터베이스에 자동으로 반영한다.

transaction.begin(); //[트랜잭션] 시작

Member memberA = em.find(Member.class, "memberA");

memberA.setUsername("hi");
memberA.setAge(10);

transaction.commit(); //[트랜잭션] 커밋

변경 감지

JPA는 Entity를 영속성 컨텍스트에 보관할 때 최초 상태를 복사하여 저장해둔다. 이걸 스냅샷이라 한다. 이후 flush()가 일어나는 시점에 Entity와 스냅샷을 비교하여 변경사항이 있는 Entity에 대한 UPDATE 쿼리를 생성하여 쓰기 지연 SQL 저장소에 모은다. 그리고 한꺼번에 데이터베이스에 날리면서 변경사항을 반영하게 된다. (트랜잭션을 커밋할 때 먼저 flush()가 호출되는데 이는 아래에서 설명한다)

 

이러한 변경 감지는 영속 상태의 Entity에만 적용되며 비영속/준영속 상태의 Entity는 데이터가 변경되더라도 데이터베이스에 반영되지 않는다. 또한 단순히 수정된 데이터에 대해서만 UPDATE 쿼리가 생성되는 게 아니라 모든 필드에 대해서 UPDATE 쿼리를 생성한다. 덕분에 항상 같은 UPDATE 쿼리를 사용할 수 있고, 이전에 한 번 파싱된 쿼리를 재사용할 수 있다는 장점이 있다. (만약 필드가 너무 많거나 내용이 너무 크다면 수정된 데이터만 사용해서 동적으로 쿼리를 생성하는 전략을 사용할 수도 있기는 하다)

5. 플러시

flush()는 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영한다. 구체적으로는 변경 감지가 동작하여 UPDATE 쿼리를 쓰기 지연 SQL 저장소에 등록하며, 쓰기 지연 SQL 저장소의 쿼리(INSERT, UPDATE, DELETE)를 데이터베이스에 전달한다.

 

영속성 컨텍스트를 플러시하는 방법은 3가지다.

  • em.flush() 직접 호출
  • 트랜잭션 커밋 시 flush()가 자동 호출
  • JPQL 쿼리 실행 시 flush()가 자동 호출
더보기

1. em.flush() 직접 호출

영속성 컨텍스트를 강제로 플러시한다. 테스트나, 다른 프레임워크와 JPA를 함께 사용할 때를 제외하고 거의 사용하지 않는다.

 

2. 트랜잭션 커밋 시 flush()가 자동 호출

영속성 컨텍스트의 변경 내용을 데이터베이스에 반영하지 않고 트랜잭션만 커밋하면 어떠한 데이터도 데이터베이스에 반영되지 않는다. 따라서 JPA는 이런 문제를 예방하기 위해 트랜잭션을 커밋할 때 flush()를 자동으로 호출한다.

 

3. JPQL 쿼리 실행 시 flush()가 자동 호출

em.persist(memberA);
em.persist(memberB);
em.persist(memberC);

query = em.createQuery("select m from Member m", Member.class);
List<Member> members = query.getResultList();

위처럼 Entity가 데이터베이스에 반영되기 이전에 JPQL 쿼리를 실행하게 된다면 쿼리 결과가 조회되지 않는다. 따라서 쿼리를 실행하기 직전에 영속성 컨텍스트를 flush()해서 변경 내용을 데이터베이스에 반영해야하며 JPA가 이를 자동 호출하도록 지원한다. 참고로 find() 메소드는 식별자 값으로 조회하기 때문에 flush()가 실행되지 않는다.

또한 flush()는 영속성 컨텍스트의 내용을 지우지 않는다. 단순히 영속성 컨텍스트의 변경 내용을 데이터베이스에 동기화하는 것이다. 

6. 준영속

앞서 설명한 거처럼 영속성 컨텍스트가 관리하던 영속 상태의 Entity가 영속성 컨텍스트에서 분리된 것을 준영속 상태라고 한다. 따라서 준영속 상태의 Entity는 영속성 컨텍스트가 제공하는 기능을 사용할 수 없다. 예를 들어 변경 감지가 동작하지 않기 때문에 Entity의 데이터를 변경하더라도 데이터베이스에 반영되지 않는다.

 

영속 상태의 Entity를 준영속 상태로 만드는 방법은 크게 3가지다.

  • em.detach(entity); : 특정 엔티티를 준영속 상태로 전환한다.
  • em.clear(); : 영속성 컨텍스트를 완전히 초기화한다.
  • em.close(); : 영속성 컨텍스트를 종료한다.
더보기

1. em.detach(entity);

영속성 컨텍스트는 더 이상 해당 Entity를 관리하지 않는다. 따라서 1차 캐시부터 쓰기 지연 SQL 저장소까지 해당 Entity를 관리하기 위한 모든 정보가 제거된다.

 

2. em.clear();

영속성 컨텍스트를 초기화하여 해당 영속성 컨텍스트의 모든 Entity를 준영속 상태로 만들어버린다.

 

3. em.close();

영속성 컨텍스트를 종료하여 해당 영속성 컨텍스트가 관리하던 영속 상태의 Entity를 모두 준영속 상태로 만든다.

준영속 상태는 다음과 같은 특징을 가진다.

  1. 거의 비영속 상태에 가깝다. 따라서 영속성 컨텍스트가 제공하는 어떠한 기능도 동작하지 않는다.
  2. 준영속 상태는 이미 한 번 영속 상태를 거쳤으므로 반드시 식별자 값을 가지고 있다.
  3. 영속성 컨텍스트가 더 이상 관리하지 않기 때문에 지연 로딩을 할 수 없다.

또한 준영속 상태의 Entity를 다시 영속 상태로 변경하기 위해서는 merge() 메소드를 사용한다.

merge() 메소드는 준영속 상태의 Entity를 받아서 해당 정보로 새로운 영속 상태의 Entity를 반환한다. 따라서 파라미터로 넘어온 Entity는 여전히 준영속 상태로 남아있게 된다. 그리고 merge()는 파라미터로 넘어온 Entity의 식별자 값으로 영속성 컨텍스트를 조회하고, 없다면 데이터베이스에서 조회한다. 그마저도 존재하지 않는다면 새로운 Entity를 생성해서 병합한다. 즉, merge()는 준영속/비영속을 신경 쓰지 않는다. 따라서 merge()는 save or update 기능을 수행한다.