이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 영속성 컨텍스트는 어떤 편의와 비용을 제공하는가?
  • Dirty checking은 언제 SQL로 바뀌는가?
  • Flush 시점을 모르면 어떤 장애와 성능 문제가 생기는가?

개요

JPA의 영속성 컨텍스트는 엔티티를 관리하는 1차 캐시이자 변경 추적 공간이다. 개발자는 엔티티 필드를 바꾸기만 해도 JPA가 변경을 감지해 update SQL을 생성한다.

편리하지만 SQL 실행 시점이 코드 위치와 다를 수 있다. flush를 이해하지 못하면 제약 위반, update 폭증, lock 획득 시점을 놓친다.

원리

트랜잭션 안에서 엔티티를 조회하면 영속 상태가 된다.

@Transactional
public void changeName(Long memberId, String name) {
    Member member = memberRepository.findById(memberId).orElseThrow();
    member.changeName(name);
}

save를 다시 호출하지 않아도 트랜잭션 커밋 전 flush 시점에 update가 나갈 수 있다.

UPDATE members
SET name = ?
WHERE id = ?;

이것이 dirty checking이다.

1차 캐시와 동일성

같은 트랜잭션에서 같은 엔티티를 두 번 조회하면 같은 객체를 돌려줄 수 있다.

Member a = entityManager.find(Member.class, 1L);
Member b = entityManager.find(Member.class, 1L);
assert a == b;

이 특성은 객체 그래프를 다루기 편하게 해준다. 하지만 트랜잭션이 길어지면 영속성 컨텍스트에 관리 객체가 많이 쌓이고 메모리와 flush 비용이 늘 수 있다.

Flush 시점

Flush는 보통 다음 시점에 발생할 수 있다.

  • transaction commit 전
  • JPQL 실행 전
  • 명시적 entityManager.flush()
@Transactional
public void register(Member member) {
    memberRepository.save(member);
    duplicateCheckQuery(); // 이 전에 flush가 발생할 수 있음
}

Flush는 DB에 SQL을 보내는 것이며, commit은 트랜잭션을 확정하는 것이다. flush 후에도 rollback될 수 있다.

Flush가 빨리 일어나면 제약 조건 위반이나 lock 획득도 예상보다 빨리 발생한다. 예를 들어 새 주문을 저장한 뒤 같은 트랜잭션에서 JPQL 조회를 실행하면 Hibernate가 조회 결과의 일관성을 위해 먼저 flush할 수 있다.

@Transactional
public void createAndCheck(Order order) {
    entityManager.persist(order);
    orderRepository.existsByOrderNo(order.getOrderNo()); // 실행 전 flush 가능
}

이때 unique constraint 위반은 메서드 마지막이 아니라 중간 조회 앞에서 터질 수 있다. 장애 로그를 볼 때 “왜 조회 코드에서 insert 예외가 나지?”라고 당황하지 않으려면 flush 시점을 알아야 한다.

Dirty Checking의 비용

Dirty checking은 변경된 엔티티를 찾아 update SQL을 만든다. 관리 엔티티가 많고 트랜잭션이 길면 변경 감지 비용과 메모리 사용량이 커진다.

@Transactional
public void touchManyMembers(List<Long> ids) {
    List<Member> members = memberRepository.findAllById(ids);
    for (Member member : members) {
        member.markChecked();
    }
}

이 코드는 엔티티 수만큼 update를 만들 수 있다. 몇십 건이면 자연스럽지만 수십만 건이면 bulk update, chunk 처리, 별도 배치가 더 적합하다.

또한 update SQL이 어떤 컬럼을 갱신하는지는 매핑과 Hibernate 설정에 따라 달라질 수 있다. “필드 하나만 바꿨으니 DB 비용도 작다”고 단정하지 말고 실제 SQL과 row lock 범위를 확인한다.

대량 처리 문제

대량 insert/update에서 영속성 컨텍스트를 비우지 않으면 메모리와 flush 비용이 커진다.

@Transactional
public void importMembers(List<Member> members) {
    for (int i = 0; i < members.size(); i++) {
        entityManager.persist(members.get(i));
        if (i % 1000 == 0) {
            entityManager.flush();
            entityManager.clear();
        }
    }
}

대량 작업은 batch size, flush/clear, 트랜잭션 chunk, JDBC batch 설정을 함께 고려한다.

JDBC batch를 쓰려면 Hibernate 설정도 맞춰야 한다.

spring.jpa.properties.hibernate.jdbc.batch_size=100
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true

식별자 생성 전략이 IDENTITY이면 insert batch가 제한될 수 있다. MySQL auto increment, PostgreSQL sequence, Hibernate sequence optimizer 같은 차이를 알아야 대량 저장 성능을 설명할 수 있다.

Bulk Update와 상태 불일치

JPQL bulk update/delete는 영속성 컨텍스트의 엔티티를 하나씩 변경하지 않고 DB에 직접 SQL을 보낸다.

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
    update Member m
    set m.status = 'DORMANT'
    where m.lastLoginAt < :threshold
""")
int markDormant(LocalDateTime threshold);

이미 같은 트랜잭션에서 Member를 로딩해두었다면 메모리 속 엔티티 상태와 DB 상태가 달라질 수 있다. bulk 쿼리 뒤에는 clear 후 재조회하거나, 애초에 bulk 작업을 별도 트랜잭션으로 분리한다.

실전 팁

  • 변경 감지는 편리하지만 update SQL이 언제 나가는지 확인한다.
  • 대량 처리에서는 flush/clear와 chunk를 사용한다.
  • readOnly 트랜잭션으로 읽기 의도를 명확히 한다.
  • 엔티티를 오래 들고 있지 않는다.
  • 제약 위반 예외가 메서드 끝에서 터질 수 있음을 고려한다.
  • bulk update/delete 뒤에는 영속성 컨텍스트와 DB 상태가 어긋날 수 있음을 전제로 clear 또는 재조회를 설계한다.
  • 대량 저장은 hibernate.jdbc.batch_size, 식별자 전략, flush 주기, 트랜잭션 chunk를 함께 테스트한다.

위험 신호!

  • 엔티티를 조회만 한다고 생각했는데 setter가 호출된다.
  • 트랜잭션 하나에서 수만 개 엔티티를 관리한다.
  • flush 시점을 몰라 예외 위치를 잘못 추적한다.
  • readOnly 메서드에서 엔티티를 변경한다.
  • bulk update와 영속성 컨텍스트 상태 불일치를 고려하지 않는다.
  • JPQL 조회 앞에서 flush가 발생할 수 있다는 사실을 모르고 예외 위치를 잘못 해석한다.
  • 대량 import를 하나의 트랜잭션과 하나의 영속성 컨텍스트로 처리한다.

확인 질문

  • Dirty checking은 무엇인가?
    • 영속성 컨텍스트가 엔티티의 변경을 감지해 flush 시점에 update SQL로 반영하는 기능이다.
  • Flush와 commit의 차이는 무엇인가?
    • flush는 SQL을 DB에 보내는 것이고, commit은 트랜잭션 변경을 확정하는 것이다.
  • 대량 처리에서 flush/clear가 필요한 이유는 무엇인가?
    • 영속성 컨텍스트에 관리 객체가 계속 쌓여 메모리와 변경 감지 비용이 커지는 것을 줄이기 위해서다.
  • Bulk update 후 clear나 재조회가 필요한 이유는 무엇인가?
    • bulk update는 영속성 컨텍스트의 엔티티 상태를 자동으로 갱신하지 않으므로 메모리 상태와 DB 상태가 달라질 수 있기 때문이다.

참고 문서