EntityManager with 영속성 컨텍스트(Persistence Context)
개요
EntityManager를 통해 영속성 컨텍스트(Persistence Context)를 활용하는 방법에 대해 알아본다.
목차
- EntityManager & EntityManagerFactory
- EntityManager Life-Cycle
- 영속성 컨텍스트(Persistence Context)의 이슈
- 영속성 컨텍스트의 이슈에 대한 해결방안
소개
EntityManager란?
"Entity(엔티티)를 Manager(관리)해주는 역할을 하는 메모리상에 존재하는 가상의 데이터베이스"라고 할 수 있다.
가상의 데이터베이스이기 때문에, EntityManager는 Entity를 생성하고, 수정하고, 삭제할 수 있다. EntityManagerFactory로 생성되는 EntityManager는 Thread-Safety(쓰레드 안전)하지 않기 때문에 쓰레드간에 공유를 하게 되면 동시성 문제가 발생하게 된다. 하지만 스프링에서 제공하는 EntityManager를 사용한다면 Proxy로 감싸서 EntityManager를 생성해주기 때문에 Thread-Safety를 보장받을 수 있게 된다.
그렇다면 위에서 언급한 EntityManagerFactory란 무엇일까?
"EntityManagerFactory란 EntityManager + Factory의 개념으로 EntityManager를 만드는 공장"이라고 할 수 있다.
프로그램을 설계할때 가장 중요하게 다루어져야하는 부분중에 하나는 데이터베이스와의 연결이다. 데이터베이스에 접근하여 잘못된 데이터를 삽입하거나, 수정중인 데이터등을 조회해오게 되면 사용자에게 잘못된 데이터를 전달하는 문제를 가져오게 된다. 이를 위해 등장한 ORM 표준이 등장하였고 JAVA 진영에서는 이를 위해 JPA를 도입하게 되었다. 이때 JPA에서 ORM을 구현하기 위해 사용하는 Interface가 EntityManagerFactory & EntityManager 인것이다.
EntityManagerFactory는 Thread-Safety하게 설계되어 여러쓰레드가 동시에 접근하여도 문제가 발생하지 않는다. 일반적으로 생성하는 비용이 크기 때문에 데이터베이스에 1:1 매핑되어 EntityManagerFactory가 생성되어 애플리케이션 전체에 공유하여 사용되고, persistence.xml 파일 혹은 Spring Framework와 같은 컨테이너에서 설정한 정보를 기반으로 EntityManager를 생성하고 관리한다.
EntityManager Life-Cycle(생명주기)
1. 비영속 상태(New)
Entity가 생성은 되었지만, 영속성 컨텍스트와 분리되어 있어 관련이 없는 상태이다.
2. 영속 상태(Managed)
비영속상태에서 persist()를 통해 Entity가 영속성 컨텍스트에 들어와 EntityManager에 의해 관리 되고 있는 상태이다. 하지만, 아직 DB에 저장된 상태는 아니다.
3. 준영속 상태(Detached)
Entity가 영속성 컨텍스트에 의해 관리되고 있다가, clear() 혹은 close() 등으로 인해 관리의 대상에서 분리되어 있는 상태이다.
4. 삭제된 상태(Removed)
Entity가 영속성 컨텍스트에 의해 관리되고 있다가, remove()를 통해 삭제된 상태이다.
위의 4가지 생명주기를 바탕으로 EntityManager를 통해 Entity를 관리할 수 있게 된다.
EntityManager는 영속성 컨텍스트와 어떻게 관련이 되어있을까?
각 EntityManager는 각자 영속성 컨텍스트를 관리한다. 기본적으로 Spring Container의 EntityManager가 관리하는 영속성 컨텍스트는 하나의 Transaction 단위로 관리되고, 이는 한 트랜잭션이 수행될동안만 영속성 컨텍스트가 유지된다는 의미이다. 즉 Commit() 혹은 Flush()가 일어나 트랜잭션이 종료되면 EntityManager는 갖고 있는 영속성 컨텍스트의 내용을 DB에 반영하고 수행을 종료하게 된다.
영속성 컨텍스트에서 갖고 있는 1차 캐시는 Session Instance에 바인딩 되고, 바인딩된 1차 캐시는 다른 트랜잭션으로부터 격리되어 다른 트랜잭션간의 영속성 컨텍스트가 공유되는것을 막아준다.
각 EntityManager는 각자 영속성 컨텍스트를 관리함에도 영속성 컨텍스트를 공유할 수 있는데, 하나의 트랜잭션안에 여러개의 EntityManager를 생성하면 트랜잭션 내부의 EntityManager들은 영속성 컨텍스트를 공유하게 된다.
그렇다면 위와같이 하나의 트랜잭션 단위로 영속성 컨텍스트가 공유된다면 JPA에서는 어떤 영향을 끼치게 될까?
JPA에서 영속성컨텍스트의 1차 캐시와 관련된 이슈
@Transactional
public void test(String id) {
int flag = 0;
while (flag != 1) {
Test result = testRepository.findById(id);
if ("Pass".equals(result.status)
flag = 1;
}
}
만약 위와 같은 코드가 있다고 가정하겠습니다.
위의 코드는 DB에서 Test Table을 id 기준으로 조회하면서, 외부의 로직에 의해 status의 값이 "Pass"로 변할때까지 무한루프가 돌아가는 코드입니다. 하지만 위의 코드는 큰 문제를 가져올 수 있다.
먼저 @Transactional이 붙어 있기 때문에, 위의 함수는 하나의 트랜잭션으로 묶여있게 된다. 따라서 트랜잭션 안에서는 하나의 영속성 컨텍스트(Persistence Context)를 공유하게 됩니다. 하지만, 여기서 한가지 의문이 들 수 있다.
"EntityManager를 선언하여 사용하지 않는데, 어떻게 EntityManager가 선언되어 영속성 컨텍스트를 공유하지?"
하이버네이트에서는 내부적으로 JPA를 사용할 때, EntityManager를 생성해서 사용한다. 따라서 직접 선언하지 않아도 JPA를 사용하는 순간 EntityManager에 의해 엔티티가 관리되는 것이다.
하이버네이트에 의해 EntityManager가 생긴 후, JPA는 다음과 같은 순서에 따라 로직을 수행하게 된다.
1. 첫번째 반복문에서는 영속성 컨텍스트의 1차 캐시에 PK의 id에 따른 값이 저장되어 있지 않으므로 DB에서 값을 가져온다.
2. 가져온 값을 1차 캐시에 저장한다.
여기까지는 일반적인 순서이다. 하지만, 두번째 반복문 부터는 어떻게 수행될까?
하이버네이트는 1차 캐시에 데이터를 저장할 때, PK를 Key 값으로 하여 저장한다.
따라서 두번째 반복문 이후, PK로 값을 조회한다면 실제 DB를 읽지 않고 1차 캐시에 저장된 값을 가져오게 된다. 즉, 우리는 외부로직에 의해 값이 변경됨에 따라 flag값을 변경하고 반복문을 탈출하고자 코드를 구현하였지만, 실제로는 처음에 가져온 값만을 계속해서 읽어오기 때문에 무한루프에 빠지는 코드가 동작되는 것이다.
그렇다면 1차 캐시를 벗어나서 DB의 값을 읽어오려면 어떻게 해야할까?
우리는 크게 두가지 방법을 떠올릴수 있다.
1. PK로 조회하지 않거나, JPQL을 사용하여 1차 캐시를 읽어오지 않고 직접 DB의 데이터를 가져온다.
2. @Transactional을 사용하지 않도록 하여 영속성 컨텍스트를 공유하지 않게 한다.
일반적인 접근 방식은 1번일 수 있다. 1차 캐시를 읽어오지 않는 직접적인 방법이며 실제 DB에 접근하기 때문에 준실시간 데이터를 계속해서 읽어올 수 있기 때문이다. 하지만, 1번의 방법은 트랜잭션으로 묶여 영속성 컨텍스트를 공유한다면 해결책이 되지 못한다.
실제로, testRepository.findByNickname(nickname)을 통해 조회를 한다면, 1차 캐시에는 PK 값이 저장되어 있기 때문에 매칭되는 값이 없어서 DB에 직접 접근하게 된다. 하지만, DB에 조회해온 데이터의 PK가 1차 캐시에 저장되어 있다면 조회해온 데이터는 무시하고 1차 캐시의 값을 반환하게 된다. 따라서 실제 DB에 접근하여 값을 읽어오지만, 1차 캐시에 저장되어 있는 변경되지 않은 기존의 값을 읽어오게 된다. JPQL도 마찬가지이다. EntityManager.createQuery(Query)를 통해 실제 쿼리를 작성하여 DB에 직접적인 Query를 보내어 조회를 하여도, 반환해온 데이터의 PK가 1차 캐시에 존재한다면 조회해온 값은 무시하고 1차 캐시에 저장된 값을 반환하게 된다.
그래서 해결한 방법이 2번 이였다. 기본적으로 하이버네이트는 트랜잭션 단위로 영속성 컨텍스트를 공유하기 때문에 반복문 안의 코드를 각각 다른 트랜잭션으로 수행하도록 한다면, 영속성 컨텍스트를 공유하지 않을 것이라고 생각하였다. 각 트랜잭션으로 구현한다면 위의 언급대로 1차 캐시가 격리되어 존재하므로 데이터를 공유하지 않게 되고, testRepository.findById(id)가 이미 앞의 반복에서 수행되었다고 하여도 트랜잭션이 끝나는 순간(각 반복에서 조회가 끝나는순간) 1차 캐시는 사라졌음으로 반복을 할 때마다 DB의 값을 읽어오게 되어 외부 로직에 의해 바뀐 DB의 값을 읽어올 수 있게 되었다.
JPA는 ORM을 표준으로 하여 데이터베이스와 비즈니스 로직을 분리시키는데 많은 도움을 주었다. 그 과정에서, 영속성 컨텍스트와 같이 좋은 기능들을 확장해 나아가며 많은 사람들이 사용하게 되었다. 하지만, 그 과정에서의 Trade-Off들은 존재하였고 이번 이슈에 대해 트래킹 하면서 기능에 대해 좀 더 세부적으로 학습할 수 있는 시간이 되었다.
'Back End > Spring Boot' 카테고리의 다른 글
[Spring JPA] JPA의 영속성(Persistence)에 관하여 (0) | 2023.10.05 |
---|---|
[Test] RestAssured vs Mock MVC (1) | 2023.06.13 |
[JAVA & Spring] JPA란? (2) | 2023.05.30 |