핵심 한 줄

멱등 테스트가 초록불이었다. 그런데 SQL 로그를 보니 INSERT 앞에 SELECT가 먼저 나가고 있었다 — save()가 INSERT가 아니라 merge를 하고 있었고, 중복을 막을 catch한 번도 실행되지 않는 죽은 코드였다. 초록불은 가짜였다. 원인은 assigned-id, 해법은 PersistableisNew()를 우리가 통제하는 것.

시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)


1. 초록불인데, 뭔가 이상했다

앞 편(04.두 번 취소해도 한 번만)에서 멱등성을 만들었다. 같은 idemKey로 두 번 요청이 와도 두 번째는 유니크 제약에 걸려 튕겨 나가고, 그걸 catch (DataIntegrityViolationException)로 잡아 첫 번째 응답을 그대로 돌려준다 — 이게 설계였다. 테스트를 돌렸고, 초록불이 떴다.

그런데 습관처럼 SQL 로그를 켜 봤다. 그리고 눈을 의심했다.

select ir1_0.idemKey, ir1_0.response, ir1_0.status from idemRecord ir1_0 where ir1_0.idemKey=?
insert into idemRecord (response, status, idemKey) values (?, ?, ?)

save()를 불렀는데 INSERT 앞에 SELECT가 먼저 나가고 있었다. 내가 기대한 건 그냥 INSERT였다. 곧장 INSERT를 때리고, 중복이면 DB가 유니크 위반을 던지는 그림. 그런데 Hibernate는 먼저 “이 키 있어?”를 SELECT로 물어보고, 없으니까 INSERT를 하고 있었다.

2. 죽은 코드였다

이게 왜 문제인가. dedup 로직의 뼈대가 유니크 충돌이기 때문이다.

try {
    repository.save(new IdemRecord(key));  // INSERT를 기대
} catch (DataIntegrityViolationException e) {
    // 두 번째 요청은 여기로 와서 기존 응답을 반환
    return existingResponse(key);
}

그런데 save()SELECT 먼저 → 없으면 INSERT 순서로 동작하면, 동시 요청이 아닌 이상 유니크 충돌 자체가 일어나지 않는다. 두 번째 요청도 SELECT에서 “어 이미 있네” 하고 조용히 UPDATE로 흘러가거나, 아무 예외 없이 지나간다. 즉 catch 블록은 단 한 번도 실행되지 않았다. 테스트가 통과한 건 dedup이 작동해서가 아니라, 우연히 다른 경로로 같은 결과가 나왔을 뿐이다.

초록불은 “테스트가 통과했다”는 뜻이지 “내가 의도한 길로 통과했다”는 뜻이 아니다. 여기서 처음으로 그 차이를 손으로 만졌다.

3. 범인은 save의 정체 — merge

Spring Data의 save()는 이름과 달리 INSERT가 아니다. 내부는 이렇게 갈린다:

// SimpleJpaRepository
if (entityInformation.isNew(entity)) {
    em.persist(entity);   // 진짜 INSERT
    return entity;
} else {
    return em.merge(entity);  // SELECT 후 UPDATE (혹은 INSERT)
}

isNew()가 참이면 persist(곧장 INSERT), 거짓이면 merge. 그리고 merge는 정의상 먼저 SELECT로 영속 상태를 확인한 뒤 없으면 INSERT, 있으면 UPDATE 한다. 로그에서 본 SELECT의 정체가 이거였다.

그럼 왜 isNew()가 거짓이었나. 기본 전략에서 Spring Data는 @Id가 null인지로 새 엔티티를 판단한다. 그런데 IdemRecord의 id는 idemKey — 클라이언트가 준 헤더값을 **직접 할당(assigned-id)**한다. 생성자에서 이미 idemKey가 채워져 있으니 id는 절대 null이 아니다. @Version 필드도 없다. 그래서 Spring Data는 “id가 있네 = 기존 엔티티네”라고 판단해 항상 merge로 보냈다.

