Hibernate ActionQueue 추적기
코드 순서와 SQL 실행 순서가 같다는 착각에서 시작된 409 Conflict
팀 프로젝트 BangCheck의 운영 버그. DELETE → INSERT 순서로 짠 코드가 두 번째 저장에서 409 Conflict를 냈고, 원인은 Hibernate ActionQueue가 INSERT를 DELETE보다 먼저 flush한다는 것이었다. 단기로는 명시적 flush(), 장기로는 diff 기반 업데이트로 갈라서 PR과 코드 주석에 같이 남겼다.
어떤 버그였나
BangCheck는 사용자가 매물을 보러 갈 때 확인할 항목들을 토글로 켜고 끄는 체크리스트 서비스다. 사용자가 항목을 토글하고 “저장”을 누르면 그 상태가 서버에 영속화된다.
첫 저장은 정상이었다. 같은 사용자가 잠시 후 한 번 더 저장하자 응답이 깨졌다.
PUT /api/checklist/settings
→ 409 Conflict
두 번째 요청에서 UNIQUE 제약 위반이 떨어지고 있었다.
첫 저장은 되는데, 두 번째 저장은 항상 409.
이 한 문장이 출발점이다.
흐름은 단순했다 — 그래서 더 의심스러웠다
코드는 단순한 “전체 삭제 후 재삽입” 패턴이었다.
@Transactional
public void saveSettings(Long userId, List<SettingRequest> requests) {
settingRepository.deleteByUserId(userId);
List<UserChecklistSetting> settings = requests.stream()
.map(req -> UserChecklistSetting.of(userId, req))
.toList();
settingRepository.saveAll(settings);
}
(user_id, item_id)에 UNIQUE 제약이 걸려 있었지만 코드 순서는 분명히 삭제 → 삽입이었고, 같은 트랜잭션 안에서 일어나는 일이니 충돌이 날 이유가 없어 보였다.
“DELETE를 먼저 했는데 왜 UNIQUE에 걸리지?”
이 시점에 처음으로 Hibernate가 SQL을 실제로 DB로 흘려보내는 순서가 의심스러워졌다.
ActionQueue는 FIFO가 아니다
JPA의 지연 쓰기(write-behind)다. save()나 delete()를 호출한다고 SQL이 즉시 가지 않는다. 영속성 컨텍스트 안에 모아두고 flush 시점에 한꺼번에 발사한다.
이 “모아두는 곳”이 ActionQueue다.
그런데 ActionQueue는 단순한 FIFO가 아니다. Hibernate는 액션 타입별로 고정된 실행 순서를 둔다.
| 순서 | 액션 |
|---|---|
| 1 | OrphanRemovalAction |
| 2 | EntityInsertAction (INSERT) |
| 3 | EntityUpdateAction |
| 4 | CollectionRemoveAction |
| 5 | CollectionUpdateAction |
| 6 | CollectionRecreateAction |
| 7 | EntityDeleteAction (DELETE) |
INSERT가 DELETE보다 먼저 실행된다. 이 순서는 org.hibernate.engine.spi.ActionQueue에 고정돼 있다 — 설정으로 바꿀 수 있는 게 아니다.
이유는 참조 무결성이다. 부모-자식 관계에서 자식 INSERT가 부모 DELETE보다 먼저 일어나야 외래 키가 안전한 경우가 더 많기 때문에, Hibernate는 일반 원칙으로 이 순서를 박아뒀다.
여기까지 이해하고 내 코드를 다시 보니 의심이 한 곳으로 모였다.
deleteByUserId(userId)호출 → ActionQueue에 DELETE가 적재될 뿐, DB에는 아직 안 감saveAll(settings)호출 → ActionQueue에 INSERT가 적재될 뿐, DB에는 아직 안 감- 트랜잭션 커밋 시점에 flush — 이때 INSERT 먼저, DELETE 나중에 실행됨
- 기존
(user_id, item_id)행이 살아있는 상태에서 같은 값으로 INSERT 시도 → UNIQUE 위반 → 409
커밋 시점을 그리면 이렇다.
[ ActionQueue ]
↓ INSERT 먼저 실행
[ MySQL ]
✗ UNIQUE 위반 (기존 행이 아직 살아있음) → 409
✗ DELETE는 실행도 못 함
해결책 두 가지를 비교했다
명시적 flush()로 DELETE를 먼저 보낸다 — 단기 처방 (선택)
@Transactional
public void saveSettings(Long userId, List<SettingRequest> requests) {
settingRepository.deleteByUserId(userId);
settingRepository.flush(); // DELETE를 먼저 DB로
List<UserChecklistSetting> settings = requests.stream()
.map(req -> UserChecklistSetting.of(userId, req))
.toList();
settingRepository.saveAll(settings);
}
DELETE만 즉시 DB에 반영하고 INSERT는 뒤이어 정상 실행된다.
- 장점: 한 줄로 끝난다. 운영 장애를 빠르게 막는다.
- 단점: “Hibernate 동작 순서에 의존하는 코드”라는 점이 다른 동료에게 즉시 보이지 않는다. 주석 없이 두면 사고가 반복된다.
Diff 기반 업데이트 — 장기 개선안 (설계는 diff 기반 업데이트 편에)
@Transactional
public void saveSettings(Long userId, List<SettingRequest> requests) {
Set<Long> currentItemIds = settingRepository.findItemIdsByUserId(userId);
Set<Long> requestedItemIds = requests.stream()
.map(SettingRequest::itemId)
.collect(Collectors.toSet());
Set<Long> toAdd = Sets.difference(requestedItemIds, currentItemIds);
Set<Long> toRemove = Sets.difference(currentItemIds, requestedItemIds);
settingRepository.deleteByUserIdAndItemIdIn(userId, toRemove);
settingRepository.saveAll(toAdd.stream()
.map(itemId -> UserChecklistSetting.of(userId, itemId))
.toList());
}
“전체 삭제 후 재삽입”을 그만두고 변경분만 처리한다.
- 장점: ActionQueue 순서에 의존하지 않는다. DB 부하, 로그, 감사 모두 가벼워진다.
- 단점: 코드량과 테스트 케이스가 늘어난다. 단기간에 적용하긴 무겁다.
판단 — 왜 둘 다 남겼나
단기에는
flush()로 막고, 다음 스프린트에서 diff 기반으로 리팩토링한다. 결정과 이유를 PR 설명과 코드 주석에 같이 남겼다.
// NOTE: Hibernate ActionQueue는 INSERT를 DELETE보다 먼저 flush한다.
// 같은 (user_id, item_id) UNIQUE 충돌을 막기 위해 명시적 flush로 순서를 강제한다.
// 장기적으로는 diff 기반 업데이트로 전환할 것.
settingRepository.flush();
이 주석 한 덩어리가 가장 중요했다. 코드만 보면 “왜 갑자기 flush()를 호출하지?”가 보이지 않는다. 주석에 결정 배경과 장기 개선 방향까지 박아두면, 6개월 뒤 다른 사람이 (혹은 미래의 내가) 와서 “이 flush() 빼도 되겠는데?”라고 지우는 사고를 막는다.
진짜로 배운 것
처음에는 단순한 “UNIQUE 제약 위반”으로 보였다. 끝까지 따라가보니 세 가지가 남았다.
- 내 코드 순서와 ORM이 DB에 보내는 순서는 같지 않다. “DELETE를 먼저 호출했으니 DELETE가 먼저 실행된다”는 직관은 Hibernate에서 틀린다.
- 단기 해결과 장기 해결을 명시적으로 분리해서 남겨야 한다. 운영 장애를 빠르게 막는 단기 처방(
flush())과, 같은 문제가 다시 안 생기게 하는 장기 개선(diff)을 코드와 문서에 같이 남겼다. 단기 처방만 남기면 미래의 내가 같은 함정에 다시 빠진다. - ORM은 편의를 주는 만큼 블랙박스를 만든다. ActionQueue처럼 평소엔 안 보이는 내부 동작을 한 번 손으로 확인해두면, 비슷한 사건이 다시 왔을 때 의심할 곳이 생긴다.
2026-09 추기. “내가 쓴 순서와 실제 실행 순서는 다르다”는 이후 nginx에서 똑같이 만났다 — location 매칭 → rewrite → access → content 순서에서
auto_redirect가deny보다 먼저 터져 접근제어가 뚫린 사건(Keycloak realm ACL 편). ORM이든 리버스 프록시든, 코드 순서를 믿으면 안 되는 지점이 있다.
만약 지금 다시 본다면
지금 이 코드를 다시 본다면 두 단계 더 갈 것 같다.
- 애초에
saveSettings를 “전체 교체” 시그니처로 둘지, “변경분 반영” 시그니처로 둘지를 도메인 차원에서 먼저 정한다. API 시그니처 자체가 ActionQueue 동작과 무관한 안전망이 된다. (user_id, item_id)UNIQUE 제약은 유지하되, 멱등성 보장을 위해 MySQL의INSERT ... ON DUPLICATE KEY UPDATE같은 DB 차원 처리도 옵션으로 검토한다.
다만 그건 “체크리스트 설정”이 “전체 교체 자원”인가 “부분 변경 자원”인가를 먼저 정한 다음의 이야기다. 도메인 경계가 ORM 동작보다 먼저라는 점은, 이 버그 이후로 계속 의식하고 있는 부분이다.
참고
- 관련 PR: #179 (
saveSettings409 해결), #184 (CUSTOM 항목 추가 시 409 해결) — 원본 레포 SWYP-Backend/BangCheck (팀 5인, 본인은 체크리스트 도메인 담당) - 이 사건의 회고와 재설계는 정리 레포에 — github.com/std-yong/bangcheck-portfolio
- 프로젝트: BangCheck (Spring Boot 3.x · JPA · MySQL 8) · 관련 코드:
ChecklistService#saveSettings