JPA를 쓰면 save()를 호출하지 않아도 엔티티 값을 바꾸면 DB가 업데이트됩니다. 이건 마법이 아니라 영속성 컨텍스트의 변경 감지 덕분인데, 정확히 어떻게 동작하는 걸까요?

개념 정의

영속성 컨텍스트(Persistence Context) 는 엔티티를 관리하는 논리적인 영역입니다. EntityManager가 관리하는 이 영역에 들어온 엔티티는 영속 상태가 되고, JPA가 라이프사이클을 추적합니다. 1차 캐시, 변경 감지, 쓰기 지연, 지연 로딩의 기반이 됩니다.

왜 필요한가

영속성 컨텍스트가 없으면 모든 변경을 수동으로 SQL에 반영해야 합니다.

JAVA
// 영속성 컨텍스트 없이
User user = jdbcTemplate.queryForObject("SELECT * FROM users WHERE id = ?", mapper, 1L);
user.setName("새이름");
jdbcTemplate.update("UPDATE users SET name = ? WHERE id = ?", user.getName(), user.getId());
// 어떤 필드가 변경됐는지 직접 추적해야 함

영속성 컨텍스트가 있으면:

JAVA
// 영속성 컨텍스트 사용
User user = em.find(User.class, 1L); // 영속 상태
user.setName("새이름");              // 값만 변경
// 트랜잭션 커밋 시 자동으로 UPDATE 실행 (변경 감지)

내부 동작

엔티티 상태

PLAINTEXT
비영속(new/transient)     → new User() 만 한 상태
    ↓ em.persist()
영속(managed)             → 영속성 컨텍스트가 관리하는 상태
    ↓ em.detach() / em.clear() / 트랜잭션 종료
준영속(detached)          → 영속성 컨텍스트에서 분리된 상태
    ↓ em.merge()
영속(managed)             → 다시 관리 상태로
    ↓ em.remove()
삭제(removed)             → 삭제 예약 상태

1차 캐시

JAVA
// 첫 번째 조회 → SELECT 쿼리 실행, 1차 캐시에 저장
User user1 = em.find(User.class, 1L); // SQL 실행

// 두 번째 조회 → 1차 캐시에서 반환, SQL 실행 안 함
User user2 = em.find(User.class, 1L); // SQL 미실행

System.out.println(user1 == user2); // true — 같은 인스턴스

1차 캐시의 구조:

PLAINTEXT
영속성 컨텍스트 1차 캐시
┌────────────────────────────────────┐
│ Key (ID)  │ 엔티티 인스턴스  │ 스냅샷    │
├────────────────────────────────────┤
│ User@1    │ User{id=1, ...} │ {원본 복사} │
│ User@2    │ User{id=2, ...} │ {원본 복사} │
│ Order@10  │ Order{id=10, ..}│ {원본 복사} │
└────────────────────────────────────┘

변경 감지 (Dirty Checking)

변경 감지는 다음 순서로 동작합니다.

  1. 엔티티를 조회하면 1차 캐시에 저장되면서 스냅샷이 복사 됩니다.
  2. 엔티티의 값을 변경합니다 (setter 호출).
  3. flush 시점(보통 트랜잭션 커밋 직전)에 1차 캐시의 모든 엔티티를 순회 합니다.
  4. 현재 상태와 스냅샷을 비교하여, 변경된 엔티티가 있으면 UPDATE SQL을 생성 합니다.
  5. 생성된 SQL을 DB에 전송하고, 트랜잭션이 커밋됩니다.

스냅샷이 있기 때문에 변경 감지가 가능합니다. 따라서 영속성 컨텍스트에서 관리되지 않는 준영속(detached) 엔티티는 값을 변경해도 UPDATE가 발생하지 않습니다.

JAVA
@Transactional
public void updateUser(Long id, String newName) {
    User user = userRepository.findById(id).orElseThrow();
    user.setName(newName);
    // repository.save() 호출 불필요!
    // 트랜잭션 커밋 시 변경 감지로 자동 UPDATE
}

쓰기 지연 (Write-Behind)

JAVA
@Transactional
public void createUsers() {
    User user1 = new User("A");
    User user2 = new User("B");
    User user3 = new User("C");

    em.persist(user1); // INSERT SQL을 쓰기 지연 저장소에 보관
    em.persist(user2); // INSERT SQL 보관
    em.persist(user3); // INSERT SQL 보관

    // 아직 DB에 SQL 전송 안 됨
    // 트랜잭션 커밋 시점에 한꺼번에 전송
}

flush 시점

flush는 영속성 컨텍스트의 변경 내용을 DB에 동기화하는 것입니다. (1차 캐시를 비우지 않음)

  • 트랜잭션 커밋 시 자동 flush
  • JPQL 실행 시 자동 flush (쿼리 결과 일관성을 위해)
  • em.flush() 수동 호출
JAVA
em.persist(new User("홍길동"));

// JPQL 실행 전에 자동 flush → 방금 persist한 User도 조회 가능
List<User> users = em.createQuery("SELECT u FROM User u", User.class).getResultList();

FlushMode 설정

JAVA
em.setFlushMode(FlushModeType.AUTO);   // 기본값: 커밋 시 + JPQL 시 flush
em.setFlushMode(FlushModeType.COMMIT); // 커밋 시에만 flush (JPQL 시 flush 안 함)

코드 예제

detach와 merge

