개발/JPA

[JPA] 영속성 컨텍스트를 알아보자!

lswnscel 2024. 1. 6. 12:27

* 본 포스트는 김영한 님의 자바 ORM 표준 JPA 프로그래밍 - 기본 편과 강의자료를 바탕으로 작성되었습니다.

* 본 포스트의 그림은 강의자료에서 발췌하였습니다.

 

영속성 컨텍스트가 뭔데?

JPA에서 가장 중요한 2가지 중 하나인 영속성 컨텍스트는 "엔티티를 영구 저장하는 환경" 이라는 의미로 Java코드 상에서의 객체와 DB를 연결하는 캐시 역할을 한다. JPA가 DB를 Java의 Collection처럼 자유롭게 사용하기 위해 기초가 되는 구조이다.

 

JPA에서 가장 중요한 2가지에서 나머지 하나는 객체와 관계형 데이터 베이스 매핑하기이다.


EntityManager와 영속성 컨텍스트의 차이는?

EntityManager는 영속성 컨텍스트를 접근하기 위한 도구로 영속성 컨텍스트는 어떤 실체라기 보단 논리적인 개념이다.

로직은 다음과 같다.

 

EntityManager Factory 생성

애플리케이션 별로 하나만 생성해서 공유

EntityManager 생성

EntityManager Factory를 통해 Transaction별로 생성해서 사용

영속성 컨텍스트 관리

EntityManager를 통해 접근 및 관리

 

여기서 EntityManager와 영속성 컨텍스트의 대응 관계는 환경에 따라 달라진다.


J2SE(Java 2 Standard Edition) 환경

현재는 JavaSE로 불리는 해당 환경은 간단하게 일반적인 Java 개발환경에 사용되는 환경이다.

J2SE에서는 EntityManager와 영속성 컨텍스트는 1:1 관계로 유지된다.

J2SE 환경

J2EE(Java 2 Enterprise Edition) 환경

J2SE에서 Java 기술로 기업환경의 애플리케이션을 만드는데 필요한 스펙들을 모아둔 스펙 집합이다. 스프링과 같은 환경이 해당된다.

J2EE는 EntityManager와 영속성 컨텍스트는 N:1 관계로 유지된다. (J2SE환경과 다른 이유는 추후 포스트에서..)

 

J2EE 환경

 

출처 : https://sh77113.tistory.com/105


Entity의 생명 주기!

Entity의 생명주기는 총 4가지로 분류된다.

Entity의 생명주기

 

비영속 (new/transient)

영속성 컨텍스트와 전혀 관계없는 새로운 상태로

commit시에 DB에 반영 X

 

영속 (managed)

영속성 컨텍스트에 의해 관리되는 상태

commit시에 변경사항이 DB에 반영 O

 

준영속 (detached)

영속성 컨텍스트에 저장되어 있다가 분리된 상태

commit시에 DB에 반영 X

 

삭제 (removed)

삭제된 상태

commit시에 변경사항이 DB에 반영 O


참고사항

EntityManager - em으로 표기

EntityManager Factory - emf으로 표기

Entity를 관리하자! - em.persist()

Java 코드 상에서 처음 객체를 생성하게 되면 비영속 상태로 영속성 컨텍스트와 관련 없이 존재한다.

이를 영속성 컨택스트가 관리하도록 EntityManager를 통해 알려줘야 한다. 이때 사용하는 메서드가 "persist()" 이다.

즉, persist() 의 역할은 비영속 상태나 삭제 상태의 객체를 영속 상태로 바꿔준다.

//객체를 생성할 땐 비영속 상태
Member member = new Member();
member.setId(1L);
member.setName("회원1");

...

//persist이후 member객체는 영속성 컨텍스트가 관리
em.persist(member);

 

* 주의할 점은 단순히 객체가 영속 상태가 되었다고 DB에 반영되는 것은 아니며 하나의 Transaction이 commit되는 시점에서 DB에 저장되고(flush) 해당 Query들은 영속성 컨텍스트의 쓰기 지연 SQL 저장소에 저장되어 대기한다.

 

