JPA Persistence Context Deep Dive

Solomon Maeng
22 min readNov 15, 2021

--

최근 저는 새로운 프로젝트를 진행하면서, JPA를 도입하였습니다.

JPA를 도입하기 전에, JPA를 다시 공부하면서 Persistence Context 동작과 관련하여 기존에 알던 내용과 다른 점들을 많이 발견하게 되었습니다.

또한, 많은 국문자료에서 JPA를 다루면서 N+1 문제나 엔터티 매핑, 성능 최적화와 관련된 주제는 많이 다루었지만 본질적으로 Persistence Context가 어떻게 동작하는지에 대한 심도 있는 내용은 찾아볼 수 없었습니다.(제가 못 찾은 걸 수도 있습니다..)

그래서 저는 Persistence Context 동작에 대해 다양한 케이스를 구성하고, 분석하면서 내부에서 벌어지는 마법과 같은 일들이 어떤 식으로 진행되는지 정리하며 이 글을 작성하게 되었습니다.

목차는 다음과 같습니다.

  • PC(PersistenceContext)의 1차 캐시 동작 심층 분석
  • @Modifying 을 사용할 때 주의할 점
  • 중복 저장 Anti Pattern
  • JPA 기본 Flush Mode인 AUTO가 PC 동작에 어떻게 영향을 미치는가? + 약간의 오해

실습에 사용된 개발 환경은 다음과 같습니다.

  • Spring Boot 2.5.6 (Web, JPA, MySQL)
  • MySQL 5.7 (Isolation level: REPEATABLE_READ)

PC(PersistenceContext)의 1차 캐시 동작 심층 분석

지금부터, 1차 캐시 동작에 대해 다양한 케이스를 나열하고 각 케이스마다 어떻게 동작하는지 분석해보겠습니다.

각 케이스에 사용된 엔터티와 저장소 개체는 다음과 같습니다.

먼저, 데이터베이스에 id 1, username rebwon이라는 Member 엔터티 하나가 저장되어 있다고 가정하고 아래의 코드를 실행해보겠습니다.

위와 같은 상황에서 PC는 어떻게 동작할까요?

  1. findById()가 호출될 때마다 SELECT 발생
  2. 처음 findById()에만 SELECT 발생

답은 2번입니다.

PC(Persistence Context)는 처음 findById()를 통해 반환된 엔터티의 식별자를 Key로 엔터티를 Value로 사용하여 Map에 저장합니다. 따라서 이후 호출된 findById()는 Map에 보관된 엔터티를 그냥 반환해주게 됩니다.

그럼 다음 상황은 어떨까요?

  1. findById(), findByUsername()이 호출될 때마다 SELECT 발생
  2. findById()에만 SELECT 발생

답은 1번입니다.

앞서 말씀드렸듯이, PC는 findById()를 통해 조회한 엔터티를 보관하고 있습니다. 하지만 PC는 Map에 식별자와 엔터티를 캐싱하는데, Username으로 조회하게 되면 식별자를 통해 Map에서 찾을 수 없기 때문에, DB에서 SELECT를 하게 됩니다.

그럼 윗 상황에서 findById()와 findByUsername()의 호출 순서를 변경하면 어떻게 될까요?

  1. findByUsername(), findById()가 호출될 때마다 SELECT 발생
  2. findByUsername()에만 SELECT 발생

답은 2번입니다.

왜냐하면, findByUsername()을 통해 반환된 엔터티에서 식별자를 Key로 엔터티를 Value로 PC에 보관하고 있기 때문에, 이후 findById()를 호출하여도 식별자를 통해 조회하게 되므로, PC에서 보관하고 있던 엔터티를 반환하게 됩니다.

이번에는 findByUsername()을 2번 호출하면 어떻게 될까요?

  1. findByUsername()이 호출될 때마다 SELECT 발생
  2. 처음 findByUsername()만 SELECT 발생

답은 1번입니다.

PC는 식별자를 Key로 엔터티를 Value로 보관하는 Map이기 때문에, 식별자를 통한 조회를 하지 않는다면 매번 DB에서 조회하게 됩니다.

그렇다면, PC는 식별자를 통한 조회는 항상 보관된 엔터티를 반환하게 될까요?

다음 케이스를 한번 보겠습니다.

  1. findById(), em.createQuery()를 호출할 때 각각 SELECT 발생
  2. findById()만 SELECT 발생

답은 1번입니다.