JAVA
@Transactional
public void example() {
    User user = em.find(User.class, 1L); // 영속 상태

    em.detach(user); // 준영속 상태로 전환

    user.setName("변경"); // 변경 감지 안 됨 (준영속이므로)

    User mergedUser = em.merge(user); // 새로운 영속 엔티티 반환
    // 주의: user != mergedUser
    // user는 여전히 준영속, mergedUser가 영속 상태
}

clear — 영속성 컨텍스트 초기화

JAVA
// 배치 처리 시 메모리 관리
@Transactional
public void batchInsert(List<UserRequest> requests) {
    for (int i = 0; i < requests.size(); i++) {
        em.persist(new User(requests.get(i)));

        if (i % 100 == 0) {
            em.flush();  // DB에 전송
            em.clear();  // 1차 캐시 비움 → 메모리 절약
        }
    }
}

1차 캐시에 수만 건의 엔티티가 쌓이면 메모리가 부족해집니다. 배치 처리에서는 주기적으로 flush() + clear()를 호출합니다.

변경 감지 vs save()

JAVA
// 방법 1: 변경 감지 (권장)
@Transactional
public void updateName(Long id, String name) {
    User user = userRepository.findById(id).orElseThrow();
    user.setName(name);
    // 끝! save() 호출 불필요
}

// 방법 2: save() 호출 (불필요하지만 동작함)
@Transactional
public void updateName(Long id, String name) {
    User user = userRepository.findById(id).orElseThrow();
    user.setName(name);
    userRepository.save(user); // 이미 영속 상태이므로 merge가 실행됨 (불필요한 호출)
}

영속 상태의 엔티티에 save()를 호출하면 em.merge()가 실행되는데, merge는 SELECT 쿼리를 추가로 실행할 수 있어 비효율적입니다.

OSIV (Open Session In View)

YAML
spring:
  jpa:
    open-in-view: true  # 기본값: true

OSIV가 켜져 있으면:

PLAINTEXT
HTTP 요청 → 영속성 컨텍스트 열림
  → 서비스 계층 (트랜잭션 시작/종료)
  → 컨트롤러 (영속성 컨텍스트 유지, Lazy 로딩 가능)
  → 뷰 렌더링 (Lazy 로딩 가능)
→ HTTP 응답 → 영속성 컨텍스트 닫힘 + DB 커넥션 반환

OSIV가 꺼져 있으면:

PLAINTEXT
HTTP 요청
  → 서비스 계층 (영속성 컨텍스트 열림/닫힘, 트랜잭션 범위)
  → 컨트롤러 (Lazy 로딩 불가! LazyInitializationException)
→ HTTP 응답

OSIV 끄기와 해결 방법

YAML
spring:
  jpa:
    open-in-view: false  # 운영에서 권장

OSIV를 끄면 컨트롤러에서 Lazy 로딩이 안 됩니다. 해결 방법:

JAVA
// 서비스에서 필요한 데이터를 미리 로드
@Transactional(readOnly = true)
public OrderResponse getOrderDetail(Long id) {
    Order order = orderRepository.findById(id).orElseThrow();

    // 서비스 안에서 Lazy 필드에 접근하여 로드
    return OrderResponse.builder()
        .id(order.getId())
        .userName(order.getUser().getName())  // Lazy 로딩 (트랜잭션 안)
        .items(order.getItems().stream()       // Lazy 로딩
            .map(ItemResponse::from)
            .toList())
        .build();
}

또는 fetch join을 사용합니다.

JAVA
@Query("SELECT o FROM Order o JOIN FETCH o.user JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findByIdWithDetails(@Param("id") Long id);

주의할 점

영속 상태에서 save()를 호출하면 merge()가 실행된다

이미 영속 상태인 엔티티에 save()를 호출하면 em.merge()가 실행됩니다. merge는 DB에서 SELECT를 한 번 더 실행할 수 있어 ** 불필요한 쿼리가 발생 **합니다. 변경 감지를 믿고 save()를 생략하는 것이 올바른 사용법입니다.

flush()와 commit()을 혼동하면 안 된다

flush()는 쓰기 지연 저장소의 SQL을 DB에 전송하는 것이지, 트랜잭션을 확정하는 것이 아닙니다. flush 후에도 ** 롤백이 가능 **합니다. 반면 commit()은 내부에서 flush를 호출한 뒤 트랜잭션을 확정하므로 롤백이 불가능합니다.

배치 처리에서 clear() 없이 대량 persist를 하면 OOM이 발생한다

1차 캐시는 트랜잭션이 끝날 때까지 계속 쌓이기 때문에, 수만 건을 persist하면 메모리가 고갈됩니다. 주기적으로 flush() + clear()를 호출하여 1차 캐시를 비워야 합니다.

정리

항목설명
1차 캐시같은 트랜잭션 내 동일 엔티티의 반복 조회를 방지
변경 감지flush 시점에 스냅샷과 현재 상태를 비교하여 UPDATE 자동 생성
flush쓰기 지연 저장소의 SQL을 DB에 전송 (커밋 아님)
clear1차 캐시를 비움 (DB에 영향 없음)
OSIV운영에서는 끄고, 서비스 계층에서 필요한 데이터를 미리 로드

영속 상태의 엔티티는 값을 변경하면 save() 호출 없이도 트랜잭션 커밋 시 자동으로 UPDATE가 실행됩니다. 이것이 가능한 이유는 영속성 컨텍스트가 최초 스냅샷과 현재 상태를 비교하기 때문입니다.

댓글 로딩 중...