[JPA/기본편] 영속성 컨텍스트
영속성 컨텍스트
영속성 컨텍스트란 '엔티티를 영구 저장하는 환경'이다.
// 객체를 생성한 상태 (비영속)
Member member = new Member();
member.setId(1L);
member.setName("HelloA");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
// 객체를 저장한 상태 (영속)
em.persist(member);
영속성 컨텍스트의 특징
- 영속성 컨텍스트가 관리하는 엔티티는 식별자 값이 반드시 존재한다.
- 영속성 컨텍스트는 트랜잭션을 커밋하는 순간 SQL을 실행한다. (저장할때 x)
엔티티의 생명주기
엔티티에는 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);
영속성 컨텍스트가 필요한 이유
영속성 컨텍스트가 엔티티를 관리할 때 장점은 아래와 같다.
- 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에서 조회 과정 설명
- em.find(Member.class, "member2")를 실행한다.
- member2가 1차 캐시에 없으므로 데이터베이스에서 조회한다.
- 조회한 데이터로 member2 엔티티를 생성해서 1차 캐시에 저장한다. (영속 상태가 된다.)
- 영속화 된 엔티티를 반환한다.
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을 추가로 저장한다.
그리고 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(); //트랜잭션 커밋
- save() 를 호출할 때마다 보낸다.
- 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(); //[트랜잭션] 커밋
위 코드를 실행하면 영속성 컨텍스트는 아래 과정을 수행한다.
- 트랜잭션을 커밋하면 엔티티 매니저 내부에서 먼저 플러시(flush())가 호출된다.
- 엔티티와 스냅샷을 비교해서 변경된 엔티티를 찾는다.
- 변경된 엔티티가 있으면 수정 쿼리를 생성해서 쓰기 지연 SQL 저장소에 보낸다.
- 쓰기 지연 저장소의 SQL을 데이터베이스에 보낸다.
- 데이터베이스 트랜잭션을 커밋한다.
1차 캐시에 스냅샷이 추가되었다. 스냅샷이란 엔티티를 영속성 컨텍스트에 보관할 때, 최초 상태를 복사해서 저장해둔 것이다.
스냅샷은 플러시 시점에 스냅샷과 엔티티를 비교해서 변경된 엔티티를 찾을 때 이용한다.
변경 감지는 영속성 컨텍스트가 관리하는 영속 상태의 엔티티에만 적용된다.
비영속, 준영속처럼 영속성 컨텍스트의 관리 밖의 엔티티는 값을 변경해도 데이터베이스에 반영되지 않는다.
변경 감지 기본 전략
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에 모든 필드를 포함한다.
이유는 수정 쿼리가 항상 같고 데이터베이스가 파싱된 쿼리를 재사용할 수 있다는 장점이 있기 때문이다.