영속성 컨텍스트(persistence context)는 JPA가 트랜잭션 동안 읽거나 저장한 엔티티 객체를 기억해 두는 저장소다. JPA는 이 객체들을 지켜보다가 정해진 시점에 SQL을 DB로 보낸다. 그래서 코드 한 줄이 실행된 순간과 SQL이 DB에 도착하는 순간이 다를 수 있고, 같은 행을 다시 읽어도 DB가 아니라 기억해 둔 객체를 받을 수 있다.

바로 나가는 쓰기

id를 DB가 정하는 방식(GenerationType.IDENTITY, MySQL의 AUTO_INCREMENT)이면 새 엔티티의 save()는 바로 INSERT를 보낸다. 영속성 컨텍스트는 객체를 id로 구분해서 기억하는데, 이 방식에서는 행을 실제로 넣어 봐야 id를 알 수 있기 때문이다.

kotlin
@Entity
class Job(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,
    var status: JobStatus = JobStatus.PENDING,
)
 
// 이 줄에서 INSERT가 나감; 반환 객체에 id가 채워짐
val saved = jobRepository.save(Job())

@Query에 직접 쓴 UPDATE나 DELETE도 호출하는 순간 DB로 나간다. 이런 쿼리는 영속성 컨텍스트를 거치지 않고 DB에 바로 실행된다.

미뤄지는 쓰기

이미 영속성 컨텍스트에 있는 엔티티의 필드를 바꾸면, 그 줄에서는 메모리의 객체만 바뀐다.

kotlin
@Transactional
fun complete(jobId: Long) {
    val job = jobRepository.findById(jobId).orElseThrow()
    // 객체만 DONE으로 바뀜; DB는 아직 PENDING
    job.status = JobStatus.DONE
    // save()를 다시 부르지 않아도 커밋 직전에 UPDATE가 나감
}

JPA는 엔티티를 읽어 올 때의 값을 따로 보관해 둔다. 트랜잭션이 커밋되기 직전에 flush(영속성 컨텍스트의 변경을 SQL로 DB에 보내는 동작)가 일어나면, 보관한 값과 지금 값을 비교해서 달라진 필드에 대한 UPDATE를 만든다. 이 비교를 변경 감지(dirty checking)라고 한다. 트랜잭션 안에서 읽은 엔티티의 값을 바꿀 때 save()를 다시 부르는 줄이 없어도 저장되는 이유가 이것이다.

커밋 전에 서버가 꺼지면 이 UPDATE는 나가지 못하고, DB에는 바뀌기 전 값이 그대로 남는다.

객체와 DB가 어긋나는 두 방향

변경 감지는 '객체가 앞서고 DB가 뒤처진' 상태를 커밋 때 맞춰 준다. 반대로 직접 쓴 쿼리로 DB를 먼저 바꾸면 'DB가 앞서고 객체가 뒤처진' 상태가 되는데, JPA는 이 방향을 맞춰 주지 않는다.

kotlin
@Transactional
fun start(jobId: Long) {
    val job = jobRepository.findById(jobId).orElseThrow()  // status = PENDING으로 기억
 
    // DB에서는 RUNNING으로 바뀜
    jobRepository.markRunning(jobId)
 
    // 영속성 컨텍스트에 있던 객체를 그대로 돌려줌; 여전히 PENDING
    val again = jobRepository.findById(jobId).orElseThrow()
}

같은 트랜잭션에서 findById로 다시 읽어도 DB에 가지 않고 기억해 둔 객체를 돌려준다. 이렇게 같은 id의 객체를 다시 쓰는 성질 때문에 영속성 컨텍스트를 1차 캐시라고도 부른다.

이 어긋남을 피하려면 직접 쓴 쿼리 뒤에 영속성 컨텍스트를 비운다. Spring Data JPA의 @Modifying에는 쿼리 실행 뒤 영속성 컨텍스트를 자동으로 비우는 clearAutomatically 옵션이 있다.

kotlin
// 쿼리 실행 전에 미뤄 둔 변경을 보내고, 실행 뒤에 기억한 객체를 비움
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("UPDATE jobs SET status = 'RUNNING' WHERE id = :id", nativeQuery = true)
fun markRunning(id: Long): Int

clearAutomatically만 켜면 비우기 전에 아직 보내지 않은 변경 감지 결과가 함께 사라질 수 있다. 그래서 같은 트랜잭션에서 엔티티 값을 바꾼 뒤 직접 쿼리를 부르는 코드라면 flushAutomatically도 함께 켠다.

공식 문서: Spring Data JPA, Modifying Queries

테스트에서 flush와 clear

@DataJpaTest는 테스트 하나를 트랜잭션 하나로 감싸고 끝나면 롤백한다. 그래서 테스트 안에서는 커밋이 일어나지 않고, 커밋 때 나가는 쓰기를 확인하려면 직접 flush를 불러야 한다.

  • entityManager.flush(): 미뤄 둔 쓰기를 지금 DB로 보낸다. 엔티티 값을 바꾼 뒤 DB 상태를 확인할 때 필요하다. IDENTITY 방식의 새 행 저장은 이미 나갔으므로 필요 없다.
  • entityManager.clear(): 기억한 객체를 전부 잊게 한다. 직접 쓴 쿼리로 DB를 바꾼 뒤 다시 읽을 때 필요하다.
kotlin
@Test
fun `작업을 꺼내면 RUNNING이 된다`() {
    val job = jobRepository.save(Job())  // INSERT는 이미 나감; flush 불필요
 
    jobRepository.markRunning(job.id!!)
    // 기억한 PENDING 객체를 버려야 DB 값을 다시 읽음
    entityManager.clear()
 
    assertEquals(JobStatus.RUNNING, jobRepository.findById(job.id!!).get().status)
}

위 테스트에서 clear()를 지우면 expected: <RUNNING> but was: <PENDING>으로 실패한다. DB는 바뀌었지만 영속성 컨텍스트에 남은 옛 객체를 받기 때문이다. 반대로 save() 뒤에 flush()를 넣어도 결과는 달라지지 않는다.

'save를 불러도 DB에 바로 안 보낸다'고 한 문장으로 기억하면 이 두 경우를 구분하지 못한다. 새 행 넣기는 바로 나가고, 값 바꾸기는 flush 때 나간다고 나눠서 기억해야 테스트에 필요한 줄만 넣을 수 있다.

트랜잭션을 거는 방법은 @Transactional과 트랜잭션 범위에서 다뤘다. 직접 쓴 쿼리로 작업 상태를 바꾸는 큐 예시는 SELECT FOR UPDATE SKIP LOCKED에 있다.