JPA Persistence Context에서 Entity 식별 방법 (feat.OSIV)

nayoung
12 min readJul 8, 2023

--

영속성 컨텍스트(persistence context)란 데이터베이스로부터 가져온(fetched) 모든 Entity instance의 집합인 1차 캐시이다. 트랜잭션에서 사용한 객체는 1차 캐시에서 관리된다.

트랜잭션 내에서 같은 객체에 대해 여러번 SELECT 질의를 하는 경우 쿼리는 한 번만 발생된다. 1차 캐시에 객체가 없다면 SELECT 쿼리 발생하여 가져온 객체를 1차 캐시에 저장한다. 그 후 나머지 질의는 1차 캐시에 있는 객체를 그대로 반환하기 때문에 쿼리가 발생하지 않는다.

여기서 궁금증이 생겼다. 1차 캐시에는 많은 객체가 존재할 텐데 내가 찾고자 하는 객체를 어떻게 식별할까?

그래서 테스트해봤다. 지금부터 찾고자 하는 사용자의 id(PK)는 1이고, username은 apple이다.

일단 OSIV가 무엇인지 파악하는 것이 좋다.

OSIV ON

아래 테스트는 OSIV ON/OFF 상태에서 진행한다. 두 상태에서 다른 결과를 내는 경우도 있다는 의미이다.

Spring Boot JPA 의존성을 주입받으면 OSIV가 적용된 상태로 애플리케이션을 구성한다. spring.jpa.open-in-view: true를 추가해도 된다.

  • 클라이언트의 요청이 들어오면 서블릿 필터나 스프링 인터셉터에서 영속성 컨텍스트를 생성한다. 위 그림을 잘 보면 트랜잭션보다 영속성 컨텍스트가 먼저 생성된다.
  • 서비스 계층에서 @Transactional로 트랜잭션을 시작하면 미리 생성해둔 영속성 컨텍스트에서 트랜잭션을 시작한다.
  • 트랜잭션이 커밋하고 영속성 컨텍스트를 flush 한 후 트랜잭션은 끝나지만 영속성 컨텍스트는 종료되지 않는다.

영속성 컨텍스트와 데이터베이스 커넥션은 일대일 관계이므로 서비스 계층에서 트랜잭션이 종료되어도 영속성 컨텍스트가 계속 유지된다는 것은 데이터베이스 커넥션을 유지하고 있다는 뜻이다. 데이터베이서 커넥션을 유지하기 때문에 서비스 계층이 아닌 곳에서도 지연 로딩이 가능하지만, 수정은 불가능하다. 오랜 시간 동안 데이터베이스 커넥션 리소스를 사용하면 실시간 트래픽이 중요한 애플리케이션에서는 커넥션이 부족해 장애로 이어질 수 있다는 것에 주의하자.

OSIV OFF

spring.jpa.open-in-view: false 로 설정하면 OSIV OFF 된다.

  • OSIV OFF 설정은 트랜잭션이 종료되면 영속성 컨텍스트를 닫고 데이터베이스 커넥션도 반환한다.
  • 데이터베이스 커넥션을 반환하므로 OSIV ON 설정에서 문제가 되었던 데이터베이스 커넥션에 대한 문제를 해결할 수 있다.
  • 뷰로 넘어왔을 땐 영속성 컨텍스트가 종료되었기 때문에 지연 로딩을 트랜잭션에서 모두 해결하고 넘어와야 한다. 즉, 많은 지연 로딩 코드를 트랜잭션에 넣어야 한다는 단점도 존재한다.

아래에 작성된 코드는 모든 예외 처리를 생략한 코드입니다.

findById ➡️ findById, @Transactional ⭕️, OSIV ⭕️

@Transactional
private void getAccount(Long accountId) {
log.info("first");
Account account1 = accountRepository.findById(accountId).get();

log.info("second");
Account account2 = accountRepository.findById(accountId).get();
}

@Transactional을 사용했으므로 getAccount()에서 데이터베이스에 접근하는 2개의 연산이 하나의 트랜잭션에서 실행된다. getAccount() 시작 시 트랜잭션 시작한다.

  • 첫 번째 findById() 는 EntityManager(SessionImpl: 61654829) 를 사용한다.
  • 첫 번째 findById() 는 기존 트랜잭션에 참가하며 SELECT 쿼리가 발생한다.
  • 두 번째 findById() 도 EntityManager(SessionImpl: 61654829) 를 사용한다.
  • 두 번째 findById() 도 기존 트랜잭션에 참가하지만, SELECT 쿼리가 발생하지 않는다.

같은 트랜잭션 내에서 1차 캐시가 유효하기 때문에 두 번째 findById() 에서는 1차 캐시에 있는 엔티티를 가져온다.

findById ➡️ findById, @Transactional ❌, OSIV ⭕️

private void getAccount(Long accountId) {
log.info("first");
Account account1 = accountRepository.findById(accountId).get();

log.info("second");
Account account2 = accountRepository.findById(accountId).get();
}

@Transactional을 사용하지 않아 getAccount()에서 데이터베이스에 접근하는 2개의 연산이 각각의 트랜잭션에서 실행된다.

  • 첫 번째 findById() 는 EntityManager(SessionImpl: 1642251533) 를 사용한다.
  • 첫 번째 findById() 는 새로운 트랜잭션을 시작하며 SELECT 쿼리가 발생한다.