findById()를 통해 조회한 엔터티를 PC가 보관하고 있어도, JPQL을 사용한 조회는 DB에서 SELECT를 하게 됩니다.

마지막으로, findById()와 getById()를 사용하여 조회할 경우는 어떻게 될까요?

  1. findById(), getById() 를 호출할 때마다 SELECT 발생
  2. findById()만 SELECT 발생

답은 2번입니다.

getById()는 내부적으로 em.getReference()라는 메서드를 호출하는데, 해당 메서드는 PC에 엔터티가 없다면, Proxy를 만들고 엔터티가 있다면 해당 엔터티를 반환합니다. 따라서 findById()를 호출하고 난 이후에 추가적인 쿼리는 발생하지 않습니다.

추가로, getById()는 Proxy를 만든다고 언급했습니다. 정확히는 PC에 보관 중인 엔터티가 없다면 만든다는 것입니다. 그렇다면 이후 findById()가 호출되면 어떻게 될까요?

findById()는 PC에 보관 중인 Proxy 엔터티를 그냥 반환하게 됩니다. 만약 findById()가 Proxy를 반환하지 않고 DB에서 SELECT하게 된다면, getById()로 가져온 엔터티와 동일 비교(==)할 시 false가 발생하게 됩니다.

이런 문제가 발생하지 않도록 PC는 내부적으로 보관 중인 엔터티가 Proxy 엔터티라면 Proxy를 반환하고, 실제 엔터티라면 엔터티를 반환합니다.

지금까지 Persistence Context의 1차 캐시가 각 상황마다 어떻게 동작하는지 살펴보았습니다.

@Modifying 을 사용할 때 주의할 점

이번에는 @Modifying 을 사용한 Member를 수정하는 코드가 어떻게 동작하는지 보면서 주의할 점에 대해서 말씀드리겠습니다.

먼저, Repository에 @Modifying 을 사용한 수정 메서드를 정의했습니다.

수정 메서드를 사용하는 서비스 클래스의메서드를 아래와 같이 정의합니다.

서비스 메서드가 실행되고 나면, 쿼리는 어떻게 발생하며, 로그에는 어떠한 값들이 찍혀있게 될까요?

  1. save() 호출로 INSERT 발생, updateUsername() 호출로 UPDATE 발생, findById() 호출로 SELECT 발생, 첫번째 로그 kitty, 두번째 로그 kitty
  2. save() 호출로 INSERT 발생, updateUsername() 호출로 UPDATE 발생, 첫번째 로그 kitty 두번째 로그 kitty
  3. save() 호출로 INSERT 발생, updateUsername() 호출로 UPDATE 발생, findById() 호출로 SELECT 발생, 첫번째 로그 rebwon 두번째 로그 rebwon
  4. save() 호출로 INSERT 발생, updateUsername() 호출로 UPDATE 발생, 첫번째 로그 rebwon 두번째 로그 rebwon

답을 확인하기 전, 답을 유추해보기 위해서, @Modifying 애노테이션의 설정 값에 대해서 살펴보겠습니다.

flushAutomatically는 @Modifying 이 붙은 쿼리 메서드를 실행하기 전, PC의 변경 사항을 DB에 flush 할 것인지 결정하는 설정입니다. 기본 값은 false입니다.

clearAutomatically는 @Modifying 이 붙은 쿼리 메서드 실행 직후, PC를 clear할 것인지를 지정하는 설정입니다. 기본 값은 false입니다.

설정 값에 대해 확인했으니, 답을 유추해 나가보겠습니다.

Member 엔터티의 @GeneratedValue 전략이 IDENTITY이므로, save()를 호출 시 JPA는 DB와 통신하여 엔터티를 INSERT하고 해당 엔터티의 식별자를 가져오게 됩니다.

이후 updateUsername()이 호출되면, JPQL로 작성된 메서드이므로, PC를 무시하고 DB에 UPDATE 쿼리를 실행합니다.

따라서, PC는 updateUsername()을 통해 DB에 반영된 내용을 알지 못하기 때문에 이후 findById()를 호출하게 되면 기존에 보관하고 있던 Member(1, rebwon) 엔터티를 반환해버립니다.

내용을 취합하면 답은 4번입니다.

그렇다면, 앞서 보았던 @Modifying 의 속성 값 중 clearAutomaticallytrue로 변경하고 실행해보겠습니다.

결과는 어떻게 바뀌었을까요?

