Stan 기술블로그

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를 그때그때 짜는 게 초기에는 복잡했다
  • “전부 지우고 다시 박는다”는 시그니처가 동기화 깨질 여지를 적게 보였다

이 판단 자체는 첫 구현 시점에선 합리적이었다. 다만 그 다음에 두 가지를 같이 결정해뒀어야 했다.

  1. UNIQUE 제약을 가진 테이블에 “delete 후 insert” 패턴은 ORM 동작 순서가 부메랑이 될 수 있는 모양이다.
  2. 그 모양을 알았다면 처음부터 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단계 편부터.


참고

백엔드·ORM 카테고리의 글