들어가며

배치나 테스트 코드를 돌리다가 SQL 로그를 보고 있으면, flush()를 호출하는 순간 update 쿼리가 찍히는 경우가 있다. 이때 처음에는 나도 아, 이제 커밋까지 끝났구나 하고 생각한 적이 있었다. 하지만 실제로는 그렇지 않다.

이전 글인 JPA에서 엔티티를 수정하면 언제 UPDATE 쿼리가 나갈까에서는 JPA가 엔티티 변경을 어떻게 추적하는지 정리했는데, 이번에는 그 과정에서 가장 자주 헷갈리는 flushcommit의 차이를 따로 정리해보려고 한다.

결론부터 말하면, flush는 SQL 실행 시점이고 commit은 트랜잭션 확정 시점이다. 둘은 비슷해 보이지만 같은 단계가 아니다.

예제

먼저 가장 단순한 예제를 보자.

tx.begin();
 
Member member = em.find(Member.class, 1L);
member.setName("changed");
 
em.flush();
// 아직 commit 안 함
 
tx.commit();

이 코드를 실행하면 보통 em.flush()를 호출하는 시점에 아래와 같은 update SQL 로그를 볼 수 있다.

select m.id, m.name
from member m
where m.id = 1;
 
update member
set name = 'changed'
where id = 1;

이쯤 되면 자연스럽게 이런 생각이 든다.

  • SQL이 이미 실행됐으니 저장도 끝난 것 아닌가
  • flush()가 사실상 commit() 역할을 하는 것 아닌가

하지만 여기서 중요한 점은, SQL이 실행됐다는 사실과 트랜잭션이 확정되었다는 사실은 다르다는 점이다. flush()는 전자에 더 가깝고, commit()은 후자에 더 가깝다.

flush

flush()는 영속성 컨텍스트의 변경 내용을 데이터베이스에 동기화하는 작업이다. 다시 말해, 메모리에서 관리하던 변경 사항을 SQL로 만들어 데이터베이스에 전달하는 시점이다.

이전 글에서 정리한 것처럼, JPA는 엔티티를 영속성 컨텍스트에서 관리하면서 Dirty Checking으로 변경 여부를 추적한다. 그리고 그 결과를 실제 SQL로 밀어 넣는 단계가 flush()다. 여기서 중요한 것은, flush()가 엔티티 변경을 확정하는 단계가 아니라는 점이다.

flush()의 특징을 정리하면 다음과 같다.

  • 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영하려고 시도한다
  • insert, update, delete 같은 SQL이 실행될 수 있다
  • 트랜잭션은 끝나지 않는다
  • 잠금 해제나 최종 확정이 일어나는 단계는 아니다

즉, flush()는 “지금까지 쌓아둔 변경 내용을 일단 DB에 전달하자”에 가깝다. “이 변경을 최종 확정하자”는 아니다.

commit

반면 commit()은 트랜잭션을 종료하고, 그 안에서 수행한 변경을 최종 확정하는 단계다.

JPA를 사용할 때는 보통 commit() 전에 내부적으로 flush()가 먼저 수행될 수 있다. 그래야 영속성 컨텍스트에만 있던 변경 사항이 실제 SQL로 나가고, 그 뒤에 트랜잭션을 확정할 수 있기 때문이다. 그래서 로그만 보면 flushcommit이 거의 붙어서 일어나는 것처럼 보이기도 한다.

하지만 역할은 분명히 다르다.

  • flush()는 SQL 실행에 가깝다
  • commit()은 트랜잭션 확정에 가깝다

이 차이는 다른 트랜잭션의 관점에서 특히 중요하다. 현재 트랜잭션 안에서는 flush() 이후에 변경된 것처럼 보일 수 있지만, 다른 트랜잭션에서 의미 있는 경계는 commit()이다. 결국 시스템 전체 관점에서 변경이 확정되는 시점은 commit()이라고 보는 편이 맞다.

왜 헷갈리는가

그런데도 많은 경우 flushcommit을 헷갈리게 된다. 이유는 몇 가지가 있다.

첫 번째는 SQL 로그가 flush() 시점에 보인다는 점이다. 개발자는 보통 로그를 보고 시스템 상태를 추론하는데, 이미 update 쿼리가 찍혔으니 저장까지 끝난 것처럼 느끼기 쉽다.