Entity를 준영속 상태로! - em.detach()

영속성 컨텍스트가 관리하는 특정 Entity를 분리하고 싶다면 "detach()" 를 활용해 보자.

Entity를 준영속 상태로 만들게 되면 영속성 컨텍스트가 더이상 관리하지 않게 되며 DB에 반영되지 않는다.

특정 Entity가 아닌 영속성 컨텍스트 내의 모든 Entity를 분리하고 싶다면 "clear()" 를 활용하면 된다.

또한 "em.close()" 로 EntityManager가 종료되면 관리 중이던 모든 Entity는 준영속 상태가 된다.

//객체를 생성할 땐 비영속 상태
Member member = new Member();
member.setId(1L);
member.setName("회원1");

...

//persist이후 member객체는 영속성 컨텍스트가 관리
em.persist(member);

//detach이후 member객체는 더이상 영속성 컨텍스트가 관리하지 않는다.
em.detach(member);

//관리 중인 모든 Entity를 detach하고 싶다면
em.clear();

Entity를 삭제해보자! - em.remove()

영속성 컨텍스트가 관리중이던 Entity를 영속성 컨텍스트와 DB에서 삭제하고 싶다면 "remove()" 를 활용해보자!

detach()와 달리 remove()는 DB에 해당 데이터가 존재했다면 해당 Transaction이 commit되는 시점에서 DB에 DELETE Query를 보내 데이터를 삭제한다. persist의 주의점과 마찬가지로 commit되기 전까지 DB에 반영되는 내용은 아니므로 유의할 필요가 있다.

삭제 상태가 된 Entity는 persist로 다시 관리할 수 있다.

//객체를 생성할 땐 비영속 상태
Member member = new Member();
member.setId(1L);
member.setName("회원1");

...

//persist이후 member객체는 영속성 컨텍스트가 관리
em.persist(member);

//remove이후 member객체는 삭제한 상태가 되고 commit시 DB에 반영됨
em.remove(memeber);

 

* 고려할 점은 DB에 값이 없다면 DELETE Query를 보내지 않는다.


대체 영속성 컨텍스트가 왜 좋은건데?

영속성 컨텍스트가 좋은 이유는 바로 DB의 데이터를 객체처럼 활용할 수 있게 한다는 점에 있다. 이를 위해 다음과 같은 기능을 제공한다.

 

1차 캐시

영속성 컨텍스트 내부의 캐시로 하나의 Transaction 내에서 동작한다.

DB에 접근하지 않아 속도가 빨라지나 짧은 생명주기로 크게 의미를 가지지 않는다.

모든 EntityManager가 공유하는 캐시는 2차 캐시라 부른다.

 

동일성(identity) 보장

DB로부터 동일한 데이터를 읽어 왔을 때, 해당 Entity들은 같음을 보장해준다.

 

Transaction을 지원하는 쓰기 지연

쓰기 지연 SQL 저장소를 통해 commit 시점에 Query를 보내 불필요한 통신을 줄인다.

 

변경 감지 (Dirty Checking)

처음 1차 캐시에 작성될 때의 값을 스냅샷으로 두어 현재 Entity와의 비교를 통해 변경을 자동으로 감지한다.

 

지연 로딩 (Lazy Loading)

특정 Entity와 연관된 값들을 매번 불러오는 것이 아니라 필요한 경우에 SELECT Query를 DB에 보내 값을 불러온다.


1차 캐시

Entity를 영속 상태로 만들면 바로 DB에 적용되는 것이 아니라 해당 영속성 컨텍스트의 1차 캐시에 먼저 저장되게 된다. 해당 Transaction에서 1차 캐시에 저장된 값을 조회하는 경우 DB에 접근하지 않아 SELECT Query를 호출하지 않고 바로 값을 읽어올 수 있어 속도가 빠르다는 장점이 있다. 하지만 하나의 Transaction이 끝나면 종료되므로 짧은 생명주기를 가져 크게 의미는 없을수도 있다ㅎㅎ..

 

