Skip to content

[BE_6] Kafka & 배치 예약 중 옵션 미존재와 동일 옵션 중복으로 인한 재고 중복 차감, 복구 불일치 이슈 (global) (domain) #187

@pjhcsols

Description

@pjhcsols

🚀 배치 예약 중 옵션 미존재와 동일 옵션 중복으로 인한 재고 중복 차감, 복구 불일치 이슈에 대한 최종 기술 문서

개요

  • 목적: /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회 차감과 정확한 복구가 이루어진다
  • 옵션 미존재 등 예외는 배치 트랜잭션 전체 롤백과 즉시 예약 정리로 중복 차감 없이 안정적으로 처리된다
  • 시그니처 바인딩 시점 교정과 멱등 예약 보강으로 레이스와 잔존 키 문제를 제거했다
  • 로깅, 예외, 컬렉션, 동시성, 메모리 관리가 일관 규약으로 정리되어 운영 안정성과 관측 가능성이 크게 향상되었다

Metadata

Metadata

Assignees

Labels

Refactor코드리팩토링feature새로운 기능 추가

Projects

Status

In progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions