자바 ORM 표준 JPA 프로그래밍 - 기본편 #3 영속성 관리

Lee Han Sol·2021년 9월 27일
1
post-thumbnail

자바 ORM 표준 JPA 프로그래밍 - 기본편

이 글은 김영한님의 Inflearn 강의를 학습한 내용을 정리하였습니다.
글에 포함된 그림의 출처는 자바 ORM 표준 JPA 프로그래밍 - 기본편 강의와 자바 ORM 표준 JPA 프로그래밍입니다.

목표

이전 글에서 맛보기로 알아본 엔티티 매니저 팩토리엔티티 매니저에 대해서 알아보고
영속성 컨텍스트가 무엇이고 어떤 특징이 있는지 그리고 엔티티의 생명주기와 관련된 플러시, 영속성의 개념에 대해서 알아보자.

  • 엔티티 매니저 팩토리
  • 엔티티 매니저
  • 영속성 컨텍스트
  • 엔티티의 생명주기
  • 플러시
  • 영속성

엔티티 매니저 팩토리와 엔티티 매니저

JPA를 사용할 때 반드시 알아야하는 개념인 엔티티 매니저 팩토리엔티티 매니저에 대해서 알아보자.

엔티티 매니저 팩토리는 클라이언트의 요청이 왔을 때 엔티티 매니저를 생성하는 역할을 한다.
그리고 생성된 엔티티 매니저는 엔티티를 저장,수정,삭제,조회하는 등 엔티티와 관련된 일을 처리한다.
그림으로 표현하면 아래와 같다.

엔티티 매니저 팩토리

역할

엔티티 매니저를 생성한다.

코드

엔티티 매니저 팩토리 객체 생성 코드이다.

EntityManagerFactory emf = 
    Persistence.createEntityManagerFactory("jpabook");

이때 META-INF/persistence.xml 에 있는 정보를 바탕으로 EntityManagerFactory 를 생성한다.

엔티티 매니저

역할

엔티티 관리자로서 엔티티와 관련된 모든 일을 담당한다.

코드

엔티티 매니저 객체 생성 코드이다.

EntityManager em = emf.createEntityManager();

주의사항

엔티티 매니저는 여로 스레드가 동시에 접근하면 동시성 문제가 발생하므로 스레드 간에 공유하면 안된다.
반면에, 엔티티 매니저 팩토리는 여러 스레드가 동시에 접근해도 안전하다.

엔티티 매니저가 DB 커넥션을 얻는 시점
엔티티 매니저는 DB 연결이 꼭 필요한 시점에 커넥션을 얻는다. (객체 생성 시점이 아니다.)


영속성 컨텍스트

영속성 컨텍스트란
'엔티티를 영구 저장하는 환경'이다.

이전 글에서는 em.persist(member); 코드가 회원 엔티티를 DB에 저장한다고 표현했다.
영속성 컨텍스트를 알고 난 뒤 위 코드는 엔티티 매니저를 사용해서 회원 엔티티를 영속성 컨텍스트에 저장한다로 이해할 것이다.

엔티티 매니저와 영속성 컨텍스트

이 글의 제일 처음에 엔티티 매니저를 알아본 이유는 영속성 컨텍스트의 생성 시점을 설명하기 위함이다.

영속성 컨텍스트는 엔티티 매니저를 생성할 때 하나 만들어진다. 그리고 엔티티 매니저를 통해서 영속성 컨텍스트에 접근할 수 있고 영속성 컨텍스트를 관리할 수 있다.

여러 엔티티 매니저가 같은 영속성 컨텍스트에 접근할 수 있다. (자세한 내용은 책의 11장 학습까지 미뤄둔다.)
(하나의 엔티티 매니저 생성 != 하나의 영속성 컨텍스트 생성)


엔티티의 생명주기

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

  • 비영속(new/transient): 영속성 컨텍스트와 전혀 관계가 없는 상태
  • 영속(managed): 영속성 컨텍스트에 저장된 상태
  • 준영속(detached): 영속성 컨텍스트에 저장되었다가 분리된 상태
  • 삭제(removed): 삭제된 상태

아래 그림은 엔티티의 생명 주기이다.

비영속

엔티티 객체를 생성한 시점의 상태이다.
따라서 영속성 컨텍스트나 데이터베이스와 전혀 관련이 없다.
이것을 비영속 상태라 한다.

//객체를 생성
Member member = new Member();
member.setId("member1");
member.setUsername("회원1");

영속

영속성 컨텍스트가 관리하는 엔티티를 영속 상태라고 한다.
(영속 상태의 객체는 엔티티 객체를 생성해서 em.persist(member);를 수행한 경우 또는 em.find(Member.class, "member1"); 얻은 경우가 있다.)

//객체를 영속성 컨텍스트에 저장
em.persist(member);

준영속

영속 상태의 엔티티를 영속성 컨텍스트가 관리하지 않으면 준영속 상태라고 한다.

준영속 상태로 만드는 방법은 아래의 메소드를 수행하면 된다.

  • em.detach(member)
  • em.close()
  • em.clear()

삭제

엔티티를 영속성 컨텍스트와 데이터베이스에서 삭제한 상태를 말한다.

//엔티티를 영속성 컨텍스트와 데이터베이스에서 삭제
em.remove(member);

영속성 컨텍스트의 특징

  • 영속성 컨텍스트가 관리하는 엔티티는 식별자 값이 반드시 존재한다.

  • 영속성 컨텍스트는 트랜잭션을 커밋하는 순간 SQL을 실행한다.


영속성 컨텍스트가 필요한 이유

영속성 컨텍스트가 엔티티를 관리할 때 장점은 아래와 같다.

  • 1차 캐시
  • 동일성(identity) 보장
  • 트랜잭션을 지원하는 쓰기 지연 (transactional write-behind)
  • 변경 감지 (Dirty Checking)
  • 지연 로딩 (Lazy Loading)

각 항목의 자세한 내용은 엔티티 CRUD를 통해서 알아보자.

1차 캐시

1차 캐시 개념은 엔티티 등록 상황을 통해 알아보자.

일단 영속 상태를 만들기 위해 회원 객체 하나를 생성해 em.persist()를 수행한다.
코드는 아래와 같다.

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

em.persist(member);

이 코드가 실행되면 영속성 컨텍스트는 아래와 같아진다.

영속성 컨텍스트는 영속 상태의 엔티티를 관리하기 위해 내부 캐시를 가지고 있다. 이를 1차 캐시라 한다.
1차 캐시를 쉽게 이야기하면 키는 @Id로 매핑한 필드, 값은 엔티티 인스턴스를 갖는 영속성 컨텍스트에 있는 Map이다.

1차 캐시는 영속성 컨텍스트가 필요한 이유 항목들의 기본이 된다.


동일성 보장

같은 트랜잭션인 상황이라 가정한다.

동일성(identity) 보장은 엔티티 조회 상황을 통해 알아보자.

em.find(Member.class, "member1");을 호출하면 먼저 1차 캐시에서 엔티티를 찾고 만약 찾는 엔티티가 1차 캐시에 없다면 데이터베이스에서 조회한다.

일단 1차 캐시에 엔티티 식별자 값이 존재하는 경우의 과정을 보자.

그리고 1차 캐시에 엔티티 식별자 값이 없는 경우 DB에서 조회하는 과정을 보자.

DB에서 조회 과정 설명
1. em.find(Member.class, "member2")를 실행한다.
2. member2가 1차 캐시에 없으므로 데이터베이스에서 조회한다.
3. 조회한 데이터로 member2 엔티티를 생성해서 1차 캐시에 저장한다. (영속 상태가 된다.)
4. 영속화 된 엔티티를 반환한다.

위 과정들을 봤을 때 동일성 보장이 어떻게 이뤄지는지 알 수 있다.

em.find()를 호출하면 일단 1차 캐시에서 식별자 값을 찾는다.
만약 있다면 반환한다.(이 경우 SQL을 실행하지 않는다.)
1차 캐시에 없다면 2번째 그림대로 DB에서 조회후 1차 캐시에 저장한 영속 상태의 엔티티를 반환한다.
이후 똑같은 식별자 값의 엔티티를 조회할 경우 영속성 컨텍스트에서 반환하는 엔티티이기 때문에 동일성이 보장된다.

여기까지 정리해보면 영속성 컨텍스트는 내부 캐시를 갖고 있다. 이를 1차 캐시라고 한다.
em.find()를 수행하면 영속성 컨텍스트의 식별자에 해당하는 엔티티를 반환한다.
만약 엔티티가 없다면 DB에서 조회한 결과를 1차 캐시에 저장·반환한다.
엔티티가 있다면 DB 조회 없이 바로 반환한다.
이런 매커니즘으로 동작하여 SQL 실행을 줄여 성능상 이점을 갖고 엔티티 객체의 동일성을 보장한다.


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

트랜잭션을 지원하는 쓰기 지연은 여러 개의 엔티티를 등록하는 상황을 통해 알아보자.

EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();
//엔티티 매니저는 데이터 변경 시 트랜잭션을 시작해야 한다.
transaction.begin(); //[트랜잭션] 시작

em.persist(memberA);
em.persist(memberB);
// 여기까지 INSERT SQL을 데이터베이스에 보내지 않는다.  

//커밋하는 순간 데이터베이스에 INSERT SQL을 보낸다.  
transaction.commit(); //[트랜잭션] 커밋

이 코드를 수행하면 영속성 컨텍스트는 아래의 과정을 거친다.

일단 em.persist(memberA);를 수행하면 1차 캐시와 쓰기 지연 SQL 저장소에 MemberA에 해당하는 INSERT SQL을 저장한다.

그리고 em.persist(memberB);를 수행하면 1차 캐시와 쓰기 지연 SQL 저장소에 MemberB에 해당하는 INSERT SQL을 추가로 저장한다.

쓰기 지연 SQL 저장소
영속성 컨텍스트는 1차 캐시 공간에 추가로 쓰기 지연 SQL 저장소를 가지고 있다.
쓰기 지연 SQL 저장소는 DB에 등록해야될 엔티티 객체에 대한 INSERT 쿼리를 저장해둔다.

마지막으로 트랜잭션을 커밋(transaction.commit())할 때 쓰기 지연 SQL 저장소에 있는 SQL을 플러시한 뒤에 transaction 커밋을 수행한다.

플러시란
영속성 컨텍스트의 변경 내용을 데이터베이스에 동기화하는 작업이다.
이때 등록, 수정, 삭제한 엔티티에 대한 SQL을 데이터베이스에 보낸다.

위 과정처럼 em.persist()를 수행할 때 매번 SQL을 전달하는게 아니라 transaction.commit()을 수행할 때 SQL을 전달한다.
이게 바로 트랜젝션을 지원하는 쓰기 지연이다.

그럼 쓰기 지연이 가능한 이유는 뭘까 아래에서 알아보자.

트랜젝션을 지원하는 쓰기 지연이 가능한 이유

아래의 로직을 보면서 SQL을 데이터베이스로 보내는 시점을 2가지 경우로 나눠보자.

begin(); //트랜잭션 시작

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

commit(); //트랜잭션 커밋
  1. save()를 호출할 때마다 보낸다.
  2. save()를 호출하면 저장 SQL을 메모리에 모은 뒤 commit()을 호출하고 데이터베이스에 commit이 수행되기 직전에 보낸다.

이 둘의 결과는 같다. 데이터베이스는 트랜젝션 커밋 전까지는 작업 내용이 반영되지 않는다.
따라서 커밋 직전에만 데이터베이스에 SQL을 전달하면 된다.
이것이 트랜잭션을 지원하는 쓰기 지연이 가능한 이유다.


변경 감지

이전 글에서 JPA를 이용하면 엔티티 값을 변경했을 때 em.persist() 또는 em.update()를 수행안해도 DB에 변경이 반영된다고 했다.

어떤 매커니즘으로 이게 가능하고 JPA를 사용하면 얻는 이점에 대해서 알아보자.

이번에는 엔티티 수정 상황을 예로든다.

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

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

//영속 엔티티 데이터 수정 
memberA.setUsername("hi");
memberA.setAge(10);

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

위 코드를 실행하면 영속성 컨텍스트는 아래 과정을 수행한다.

1차 캐시에 스냅샷이 추가되었다.
스냅샷이란 엔티티를 영속성 컨텍스트에 보관할 때, 최초 상태를 복사해서 저장해둔 것이다.
스냅샷은 플러시 시점에 스냅샷과 엔티티를 비교해서 변경된 엔티티를 찾을 때 이용한다.

  1. 트랜잭션을 커밋하면 엔티티 매니저 내부에서 먼저 플러시(flush())가 호출된다.
  2. 엔티티와 스냅샷을 비교해서 변경된 엔티티를 찾는다.
  3. 변경된 엔티티가 있으면 수정 쿼리를 생성해서 쓰기 지연 SQL 저장소에 보낸다.
  4. 쓰기 지연 저장소의 SQL을 데이터베이스에 보낸다.
  5. 데이터베이스 트랜잭션을 커밋한다.

주의사항!
변경 감지는 영속성 컨텍스트가 관리하는 영속 상태의 엔티티에만 적용된다.
비영속, 준영속처럼 영속성 컨텍스트의 관리 밖의 엔티티는 값을 변경해도 데이터베이스에 반영되지 않는다.

위 과정을 통해 JPA는 엔티티 변경사항을 데이터베이스에 자동으로 반영해준다.

변경 감지 기본 전략

JPA의 변경 감지 기본 전략은 엔티티의 모든 필드를 업데이트한다.

아래의 엔티티 객체와 값을 변경하는 코드를 예로 보자.

@Entity
public class Member {
  @Id
  private Long id;
  private String name;
  private int age;
  private int grade;
  // getter, setter
}
Member member = em.find(Member.class, 1L);
member.setAge(10);

조회한 member의 나이를 10으로 수정했을 때 예상되는 SQL은 아래와 같다.

UPDATE MEMBER
SET
  AGE=?
WHERE
  id=?

하지만 JPA가 실제 생성하는 SQL은 아래와 같다.

UPDATE MEMBER
SET
  NAME=?
  AGE=?
  GRADE=?
WHERE
  id=?

이렇게 모든 필드를 사용하면 데이터베이스에 보내는 데이터 전송량이 증가하는 단점이 있지만,
다음과 같은 장점으로 인해 모든 필드를 업데이트한다.

  • 모든 필드를 사용하면 수정 쿼리가 항상 같다.
    따라서 애플리케이션 로딩 시점에 수정 쿼리를 미리 생성해두고 재사용할 수 있다.

  • 데이터베이스에 동일한 쿼리를 보내면 데이터베이스는 이전에 한 번 파싱된 쿼리를 재사용할 수 있다.

변경 감지를 정리해보면 업데이트를 코드를 따로 작성하지 않아도 된다.
1차 캐시에서 스냅샷 비교를 통해 update sql을 생성해주기 때문이다.
다만 변경 감지의 대상은 영속 상태 엔티티이다.

JPA는 기본 전략으로 UPDATE SQL에 모든 필드를 포함한다.
이유는 수정 쿼리가 항상 같고 데이터베이스가 파싱된 쿼리를 재사용할 수 있다는 장점이 있기 때문이다.


플러시

플러시는 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영한다.

플러시를 수행하면 구체적으로 아래의 일이 일어난다.