추가로 모든 EntityManager가 공유하는 캐시는 2차 캐시라고부른다.

1차 캐시

만약 값을 조회 했을 때, 해당 값이 1차 캐시에 존재하지 않는다면 DB에 SELECT Query를 보내 해당 값을 1차 캐시에 저장하고 이를 반환하는 형식으로 동작하게 된다.

1차 캐시에 값이 존재하지 않는 경우

 

영속 Entity의 동일성(identity) 보장

단순히 DB에 SELECT Query를 통해 값을 읽어 왔을 땐, DB상에선 같은 데이터일지라도 new를 통해 값만 가져와 새로운 객체로 만들어내는 과정이 일반적이었기에 코드 상에선 서로 다른 객체로 인식되었다. 이는 DB의 데이터를 Java Collection에서 가져온 객체처럼 활용할 수 없는 또 하나의 문제점이었기에 영속성 컨텍스트는 이를 보장해주기 위해 1차 캐시를 활용하였다.

 

1차 캐시에 미리 저장되어 있는 값이 아닐지라도 처음에만 DB에 SELECT Query를 보내게 되고, 이후에는 1차 캐시에 저장된 후 값을 불러오므로 동일한 데이터는 동일한 객체에 저장되는 것을 보장할 수 있는 것이다.

Member member1 = em.find(Member.class, "member");
Member member2 = em.find(Member.class, "member");

Systme.out.println(member1 == member2); //true

 

1차 캐시로 반복 가능한 읽기(REPEATABLE READ) 등급의 Transaction 격리 수준을 DB가 아닌 애플리케이션 차원에서 제공한다.

반복 가능한 읽기 등급이란, 해당 영속성 컨텍스트가 존재하는 Transaction이 시작되기 전 마지막으로 commit된 데이터만을 읽어 하나의 Transaction에서 동일한 결과를 보장해주는 것을 의미한다. 하지만 새로 추가되거나 업데이트되는 값에 대해서는 DB에 값과 맞지 않는 경우가 발생할 수 있다.

Transaction을 지원하는 쓰기 지연

DB에서 데이터를 읽고 쓸 때마다 매번 Query를 보내면 해당 애플리케이션의 속도는 처참하게 느려질 것이다. 또한 Transaction에서 에러가 발생해 rollback 시켜야할 경우에도 처리과정이 복잡해지기 마련이다. 따라서 영속성 컨텍스트는 이를 방지하고자 "쓰기 지연 SQL 저장소" 를 두어 Transaction이 commit되는 시점에 쓰기 관련 SQL을 한번에 처리하도록 기능을 제공한다. 이 과정을 flush라고 한다. (단순 읽기가 필요한 경우엔 DB에 Query를 보내기도 한다.) 

EntityTransaction transaction = em.getTransaction();

//EntityManager는 데이터를 변경할 경우, Transaction을 시작해야 한다.
transaction.begin();

Member member1 = new Member(1L,"member1");
Member member2 = new Member(2L,"member2");

//단순히 Entity를 영속 상태로 만든 경우에는 INSERT Query가 발생하지 않는다.
em.persist(member1);
em.persist(member2);

//Transaction을 commit하는 순간에 이전까지 쓰기 지연 SQL저장소에 담겨있는 Query들이 DB로 보내진다.
transaction.commit();

commit하는 순간 동작 과정

변경 감지 (Dirty Checking)

코드를 작성하다보면 특정 Entity를 수정해야하는 경우가 생긴다. 이 때, 수정한 후에 persist를 호출해 Entity를 다시 영속 상태로 만들어줘야 할까? 정답은 "그렇지 않다!" 이다. 영속성 컨텍스트가 관리중인 Entity의 경우엔 commit시점에 알아서 변경된 부분을 체크해 DB에 반영해준다. 이를 가능하게 한 도구는 바로 스냅샷이다. 스냅샷이란 해당 데이터가 1차 캐시에 처음 저장되는 시점의 정보를 같이 기록해둔 것을 뜻한다. 이를 통해 commit시점에 현재 데이터와 스냅샷을 비교하여 값이 다른 경우 DB에 UPDATE Query를 보내게 된다. (flush)

