Spring 쿠폰 발급에서 existsBy 이후 중복 INSERT가 발생한 이유
쿠폰마다 다른 발급 횟수#
이전 글에서 쿠폰 정책을 운영 데이터로 분리한 뒤, 사장님은 쿠폰마다 발급 횟수도 다르게 관리하고 싶어 했습니다. 어떤 쿠폰은 사용자당 한 번만 발급해야 했고, 어떤 쿠폰은 연도나 이벤트가 바뀌면 다시 받을 수 있어야 했습니다.
이 조건을 발급 서비스의 if 문으로 늘리지 않고 ONCE_PER_USER, ONCE_PER_YEAR, ONCE_PER_EVENT Strategy로 분리했습니다. Strategy는 정책에 맞는 중복 기준을 검증하고, 검증을 통과하면 processor가 쿠폰을 저장하는 구조였습니다. ONCE_PER_USER에서는 동일한 사용자와 정책으로 발급된 쿠폰이 있는지 existsBy로 확인했습니다.
if (couponRepository.existsByUserIdAndPolicyId(userId, policyId)) {
throw new AlreadyIssuedException();
}
couponRepository.save(Coupon.issue(userId, policyId));요청이 순서대로 들어오면 첫 번째 발급 이후의 existsBy가 true를 반환하므로 중복 요청을 막을 수 있습니다. 다만 이 흐름에서 Strategy가 확인하는 것은 조회 시점의 발급 여부이며, 조회가 끝난 뒤 실행되는 INSERT까지 다른 트랜잭션에서 보호하지는 않습니다.
두 요청이 함께 미발급으로 조회되는 순서#
동일한 사용자와 정책으로 두 요청이 들어오면 각 트랜잭션은 다음 순서로 처리됩니다.
T1: existsBy(userId, policyId) → false
T2: existsBy(userId, policyId) → false
T1: INSERT coupon
T2: INSERT couponexistsBy는 일반 SELECT이므로 아직 커밋되지 않은 다른 트랜잭션의 발급 내역을 볼 수 없습니다. 두 조회가 먼저 끝나면 Strategy는 두 요청을 모두 발급 가능한 것으로 판단하고, 유일 제약이 없는 테이블에서는 이어지는 INSERT도 각각 성공합니다.
이 실행 순서는 MySqlCouponRaceProbe.java에서 MySQL 8.0.44의 두 커넥션으로 재현했습니다. 두 트랜잭션이 모두 exists=false를 읽은 뒤에만 INSERT를 시작하도록 CyclicBarrier로 순서를 고정했습니다.
exists-then-insert-no-constraint existed=0 inserted=2 issued=2메서드 전체에 @Transactional을 적용해도 결과는 달라지지 않습니다. 트랜잭션은 조회와 저장을 하나의 커밋 단위로 묶지만, 일반적인 격리 수준에서 다른 트랜잭션의 같은 조회를 막거나 조회 결과가 커밋까지 유지되도록 예약하지는 않습니다.
유일 제약에서 한 건만 커밋하기#
ONCE_PER_USER에서 중복을 구분하는 값은 사용자와 발급 정책의 조합입니다. 이 두 컬럼을 유일 인덱스로 만들면 여러 애플리케이션 인스턴스가 동시에 저장하더라도 DB가 한 건만 허용합니다.
ALTER TABLE coupon
ADD CONSTRAINT uk_coupon_user_policy
UNIQUE (user_id, policy_id);같은 실행 순서에 유일 제약을 적용하자 두 트랜잭션은 여전히 exists=false를 확인했지만, INSERT는 하나만 커밋됐습니다. 나머지 한 건은 첫 번째 트랜잭션의 결과가 확정된 뒤 duplicate key로 거절됐습니다.
exists-then-insert existed=0 inserted=1 duplicateRejected=1 issued=1UNIQUE 인덱스는 같은 키의 저장을 거부하지만, 인덱스에 포함된 nullable 컬럼에는 여러 NULL을 허용합니다. user_id와 policy_id가 발급 완료 시점까지 반드시 존재하는 값이라면 두 컬럼도 NOT NULL로 정의해야 유일 제약이 의도한 범위를 그대로 지킵니다.
사전 existsBy는 유일 제약을 적용한 뒤에도 남길 수 있습니다. 이미 커밋된 중복 요청에 빠르게 응답하고 메시지를 구체화하는 데는 도움이 되지만, 동시 요청의 최종 판단은 계속 INSERT와 유일 제약이 담당합니다. 중복 요청이 자주 발생해 사전 조회와 예외 비용이 모두 부담된다면, MySQL의 원자적 INSERT 구문이나 요청 멱등 키를 사용하는 방식으로 발급 API의 반환 계약까지 함께 바꿔야 합니다.
JPA flush 시점과 중복 예외 변환#
JPA의 save()는 영속성 컨텍스트에 엔티티를 등록하며, 실제 INSERT와 제약 위반은 flush나 트랜잭션 commit 시점에 발생할 수 있습니다. 발급 메서드 안에서 중복을 AlreadyIssuedException으로 변환하려면 saveAndFlush()로 SQL을 실행한 뒤 예외를 처리합니다.
@Transactional
public Coupon issue(Long userId, Long policyId) {
if (couponRepository.existsByUserIdAndPolicyId(userId, policyId)) {
throw new AlreadyIssuedException();
}
try {
return couponRepository.saveAndFlush(Coupon.issue(userId, policyId));
} catch (DataIntegrityViolationException error) {
if (isDuplicateIssueConstraint(error, "uk_coupon_user_policy")) {
throw new AlreadyIssuedException();
}
throw error;
}
}DataIntegrityViolationException에는 유일 제약뿐 아니라 NOT NULL, 외래 키와 다른 무결성 오류도 포함될 수 있습니다. 모든 예외를 중복 발급으로 바꾸면 스키마 오류나 잘못된 참조까지 정상적인 중복 요청처럼 감춰지므로, 실제 constraint 이름이나 원인이 uk_coupon_user_policy일 때만 도메인 예외로 변환해야 합니다.
saveAndFlush()는 예외 발생 위치를 이 메서드 안으로 당기는 선택이며, 중복 방지 자체를 강화하지는 않습니다. commit 시점의 예외를 공통 예외 처리 계층에서 변환할 수 있다면 save()를 유지할 수 있고, 발급 과정에서 여러 건을 모아 쓰는 경우에는 매번 flush하는 비용도 함께 고려해야 합니다.
발급 전략에 맞는 유일 키 추가하기#
Strategy는 어떤 요청을 중복으로 볼지 결정하고, 유일 인덱스는 그 판단을 저장 경계에서 강제합니다. ONCE_PER_USER는 (user_id, policy_id)로 표현할 수 있지만 다른 전략에 같은 인덱스를 그대로 적용하면 정책의 의미가 달라질 수 있습니다.
연도마다 같은 정책을 다시 사용할 수 있다면 유일 키에도 발급 연도가 필요합니다. 이벤트마다 정책 row를 새로 만드는 구조라면 policy_id가 이벤트를 구분할 수 있지만, 하나의 정책을 여러 이벤트에서 재사용한다면 이벤트 식별자를 별도로 포함해야 합니다.
재고 차감을 조건부 UPDATE로 처리한 글에서 다룬 수량 제한과 사용자별 유일성도 서로 다른 규칙입니다. 재고 row의 stock > 0은 전체 발급량을 제한하고, 쿠폰 테이블의 유일 인덱스는 한 사용자의 중복 발급을 막으므로 두 조건은 같은 트랜잭션 안에서 각각 확인돼야 합니다.
마무리하며#
Dayner에서는 Strategy를 쿠폰별 발급 조건을 나누는 구조로 유지했습니다. 다만 existsBy가 반환한 false는 조회 시점의 결과일 뿐이어서, 같은 사용자와 정책으로 들어온 두 요청이 저장 단계까지 함께 진행할 수 있었습니다.
ONCE_PER_USER는 중복 기준이 (user_id, policy_id)로 정해져 있었으므로 existsBy는 이미 커밋된 발급을 빠르게 확인하는 용도로 한정했습니다. 동시에 도착한 요청은 유일 제약에서 한 건만 저장되도록 하고, 거절된 INSERT를 중복 발급 응답으로 변환했습니다. Strategy를 없앤 것이 아니라 발급 조건을 판단하는 역할과 동시 저장을 제어하는 역할을 나눈 변경이었습니다.