diff 기반 업데이트로 saveSettings를 다시 짠다면
단기 처방
flush()로 막아두고 PR에 약속해뒀던 “다음 스프린트의 리팩토링”을 어떤 모양으로 닫을지 설계로 정리한 글
전편에서 단기로는 명시적 flush(), 장기로는 “전체 삭제 후 재삽입”을 그만두고 변경분만 처리하는 diff 기반 업데이트로 전환할 것이라고 PR #179와 코드 주석에 적어뒀다.
그 약속은 닫지 못했다. 팀 프로젝트가 5월에 종료돼 이 개선은 실 적용까지 가지 못했고, 그래서 이 글은 구현기가 아니라 설계 기록이다 — 어떤 모양이어야 같은 사건이 다시 안 돌아오는지를 남겨둔다.
전편 요약 — 무엇이 한계로 남아있었나
전편의 코드는 단순한 “전체 삭제 후 재삽입”이었다.
@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);
}
flush()로 운영 장애는 막혔지만, 코드는 여전히 Hibernate ActionQueue 동작 순서에 의존하고 있다. 이 코드는 다음 세 가지 함정을 그대로 갖고 있다.
| 함정 | 이유 |
|---|---|
| ① 매 저장마다 N행 DELETE + N행 INSERT | 변경 없는 항목까지 다시 쓴다 |
② flush() 한 줄을 무심코 지우면 다시 409 |
“왜 flush가 있는지”가 코드만 보고는 안 보임 |
| ③ 감사 로그·트리거가 변경 없는 행에도 발화 | DB 쪽 부수효과를 헷갈리게 만듦 |
단기 처방은 운영 장애를 멈추기 위한 것이지, 코드를 안전하게 만든 것은 아니다.
delete-insert가 잘못이라고 단정하기 전에 — 왜 처음엔 그렇게 짰나
리팩토링 글은 “예전 코드는 틀렸고 새 코드는 옳다”는 톤으로 빠지기 쉽다. 그건 정직하지 않다. 처음 delete-insert를 택한 데에는 이유가 있었다.
- 저장 요청마다 활성/비활성 항목 수가 달라진다 — 단순 UPDATE로 풀 수 없다
- 항목별 diff를 그때그때 짜는 게 초기에는 복잡했다
- “전부 지우고 다시 박는다”는 시그니처가 동기화 깨질 여지를 적게 보였다
이 판단 자체는 첫 구현 시점에선 합리적이었다. 다만 그 다음에 두 가지를 같이 결정해뒀어야 했다.
- UNIQUE 제약을 가진 테이블에 “delete 후 insert” 패턴은 ORM 동작 순서가 부메랑이 될 수 있는 모양이다.
- 그 모양을 알았다면 처음부터 diff 기반으로 짤 것인지, 단기 단순화로 갈 것인지를 명시적으로 결정해두는 게 맞다.
전편 PR에서 “단기로 flush, 장기로 diff” 라고 분리해서 남겨둔 이유가 이거다. 결정을 미룬 게 아니라, 결정을 미룬다는 사실을 결정해두는 절차다.
diff 기반 업데이트가 하는 일 — 세 단계로 쪼개기
“변경분만 처리한다”는 말은 코드로 옮기기 전엔 단순해 보인다. 실제로 짜보면 현재 상태 조회 → 차집합 계산(교집합은 손대지 않는다) → 최소한의 INSERT/DELETE 세 단계가 명확히 분리돼 있어야 테스트가 가능해진다.
@Transactional
public void saveSettings(Long userId, List<SettingRequest> requests) {
// 1단계 — 현재 상태 조회
Set<Long> currentItemIds =
settingRepository.findItemIdsByUserId(userId);
// 2단계 — 차집합 계산
Set<Long> requestedItemIds = requests.stream()
.map(SettingRequest::itemId)
.collect(Collectors.toSet());
Set<Long> toAdd = new HashSet<>(requestedItemIds);
toAdd.removeAll(currentItemIds);
Set<Long> toRemove = new HashSet<>(currentItemIds);
toRemove.removeAll(requestedItemIds);
// 3단계 — 최소한의 INSERT/DELETE만
if (!toRemove.isEmpty()) {
settingRepository.deleteByUserIdAndItemIdIn(userId, toRemove);
}
if (!toAdd.isEmpty()) {
List<UserChecklistSetting> newSettings = toAdd.stream()
.map(itemId -> UserChecklistSetting.of(userId, itemId))
.toList();
settingRepository.saveAll(newSettings);
}
}
여기서 중요한 건 코드 라인 수가 늘어난 게 아니라, 세 단계가 서로 독립적으로 테스트 가능한 형태가 됐다는 점이다. 차집합 계산은 순수 함수(Set 연산)라 도메인 의존성이 없고, INSERT/DELETE는 비어있는 컬렉션을 받았을 때 안 나가도록 분리된다.
ActionQueue 함정이 사라지는 이유
새 코드는 flush()를 쓰지 않는다. 그 이유를 한 줄로 정리하면 이렇다.
같은
(user_id, item_id)키로 DELETE와 INSERT가 동시에 큐에 쌓이는 모양 자체가 사라진다.
toRemove와 toAdd는 차집합이라 교집합이 없다. 같은 키가 두 큐에 동시에 들어갈 수 없다. ActionQueue가 INSERT를 먼저 실행하든 DELETE를 먼저 실행하든 결과가 동일하다.
이게 단기 처방과 장기 개선의 결정적 차이다.
| 구분 | 단기 처방 (flush()) |
장기 개선 (diff) |
|---|---|---|
| ActionQueue 순서 영향 | 우회한다 | 영향을 받지 않는 모양으로 바꾼다 |
| 코드에서 의도 가시성 | 주석 필요 | 코드 자체로 자명 |
| 변경 없는 행 처리 | 매번 DELETE → INSERT | 손대지 않는다 |
| 감사 로그·트리거 | 매번 발화 | 변경분에만 발화 |
ORM 내부 동작에 의존하는 코드를 작성하는 것보다, 그 동작이 영향을 줄 수 없는 모양으로 코드를 바꾸는 게 더 안전하다.
검증 설계 — 어떤 케이스를 테스트로 박을 것인가
diff 기반 업데이트는 분기가 많다. 단위 테스트는 다음 다섯 케이스로 설계했다.
| # | 시나리오 | 기대 동작 |
|---|---|---|
| 1 | 처음 저장 (현재 0개 → 요청 N개) | INSERT N건, DELETE 0건 |
| 2 | 전부 그대로 (현재 = 요청) | 쿼리 0건 |
| 3 | 일부만 추가 (현재 ⊂ 요청) | INSERT 차집합만, DELETE 0건 |
| 4 | 일부만 제거 (요청 ⊂ 현재) | INSERT 0건, DELETE 차집합만 |
| 5 | 첫 사건 재현 (같은 요청 두 번) | 두 번째 호출 = 쿼리 0건, 409 안 남 |
특히 5번 — 전편에서 운영 장애를 낸 정확한 시나리오 — 가 새 구조에선 두 번째 호출에서 아무 쿼리도 나가지 않는 상태가 된다. ActionQueue가 끼어들 여지 자체가 없다.
5번 케이스는 이런 형태다 — Hibernate Statistics로 쿼리 수를 직접 센다.
@Test
@DisplayName("같은 요청을 두 번 보내도 두 번째 호출은 쿼리 0건")
void saveSettings_idempotent_secondCallProducesNoQueries() {
// given
List<SettingRequest> sameRequest = List.of(
new SettingRequest(1L),
new SettingRequest(2L),
new SettingRequest(3L)
);
checklistService.saveSettings(userId, sameRequest);
statistics.clear();
// when
checklistService.saveSettings(userId, sameRequest);
// then
assertThat(statistics.getPrepareStatementCount())
.as("두 번째 호출은 현재 상태 조회 한 번 외에 INSERT/DELETE 0건")
.isEqualTo(1L); // SELECT만 1건
}
같은 입력에 같은 결과를 내는 것을 함수의 멱등성이라고 부른다면, 저장 API에 멱등성을 부여하는 셈이다.
Before / After 쿼리 패턴 비교
아래는 쿼리 패턴 기준으로 계산한 값이다(N=10). 실측이 아니라 설계 단계의 비교이고, 실측은 Hibernate Statistics로 같은 표를 채우면 된다.
| 시나리오 | Before (delete-insert + flush) | After (diff) |
|---|---|---|
| 첫 저장 (N=10) | DELETE 1 + INSERT 10 = 11 | SELECT 1 + INSERT 10 = 11 |
| 같은 요청 두 번째 | DELETE 1 + INSERT 10 = 11 | SELECT 1 = 1 |
| 일부만 추가 (10→12) | DELETE 1 + INSERT 12 = 13 | SELECT 1 + INSERT 2 = 3 |
| 일부만 제거 (10→8) | DELETE 1 + INSERT 8 = 9 | SELECT 1 + DELETE 1 = 2 |
전반적으로 변경이 적을수록 쿼리가 적게 나간다. 운영 트래픽 패턴이 “조금씩 자주 저장”하는 형태라면 부하 차이가 더 벌어진다.
만약 지금 짠다면 어디까지 갈 것인가
이 설계 위에 두 단계 더 갈 수 있다.
- API 시그니처를 도메인 차원에서 다시 정의한다. 지금은 “활성 항목 전체 목록” 을 받는 시그니처라 클라이언트가 매번 전체 리스트를 보낸다. “켜기 / 끄기 단일 항목” 시그니처로 분리하면 변경 범위가 더 명확해지고 트래픽도 가벼워진다. 다만 “여러 항목을 한 번에 토글하는 UX”가 필요하면 현 시그니처가 더 자연스럽다 — 도메인 요구 우선.
@DataJpaTest로 ActionQueue 케이스를 회귀 테스트로 박는다. 전편 사건은 통합 테스트가 없어서 운영에 도달한 뒤 발견됐다. ActionQueue처럼 ORM 내부 동작이 얽힌 시나리오는 문서로 남기는 것 보다 테스트로 굳히는 것 이 훨씬 강한 안전망이다.
다만 그건 “체크리스트 설정”이 도메인 차원에서 전체 교체 자원인지 부분 변경 자원인지를 먼저 정한 다음의 이야기다. 이 글에선 전체 교체 시그니처를 유지한 채 내부 구현만 diff로 바꾸는 선까지로 범위를 좁혔다.
마무리
운영 장애 한 건이 결국 멱등성과 변경분 처리라는 두 가지 일반 원칙으로 연결됐다. 구현은 못 했지만, 다음에 같은 모양의 테이블을 만나면 처음부터 이 형태로 짤 것이다. 그게 이 글에서 남기고 싶은 부분이다.
백엔드·ORM 시리즈는 여기까지. 이후 글은 인프라 담당자로 옮긴 뒤의 기록이다 — Sentry CSRF 6단계 편부터.
참고
- 단기 처방 PR: #179 fix: saveSettings 409 — deleteByUserId 직후 flush 호출 — 장기 개선 방향도 여기 주석으로 남겼다
- 사건 회고: docs/incidents/03-actionqueue-flush-409.md — 정리 레포
- 프로젝트: BangCheck (Spring Boot 3.x · JPA · MySQL 8) · 원본 레포 SWYP-Backend/BangCheck (팀 5인, 본인은 체크리스트 도메인 담당)