이전에는 INSERT와 UPDATE 쿼리만 실행되고, Member username과 UpdateMember username이 동일하게 rebwon이었지만, 이번에는 INSERT, UPDATE, SELECT가 발생하고 UpdateMember username이 kitty로 변경된 것을 확인할 수 있습니다.

clearAutomaticallytrue로 변경함으로써, INSERT 이후 UPDATE 쿼리가 DB에 반영되고, PC를 clear함으로써 기존에 존재하던 Member(1L, rebwon) 엔터티는 제거되고, 이후 findById()를 호출했을 때PC에 엔터티가 없기 때문에 DB에서 조회하여 엔터티를 가져오게 됩니다.

따라서 @Modifying 을 사용하면서 쿼리의 반영사항을 PC와 동기화하고 싶다면, clearAutomaticallytrue로 설정하여 사용하는 것이 좋습니다.

중복 저장 Anti Pattern

수정 메서드에 대한 동작을 분석하면서, 중복 저장 Anti Pattern에 대해 알게 되었습니다.

@Transactional
public void savePostTitle(Long postId, String title) {
Post post = postRepository.findOne(postId);
post.setTitle(title);
postRepository.save(post);
}

예를 들어 위와 같은 코드를 실행한다면, 조회한 Post의 title을 수정하고, save()를 호출하여 UPDATE를 발생시키는 것처럼 보입니다.

하지만, PC에는 Dirty Checking이라는 기능이 있기 때문에 post.setTitle()만 호출하여도 이후 트랜잭션이 커밋되는 시점에 UPDATE가 DB에 반영이 됩니다.

save()를 호출하여도 동작에는 아무런 문제가 없지만, 왜 Anti Pattern으로 분류되는 것일까요?

이미 보관된 엔터티에 대해서 save()를 호출할 때, Hibernate는 MergeEvent를 발생시키는데, 해당 Event를 처리하는 DefaultMergeEventListener는 다음과 같은 코드를 실행하게 됩니다.

protected void entityIsPersistent(MergeEvent event, Map copyCache) {
LOG.trace( "Ignoring persistent instance" );

final Object entity = event.getEntity();
final EventSource source = event.getSession();
final EntityPersister persister = source
.getEntityPersister( event.getEntityName(), entity );

( (MergeContext) copyCache ).put( entity, entity, true );

cascadeOnMerge( source, persister, entity, copyCache );
copyValues( persister, entity, entity, source, copyCache );

event.setResult( entity );
}

copyValues() 호출에서 이미 Managed(Persist) 상태인 엔터티를 다시 복사하게 되기 때문에, 새로운 배열을 중복 생성하게 되면서 CPU 사이클이 낭비됩니다. 만약 최상위 엔터티에 다른 하위 엔터티가 포함되어 있고 최상위 엔터티에서 하위 엔터티로 계단식으로 복사가 진행되면 각 하위 엔터티가 MergeEvent를 전파하고 CPU 사이클이 계속되기 때문오버헤드가 커지게 됩니다.

다양한 글에서 트랜잭션을 시작한 상황에서 엔터티를 수정하고 나면 Dirty Checking이 작동하여 UPDATE가 DB에 반영되기 때문에 굳이 save()를 호출하지 않아도 된다고 말합니다.

하지만 왜? 호출하지 말아야 하는가에 대한 글은 종종 보지는 못했기에, 이렇게 글로 정리하게 되었습니다.

JPA 기본 Flush Mode인 AUTO가 PC 동작에 어떻게 영향을 미치는가? + 약간의 오해

자바 ORM 표준 JPA 프로그래밍 책의 3장 영속성 관리 절을 보면 다음과 같은 내용이 언급되어 있습니다.

PC(Persistence Context)를 Flush 하는 방법은 3가지다

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

JPQL은 쿼리 실행 시 Flush가 자동 호출된다고 말합니다. 정말 그럴까요?

위 두개의 엔터티를 사용하여 아래와 같은 코드를 실행하면 어떻게 될까요?

  1. select a from Article a 호출 시, INSERT가 먼저 발생하고 이후 SELECT가 진행된 다음에, 다시 select m from Member m이동작하여 SELECT가 발생한다.
  2. select a from Article a 호출 시, DB에서 SELECT가 발생한 후 INSERT가 발생하고 SELECT가 발생한다.

답은 2번입니다.