변경 감지

지연 로딩

Member 테이블에서 이름이 "kim"인 멤버를 조회하고 싶다. 이 때, "kim"이 소속된 학급에 대한 정보도 같이 가져오게 된다! 하지만 당장 필요한 정보는 "kim"의 나이인데 매번 학급의 정보를 같이 읽어 온다면 속도 저하는 필연적으로 따라오게 될 것이다. 물론 하나 정도야 눈감아줄 수 있지만 연관관계가 복잡하게 얽혀있는 경우라면 걷잡을 수 없이 느려질 것이다. 이를 해결하기 위해 영속성 컨텍스트가 선택한 방법은 지연 로딩이다. 지연 로딩이란 연관된 테이블을 미리 모두 불러오는게 아니라 그 테이블에 대한 정보가 필요한 시점에 불러오는 방식이다. 이렇게 된다면 매번 연관된 테이블을 불러오지 않아서 속도 관점으로 큰 이점을 얻는다. 실무적인 관점에서 일단 모든 경우에 지연 로딩방식으로 설정하는 편이 좋다고 한다. 추후 리펙토링 과정에서 지연 로딩이 필수가 아닌 테이블에 대해서는 즉시 로딩으로 바꿔주는 방식으로 설계된다고 한다.

class Member{
    @ManyToOne(fetch = FetchType.LAZY) //지연 로딩 설정
    private Team team;
}

플러시(flush)

앞서, DB에 쓰기 작업이 필요한 경우 바로바로 실행하지 않고 쓰기 지연 SQL 저장소에 저장한다고 했다. 

추후 Transaction이 commit되는 시점에 저장되어 있는 SQL들을 DB에 반영하는 것을 플러시(flush)라고 한다.

여기서 플러시라는 명칭때문에 영속성 컨텍스트의 모든 내용이 사라진다고 오해하는 경우가 많은데 영속성 컨텍스트의 내용은 비워지지 않는다. 따라서 영속성 컨텍스트의 생명주기에 맞춰 Transaction이라는 작업 단위에 맞춰 commit 직전에만 동기화 하면 된다.

 

플러시 발생 과정은 다음과 같다.

 

변경 감지(등록, 수정) or Entity 삭제

Entity관련한 쓰기 작업이 필요한 경우 발생

수정된 Entity 쓰기 지연 SQL 저장소에 등록

쓰기 관련 SQL (INSERT, UPDATE, DELETE) 저장소에 등록

쓰기 지연 SQL 저장소의 쿼리를 DB에 전송

Transaction commit시점에 DB에 플러시(flush)


영속성 컨텍스트를 플러시하는 방법은?

1. em.flush() → 직접 호출
2. Transactino commit → 플러시 자동 호출
3. JPQL Query 실행 → 플러시 자동 호출

 

여기서 3번 JPQL Query를 실행할 경우 플러시가 자동 호출하는 이유가 뭘까?

em.persist(member1);
em.persist(member2);
em.persist(member3);

//JPQL이 중간에 실행되면 그 전에 미리 flush된다.
query = em.createQuery("select m from Member m", Member.class);
List<Member> members = query.getResultList();

 

그 이유는 JPQL Query는 DB에 직접 접근하기 때문에 이전까지 바뀐 내용들을 미리 DB에 반영해야지 영속성 컨텍스트의 내용과 차이가 없어지기 때문이다!

플러시 모드 옵션

보통 기본값으로 설정된 것으로 사용하고 변경하지 않는다.

em.setFlushMode(FlushModeType.AUTO)	//기본값
em.setFlushMode(FlushModeType.COMMIT)

 

FlushModeType.AUTO

commit이나 JPQL을 실행할 때 플러시됨(기본값)

 

FlushModeType.COMMIT

커밋할 때만 플러시