Java Hibernate flush 타이밍은 왜 헷갈릴까?
Java 애플리케이션에서 Hibernate flush 문제는 JPA를 어느 정도 사용해본 뒤에 자주 마주칩니다. 처음에는 save()나 persist()를 호출하면 바로 DB에 반영된다고 생각하기 쉽지만, Hibernate는 보통 변경 내용을 영속성 컨텍스트에 모아두었다가 필요한 시점에 SQL을 실행합니다.
이 차이 때문에 코드상으로는 저장한 것처럼 보이는데 실제 DB에는 아직 SQL이 나가지 않았거나, 반대로 예상하지 못한 조회 쿼리 직전에 flush가 발생해 제약조건 오류가 터지는 경우가 있습니다. 문법은 맞고 트랜잭션도 걸려 있는데 결과가 애매하게 보이면, flush 타이밍부터 확인하는 편이 좋습니다.
Hibernate flush의 정확한 의미
Hibernate flush는 영속성 컨텍스트에 쌓여 있는 변경 내용을 DB에 SQL로 동기화하는 작업입니다. 여기서 중요한 점은 flush가 곧 commit은 아니라는 점입니다. flush는 SQL을 DB에 보내는 것이고, commit은 트랜잭션을 확정하는 것입니다.
예를 들어 엔티티를 수정하면 Hibernate는 즉시 UPDATE SQL을 실행하지 않을 수 있습니다. 대신 변경 감지를 위해 엔티티 상태를 관리하다가 flush 시점에 필요한 SQL을 만들어 DB로 보냅니다. 이 방식 덕분에 여러 변경을 한 트랜잭션 안에서 모아서 처리할 수 있지만, 개발자가 실행 시점을 잘못 예상하면 문제가 생깁니다.
@Transactional
public void changeUserName(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow();
user.changeName("new-name");
// 이 시점에 UPDATE SQL이 반드시 실행됐다고 보면 안 됩니다.
// Hibernate는 flush 시점에 변경 내용을 DB에 반영합니다.
}
위 코드에서 changeName()을 호출했다고 해서 바로 UPDATE가 실행되는 것은 아닙니다. 트랜잭션이 끝나기 전, 또는 쿼리 실행 전과 같은 특정 시점에 flush가 일어날 수 있습니다.
Hibernate flush가 발생하는 대표적인 시점
Hibernate flush 타이밍을 이해하려면 자동으로 flush가 발생하는 상황을 먼저 알아야 합니다. 실무에서는 이 지점에서 예상과 다른 SQL 실행 순서를 보고 원인을 찾는 경우가 많습니다.
트랜잭션 commit 직전
가장 일반적인 flush 시점은 트랜잭션이 commit되기 직전입니다. 트랜잭션 안에서 변경된 엔티티는 commit 전에 DB에 SQL로 반영되어야 하므로, Hibernate가 이때 flush를 수행합니다.
@Transactional
public void createOrder() {
Order order = new Order("READY");
orderRepository.save(order);
// 메서드 종료 시점에 트랜잭션 commit
// commit 직전에 INSERT SQL이 실행될 수 있습니다.
}
이 경우에는 메서드 중간에서 SQL이 보이지 않다가 메서드가 끝나는 시점에 INSERT가 실행되는 것처럼 보일 수 있습니다. 그래서 로그를 볼 때는 메서드 호출 위치가 아니라 트랜잭션 경계를 함께 확인해야 합니다.
JPQL 또는 Query 실행 직전
두 번째로 자주 놓치는 시점은 JPQL이나 Criteria 쿼리를 실행하기 직전입니다. Hibernate는 쿼리 결과의 일관성을 맞추기 위해, 필요한 경우 쿼리 실행 전에 flush를 수행합니다.
@Transactional
public void registerAndCount() {
User user = new User("test@example.com");
userRepository.save(user);
long count = userRepository.countByEmail("test@example.com");
// count 쿼리 실행 전에 INSERT SQL이 flush될 수 있습니다.
}
개발자는 단순 조회를 실행한다고 생각했는데, 그 직전에 저장 SQL이 먼저 실행될 수 있습니다. 그래서 조회 코드에서 갑자기 unique constraint 오류나 not null 오류가 발생하는 것처럼 보이는 상황이 생깁니다. 실제 원인은 조회가 아니라 조회 직전 flush된 변경 내용인 경우가 있습니다.
flush를 명시적으로 호출한 경우
EntityManager.flush() 또는 Spring Data JPA의 saveAndFlush()를 사용하면 그 시점에 flush를 강제로 실행할 수 있습니다. 다만 명시적 flush는 필요한 경우에만 사용하는 편이 낫습니다. 코드 곳곳에 흩어지면 SQL 실행 순서를 파악하기 어려워집니다.
@Transactional
public void createUser() {
User user = new User("test@example.com");
userRepository.saveAndFlush(user);
// 이 시점에는 INSERT SQL이 DB에 전달됩니다.
// 단, 아직 commit된 것은 아닙니다.
}
여기서도 flush와 commit을 구분해야 합니다. saveAndFlush()를 호출하면 SQL은 실행되지만, 트랜잭션이 rollback되면 최종 데이터는 남지 않습니다.
실제로 발생하기 쉬운 Hibernate flush 문제
Hibernate flush 문제는 대부분 코드 흐름과 SQL 실행 흐름을 동일하게 생각할 때 발생합니다. 코드 위에서 아래로 읽을 때는 자연스러워 보여도, Hibernate가 SQL을 보내는 시점은 다를 수 있습니다.
조회 쿼리에서 갑자기 제약조건 오류가 발생하는 경우
다음과 같은 코드는 겉으로 보기에는 이메일 중복 여부를 확인하는 흐름처럼 보일 수 있습니다. 하지만 저장 대상 엔티티가 이미 영속성 컨텍스트에 들어간 뒤 조회가 실행되면, 조회 직전에 flush가 발생할 수 있습니다.
@Transactional
public void register(String email) {
User user = new User(email);
userRepository.save(user);
boolean exists = userRepository.existsByEmail(email);
if (exists) {
throw new IllegalArgumentException("이미 가입된 이메일입니다.");
}
}
이 코드는 의도 자체가 어색합니다. 중복 검사를 하기 전에 이미 저장 대상 엔티티를 영속성 컨텍스트에 올렸기 때문입니다. existsByEmail() 실행 전에 flush가 발생하면, DB에는 이미 INSERT SQL이 전달될 수 있습니다. 그 결과 중복 체크 로직이 아니라 DB unique 제약조건에서 먼저 예외가 날 수 있습니다.
이 경우에는 순서를 바꾸는 것이 더 명확합니다. 검증을 먼저 하고, 그 다음 저장하는 흐름이 코드 의도를 잘 드러냅니다.
@Transactional
public void register(String email) {
if (userRepository.existsByEmail(email)) {
throw new IllegalArgumentException("이미 가입된 이메일입니다.");
}
User user = new User(email);
userRepository.save(user);
}
실무에서는 이런 코드가 리뷰에서 잘 드러나지 않을 때가 있습니다. 메서드 이름만 보면 둘 다 자연스럽기 때문입니다. 그래서 저장과 검증의 순서가 의미를 갖는 코드에서는 영속성 컨텍스트와 flush 시점을 함께 봐야 합니다.
flush는 됐지만 다른 트랜잭션에서 조회되지 않는 경우
또 하나의 오해는 flush가 되면 다른 트랜잭션에서도 바로 조회된다고 생각하는 것입니다. flush는 현재 트랜잭션의 변경 내용을 DB에 전달하는 작업이지, 트랜잭션을 확정하는 작업은 아닙니다.
@Transactional
public void createAndFlush() {
Order order = new Order("READY");
orderRepository.save(order);
entityManager.flush();
// SQL은 실행됐지만 아직 commit 전입니다.
// 다른 트랜잭션에서 바로 보인다고 단정하면 안 됩니다.
}
이 부분은 테스트 코드나 비동기 처리와 섞일 때 특히 헷갈립니다. 현재 트랜잭션 안에서는 flush된 데이터가 DB에 전달되었지만, 격리 수준과 commit 여부에 따라 다른 트랜잭션에서 보이는지는 달라집니다.
외래 키 관계에서 SQL 실행 순서가 예상과 다른 경우
부모와 자식 엔티티를 함께 저장할 때도 flush 타이밍과 SQL 순서가 중요합니다. 연관관계 설정이 잘못되어 있으면 flush 시점에 외래 키 오류가 발생할 수 있습니다.
@Transactional
public void createOrder() {
Order order = new Order();
OrderItem item = new OrderItem();
item.setOrder(order);
orderRepository.save(order);
orderItemRepository.save(item);
}
단순한 예제에서는 문제가 없어 보일 수 있지만, 실제 도메인에서는 cascade, 양방향 연관관계 편의 메서드, nullable 제약조건이 함께 들어갑니다. 이때 연관관계의 주인이 제대로 설정되지 않으면 flush 시점에 의도와 다른 INSERT나 UPDATE가 실행될 수 있습니다.
이런 코드는 저장 순서만 고치기보다 도메인 메서드로 관계를 일관되게 잡는 것이 낫습니다. 저장하는 쪽에서 매번 set을 흩뿌리면, 유지보수 단계에서 누락이 생기기 쉽습니다.
public class Order {
private List<OrderItem> items = new ArrayList<>();
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this);
}
}
FlushMode를 이해하면 문제 원인을 좁히기 쉽다
Hibernate flush 타이밍을 볼 때는 FlushMode도 함께 확인해야 합니다. 기본적으로 JPA에서는 AUTO 모드가 많이 사용됩니다. 이 모드에서는 트랜잭션 commit 전이나 쿼리 실행 전에 flush가 자동으로 일어날 수 있습니다.
FlushModeType.AUTO
AUTO는 Hibernate가 필요하다고 판단하는 시점에 flush를 수행하는 방식입니다. 대부분의 애플리케이션에서는 이 기본 동작을 그대로 사용하는 경우가 많습니다.
entityManager.setFlushMode(FlushModeType.AUTO);
이 모드에서는 쿼리 결과의 정합성을 맞추기 위해 조회 전 flush가 발생할 수 있습니다. 그래서 “조회만 했는데 왜 INSERT가 먼저 나갔지?”라는 상황이 생기면 AUTO 모드의 동작을 의심해볼 수 있습니다.
FlushModeType.COMMIT
COMMIT은 flush를 가능한 commit 시점으로 미루는 방식입니다. 다만 이 모드를 쓴다고 해서 모든 상황에서 조회 전 flush가 절대 발생하지 않는다고 단정하는 것은 위험합니다. 구현체와 쿼리 방식에 따라 세부 동작을 확인해야 합니다.
entityManager.setFlushMode(FlushModeType.COMMIT);
실무에서는 FlushMode를 바꾸는 방식보다 코드 흐름을 명확히 정리하는 쪽을 먼저 봅니다. 중복 검사 전에 저장하지 않기, 연관관계 메서드로 관계를 일관되게 만들기, 명시적 flush를 남발하지 않기 같은 기본 정리가 더 효과적인 경우가 많습니다.
Hibernate flush 문제를 디버깅하는 방법
Hibernate flush 문제를 확인할 때는 코드만 보는 것보다 SQL 로그를 함께 보는 것이 좋습니다. 특히 insert, update, select의 실행 순서를 확인하면 원인을 빠르게 좁힐 수 있습니다.
SQL 로그로 실행 순서 확인하기
Spring Boot에서는 개발 환경에서 SQL 로그를 켜고 flush 시점의 SQL 실행 순서를 보는 방식이 가장 단순합니다. 운영 환경에 그대로 적용하기보다는 로컬이나 테스트 환경에서 재현할 때 사용하는 편이 좋습니다.
spring:
jpa:
properties:
hibernate:
format_sql: true
highlight_sql: true
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace
로그를 볼 때는 단순히 SQL이 실행됐는지보다 어떤 쿼리 직전에 flush가 발생했는지를 봐야 합니다. 특히 SELECT 바로 앞에 INSERT나 UPDATE가 찍힌다면, 조회 쿼리 실행 전 자동 flush가 원인일 수 있습니다.
테스트 코드에서 flush를 일부러 호출해보기
문제가 되는 코드가 commit 시점에만 실패한다면, 테스트에서 flush()를 명시적으로 호출해 원인을 앞당겨 확인할 수 있습니다. 이렇게 하면 어떤 엔티티 변경이 DB 제약조건과 충돌하는지 더 빨리 볼 수 있습니다.
@SpringBootTest
@Transactional
class UserServiceTest {
@Autowired
private UserService userService;
@Autowired
private EntityManager entityManager;
@Test
void registerTest() {
userService.register("test@example.com");
entityManager.flush();
// flush 시점에 DB 제약조건 오류를 확인할 수 있습니다.
}
}
테스트에서 flush를 호출하는 것은 문제를 숨기기 위한 방법이 아니라, 실패 지점을 명확히 드러내기 위한 방법입니다. 특히 JPA 테스트는 트랜잭션 rollback으로 끝나는 경우가 많기 때문에, flush를 호출하지 않으면 실제 DB 반영 과정에서 발생할 오류를 놓칠 수 있습니다.
saveAndFlush를 언제 써야 할까?
Java Hibernate flush 문제를 해결하려고 모든 저장 코드를 saveAndFlush()로 바꾸는 경우가 있습니다. 하지만 이 방식은 문제 원인을 가리는 임시 처방이 될 수 있습니다.
saveAndFlush()는 저장 직후 DB 제약조건 검증이 반드시 필요하거나, 이후 로직에서 DB에 반영된 상태를 전제로 해야 할 때 사용할 수 있습니다. 다만 일반적인 저장 흐름에서는 save()와 트랜잭션 commit에 맡기는 편이 더 단순합니다.
// 필요한 경우에만 사용
userRepository.saveAndFlush(user);
// 대부분의 일반 저장 흐름에서는 save로 충분한 경우가 많습니다.
userRepository.save(user);
팀 코드에서는 saveAndFlush()가 보이면 왜 즉시 flush가 필요한지 이유가 드러나야 합니다. 이유 없이 사용되면 이후 코드를 읽는 사람이 “여기서 꼭 DB에 SQL을 보내야 하나?”를 다시 추적하게 됩니다.
Hibernate flush 문제를 줄이는 실무 기준
Hibernate flush는 피해야 할 기능이 아니라, JPA가 영속성 컨텍스트와 DB를 맞추기 위해 사용하는 기본 동작입니다. 문제는 flush 자체보다 개발자가 그 시점을 다르게 예상하는 데서 생깁니다.
검증과 저장 순서를 분명히 한다
중복 체크, 권한 확인, 상태 검증 같은 로직은 가능한 저장 전에 수행하는 것이 읽기 쉽습니다. 저장 후 검증이 필요한 특별한 이유가 없다면, 검증을 먼저 하고 엔티티를 생성하거나 변경하는 흐름이 낫습니다.
명시적 flush는 의도가 있을 때만 사용한다
flush()는 강력하지만 코드 흐름을 복잡하게 만들 수 있습니다. 테스트에서 오류를 빠르게 드러내기 위한 flush와 서비스 로직에서 비즈니스 흐름을 바꾸는 flush는 성격이 다릅니다. 서비스 코드에 명시적 flush가 필요하다면 주석이나 메서드 분리로 이유를 드러내는 것이 좋습니다.
연관관계는 도메인 메서드로 일관되게 관리한다
flush 시점에 외래 키 오류가 발생하는 코드는 연관관계 설정이 흩어져 있는 경우가 많습니다. 양방향 관계라면 편의 메서드를 두고, 한쪽만 설정되는 상황을 줄이는 편이 유지보수에 유리합니다.
SQL 로그를 실행 순서 중심으로 본다
Hibernate 로그를 볼 때는 SQL 개수만 세기보다 실행 순서를 봐야 합니다. 특히 조회 쿼리 직전에 변경 SQL이 실행되는지, commit 직전에 모아서 실행되는지 확인하면 flush 문제인지 단순 쿼리 문제인지 구분하기 쉽습니다.
정리: Hibernate flush는 저장 시점이 아니라 동기화 시점의 문제다
Hibernate flush는 엔티티 변경 내용을 DB에 SQL로 동기화하는 과정입니다. save()를 호출했다고 바로 DB에 반영된다고 보면 안 되고, flush()가 됐다고 commit까지 완료됐다고 봐서도 안 됩니다.
문제가 발생했을 때는 먼저 트랜잭션 경계, 조회 쿼리 실행 위치, SQL 로그 순서를 확인하는 것이 좋습니다. 특히 조회 직전 자동 flush, commit 직전 flush, 명시적 flush를 구분하면 원인을 훨씬 빠르게 찾을 수 있습니다.
실무 기준으로는 단순합니다. 검증은 저장 전에 명확히 하고, 연관관계는 도메인 메서드로 일관되게 관리하며, saveAndFlush()나 flush()는 이유가 있을 때만 사용합니다. 이 정도만 지켜도 Hibernate flush 때문에 생기는 애매한 문제를 상당히 줄일 수 있습니다.
'개발 > JAVA' 카테고리의 다른 글
| [JAVA] Java Connection reset 에러 발생 원인과 해결 과정 (0) | 2026.05.30 |
|---|---|
| [JAVA] 트랜잭션 분리 안 해서 장애 난 사례: 외부 API 호출과 DB 저장을 같은 트랜잭션에 묶으면 생기는 일 (0) | 2026.05.24 |
| [JAVA] Lock wait timeout exceeded 에러가 발생하는 원인과 해결 방법 (0) | 2026.05.22 |
| [JAVA] Java DB Connection Pool 부족으로 장애가 났을 때 원인 분석과 해결 방법 (0) | 2026.05.21 |
| [JAVA] JPA N+1 문제 발견하고 해결한 과정: 원인 분석부터 Fetch Join 적용까지 (0) | 2026.05.20 |
