핵심 한 줄
멱등 테스트가 초록불이었다. 그런데 SQL 로그를 보니 INSERT 앞에 SELECT가 먼저 나가고 있었다 —
save()가 INSERT가 아니라 merge를 하고 있었고, 중복을 막을catch는 한 번도 실행되지 않는 죽은 코드였다. 초록불은 가짜였다. 원인은 assigned-id, 해법은Persistable로isNew()를 우리가 통제하는 것.
시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)
00.카타 서문 · 01.돈을 primitive로 두면 언젠가 샌다 · 02.불변식은 누가 지키나 · 03.동시에 사면 초과판매된다 · 04.두 번 취소해도 한 번만 · 05.save가 INSERT가 아니었다 · 06.기다리게 하지 말고 거절하라 · 07.트랜잭션 안에서 HTTP를 부르지 마라 · 08.응답을 못 받았다는 실패가 아니다 · 09.요청 하나에 쿼리 1+N · 10.5만 행을 1행으로 · 11.같은 걸 두 번 묻지 않기 · 12.가짜는 내가 시킨 답만 한다 · 13.분명히 잡았는데 터졌다 · 14.5개 주세요 했는데 5만 개를 읽었다 · 15.순수하게 만들었더니 저장이 사라졌다 · 16.설정 한 줄 바꿨더니 세 번 터졌다 · … 그리고 계속
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 / @PostLoad→markNotNew(). 한 번 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은 이제 진짜로 작동한다. 그런데 여기엔 아직 거친 구석이 있다. 순진한 유니크 방식은 중복 요청이 오면 첫 요청이 끝날 때까지 블로킹시킨다 — 락에서 기다리게 만든다. 기다리게 하지 말고, 진행 중이면 거절해야 한다. 다음 편이다.