본문 바로가기

탐구일지/Java

영속성 컨텍스트(Persistence Context)의 이해와 활용 (1)

1. 배경

 

JPA(Java Persistence API)를 사용하다 보면 영속성 컨텍스트(Persistence Context)라는 용어를 많이 접한다

대부분의 주니어는 "영속성 컨텍스트란 무엇인가?"라는 질문에 대해 명확한 답을 내리기 어렵다고 느낄 것이다

실제로 많은 주니어들이 영속성 컨텍스트를 단순히 'Entity를 저장하는 공간' 정도로 이해하고 사용한다

 

하지만 이러한 이해는 영속성 컨텍스트의 다양한 예외 상황에 대해 설명하지 못한다

같은 객체인데 다른 주소값을 가진다던가,

save()를 호출하지 않았는데도 DB에 반영되거나,

반대로 비슷한 코드인데 아무 일도 일어나지 않는 경우도 있다


이는 코드의 문제가 아니라,

영속성 컨텍스트가 가진 규칙과 동작 방식에서 비롯된 문제이기 때문이다

 

이 글에서는 영속성 컨텍스트의 원칙과 동작 원리, 그리고 그에 대한 이해와 활용까지 알아보고자 한다

 

 

2. 영속성 컨텍스트의 불변식

 

영속성 컨텍스트는 다음과 같은 기본 원칙을 따른다

 

엔티티 타입 - ID 조합은 영속성 컨텍스트 안에서 단 하나만 존재한다

 

이는 JVM 환경에서 객체 동일성을 보장하고,
변경 감지와 트랜잭션 일관성을 유지하기 위한 규칙이다

 

간단히 말하면, 같은 DB row를 나타내는 객체가 메모리 상에서 중복 생성되지 않도록 보장하기 위해서이다

 

 

3. Entity 생명 주기

 

이 불변식을 지키기 위해 엔티티는 아래와 같은 생명 주기를 갖는다

 

  • Transient: DB와 영속성 컨텍스트에서 모두 갖고 있지 않은 새로 생성된 객체
  • Managed: 영속성 컨텍스트가 관리하는 유일한 객체. 변경 추적의 대상
  • Detached: 이전에 영속성 컨텍스트에서 관리되었으나, 현재는 관리되지 않는 객체
  • Removed: 영속성 컨텍스트에서 관리되고 있지만, 트랜잭션이 끝날 때 DB에서 삭제될 예정인 객체

 

그리고 생명주기 변경을 위한 API 역시 존재한다

 

 

EntityManager

  • Persist: Transient → Managed. DB에 존재하지 않는 Entity에 대해 적용
  • Merge: Detached → Managed. 이미 DB에 존재하면서 추적중이지 않은 Entity에 대해 적용
  • Detach: Managed → Detached. 영속 상태이던 Entity를 준영속 상태로 변경
  • Remove: Managed → Removed. 실제 Delete는 트랜잭션 종료 시 반영

 

TransactionManager

  • Commit: Flush 후 DB에 반영 확정
  • Rollback: 트랜잭션 내 변경 사항 폐기

 

Entity Lifecycle (그림 출처)

 

 

4. 영속성 컨텍스트 생명 주기

 

그렇다면 영속성 컨텍스트는 언제 생성되는가?

싱글톤 객체처럼 단일로 존재하는가?

 

RedHat의 공식 문서에 따르면 아래와 같다 (출처)

The lifetime of a container-managed persistence context can either be scoped to a transaction, which is referred to as a transaction-scoped persistence context, or have a lifetime scope that extends beyond that of a single transaction, which is referred to as an extended persistence context.

 

요약하면 영속성 컨텍스트는 Transaction-Scope와 Extended-Scope가 존재한다

그리고 기본은 Transaction-Scope임을 명시한다

 

그럼 실제로 그런지 한 번 테스트해보자

 

# 테스트 01. 트랜잭션별 영속성 컨텍스트 상태

public class JpaController {
    private final JpaService jpaService;

    @PersistenceContext
    private final EntityManager em;

