-
Notifications
You must be signed in to change notification settings - Fork 3
Open
Labels
Description
🚀 배치 예약 중 옵션 미존재와 동일 옵션 중복으로 인한 재고 중복 차감, 복구 불일치 이슈에 대한 최종 기술 문서
개요
- 목적: /b1/payment/request-batch 요청에서 옵션 미존재와 동일 옵션 중복으로 발생하던 재고 중복 차감과 복구 불일치를 근본적으로 제거한 설계를 코드 레벨로 완전 정리
- 범위: PaymentController, KafkaProductUpdateListener, PaymentService, ProductService, SSE, 인메모리 인덱스, 트랜잭션 경계, 로깅과 메모리 안전까지 전체 경로
- 원칙
- 동일 옵션 중복 입력은 서버에서 반드시 병합 처리
- 배치 차감은 하나의 트랜잭션에서 원자적으로 처리
- 실패 즉시 예약 키와 타이머를 정리해 레이스 윈도우를 최소화
- 예약과 차감은 멱등적으로 설계하며 로그와 상태는 관측 가능해야 함
문제상황
- /b1/payment/request-batch 요청에 동일 옵션이 중복 포함됨
- 일부 옵션은 실제로 존재하지 않아 배치 마지막 항목에서 예외가 발생
- 과거 구현에서는 아이템 단위 부분 커밋으로 앞서 성공한 차감이 커밋되고 마지막에서 실패하면 리트라이로 인해 재고가 계속 감소
- 한 배치 내 동일 옵션 중복 시 복구 단계에서 동일 키를 한 번만 복구하는 불일치가 발생
문제 원인과 재현 시나리오
- 입력 예시
- (productId=1, M, BLACK, count=3)
- (productId=1, M, BLACK, count=3) 동일 옵션 중복
- (productId=2, M, BLACK, count=3) 실제 상품 2는 WHITE만 존재
- 과거 흐름 요약
- 컨트롤러가 병합 없이 그대로 카프카 발행
- 리스너가 각 항목을 개별 트랜잭션으로 차감
- 마지막 항목에서 옵션 미존재 예외 발생 시 앞선 차감은 커밋 상태로 남고 리트라이 시 재차 감소
- 동일 옵션 중복은 예약 단계에서만 중복 감지되어 차감은 2회 수행
- 복구 시 동일 키를 한 번만 복구해 총량 불일치
핵심 설계 변경 요약
- 컨트롤러
- buildSignatureKey로 사용자별 옵션 멀티셋 기반 시그니처 생성
- registerSignatureIfAbsent로 시그니처 원자 선점
- mergeByOption으로 (productId, size, color) 기준 서버 병합 후 addReservation 호출
- 번들 구성 완료 뒤 bindRidSignature로 바인딩해 정리 경로에서 반드시 시그니처 키 제거
- 카프카에는 병합된 항목만 발행
- 리스너
- 배치 전체를 단일 @transactional 트랜잭션으로 처리
- try 블록에서 하나라도 실패하면 catch에서 즉시 cancelAllNoRestore(rid) 실행 후 예외 재던짐으로 롤백 보장
- afterCompletion에서도 롤백이면 cancelAllNoRestore 재호출로 2차 방어
- afterCommit에서만 addReservation 멱등 보강, SSE는 ProductService에서 afterCommit으로만 발행
- 서비스
- 인메모리 인덱스 index: ConcurrentHashMap<IndexKey,String> IndexKey는 CompositeKey와 SignatureKey
- 번들 ridBundles: RID당 Bundle에 items, 타이머, 시그니처, ETA, 감사 버퍼 관리
- finalizeByRid(success=false)와 만료 타이머에서 bundle.items를 정확히 복구하고 모든 키 제거
- cancelAllNoRestore는 롤백 전용 경로로 복구 없이 인덱스와 타이머 제거
- 품질 규약
- StringBuilder.isEmpty 사용, 메서드 파라미터 동기화 금지, 전용 auditLock 사용
- null 비교 연산자 미사용, Optional과 List.copyOf로 항상 non-null 불변 컬렉션
- 예외는 BasiliumCustomException 통일, ApiResponse 일관 응답, 로그는 @slf4j와 감사 전용 로거 분리
컴포넌트와 데이터 구조
- PaymentController
- request-batch: existingRidForAny → newRid → buildSignatureKey → registerSignatureIfAbsent → mergeByOption → addReservation(each) → bindRidSignature → Kafka 배치 발행
- KafkaProductUpdateListener
- handleBatch: @transactional, for each item processPaymentProductQuantity, afterCommit에서 addReservation, 실패 catch 즉시 cancelAllNoRestore, afterCompletion 롤백 방어
- handleSingle: 단건도 동일한 패턴
- PaymentService
- ridBundles: RID → Bundle
- index: IndexKey → RID IndexKey는 CompositeKey(userId, productId, count, size, color) 또는 SignatureKey(userId, Map<OptionKey, count))
- addReservation, finalizeByRid, cancelAllNoRestore, buildSignatureKey, bindRidSignature, 만료 타이머
- ProductService
- processPaymentProductQuantity: Propagation.MANDATORY, 옵션 조회, 재고 차감, afterCommit에서 SSE 발행
불변 조건
- 동일 옵션 중복 입력은 병합되어 단 1회 차감
- 배치에서 하나라도 실패하면 DB 차감은 전부 롤백
- 실패 즉시 예약 키와 타이머 제거로 재시도 시 중복 차감 차단
- SSE는 afterCommit에서만 발행
- 성공 확정은 인덱스 제거만, 실패 확정과 만료는 정확한 수량 복구
시퀀스 다이어그램 1 배치 성공 경로
--------------------------------------------------------------------------------
Client PaymentController Kafka KafkaProductUpdateListener ProductService PaymentService
| POST /b1/payment/request-batch | | | |
|----------------------------------->| want = toInfos(user, items) | | |
| | existingRidForAny(user, want) -> empty | | |
| | rid = newRid(user) | | |
| | sigKey = buildSignatureKey(user, want) | | |
| | registerSignatureIfAbsent(sigKey, rid) -> ok | | |
| | merged = mergeByOption(want) 동일 옵션 서버 병합 | | |
| | for it in merged: addReservation(rid, it) 번들 구성 | | |
| | bindRidSignature(rid, sigKey) 정리 경로용 바인딩 | | |
| | send PRODUCT_UPDATE_BATCH_TOPIC(merged) ---------------------------------------->| | |
|<----------------------------------| ApiResponse.ok(rid, eta, items) | | |
| | | @Transactional begin | |
| | | for it in merged: | processPayment... -------------->|
| | | | afterCommit 예약 보강 등록 |
| | | commit | |
| | | afterCommit: | addReservation(rid, it) -------->| 중복이면 no-op
| | | | |
--------------------------------------------------------------------------------
정합성 검증 포인트
- 병합으로 동일 옵션은 단 1회 차감
- afterCommit에서만 예약 보강과 SSE 발행
- 컨트롤러 선점이 이미 있어도 addReservation는 멱등 no-op
시퀀스 다이어그램 2 옵션 미존재로 실패, 전체 롤백, 즉시 정리
--------------------------------------------------------------------------------
Client PaymentController Kafka KafkaProductUpdateListener ProductService PaymentService
| POST /b1/payment/request-batch | | | |
|----------------------------------->| 병합, 시그니처 선점, addReservation, 바인딩, 배치 발행 완료 | | |
| | | @Transactional begin | |
| | | afterCompletion 등록 | |
| | | try { | |
| | | for it in merged: | processPayment... -------------->|
| | | ... 마지막 it에서 옵션 미존재 예외 | throw BasiliumCustomException
| | | } catch (RuntimeException ex) { | |
| | | cancelAllNoRestore(rid) -------------------->| 키와 타이머 즉시 제거
| | | throw ex 롤백 보장 | |
| | | TX rollback | |
| | | afterCompletion(rolled_back): | |
| | | cancelAllNoRestore(rid) -------------------->| 2차 방어 idempotent
| | | no afterCommit SSE 미발행 | |
| | | 레코드 재시도 정책 | |
--------------------------------------------------------------------------------
정합성 검증 포인트
- DB 차감은 트랜잭션 롤백으로 전부 되돌아감
- SSE는 afterCommit에서만 발행하므로 실패 경로에서 절대 발행되지 않음
- 즉시 정리로 stale 키 레이스 윈도우 최소화
- afterCompletion에서 중복 제거 시도도 안전한 no-op
시퀀스 다이어그램 3 동일 옵션 중복 입력도 1회 차감과 정확 복구
--------------------------------------------------------------------------------
Client PaymentController Kafka KafkaProductUpdateListener ProductService PaymentService
| items: (1,M,BLACK,3) x2, (2,M,BLACK,3) |
|----------------------------------->| merged: (1,M,BLACK,6), (2,M,BLACK,3) |
| | addReservation(rid, (1,M,BLACK,6)), addReservation(rid, (2,M,BLACK,3)) |
| | send batch((1,M,BLACK,6), (2,M,BLACK,3)) ----------------------------------------->|
| | | 트랜잭션에서 각 항목 1회 차감
| 실패 시 | | catch 즉시 cancelAllNoRestore, rollback
| finalizeByRid(false) 또는 만료 시 | | | restoreProductQuantity(1,M,BLACK,6) 정확 복구 1회
| | | | restoreProductQuantity(2,M,BLACK,3) 정확 복구 1회
| | | | 인덱스와 시그니처 제거
--------------------------------------------------------------------------------
정합성 검증 포인트
- 동일 옵션은 병합되어 번들에 단일 CompositeKey만 존재
- 실패 확정이나 만료에서 복구는 병합된 총량 기준으로 정확히 1회
- 실패 트랜잭션 경로에서는 DB가 롤백되므로 복구 자체가 호출되지 않음
컨트롤러 설계 세부
- 기존 완전 동일 멀티셋 중복 스킵
- existingRidForAny(user, want)로 O(1) 조회
- 있으면 기존 RID 응답과 ETA 반환, 카프카 재발행 없음
- 신규 시그니처 선점
- sigKey = buildSignatureKey(user, want)
- registerSignatureIfAbsent(sigKey, rid) 성공 시 진행, 실패 시 raced RID와 ETA 반환
- 병합과 선점
- merged = mergeByOption(want)
- merged.forEach(addReservation(rid, it))
- bindRidSignature(rid, sigKey) 번들 구성 완료 이후 바인딩
- 카프카 발행
- 배치 메시지는 병합된 항목만 포함
- 응답
- ETA는 PaymentService.getExpiresAt 또는 defaultExpiresAtNow
리스너 설계 세부
- 공통 방어 로직
- afterCompletion에서 status가 rolled_back이면 cancelAllNoRestore(rid)
- catch(RuntimeException)에서 즉시 cancelAllNoRestore(rid) 후 재던짐
- 차감 로직
- for each item processPaymentProductQuantity
- ProductService는 Propagation.MANDATORY, 옵션 미존재나 부족 시 BasiliumCustomException
- 커밋 후 동작
- afterCommit에서 for each item addReservation 멱등 보강
- 컨트롤러 선점이 이미 수행되었기 때문에 대부분 no-op
- 단건 리스너도 동일 패턴
PaymentService 설계 세부
- 인덱스와 번들
- index: ConcurrentHashMap<IndexKey,String>
- ridBundles: ConcurrentHashMap<String, Bundle>
- Bundle
- items: ConcurrentHashMap<CompositeKey, RequestTaskInfo>
- scheduled: AtomicBoolean 타이머 1회 보장
- futureRef: AtomicReference<Optional<ScheduledFuture<?>>>
- signatureKey: AtomicReference
- expiresAt: LocalDateTime
- 감사 버퍼: StringBuilder, auditLock으로 보호, isEmpty 체크
- 예약 등록 addReservation
- index.putIfAbsent(CompositeKey, rid)로 전역 O(1) 중복 차단
- 신규라면 번들 생성 및 아이템 추가, 타이머 1회 스케줄
- finalizeByRid
- success=true 복구 없이 index 키들 제거
- success=false 각 아이템 정확 복구 후 키 제거
- SSE는 복구 시점에 상품 단위 updateInventory
- cancelAllNoRestore
- 롤백 전용 경로 복구 없이 번들 제거, 키 제거, 타이머 취소
- idempotent
- 만료 타이머
- TTL 경과 시 auditRestoreOnce 기록
- 각 아이템 정확 복구, 시그니처 키 제거, 번들 제거
ProductService 설계 세부
- processPaymentProductQuantity
- findByIdWithDetails로 옵션 조회
- 유효성 위반과 재고 부족, 옵션 미존재 시 BasiliumCustomException
- option.updateQuantity로 차감
- afterCommit에서 SseController.updateInventory
- Propagation.MANDATORY로 리스너 트랜잭션에 종속
성능과 동시성
- 모든 중복 탐지, 선점, 바인딩, 해제는 평균 O(1)
- 경합 구역은 ConcurrentHashMap 연산과 짧은 auditLock 구간으로 한정
- 타이머는 RemoveOnCancelPolicy 사용, future 즉시 제거
- @PreDestroy에서 future 취소와 맵 비우기로 종료시 릭 방지
로깅과 관측성
- 슬림 스냅샷 로그
- rid 수, item 수, index 크기, 스케줄러 큐 길이
- 감사 로그
- 선점 시 1회, 실패 확정과 만료 시 1회
- StringBuilder.isEmpty, auditLock으로 짧은 임계 구역
- 에러 로그
- BasiliumCustomException으로 표준화
- log.error는 예외와 키 정보를 포함
품질 규약 준수
- StringBuilder.isEmpty로 버퍼 빈값 체크
- 메서드 파라미터 동기화 금지, 전용 auditLock 사용
- Optional.ofNullable(...).map(...).orElseGet(...)로 null 비교 제거
- 반환 컬렉션은 List.copyOf나 List.of로 불변 보장
- ApiResponse 일관 응답, 글로벌 예외 핸들러
- Lazy Loading은 EntityGraph 사용으로 N+1 제거
검증 시나리오
- 옵션 미존재 케이스
- 마지막 아이템 옵션 미존재로 BasiliumCustomException
- DB 재고 변화 없음, SSE 미발행, /reservations/{rid}는 곧바로 INACTIVE, index 크기는 0
- 동일 옵션 중복 케이스
- 입력은 (1,M,BLACK,3) x2
- 병합 후 (1,M,BLACK,6)으로 단 1회 차감
- 실패 확정이나 만료 시 6을 정확히 1회 복구
- 배치 성공 케이스
- 전체 커밋
- afterCommit에서 addReservation 멱등 보강, SSE 발행
- finalizeByRid(true)에서 키만 제거
리스크와 완화
- 컨슈머 재시도와 컨트롤러 재요청 레이스
- catch 즉시 cancelAllNoRestore와 afterCompletion 방어로 stale 키 윈도우 최소화
- 컨트롤러는 기존 시그니처 선점 여부를 먼저 확인하므로 재발행 중복 차단
- 대량 배치
- @batchsize, 인덱스 설계, 병합으로 DB 라운드트립과 IO 비용 절감
결론
- 동일 옵션 중복 입력은 병합 처리로 단 1회 차감과 정확한 복구가 이루어진다
- 옵션 미존재 등 예외는 배치 트랜잭션 전체 롤백과 즉시 예약 정리로 중복 차감 없이 안정적으로 처리된다
- 시그니처 바인딩 시점 교정과 멱등 예약 보강으로 레이스와 잔존 키 문제를 제거했다
- 로깅, 예외, 컬렉션, 동시성, 메모리 관리가 일관 규약으로 정리되어 운영 안정성과 관측 가능성이 크게 향상되었다
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
Projects
Status
In progress