답을 분석하기 전, Spring Data JPA의 구현체인 Hibernate의 Flush Mode AUTO는 다음과 같은 상황에서 Flush가 발생한다고 공식 문서에 언급되어 있습니다.

  • 트랜잭션을 커밋하기 전
  • 대기 중인 엔터티의 작업과 겹치는 JPQL 쿼리를 실행하기 전
  • 네이티브 SQL 쿼리를 실행하기 전

Hibernate는 대기 중인 엔터티의 작업과 겹치는 JPQL 쿼리를 실행하기 전에 Flush 한다고 말합니다.

그렇다면, 위 상황에서는 대기 중인 엔터티(Member)의 작업과 겹치지 않는 엔터티(Article)의 JPQL을 호출하였기 때문에, Flush를 생략해버리고 그 다음 JPQL에서 Flush가 발동하게 됩니다.

예상하던 것과는 다른 동작이지만 구현체인 Hibernate 입장에서는 지극히 당연한 동작이었습니다.

정리하자면, JPA의 기본 Flush Mode가 AUTO 인데, 구현체로 Hibernate를 사용할 경우 JPQL 쿼리를 실행할 때마다 Flush를 자동 호출하지 않습니다.

추가로, 앞서 @Modifying 을 언급하면서 flushAutomaticallyfalse일 경우 쿼리 메서드 실행 전 flush를 하지 않는다고 했습니다. 하지만, Flush MODE가 AUTO일 때flushAutomaticallyfalse로 설정되어있어도 실제 동작은 true로 작동하게 됩니다.

마지막으로 Flush Mode AUTO와 관련하여 전혀 이상할 것 없는 코드지만, 매우 이상한 코드를 실행한 후 결과를 분석해나가보겠습니다.

실행 결과입니다.

실행 결과가 조금 이상하지 않나요?

Member 엔터티의 @GeneratedValue 가 IDENTITY로 설정되어 있기 때문에, 처음 save()를 호출할 때 INSERT가 발생합니다.

이후 findById()는 PC에 보관된 엔터티를 반환하고 엔터티를 수정합니다. 그리고 수정된 username을 비교하고, delete()를 호출합니다. delete가 호출되고 나면 PC는 보관된 엔터티를 삭제하고 쓰기 지연 저장소에 DELETE 쿼리를 저장합니다.

트랜잭션이 아직 커밋되지 않았기 때문에, PC에서 제거된 엔터티를 다시 조회하게 되면 SELECT를 호출할 것으로 기대하게 됩니다.

하지만 SELECT가 발생하지 않았고, NULL이 리턴되었습니다.

왜 이렇게 동작하는 것일까요?

그 궁금증을 파헤쳐보고자 아래와 같은 설정을 추가해보겠습니다.

logging.level.org.hibernate=TRACE
logging.level.org.hibernate.hql=INFO // 과도하게 많은 hql 로그를 보지 않기 위함

애플리케이션을 다시 실행하여 로그를 확인해보겠습니다.

로그를 살펴보면, Entity is null 로그 바로 위에 DefaultLoadEventListener를 통해서 Loading Entity를 한 것을 확인할 수 있습니다. 하지만 바로 아래에서 CacheEntityLoaderHelper의 로그에 찍혀 있는 내용을 확인해보겠습니다.

Load request found matching entity in context, but it is scheduled for removal; returning null.

해석하자면, LoadEvent가 발생하여 PC에서 엔터티를 찾았지만, 해당 엔터티가 remove 상태로 예약되어 있기 때문에, null을 반환한다는 것입니다.

JPA, Hibernate Flush Mode AUTO와 관련하여 다음 글을 찾아본 결과 위와 같이 동작하는 이유를 추측할 수 있었습니다.

The entity state transition operations should be pushed towards the end of the transaction, trying to avoid interleaving them with query operations (therefore preventing a premature flush trigger).

엔터티 상태 전환 작업은 트랜잭션 커밋 시에 반영되어야 하며, 조기에 Flush를 트리거하는 것을 방지 하기 위해, 쿼리 작업과 상호 배치되면 안된다고 합니다.

Flush Mode가 AUTO인 경우 엔터티 상태 전환 작업이 발생하고 나면, Flush가 발생하지 않도록 하기 위해서 DB에서 엔터티를 가져오지 않고 PC에서만 찾고 엔터티의 상태를 확인하여 그에 따라 다르게 동작하게 됩니다.

따라서 Entity is null 부분에서 DB에 반영된 Member 엔터티를 조회하려면 다음과 같은 2가지 방법을 사용해야 합니다.

첫번째로, JPA의 하위 계층인 Jdbc 계층의 코드를 작성하여 가져오거나

