결제 API에서 중복 호출을 고려해야 하는 이유
처음에는 결제 API가 단순했습니다. 요청을 받으면 Payment 레코드를 INSERT하고 반환하는 것이 전부였고 중복 방지 로직이 따로 없었습니다.
이 상태에서 JMeter로 결제 API에 50개의 동시 요청을 보냈습니다. 같은 사용자가 같은 예매에 대해 동시에 50번 결제를 시도하는 시나리오였습니다. 결과를 보고 문제를 발견했습니다. 동일한 사용자의 결제 요청이 여러 번 처리될 수 있는 구조였습니다.
생각해보면 이 상황이 억지스러운 가정이 아닙니다. 모바일 환경에서는 네트워크 지연으로 사용자가 결제 버튼을 반복 클릭하거나 앱이 타임아웃 후 자동으로 재전송하는 경우가 흔합니다. 이때 서버는 두 요청이 동일한 의도에서 나온 것인지 구분할 방법이 없습니다.
각각은 독립적인 HTTP 커넥션으로 도달합니다.
| 시나리오 | 원인 | 서버가 보는 것 |
| 더블 클릭 | UI 비활성화 처리 누락 | 거의 동시에 도달하는 요청 2건 |
| 타임아웃 재전송 | HTTP 읽기 타임아웃 후 클라이언트 자동 재시도 | 서버가 처리중인데 2차 요청 도달 |
| 네트워크 단절 후 재연결 | 응답 미수신 상태에서 앱이 재전송 | 1차가 이미 성공했는데 2차 요청 도달 |
결제 도메인에서 "한 번 더 처리되는 것" 자체가 장애입니다. 중복 결제는 환불 처리, CS 폭증, 회계 불일치로 이어집니다.
멱등성 보장을 위한 요구사항 정의
해결책을 탐색하기 전에 먼저 "무엇을 보장해야 하는가"를 명확히 했습니다. 모호한 요구사항은 과잉 설계 또는 과소 설계를 낳습니다.
멱등성(Idempotency): 같은 요청을 N번 보내도 결과가 1번 보낸 것과 동일한 성질.
이 시스템에서 보장해야 하는 것을 체크리스트로 정리했습니다.
✓ 동시에 같은 키로 2개 요청 → 1개만 처리, 1개는 즉시 거절
✓ 처리 완료 후 같은 키로 재요청 → 저장된 결과 재반환 (DB 재처리 없음)
✓ 어떤 상황에서도 같은 키로 Payment 레코드가 2건 생성되지 않음
✓ 키가 다르면 독립적인 결제로 처리
구현 방식은 클라이언트가 UUID v4 형식의 idempotencyKey를 생성해 요청 바디에 포함하고 서버가 이를 Payment 레코드에 저장하는 방식으로 정했습니다.
POST /api/concerts/1/payment
{
"idempotencyKey": "550e8400-e29b-41d4-a716-446655440000",
"paymentMethod": "CARD"
}
이 키가 이후 모든 중복 판단의 기준이 됩니다.
단순 조회 방식의 문제점
가장 먼저 시도한 방법은 결제 처리 전 DB를 조회해 동일한 키로 처리된 기록이 있는지 확인하는 것이었습니다.
Optional<Payment> existing = paymentRepository.findByIdempotencyKey(iKey);
if (existing.isPresent()) return PaymentResponse.from(existing.get());
// 없으면 → 결제 처리 진행
단일 요청에서는 완벽하게 동작합니다. 문제는 동시 요청입니다.
시각 Thread A Thread B
T1 SELECT idempotency_key → 결과 없음
T2 SELECT idempotency_key → 결과 없음
T3 결제 처리 시작...
T4 결제 처리 시작...
T5 Payment INSERT 완료 ✓
T6 Payment INSERT 완료 ✓ ← 중복 결제!
T2에서 B가 SELECT를 실행할 때 A의 INSERT는 아직 커밋되지 않았습니다. MySQL 기본 격리 수준(READ COMMITTED)에서 B는 커밋된 데이터만 읽으므로 "없음"으로 판단하고 결제를 진행합니다. 결국 두 스레드 모두 INSERT에 성공해 Payment 레코드가 2건 생깁니다.
격리 수준을 SERIALIZABLE로 올리면 이 문제는 사라집니다. 하지만 직렬화 격리는 처리량(Throughput)을 크게 낮춥니다.
초당 수백 건의 예매가 발생하는 시스템에서 SERIALIZABLE은 해결책이 아니라 병목 그 자체가 됩니다.
핵심 인사이트: DB SELECT는 "이미 완료된 결제"를 확인하는 데는 유효하지만, "지금 처리 중인 결제"는 감지하지 못합니다.
왜 다른 방법은 선택하지 않았는가?
DB 조회의 한계를 확인한 뒤 여러 대안을 검토했습니다.
| 방법 | 기각 이유 |
| synchronized | 단일 JVM 내부에서만 유효합니다. 인스턴스가 2대 이상으로 수평 확장되면 각 JVM이 독립적으로 동작해 인스턴스 간 조율이 전혀 되지 않습니다. |
| DB 비관적 락 (SELECT FOR UPDATE) | 신규 INSERT 시나리오에서는 잠글 행이 아직 존재하지 않습니다. Booking 행에 락을 걸 수 있지만 트랜잭션 간 락 획득 순서가 달라지면 데드락 위험이 생깁니다. |
| DB 낙관적 락 (version 필드) | UPDATE 충돌 감지 패턴이므로 INSERT 경쟁에는 적용할 수 없습니다. 충돌 시 재시도(retry)를 유도하는데 재시도 자체가 중복 결제 시도가 되는 아이러니가 생깁니다. |
| DB UNIQUE Constraint 단독 | 정합성은 보장됩니다. 그러나 50개의 동시 요청이 모두 doProcess()까지 진입해 INSERT를 시도하고 DB가 49건의 충돌을 직접 처리합니다. DB 커넥션 풀이 불필요한 실패 처리로 소모됩니다. |
| Redis SETNX 단독 | DB 레벨 안전장치가 없습니다. Layer 2가 유일한 방어선이 되어 예기치 못한 경쟁 조건이나 코드 변경 시 중복 결제를 막을 보장이 없습니다. |
DB UNIQUE Constraint 단독을 기각한 이유 - 구체적으로 어떤 일이 벌어지는가
표로 정리하면 "DB 커넥션 풀이 소모된다"로 끝나는데 이것만으로는 왜 충분히 나쁜지 와닿지 않습니다.
구체적으로 어떤 일이 벌어지는지 따라가보겠습니다.
50개의 동시 요청이 DB UNIQUE Constraint 하나만 믿고 들어오면 다음 과정이 50번 반복됩니다.
1. DB 커넥션 풀에서 커넥션 획득
2. INSERT 쿼리 실행
3. UNIQUE 인덱스 검사 — 49건 충돌
4. DataIntegrityViolationException 생성
5. 트랜잭션 롤백
6. 커넥션 반환
DB가 49번의 INSERT를 시도하고 49번의 예외를 처리하고 49번의 트랜잭션을 롤백합니다. 커넥션 풀의 커넥션이 이 실패 처리에 묶여있는 동안 다른 정상 요청들은 커넥션을 기다립니다. 동시 접속자가 많은 예매 시스템에서 이 구조는 부하가 몰릴수록 증폭됩니다.
Redis SETNX를 앞에 두면 49개 요청은 메모리에서 즉시 거절됩니다. DB는 실제로 처리해야 하는 1건만 받습니다.
DB UNIQUE Constraint 단독 구조는 정합성은 맞지만 비효율적입니다. Redis를 추가한 이유는 단지 안전을 강화하기 위해서가 아니라 정합성과 성능을 동시에 확보하기 위해서입니다.
이 검토 과정에서 설계 방향이 잡혔습니다.
Redis로 빠르게 차단하되 DB UNIQUE Constraint를 최후 안전망으로 둔다.
Redis 기반 1차 방어선 설계
Redis의 SETNX 연산은 원자적(Atomic)입니다. 두 클라이언트가 동시에 같은 키로 SETNX를 시도하면 반드시 한 쪽만 성공합니다.
"처리 중" 상태를 분산 환경에서 안전하게 표시할 수 있는 특성입니다.
키 설계 :
Key: payment:idempotency:{idempotencyKey}
Value: "PROCESSING"
TTL: 30초
String redisKey = IDEMPOTENCY_PREFIX + iKey;
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(redisKey, "PROCESSING", Duration.ofSeconds(IDEMPOTENCY_TTL_SEC));
if (!Boolean.TRUE.equals(acquired)) {
log.warn("[PAYMENT][PROCESSING] userId={} concertId={} idempotencyKey={}",
userId, concertId, iKey);
throw new IllegalStateException("결제가 처리 중입니다. 잠시 후 다시 시도해주세요.");
}
try {
return doProcess(concertId, userId, request, iKey);
} finally {
redisTemplate.delete(redisKey); // 성공/실패 모두 반드시 해제
}
이 코드에서 세 가지 설계 결정을 짚고 넘어가겠습니다.
설계 결정 1: TTL 30초를 선택한 이유
30초는 임의로 정한 숫자가 아닙니다.
이 3계층 구조는 현재 pay() 메서드(Mock PG 기준)에 적용된 설계입니다. Toss Payments 연동 확정 API(tossConfirm())는 현재 Layer 1(DB SELECT)만 사용하며 Redis SETNX는 적용되어 있지 않습니다. TTL 30초 수치는 동일 서비스 내 Toss Payments API 설정(TossPaymentClient 연결 타임아웃 + 읽기 타임아웃 합산 최대 3초)을 기준으로 삼았습니다. 향후 tossConfirm()에도 동일한 3계층을 적용한다면 이 수치가 그대로 적용될 수 있습니다.
결제 처리 시간을 결정하는 가장 큰 변수는 외부 PG 연동입니다. Toss Payments API 연결 타임아웃과 읽기 타임아웃을 합산하면 최대 3초입니다. Mock 결제는 순간적으로 처리됩니다. 즉 실제 결제 처리 시간은 아무리 길어도 5초 이내입니다.
TTL 설정에는 두 가지 위험이 있습니다.
- TTL이 너무 짧으면: 결제 처리가 아직 진행 중인데 Redis Key가 먼저 만료됩니다. 그 틈에 새로운 중복 요청이 Layer 2를 통과해 Layer 3 충돌이나 중복 처리로 이어질 수 있습니다.
- TTL이 너무 길면: 서버 크래시로 finally가 실행되지 않은 경우, TTL이 만료될 때까지 해당 키로 결제가 불가능합니다.
30초는 실제 최대 처리 시간(~5초)의 6배에 해당하는 충분한 여유를 두면서도 장애 상황에서 지나치게 오래 차단되지 않는 타협점으로 결정했습니다.
설계 결정 2: !Boolean.TRUE.equals(acquired)를 쓴 이유와 Fail-Fast 전략
!Boolean.TRUE.equals(acquired)는 false(다른 스레드가 처리 중)와 null(Redis 장애) 모두 예외를 던집니다. Redis가 다운되면 정상적인 결제 요청도 막힙니다.
다른 선택지가 있었습니다. Redis 예외를 catch해서 doProcess()로 넘기는 방식입니다.
Boolean acquired;
try {
acquired = redisTemplate.opsForValue().setIfAbsent(...);
} catch (Exception e) {
log.warn("[PAYMENT][REDIS_DOWN] 폴백 진행");
acquired = null; // null로 처리해 doProcess()로 진입
}
이렇게 하면 Redis 장애 시에도 결제가 가능해집니다. Layer 3이 중복을 막아주므로 정합성은 유지됩니다.
그런데 이 선택지를 기각했습니다. 이유는 하나입니다.
Redis가 죽은 상황에서는 Layer 2가 없는 것과 같습니다. 이 상태에서 50개의 동시 요청이 들어오면 모두 doProcess()에 진입해 INSERT를 시도합니다. Layer 3이 49건을 막아주지만 막는 방식이 DB UNIQUE 충돌입니다. 앞서 "UNIQUE 단독을 기각한 이유"에서 설명한 바로 그 상황이 Redis 장애 때마다 재현됩니다.
반면 Fail-Fast를 선택하면 Redis 장애 시 결제가 일시적으로 불가능해지지만 두 가지 이점이 있습니다.
첫째, 중복 결제 가능성 자체가 사라집니다. 결제 도메인에서 "중복 결제 가능성이 있는 상태"를 유지하는 것보다 "일시적 결제 불가"가 덜 위험하다고 판단했습니다. 결제 불가는 사용자에게 즉각적인 피드백(409)이 가능합니다. 중복 결제는 사후에야 발견됩니다.
둘째, 코드가 단순해집니다. 폴백 로직을 추가하면 Redis 장애 경로와 정상 경로가 분기되어 테스트해야 할 시나리오가 늘어납니다.
Redis 가용성을 높이는 것은 코드가 아니라 인프라 레벨에서 해결해야 할 문제라고 생각했습니다. 코드에서 Redis 장애를 "우아하게" 처리하려는 시도는 더 복잡한 코드로 Redis 의존성을 감추는 것에 가깝습니다.
또한 acquired == false로 비교하면 null 반환 시 NPE가 발생합니다. !Boolean.TRUE.equals(acquired) 형태가 false와 null 모두를 안전하게 거절 경로로 흘려보냅니다.
설계 결정 3: finally 블록과 Redis Key 삭제 시점의 트레이드오프
finally 블록은 단순한 정리가 아닙니다. 예외가 발생해도 Redis 키는 반드시 삭제되어야 합니다. 삭제하지 않으면 TTL(30초)이 만료될 때까지 해당 키로는 정상 결제조차 진행되지 않습니다. TTL은 서버 크래시로 finally마저 실행되지 않는 극단적 상황의 마지막 안전망입니다.
그런데 현재 구조에서 Redis Key는 finally 블록에서 삭제됩니다. 트랜잭션이 커밋되기 전에 삭제된다는 뜻입니다. 여기서 짧지만 실재하는 위험 구간이 생깁니다.
Payment INSERT 성공
Redis Key 삭제 ← 트랜잭션 커밋 전
트랜잭션 커밋 중...
↑ 이 구간에 새 요청이 들어오면:
Layer 1: DB SELECT → miss (아직 커밋 안 됨)
Layer 2: Redis SETNX → 성공 (key가 이미 삭제됨)
→ doProcess() 진입 → INSERT 시도 → UNIQUE 충돌
Layer 3이 막아주므로 중복 결제는 발생하지 않습니다. 하지만 불필요한 INSERT 시도와 예외 처리가 발생합니다.
TransactionSynchronizationManager를 사용하면 이 구간을 닫을 수 있습니다.
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
redisTemplate.delete(redisKey); // 트랜잭션 커밋 이후 삭제
}
}
);
커밋 이후에 Key가 삭제되면 흐름이 달라집니다.
트랜잭션 커밋 완료
afterCommit(): Redis Key 삭제
↑ 이후에 새 요청이 들어오면:
Layer 1: DB SELECT → hit (커밋된 Payment 발견) → 즉시 반환
Layer 1이 커밋된 Payment를 찾아 즉시 반환하므로 Redis와 DB 모두 추가 부하를 받지 않습니다.
그런데 이 방식에도 트레이드오프가 있습니다. afterCommit() 실행 전에 서버가 다운되면 Redis Key가 TTL(30초)이 만료될 때까지 남아, 그동안 해당 키로 결제가 불가능합니다.
두 방식을 비교하면 아래와 같습니다.
| finally 즉시 삭제 | afterCommit 이후 삭제 | |
| 커밋 전 구간 노출 | 있음 (Layer 3이 커버) | 없음 |
| 서버 크래시 시 차단 시간 | TTL 30초 | TTL 30초 |
| 구현 복잡도 | 낮음 | 높음 |
현재는 finally로 즉시 삭제하는 단순함을 선택했습니다. 커밋 전 구간에서 발생할 수 있는 불필요한 INSERT 시도는 Layer 3이 방어합니다. 해당 구간은 트랜잭션 커밋 시간(수 밀리초) 수준으로 매우 짧고 실제로 이 타이밍에 요청이 도달할 확률도 낮습니다. afterCommit() 방식은 향후 개선 대상으로 남겨뒀습니다.
데이터 정합성을 위한 최종 방어선 설계
// Payment.java
@Table(
name = "payment",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_payment_idempotency_key",
columnNames = {"idempotency_key"}
)
}
)
public class Payment { ... }
idempotency_key 컬럼에 UNIQUE 제약을 걸면 어떤 경로로든 두 요청이 동시에 INSERT를 시도할 때 DB 엔진이 한 건을 거절합니다. 거절된 쪽은 DataIntegrityViolationException을 받아 409로 응답합니다.
한 가지 표현을 정확히 짚고 넘어가겠습니다.
"Layer 3은 평상시에 작동하지 않는다"는 표현은 정확하지 않습니다. UNIQUE 인덱스는 모든 INSERT마다 검사됩니다. 더 정확한 표현은 이것입니다.
Layer 3은 평상시에 충돌을 발생시키지 않는다.
Layer 2가 동시 중복을 이미 막기 때문에 충돌이 일어날 일이 없는 것이지 인덱스 검사 자체가 생략되는 것이 아닙니다.
이 차이는 인덱스의 실제 동작 방식을 이해하고 있는지를 보여줍니다.
Layer 3의 실제 역할은 코드 외부에서 불변 조건을 선언하는 것입니다.
"이 시스템에서 같은 idempotencyKey로 Payment가 2건 존재하는 상황은 절대 허용하지 않는다." 코드가 아무리 복잡해지거나 리팩터링되더라도 이 선언은 지워지지 않습니다. 코드는 변하지만 스키마는 코드 밖에 있습니다.
3계층 중복 결제 방지 구조
결제 요청 수신
│
▼
┌──────────────────────────────────────────────────────────────┐
│ [Layer 1] DB SELECT │
│ paymentRepository.findByIdempotencyKey(iKey) │
│ │
│ 이미 처리됨 → 캐시된 응답 즉시 반환 (가장 빠른 경로) │
│ 없음 ↓ │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ [Layer 2] Redis SETNX │
│ payment:idempotency:{key} TTL 30s │
│ │
│ false → 409 즉시 반환 (동시 중복 차단) │
│ null → 409 즉시 반환 (Redis 장애, Fail-Fast) │
│ true → doProcess()로 진입 │
└──────────────────────────────────────────────────────────────┘
│ true
▼
┌──────────────────────────────────────────────────────────────┐
│ [doProcess] Payment INSERT │
│ │
│ [Layer 3] DB UNIQUE 인덱스 (항상 검사) │
│ 충돌 시 → DataIntegrityViolationException → 409 │
│ 정상 → 결제 처리 완료 │
└──────────────────────────────────────────────────────────────┘
│
▼
finally: Redis 키 삭제
왜 이 순서여야 하는가
계층을 나열하는 것만으로는 설계가 완성되지 않습니다. 왜 이 순서여야 하는가가 설계의 핵심입니다. 순서를 바꾸면 각각 다른 문제가 생깁니다.
Layer 1이 Layer 2보다 앞에 있는 이유 - Redis 키의 생명주기
Redis 키는 결제 처리가 시작될 때 생성되고 finally 블록에서 즉시 삭제됩니다. 결제가 완료된 순간 Redis 키는 사라집니다.
만약 Layer 2(Redis SETNX)를 Layer 1(DB SELECT)보다 먼저 두면 어떻게 될까 생각했습니다.
결제 완료 후 재요청 — Layer 2가 먼저인 경우:
재요청 도착
→ Redis SETNX 시도 → 키가 없으므로 성공, 불필요한 락 획득
→ DB SELECT → 기존 Payment 발견
→ 결과 반환
→ finally: Redis 키 삭제 (불필요한 쓰기)
이미 처리된 결제의 재요청인데, Redis에 불필요한 쓰기가 발생합니다.
Layer 1이 먼저면 이 케이스가 훨씬 단순합니다.
결제 완료 후 재요청 — Layer 1이 먼저인 경우:
재요청 도착
→ DB SELECT → 기존 Payment 발견 → 즉시 반환
(Redis 건드리지 않음)
영구 저장소인 DB는 결제 완료 기록을 언제까지나 갖고 있습니다. 반면 Redis 키는 처리 중에만 존재합니다.
Layer 1이 앞에 있는 이유: 영구 기록(DB)으로 해결 가능한 케이스는 임시 상태(Redis)를 거치지 않습니다.
Layer 2가 Layer 3보다 앞에 있는 이유 - 정합성과 성능
Layer 3(DB UNIQUE)만으로도 중복 결제는 막을 수 있습니다. 그렇다면 Layer 2는 왜 필요할까 고민했습니다.
Layer 3만 있는 경우 (50개 동시 요청):
50개 요청 전부 doProcess() 진입
→ DB 커넥션 획득
→ INSERT 수행
→ UNIQUE 인덱스 검사
→ 49건 충돌 → Exception 생성 및 처리
결과: DB가 49번의 실패를 직접 처리
Layer 2 + Layer 3 (50개 동시 요청):
1개 → Redis SETNX 성공 → doProcess() → INSERT 1건
49개 → Redis SETNX 실패 → 즉시 409 (DB에 도달하지 않음)
결과: DB에는 INSERT 1건만 도달
Layer 3 단독으로도 정합성은 보장됩니다. 하지만 49번의 충돌을 DB가 직접 처리하면 DB 커넥션 획득, INSERT 수행, 인덱스 검사, Exception 생성이 49번 반복됩니다. Redis SETNX는 이 49개를 메모리에서 즉시 차단합니다.
Layer 2가 앞에 있는 이유: 정합성과 성능을 동시에 확보하기 위해, 저렴한 자원(Redis 메모리)이 먼저 필터링하고 비싼 자원(DB)은 필요한 요청만 처리합니다.
Layer 3이 마지막인 이유 - 불변 조건의 위치
Layer 2는 코드입니다. 코드는 변합니다. 리팩터링, 신규 결제 경로 추가, 버그 수정 - 어느 시점에 누군가 Layer 2를 건드리거나 우회하는 코드를 작성할 수 있습니다.
DB UNIQUE Constraint는 코드 밖에 있습니다. 애플리케이션 레이어가 어떻게 바뀌더라도 스키마 수준의 불변 조건은 지워지지 않습니다.
Layer 3이 마지막인 이유: 코드 계층이 어떻게 변해도 변하지 않는 스키마 수준 불변 조건을 가장 아래에 둡니다.
| 계층 | 역할 | 왜 이 자리인가? |
| Layer 1 (DB SELECT) | 완료된 결제 재요청 조기 종료 | 영구 기록은 임시 상태보다 먼저 |
| Layer 2 (Redis SETNX) | 동시 중복 차단 + 성능 필터 | 저렴한 자원이 비싼 자원 앞에 |
| Layer 3 (DB UNIQUE) | 스키마 수준 불변 조건 | 코드가 변해도 지워지지 않는 자리 |
순서를 바꾸면 각 계층이 자신의 역할 밖의 일을 하게 되거나 더 비싼 자원이 더 많은 부하를 받습니다.
각 계층의 생명주기와 비용 구조에서 나온 결과입니다.
동시성 테스트 환경 구성
설계를 말로 증명하는 것만으로는 부족했습니다. 실제로 50개의 스레드가 동시에 같은 키로 결제 API를 호출했을 때 단 1건만 처리되는지 확인해야 했습니다.
테스트 도구는 Apache JMeter를 사용했습니다. Synchronizing Timer를 사용하면 설정한 수의 스레드가 모두 준비될 때까지 대기했다가 일제히 출발시킬 수 있습니다. 이것 없이는 스레드가 ramp-up 도중 순차 출발해 진짜 동시성이 재현되지 않습니다.