플러시 수행 과정

  1. 변경 감지가 동작해서 영속성 컨텍스트에 있는 모든 엔티티를 스냅샷과 비교한다.
    수정된 엔티티가 있다면 수정 쿼리를 만들어 쓰기 지연 SQL 저장소에 등록한다.

  2. 쓰기 지연 SQL 저장소의 쿼리를 데이터베이스에 전송한다.
    (등록, 수정, 삭제 쿼리가 저장된다. 조회 쿼리는 저장되지 않고 바로바로 수행한다.)

영속성 컨텍스트 플러시 방법

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

  • em.flush() 직접 호출한다.
  • 트랜잭션 커밋 시 플러시가 자동 호출된다.
  • JPQL 쿼리 실행 시 플러시가 자동 호출된다.

직접 호출

엔티티 매니저의 flush() 메소드를 직접 호출해서 영속성 컨텍스트를 강제로 플러시한다.
특수한 상황을 제외하고는 거의 사용하지 않는다.
(특수한 상황에는 테스트 또는 다른 프레임워크와 JPA를 사용하는 상황이 있다.)

flush()를 수행해도 1차 캐시의 데이터는 지워지지 않는다.

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

커밋은 데이터베이스에 작업 내용을 반영하는 것이고 플러시는 SQL을 데이터베이스로 전달하는 것이다.
따라서 커밋전 플러시를 하지 않으면 어떠한 데이터의 추가, 수정, 삭제도 반영되지 않는다.
JPA는 이런 문제를 예방하기 위해 트랜잭션을 커밋할 때 플러시를 자동으로 호출한다.

JPQL 쿼리 실행 시 플러시 자동 호출

JPQL이나 Criteria 같은 객체지향 쿼리를 호출할 때도 플러시가 실행된다.
이유는 아래의 예를 통해 알아보자.

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

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

먼저 em.persist()를 호출해서 엔티티 memberA, memberB, memberC를 영속 상태로 만들었다.
하지만 데이터베이스에 반영되지는 않았다.

이때 JPQL을 실행하면 memberA, memberB, memberC는 아직 데이터베이스에 없기때문에 쿼리 결과로 조회되지 않는다.
따라서 JPA는 이런 문제를 예방하기 위해 JPQL을 실행할 때도 플러시를 자동 호출한다.

참고
식별자를 기준으로 조회하는 find() 메소드를 호출할 때는 플러시가 실행되지 않는다.

플러시 모드 옵션

가급적 플러시 모드는 기본값 사용을 권장한다.

  • FlushModeType.AUTO: 커밋이나 쿼리를 실행할 때 플러시한다.(기본값)
  • FlushModeType.COMMIT: 커밋할 때만 플러시한다.

코드

em.setFlushMode(FlushModeType.COMMIT) //플러시 모드 직접 설정

준영속 상태

준영속 상태란
영속성 컨텍스트가 관리하는 영속 상태의 엔티티가 영속성 컨텍스트에서 분리된 것이다.

준영속 엔티티는 영속성 컨텍스트가 제공하는 기능을 사용할 수 없다.

준영속 상태로 만드는 방법

영속 엔티티를 준영속 엔티티로 만드는 방법은 아래 3가지이다.

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

엔티티를 준영속 상태로 전환: detach()

예제를 통해 detach() 메소드 사용과 영속성 컨텍스트의 상태 변화에 대해서 알아보자.

public void testDetached() {
  ...
  //회원 엔티티 생성, 비영속 상태 
  Member member = new Member();
  member.setId("memberA");
  member.setUsername("회원A");
  
  //회원 엔티티 영속 상태 
  em.persist(member);
  
  //회원 엔티티를 영속성 컨텍스트에서 분리, 준영속 상태 
  em.detach(member);
  
  transaction.commit(); //트랜잭션 커밋 
}

위 코드를 수행하면 member에 대한 영속성 컨텍스트의 상태는 아래의 과정을 거친다.

em.detach(member)를 호출하면 영속성 컨텍스트에 detach(memberA)를 전달한다.

그러면 영속성 컨텍스트는 아래의 과정으로 memberA에 대한 정보를 삭제한다.