정리하면 — assigned-id + @Version 없음 → isNew()가 늘 false → save()가 merge → 유니크 충돌이 안 나 → dedup의 기반이 무너진다.

4. 결정 — isNew()를 우리가 쥔다

merge를 막고 persist를 강제하려면 isNew()의 판단을 Hibernate의 추측에서 빼앗아 와야 한다. Spring Data는 이걸 위한 창구를 열어 뒀다 — Persistable<ID> 인터페이스다. 엔티티가 이걸 구현하면 Spring Data는 id 추측 대신 우리가 만든 isNew()를 그대로 믿는다.

@Entity
@Table(name = "idemRecord")
public class IdemRecord implements Persistable<String> {
 
    @Id
    @Column(unique = true)
    private String idemKey;
 
    @Transient
    private boolean isNew = true;
 
    @Override
    public String getId() {
        return idemKey;
    }
 
    @Override
    public boolean isNew() {
        return isNew;
    }
 
    @PostPersist
    @PostLoad
    void markNotNew() {
        this.isNew = false;
    }
}

핵심은 세 조각이다:

  • @Transient boolean isNew = true — DB에 저장하지 않는 순수 메모리 플래그. 새로 만든 객체는 무조건 새것으로 시작한다.
  • isNew()가 이 플래그를 그대로 반환 → save()persist, 즉 진짜 INSERT를 때린다. SELECT 없이 곧장.
  • @PostPersist / @PostLoadmarkNotNew(). 한 번 INSERT 됐거나(=PostPersist) DB에서 읽어온(=PostLoad) 객체는 더 이상 새것이 아니므로 플래그를 false로 뒤집는다. 이후 그 객체를 save()하면 정상적으로 merge(UPDATE)가 된다.

이제 로그에서 SELECT가 사라졌다. 첫 요청은 곧장 INSERT, 두 번째 같은 키의 요청은 DB가 유니크 위반을 던지고 — 드디어 catch (DataIntegrityViolationException)살아 움직인다. dedup이 의도한 길로 작동한다.

5. 이 고생은 id 전략 탓이었다

한 발 물러서서 보면, 이 모든 소동의 뿌리는 하나다 — id를 직접 할당했다는 것.

id를 우리가 채우니 null이 될 수 없고, null이 될 수 없으니 Spring Data가 새것/기존을 구분하지 못했다. 만약 id를 DB가 채웠다면 이 문제는 애초에 없다. 뒤의 외부호출 편에서 나올 Refund@GeneratedValue(strategy = IDENTITY)라 저장 전 id가 null이다. 그러면 isNew()가 자연히 true가 되고 save()는 알아서 persist를 한다 — Persistable도, @Transient 플래그도 필요 없다.

ID 전략이 save의 동작을 바꾼다. 같은 save() 한 줄이 assigned-id에선 merge가 되고 generated-id에선 persist가 된다. 이 비대칭을 모르고 assigned-id 엔티티를 쓰면 오늘 같은 함정에 빠진다. (→ 07.트랜잭션 안에서 HTTP를 부르지 마라)

6. 교훈과 다음

  • 초록불을 믿지 말고 SQL 로그로 검증하라. 테스트 통과는 “결과가 맞다”까지만 보증한다. “내가 설계한 경로로 맞았는가”는 로그를 봐야 안다. dead code로 지나간 초록불은 다음 리팩터에서 조용히 무너진다.
  • save()는 INSERT가 아니라 persist냐 merge냐의 분기다. 그 분기는 isNew()가 정하고, isNew()의 기본 판단은 id 전략에 달렸다.
  • assigned-id 엔티티에서 persist를 강제하려면 Persistable + @Transient 플래그 + @PostPersist/@PostLoad가 정석이다.

dedup은 이제 진짜로 작동한다. 그런데 여기엔 아직 거친 구석이 있다. 순진한 유니크 방식은 중복 요청이 오면 첫 요청이 끝날 때까지 블로킹시킨다 — 락에서 기다리게 만든다. 기다리게 하지 말고, 진행 중이면 거절해야 한다. 다음 편이다.

06.기다리게 하지 말고 거절하라