Payment 레코드가 단 1건입니다.
50개의 동시 요청 중 실제로 INSERT된 레코드는 하나뿐입니다. failure_reason = NULL은 정상 처리 완료를 의미합니다.
두 번째 행이 없다는 것이 핵심입니다.
케이스별로 무슨 일이 일어났는지 정리하면 다음과 같습니다.
| 케이스 | 응답 | 동작한 계층 |
| Redis SETNX를 먼저 획득한 1개 스레드 | 200, 실제 처리 | Layer 2 통과 → doProcess |
| 처리 중에 도달한 스레드들 | 409 | Layer 2 차단 |
| 처리 완료 후 도달한 스레드들 | 200, 캐시 반환 | Layer 1 조기 종료 |
설계 과정에서 얻은 교훈
각 계층을 어디에 둘지는 그 자원의 생명주기와 비용 구조에서 결정됩니다.
- 영구 기록은 임시 상태보다 먼저
- 저렴한 자원은 비싼 자원 앞에
- 코드 불변 조건은 스키마 불변 조건보다 위에
이 논리를 이해하면 계층이 늘어나거나 구조가 바뀌어도 올바른 순서를 스스로 도출할 수 있습니다.
Fail-Fast도 하나의 설계 선택이다
!Boolean.TRUE.equals(acquired)는 null(Redis 장애)과 false(락 획득 실패)를 같은 방식으로 처리합니다.
Redis 장애 시 결제가 일시적으로 불가능해집니다. 이것은 실수가 아니라 의도적 선택입니다.
폴백 방식을 선택하면 Redis 장애 시에도 결제가 가능하지만, Layer 2가 없는 것과 같아져 UNIQUE 단독 구조의 문제가 재현됩니다. Redis 가용성을 높이는 것은 코드가 아니라 인프라 레벨에서 해결해야 할 문제입니다.
남은 한계와 향후 개선
idempotencyKey를 클라이언트가 생성한다는 점이 현재 설계의 가장 큰 한계입니다. 클라이언트 버그로 매번 새 키를 생성하면 멱등성 보장이 약화됩니다. 개선 방향은 서버가 예매 확정 시점에 키를 발급해 클라이언트에 전달하는 것입니다. 서버 발급 키라면 클라이언트 구현 버그의 영향을 받지 않습니다.
TransactionSynchronizationManager.afterCommit()을 통한 Redis Key 삭제 시점 조정도 검토 대상으로 남아있습니다. 커밋 이후 삭제 방식은 트랜잭션-Redis 간 타이밍 문제를 근본적으로 해결하지만 서버 크래시 시 Key가 TTL까지 살아있는 트레이드오프가 있습니다. 현재는 단순함을 선택했고 Layer 3이 그 틈을 커버합니다.
Redis Sentinel / Cluster 미도입도 남은 숙제입니다. Fail-Fast 전략을 선택한 만큼 Redis 자체의 가용성을 높이는 것이 이 설계의 다음 과제입니다. 단일 Redis 인스턴스에서 Sentinel 또는 Cluster 구성으로 Layer 2의 신뢰도 자체를 높이는 것이 선행되어야 합니다.
'프로젝트 > 공연 티켓팅 프로젝트' 카테고리의 다른 글
| 🎟️ 결제 실패 보상 이벤트 유실 방지하기 - Outbox 패턴 적용기 (1) | 2026.06.01 |
|---|---|
| 🎟️ 대기열 시스템에서 WebSocket 대신 Polling을 선택한 이유 (0) | 2026.06.01 |
| 🎟️ Redis로 좌석 차감을 옮겼는데도 Lock Wait이 사라지지 않았다. (0) | 2026.05.31 |
| 🎟️ Docker-compose up 한 번으로 실행되는 백엔드 만들기 (1) | 2026.05.18 |
| 🎟️ MySQL 인덱스 실험: 예약 만료 배치 쿼리 성능 개선 (10만 건 실험) (1) | 2026.05.13 |