    @GetMapping("/example")
    public void simplified_example(
            @RequestParam Long id) {
        log.info("--- Tx 1:");
        Temp temp = jpaService.tx1(id);

        log.info("--- Tx 2:");
        TempTwins tempTwins = jpaService.tx2(id, temp);

        log.info("--- Out of Tx:");
        log.info("{}: {}", temp.toString(), em.contains(temp));
        log.info("{}: {}", tempTwins.toString(), em.contains(temp));
    }
}
public class JpaService {
    private final TempRepository tempRepository;
    private final TempTwinsRepository tempTwinsRepository;

    @PersistenceContext
    private final EntityManager em;

    @Transactional(readOnly = true)
    public Temp tx1(Long id) {
        Temp temp = tempRepository.findById(id)
                .orElseThrow(() -> new RuntimeException(""));
        log.info("{}: {}", temp.toString(), em.contains(temp));
        return temp;
    }

    @Transactional(readOnly = true)
    public TempTwins tx2(Long id, Temp temp) {
        TempTwins tempTwins = tempTwinsRepository.findById(id)
                .orElseThrow(() -> new RuntimeException(""));
        log.info("{}: {}", temp.toString(), em.contains(temp));
        log.info("{}: {}", tempTwins.toString(), em.contains(tempTwins));
        return tempTwins;
    }
}

 

Temp와 TempTwins라는 각각의 객체를 Select 쿼리로 불러오고, 

영속성 컨텍스트에 존재하는지 체크하는 로그를 작성했다

 

결과는 어떠할까?

 

test.demo.controller.JpaController       : --- Tx 1:
test.demo.service.JpaService             : test.demo.domain.entity.Temp@2ffc5130: true
test.demo.controller.JpaController       : --- Tx 2:
test.demo.service.JpaService             : test.demo.domain.entity.Temp@2ffc5130: true
test.demo.service.JpaService             : test.demo.domain.entity.TempTwins@5cb77ebd: true
test.demo.controller.JpaController       : --- Out of Tx:
test.demo.controller.JpaController       : test.demo.domain.entity.Temp@2ffc5130: true
test.demo.controller.JpaController       : test.demo.domain.entity.TempTwins@5cb77ebd: true

 

 

예상한 것과 달리 트랜잭션과 상관 없이, 그리고 트랜잭션 외부에서도 영속성 컨텍스트가 유지됨을 알 수 있다

왜 이런 현상이 발생하는 것일까? 공식 문서가 잘못 표기된 것일까?

 

이는 Spring Boot에서의 기본 설정이 다르기 때문이다

이를 이해하기 위해 OSIV(Open Session In View)라는 개념에 대한 이해가 필요하다

 

 

5. OSIV(Open Session In View)

 

Spring Framework가 제공하는 기능으로, JPA 스펙 바깥에 존재하는 기능이다

View까지 영속성 컨텍스트를 유지하여 Lazy Loading이 가능하도록 하는 것이 목적이다

이 범위는 HTTP 요청 범위, 즉 Persistence Context per Request가 되는 것이다

그렇다면 OSIV를 사용하지 않는 경우는 어떨까?

 

(사진 출처)

 

이렇게 Transaction 기반으로 유지가 될 것이다

이번에는 OSIV를 OFF로 변경하고 같은 코드에 대해 테스트를 해 보자

 

# 테스트 02. 트랜잭션별 영속성 컨텍스트 상태 (OSIV off)

test.demo.controller.JpaController       : --- Tx 1:
test.demo.service.JpaService             : test.demo.domain.entity.Temp@1aa85ee6: true
test.demo.controller.JpaController       : --- Tx 2:
test.demo.service.JpaService             : test.demo.domain.entity.Temp@1aa85ee6: false
test.demo.service.JpaService             : test.demo.domain.entity.TempTwins@2c47975f: true
test.demo.controller.JpaController       : --- Out of Tx:
test.demo.controller.JpaController       : test.demo.domain.entity.Temp@1aa85ee6: false
test.demo.controller.JpaController       : test.demo.domain.entity.TempTwins@2c47975f: false

 

OSIV를 사용하지 않을 때의 결과는 이와 같다

 

예상한대로,