두 번째는 같은 트랜잭션 안에서는 이미 변경된 값이 자연스럽게 보인다는 점이다. JPA는 영속성 컨텍스트 안에서 엔티티를 관리하므로, 같은 엔티티 매니저 안에서 다시 엔티티를 보거나 관련 로직을 수행하면 이미 변경된 상태를 기준으로 동작하게 된다. 여기에 flush()까지 호출해서 SQL 로그가 찍히면, 더더욱 저장이 완료된 것처럼 느껴진다.

세 번째는 flush()commit()이 실무 코드에서 자주 붙어 다닌다는 점이다. 대부분의 서비스 로직은 메서드가 끝나면 바로 커밋되기 때문에, 평소에는 둘의 차이가 잘 드러나지 않는다. 차이를 의식하게 되는 것은 배치, 테스트, 중간 검증, 예외 처리처럼 트랜잭션 경계를 조금 더 자세히 들여다볼 때다.

flush 후 rollback 하면 어떻게 되는가

이 차이를 가장 직관적으로 보여주는 예제가 바로 rollback()이다.

tx.begin();
 
Member member = em.find(Member.class, 1L);
member.setName("changed");
 
em.flush();
tx.rollback();

이 코드를 실행하면 flush() 시점에 update SQL이 실행될 수 있다. 로그만 보면 이미 반영이 끝난 것처럼 보인다. 하지만 그 뒤에 rollback()을 호출하면 트랜잭션은 취소되고, 최종 결과는 반영되지 않는다.

즉, 다음 사실을 동시에 만족할 수 있다.

  • flush()로 인해 SQL은 실행되었다
  • 하지만 rollback() 때문에 최종 반영은 되지 않았다

이 예제 하나만 기억해도 flushcommit은 다른 단계라는 점을 훨씬 쉽게 이해할 수 있다. 만약 flush가 곧 commit이었다면, flush() 이후 rollback()이라는 말 자체가 성립하기 어려웠을 것이다.

언제 flush를 직접 쓰는가

그렇다면 평소 서비스 코드에서 flush()를 직접 호출할 일이 있을까. 대부분의 경우는 프레임워크가 적절한 시점에 알아서 처리해주기 때문에 직접 호출할 일이 많지는 않다. 그래도 실무에서 종종 필요한 경우가 있다.

첫 번째는 중간 시점에 SQL이나 제약조건 오류를 빨리 확인하고 싶을 때다. 예를 들어 트랜잭션 끝까지 갔다가 실패하는 것보다, 특정 구간에서 한 번 flush()를 호출해서 문제를 먼저 드러내고 싶을 수 있다.

두 번째는 배치 처리 중 영속성 컨텍스트를 주기적으로 비우기 전이다. 배치를 오래 돌리다 보면 영속성 컨텍스트에 엔티티가 너무 많이 쌓일 수 있는데, 이때는 보통 flush()로 변경 내용을 먼저 반영한 뒤에 다음 단계로 넘어간다. 그렇지 않으면 아직 반영되지 않은 변경까지 같이 날려버리는 상황을 만들 수 있다.

세 번째는 JPQL이나 특정 쿼리를 실행하기 전에 현재 변경 내용을 데이터베이스 기준으로 맞춰두고 싶을 때다. 중간 조회 로직이 이어지는 경우에는 지금까지의 변경이 SQL로 반영된 상태를 기준으로 다음 쿼리를 보고 싶을 때가 있다.

다만 여기서 더 깊게 들어가면 FlushMode나 구현체 차이 같은 주제로 이어지기 때문에, 이번 글에서는 그 부분까지 확장하지는 않겠다. 이번 글의 목적은 flush를 고급 튜닝 관점에서 다루는 것이 아니라, flushcommit의 경계를 분리해서 이해하는 데 있다.

정리

  • flush()는 영속성 컨텍스트의 변경 내용을 SQL로 동기화하는 단계다
  • commit()은 트랜잭션을 종료하고 변경을 최종 확정하는 단계다
  • SQL 로그가 flush() 시점에 보인다고 해서 커밋까지 끝난 것은 아니다
  • flush() 이후에도 rollback()은 가능하며, 이 경우 최종 반영은 취소된다
  • flush는 반영 시도이고, commit은 반영 확정이다