actual = jdbcTemplate.queryForObject("SELECT * FROM member where id = ?", (rs, rowNum) -> {
Member dbMember = new Member();
dbMember.setId(rs.getLong("id"));
dbMember.setUsername(rs.getString("username"));
return dbMember;
}, member.getId());

두번째로, JPQL을 사용할 때 다음과 같이 Flush Mode를 COMMIT으로 변경하여 가져오면 됩니다.

actual = em.createQuery("select m from Member m where m.id = :id", Member.class)
.setParameter("id", member.getId())
.setFlushMode(FlushModeType.COMMIT)
.getSingleResult();

Flush Mode를 변경해야 하는 이유는, Hibernate 공식 문서에 나와 있습니다.

If FlushModeType.COMMIT is set, the effect of updates made to entities in the persistence context upon queries is unspecified.

해석하자면, FlushMode가 COMMIT으로 설정된 경우 PC에서 엔터티의 상태 변경에 영향이 쿼리에 영향을 미치지 않는다는 것입니다.

정리해보자면, JPA의 기본 Flush Mode인 AUTO는 때때로 까다롭고 쿼리를 기준으로 일관성 문제를 해결하는 것이 쉽지 않다는 것입니다.

마지막에 마지막으로 다시 아까 보았던 코드에서 저는 Entity가 Null이 반환되는 것 외에도 Persistence Context에 대해서 오해를 하고 있었습니다.

그 오해는 다음과 같습니다. 저는 setUsername()을 호출한 다음에 Dirty Checking이 발동하여 쓰기 지연 저장소에 UPDATE 쿼리가 저장되고 이후 delete()가 호출되면 DELETE 쿼리가 모여서 UPDATE 쿼리와 DELETE 쿼리가 차례로 발생할 것으로 생각했습니다.

하지만 우리는 Persistence Context를 공부할 때 보았던 그림을 유심히 살펴보아야 합니다.

https://vladmihalcea.com/jpa-persist-merge-hibernate-save-update-saveorupdate/

엔터티가 Managed 상태인 경우 DB로 Flush가 발생하는 상황은 Dirty Checking이 동작하는 경우와 remove가 호출된 경우 뿐이지, 두 가지 경우가 혼합되어 Flush가 발생하는 경우는 없다는 것입니다.

The entity state transition operations should be pushed towards the end of the transaction, trying to avoid interleaving them with query operations (therefore preventing a premature flush trigger).

다시 위에서 언급된 내용을 가져와보자면, 엔터티 상태 전환 작업은 트랜잭션 커밋 시에 반영되어야 하며, 조기에 Flush를 트리거하는 것을 방지 하기 위해, 쿼리 작업과 상호 배치되면 안된다고 합니다.

또한 Pro JPA 2 2nd Edition에서도 유사한 내용을 확인할 수 있었습니다.

As we discussed in Chapter 6, the persistence provider will attempt to minimize the number of times the persistence context must be flushed within a transaction. Optimally this will occur only once, when the transaction commits.

해석해보자면, Persistence Provider(Hibernate, Eclipse Link.. etc)에 따라 트랜잭션 내에서 PC를 Flush해야 하는 횟수를 최소화하려고 시도한다는 것과 최적으로는 트랜잭션이 커밋될 때 한번만 발생한다는 뜻입니다.

글을 마무리하며, 지금까지 기술한 모든 내용에서의 핵심은 다음과 같습니다.

JPA에서 Persistence Context가 하는 모든 일은 엔터티의 식별자를 관리하는데 목적이 있다입니다.

만약 여러분이 JPA를 프로젝트에 도입하신다면 반드시, Persistence Context 동작에 대해서 이해하고 넘어가야 합니다.

덧붙여서 JPA의 구현체에 대한 동작 방식까지 이해해야 하는가?에 대해서 의문을 가지실 수 있습니다.

하지만, 이 글을 준비하면서 여러 구현체 중 가장 많이 사용되는 Hibernate의 Persistence Context와 Flush 동작 방식에 대해서는 공식 문서를 읽어보시면서 이해하고 넘어가시면 좋겠다고 생각합니다.

JPA를 사용하면서 저와 같은 오해를 하고 있거나 깊이 있는 국문 자료를 원하시는 분들에게 도움이 되기를 바라면서 글을 마치겠습니다.

지금까지 두서 없는 글을 읽어주셔서 감사합니다 :)

참조 자료

--

--