각각의 트랜잭션에서 서로 다른 영속성 컨텍스트를 사용하고, Select 시점에 추가된다는 점을 알 수 있다

 

이렇게 OSIV가 Lazy Loading 문제 회피에 대한 편의성을 제공하는 반면에 생길 수 있는 여러 문제 역시 존재한다

 

트랜잭션 외부에서도 Entity가 관리 상태로 유지되기 때문에 요청이 길어질수록 영속성 컨텍스트에 많은 데이터가 적재된다

이로 인해 관리 오버헤드가 발생할 수 있다

 

또한 Dirty Checking에 대해서도, 트랜잭션의 기능을 제대로 이해하지 못하면 잘못 활용될 수 있는 여지가 생긴다

 

 

6. Dirty Checking

 

JPA를 사용하면서  repository.save() 메소드를 사용하지 않고도 변경된 것에 대해서 업데이트 된 경험이 있는가?

영속성 컨텍스트에서 Entity 변경 사항을 확인하고, 이를 자동으로 Flush하는 것이 Dirty Checking이다

 

# 테스트 03. DirtyChecking 작동 테스트

@PatchMapping("/example")
public void simplified_example(
        @RequestHeader("X-Id") Long id) {
    Temp temp = jpaService.dirtyCheck(id);
    log.info("After Name: {}", em.find(Temp.class, id).getName());
}
@Transactional
public void dirtyCheck(Long id) {
    Temp temp = tempRepository.findById(id)
            .orElseThrow(() -> new RuntimeException(""));
    log.info("Before Name: {}", em.find(Temp.class, id).getName());
    temp.setName("modified");
}

 

이와 같은 코드로 작성했다고 해보자

별도의  save() 를 호출하지 않았는데 과연 DB에 저장 되었을까?

 

먼저 영속성 컨텍스트를 확인하는 결과 로그를 확인해보면

 

test.demo.service.JpaService             : Before Name: name
test.demo.controller.JpaController       : After Name: modified

 

이처럼 변경된 것을 볼 수 있다

다만 이는 Setter로 변경한 것이니 객체이고, 실제 DB가 바뀌었는지 하는 의문이 생길 수 있다

 

 

DB에 역시 적절히 적용된 것을 볼 수 있다

 

그렇다면 트랜잭션 외부에서 변경이 있을 경우에는 Dirty Checking을 수행할 수 있을까?

 

# 테스트 04. DirtyChecking 작동 테스트 (트랜잭션 외부)

public void dirtyCheck(Long id) {
    Temp temp =tempRepository.findById(id)
            .orElseThrow(() -> new RuntimeException(""));
    log.info("Before Name: {}", em.find(Temp.class, id).getName());
    temp.setName("modified");
}

 

앞선 코드와 거의 동일하고, @Transactional만 제거한 상태이다

이 때는 과연 DB가 변경될까?

 

test.demo.service.JpaService             : Before Name: name
test.demo.controller.JpaController       : After Name: name

 

로그를 살펴보면 분명 Entity의 상태는 변경하였으나,

트랜잭션이 존재하지 않아 Flush가 수행되지 않았고,

그 결과 Dirty Checking과 DB 반영이 이루어지지 않았다

 

 

그리고 DB 역시 변경되지 않은 것을 확인해볼 수 있다

 

# 테스트 05. DirtyChecking 작동 테스트 (트랜잭션 외부 + 강제 Flush)

public void dirtyCheck(Long id) {
    Temp temp =tempRepository.findById(id)
            .orElseThrow(() -> new RuntimeException(""));
    log.info("Before Name: {}", em.find(Temp.class, id).getName());
    temp.setName("modified");
    em.flush();
}


앞선 코드의 마지막에 Flush를 보내 변경이 영속성 컨텍스트에 적용되도록 시도해봤다

 

jakarta.persistence.TransactionRequiredException:
    No EntityManager with actual transaction available for current thread - cannot reliably process 'flush' call

 

그 결과, 트랜잭션 외부에서는 Flush를 호출할 수 없다는 오류가 나타났다

즉 영속성 컨텍스트가 존재하더라도 트랜잭션 외부에서는 영속성 컨텍스트에 이를 반영하지 못함을 알 수 있다