findById() 에 대한 트랜잭션를 종료하기 전에 커밋한다. 메소드 레벨에 @Transactional을 추가했던 위 예제에서는 이 시점에서 커밋하지 않았다.

  • 두 번째 findById() 도 EntityManager(SessionImpl: 1642251533) 를 사용한다.
  • 두 번째findById() 는 새로운 트랜잭션을 시작하지만, SELECT 쿼리가 발생하지 않는다.

메소드 레벨에 @Transactional을 추가하지 않았기 때문에 2개의 연산이 다른 트랜잭션에서 처리되었지만, OSIV ON 설정으로 인해 영속성 컨텍스트가 유지되므로 2번째 트랜잭션에서는 1차 캐시에 있는 엔티티를 가져온다.

또한 OSIV ON 상태에서는 같은 트랜잭션 내에서의 연산도, 다른 트랜잭션 끼리도 같은 EntityManager(SessionImpl)을 사용한다.

findById ➡️ findById, @Transactional ⭕️, OSIV ❌

OSIV ON 상태일 때는 Service Layer에 진입하기 전에 EntityManager가 생성되지만, OSIV OFF 상태이므로 트랜잭션과 영속성 컨텍스트의 라이프 사이클이 같다. 즉, 트랜잭션이 시작하는 Service Layer에 진입해야 EntityManager가 생성된다.

@Transactional 설정으로 인해 두 연산이 같은 트랜잭션에서 처리되며 같은 EntityManager를 사용한다.

같은 트랜잭션에서 처리된다는 것은 두 연산이 같은 영속성 컨텍스트를 사용한다는 의미이므로 두 번째 findById() 는 쿼리를 발생하지않고 1차 캐시에 있는 entity를 가져온다.

findById ➡️ findById, @Transactional ❌, OSIV ❌

@Transactional을 사용하지 않아 getAccount()에서 데이터베이스에 접근하는 2개의 연산이 각각의 트랜잭션에서 실행된다.

OSIV OFF 상태이므로 트랜잭션과 영속성 컨텍스트의 라이프 사이클이 같다. 즉, 2개의 영속성 컨텍스트가 생성되므로 2개의 SELECT 쿼리가 발생한다.

PK로 검색해도 여러 연산을 트랜잭션으로 묶지않고 OSIV OFF 상태이면 각 트랜잭션마다 영속성 컨텍스트가 생생되어 연산마다 쿼리가 발생한다.

findById ➡️ findByUsername, @Transactional ⭕️, OSIV ⭕️

@Transactional
private void getAccount(Long accountId) {
Account account1 = accountRepository.findById(accountId).get();
Account account2 = accountRepository.findByUsername(account1.getUsername()).get();
}
select account0_.id as id1_0_0_, account0_.balance as balance2_0_0_, account0_.password as password3_0_0_, account0_.username as username4_0_0_ 
from account account0_ where account0_.id=1;
select account0_.id as id1_0_, account0_.balance as balance2_0_, account0_.password as password3_0_, account0_.username as username4_0_
from account account0_ where account0_.username='apple';

같은 사용자에 대해 2번 질의를 한 결과, 2번의 쿼리가 발생한다.

findById로 가져온 객체가 이미 1차 캐시에 존재하지만, 1차 캐시의 식별자는 Entity의 PK 이다. 그러므로 PK가 아닌 필드 값으로 1차 캐시에 존재하는 객체를 찾아낼 수 없어서 쿼리가 발생한다.

findByUsername ➡️ findById, @Transactional ⭕️, OSIV ⭕️

@Transactional
private void getAccount(String username) {
Account account1 = accountRepository.findByUsername(username).get();
Account account2 = accountRepository.findById(account1.getId()).get();
}
select account0_.id as id1_0_, account0_.balance as balance2_0_, account0_.password as password3_0_, account0_.username as username4_0_ 
from account account0_ where account0_.username='apple';

같은 사용자에 대해 2번의 질의를 한 결과, findByUsername() 에서만 쿼리가 발생한다.

findByUsername() 로 가져온 객체는 1차 캐시에 있는 상태이며, findById()에 전달한 id(PK) 값으로 1차 캐시의 객체를 판별할 수 있기 때문에 쿼리가 발생하지 않는다.

findByUsername ➡️ findByUsername, @Transactional ⭕️, OSIV ⭕️

@Transactional
private AccountResponse getAccountInfo(String username) {
Account account1 = accountRepository.findByUsername(username).get();
Account account2 = accountRepository.findByUsername(username).get();
}
select account0_.id as id1_0_, account0_.balance as balance2_0_, account0_.password as password3_0_, account0_.username as username4_0_ 
from account account0_ where account0_.username='apple';
select account0_.id as id1_0_, account0_.balance as balance2_0_, account0_.password as password3_0_, account0_.username as username4_0_
from account account0_ where account0_.username='apple';

같은 사용자에 대해 2번 질의를 한 결과, 2번의 쿼리가 발생한다. PK가 아닌 필드 값으로 1차 캐시에 존재하는 객체를 찾아낼 수 없어서 쿼리가 발생한다.

테스트를 끝으로… 🤔

public Account getAccount(String username) {...}
public void findAccount(String nickname) {...}

해당 주제에 대해 고민하기 전에는 위와 같이 거의 모든 서비스 메소드에 PK가 아닌 필드 값을 던져주었다. 트랜잭션이 끝나면 영속성 컨텍스트를 지우기 때문에 사실상 너무 짧은 순간이라 1차 캐시는 성능 향상에 많은 영향을 주지는 않다고 한다.

그래도 아무 값을 던져주는 것보다 같은 트랜잭션에서는 PK를 전달해 중복 쿼리를 없애는 것도 조금은.. 아주 조금은 도움이 되지 않을까라고 생각해본다.

--

--