과정은 아래와 같다.

  1. detach(memberA) 전달 받았다.
  2. 1차 캐시에 memberA에 대한 정보를 제거한다.
  3. 쓰기 지연 SQL 저장소에서 memberA에 관련된 SQL을 제거한다.

이렇게 영속 상태에서 영속석 컨텍스트가 관리하지 않는 상태를 준영속 상태라고 한다.


영속성 컨텍스트 초기화: clear()

em.detach()가 특정 엔티티 하나를 준영속 상태로 만들었다면 em.clear()는 영속성 컨텍스트를 초기화해서 해당 영속성 컨텍스트의 모든 엔티티를 준영속 상태로 만든다.

//엔티티 조회, 영속 상태 
Member member = em.find(Member.class, "memberA");

em.clear(); //영속성 컨텍스트 초기화 

//준영속 상태
member.setUsername("changeName");

위 코드를 실행하면 영속성 컨텍스트 상태는 아래와 같이 변한다.

영속성 컨텍스트 종료: close()

영속성 컨텍스트를 종료하면 해당 영속성 컨텍스트가 관리하던 영속 상태의 엔티티가 모두 준영속 상태가 된다.

public void closeEntityManage() {
  EntityManagerFactory emf = 
      Persistence.createEntityManagerFactory("jpabook");
      
  EntityManager em = emf.createEntityManager();
  EntityTransaction transaction = em.getTransaction();
  
  transaction.begin(); //[트랜잭션] - 시작
  
  Member memberA = em.find(Member.class, "memberA");
  Member memberB = em.find(Member.class, "memberB");
  
  transaction.commit(); //[트랜잭션] - 커밋 
  
  em.close(); //영속성 컨텍스트 닫기(종료)
}

위 코드를 실행하면 영속성 컨텍스트 상태는 아래와 같이 변한다.

준영속 상태의 특징

  • 거의 비영속 상태에 가깝다.
    영속성 컨텍스트가 관리하지 않기 때문에 1차 캐시, 쓰기 지연, 변경 감지, 지연 로딩을 포함한 영속성 컨텍스트가 제공하는 어떠한 기능도 동작하지 않는다.

  • 식별자 값을 가지고 있다.
    비영속 상태는 식별자 값이 없을 수도 있지만 준영속 상태는 이미 한 번 영속 상태였으므로 반드시 식별자 값을 가지고 있다.

  • 지연 로딩을 할 수 없다.
    지연 로딩(Lazy Loading)은 실제 객체 대신 프록시 객체를 로딩해두고 해당 객체를 실제 사용할 때 영속성 컨텍스트를 통해 데이터를 불러오는 방법이다.
    하지만 준영속 상태는 영속성 컨텍스의 관리 대상이 아니라서 지연 로딩 시 문제가 발생한다.

정리

JPA의 가장 기본이면서 중요한 영속성 컨텍스트에 대해 알아보았다.
일단 엔티티 매니저 팩토리에서 엔티티 매니저를 만들고 이때 영속성 컨텍스트도 함께 만들어 진다.
영속성 컨텍스트는 애플리케이션과 데이터베이스 사이에서 데이터 캐시 기능을 비롯해 동일성 보장, 트랜잭션을 지원하는 쓰기 지연, 변경 감지, 지연 로딩 기능을 제공해준다.
영속성 컨텍스트에 저장한 엔티티는 플러시 시점에 데이터베이스에 반영되고 일반적으로는 커밋시점에 플러시된다.
영속성 컨텍스트가 관리하는 엔티티를 영속 상태 엔티티라고 한다. 영속 상태 엔티티가 준영속 상태로 바뀌면 더이상 영속성 컨텍스트의 관리를 받지 못한다. 그리고 1차 캐시, 동일성 보장, 트랜잭션을 지원하는 쓰기 지연, 변경 감지, 지연 로딩 기능을 사용할 수 없게된다.

profile
주 7일, 배움엔 끝이 없다

0개의 댓글