From ddac78a666ed92c54f5f4232ac734c6931811248 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 18 May 2026 15:39:11 +0900 Subject: [PATCH] =?UTF-8?q?fix(charge):=20=EC=BF=A0=ED=8F=B0=20=EC=B6=A9?= =?UTF-8?q?=EC=A0=84=20=ED=9A=8C=EC=9B=90=20=EB=9D=BD=EC=9D=84=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan-task/20260518_쿠폰충전회원락보강.md | 50 ++++++ docs/prd/20260518_쿠폰충전회원락보강_prd.md | 69 ++++++++ .../sodalive/can/charge/ChargeService.kt | 6 +- .../sodalive/can/charge/ChargeServiceTest.kt | 166 ++++++++++++++++++ 4 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 docs/plan-task/20260518_쿠폰충전회원락보강.md create mode 100644 docs/prd/20260518_쿠폰충전회원락보강_prd.md create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceTest.kt diff --git a/docs/plan-task/20260518_쿠폰충전회원락보강.md b/docs/plan-task/20260518_쿠폰충전회원락보강.md new file mode 100644 index 00000000..62582828 --- /dev/null +++ b/docs/plan-task/20260518_쿠폰충전회원락보강.md @@ -0,0 +1,50 @@ +# 쿠폰 충전 회원 락 보강 작업 계획 + +## 목적 +- 쿠폰 캔 지급 흐름에서도 일반 결제 완료 흐름과 동일하게 회원 row lock을 잡은 뒤 회원 잔액을 증가시킨다. +- 동시 충전/쿠폰 사용 상황에서 `Member.charge(...)`의 `+=` 기반 잔액 증가가 유실되지 않도록 한다. + +## 현재 확인된 문제 +- `ChargeService.chargeByCoupon(...)`은 `CouponType.CAN` 분기에서 컨트롤러/인증 계층이 전달한 `member` 인스턴스에 바로 `member.charge(0, coupon.can, "pg")`를 호출한다. +- 일반 결제 완료 경로(`payverseWebhook`, `payverseVerify`, `verify`, `verifyHecto`, `appleVerify`, `processGoogleIap`)는 `memberRepository.findByIdForUpdate(...)`로 회원 row를 잠근 뒤 `member.charge(...)`를 호출한다. +- 쿠폰 충전만 이 패턴과 달라, 같은 회원에게 쿠폰 충전과 다른 잔액 증가 작업이 동시에 실행될 경우 회원 캔 잔액 유실 가능성이 남는다. + +## 확정 범위 +- `ChargeService.chargeByCoupon(...)`의 `CouponType.CAN` 분기만 최소 수정한다. +- 쿠폰 번호 검증, 이미 사용된 쿠폰 검증, `Charge(status = COUPON)` 생성, 성공 메시지 반환 동작은 유지한다. +- 포인트 쿠폰(`CouponType.POINT`) 로직은 변경하지 않는다. +- 공개 API 요청/응답 스키마는 변경하지 않는다. +- 본 문서를 기준으로 최소 구현을 진행한다. + +## 구현 항목 +- [x] 쿠폰 캔 지급 lock 패턴 적용 + - [x] `chargeByCoupon(...)`에서 `member.id!!`를 기준으로 `memberRepository.findByIdForUpdate(...)`를 호출한다. + - [x] lock 조회로 얻은 회원 엔티티를 `couponCharge.member`에 연결한다. + - [x] lock 조회로 얻은 회원 엔티티에 `member.charge(0, coupon.can, "pg")`를 호출한다. + - [x] lock 조회 실패 시 기존 인증 실패 계열 예외 메시지 패턴을 따른다. + +- [x] 회귀 테스트 추가 + - [x] `CouponType.CAN` 쿠폰 사용 시 `memberRepository.findByIdForUpdate(...)`가 호출되는지 검증한다. + - [x] lock 조회로 얻은 회원에 쿠폰 캔이 반영되는지 검증한다. + - [x] 이미 사용된 쿠폰이면 lock 조회와 캔 지급이 진행되지 않는지 검증한다. + - [x] `CouponType.POINT` 쿠폰 동작이 변경되지 않았는지 기존 또는 신규 테스트로 확인한다. + +- [x] 최소 구현 검증 + - [x] 관련 테스트를 먼저 실행해 실패를 확인한다. + - [x] 구현 후 관련 테스트가 통과하는지 확인한다. + - [x] `./gradlew ktlintCheck`를 실행한다. + - [x] 필요 시 `./gradlew test`로 전체 회귀를 확인한다. + +## 검증 항목 +- [x] 쿠폰 캔 지급 테스트 통과 +- [x] 쿠폰 포인트 지급 기존 동작 유지 확인 +- [x] `./gradlew ktlintCheck` +- [x] `./gradlew test` + +## 검증 로그 +- [x] 문서 작성 검증: `ChargeService.chargeByCoupon(...)`의 `CouponType.CAN` 분기와 일반 결제 완료 경로의 `memberRepository.findByIdForUpdate(...)` 사용 패턴을 확인하고 작업 범위를 문서화했다. +- [x] RED 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceTest'` 실행 시 `WantedButNotInvoked`로 `memberRepository.findByIdForUpdate(10L)` 미호출 실패를 확인했다. +- [x] 구현 검증: `CouponType.CAN` 분기에서 locked member를 조회하고, `couponCharge.member`와 `member.charge(...)` 모두 locked member를 사용하도록 변경했다. +- [x] 관련 테스트 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceTest'` 통과를 확인했다. +- [x] 린트 검증: `./gradlew ktlintCheck` 통과를 확인했다. +- [x] 전체 테스트 검증: `./gradlew test` 통과를 확인했다. diff --git a/docs/prd/20260518_쿠폰충전회원락보강_prd.md b/docs/prd/20260518_쿠폰충전회원락보강_prd.md new file mode 100644 index 00000000..835975a8 --- /dev/null +++ b/docs/prd/20260518_쿠폰충전회원락보강_prd.md @@ -0,0 +1,69 @@ +# PRD: 쿠폰 충전 회원 락 보강 + +## 1. Overview +- 쿠폰 사용으로 캔을 지급하는 흐름에서도 회원 잔액 변경 전에 회원 row lock을 확보하도록 보강한다. +- 상세 구현 방향은 `docs/plan-task/20260518_쿠폰충전회원락보강.md`를 기준으로 한다. +- 이번 문서는 구현 전 요구사항과 최소 작업 범위를 확정하기 위한 신규 작업 문서다. + +--- + +## 2. Problem +- 일반 결제 완료 흐름은 `memberRepository.findByIdForUpdate(...)`로 회원 row lock을 잡은 뒤 `member.charge(...)`를 호출한다. +- 쿠폰 충전 흐름인 `ChargeService.chargeByCoupon(...)`은 컨트롤러/인증 계층에서 전달받은 `member` 인스턴스에 바로 `member.charge(...)`를 호출한다. +- 같은 회원에게 쿠폰 충전과 다른 충전/보너스 지급이 동시에 발생하면 회원 잔액 갱신의 동시성 안전성이 일반 결제 흐름과 달라질 수 있다. + +--- + +## 3. Goals +- `CouponType.CAN` 쿠폰 사용 시 회원 row lock을 잡은 엔티티에 `member.charge(...)`를 호출한다. +- 기존 쿠폰 사용 성공/실패 응답과 메시지는 변경하지 않는다. +- 쿠폰 중복 사용 방지 로직은 기존 동작을 유지한다. +- 일반 결제 완료 흐름의 락 패턴과 일관되게 동작하도록 최소 수정한다. +- 회귀 테스트로 쿠폰 캔 지급 시 `memberRepository.findByIdForUpdate(...)`가 사용되는지 검증한다. + +--- + +## 4. Non-Goals +- 쿠폰 정책, 쿠폰 타입, 쿠폰 발급/조회 로직을 변경하지 않는다. +- 포인트 쿠폰 지급 로직은 이번 범위에서 변경하지 않는다. +- `Member.charge(...)` 구현 방식과 잔액 컬럼 구조를 변경하지 않는다. +- 신규 API나 관리자 기능을 추가하지 않는다. + +--- + +## 5. Core Features + +### Feature A. 쿠폰 캔 지급 회원 row lock 적용 + +#### Requirements +- `ChargeService.chargeByCoupon(...)`의 `CouponType.CAN` 분기에서 회원 ID로 `memberRepository.findByIdForUpdate(...)`를 호출한다. +- lock 조회에 실패하면 기존 인증 실패 계열 예외 메시지 패턴을 따른다. +- `Charge`의 `member` 연결과 `member.charge(...)` 호출은 lock 조회로 얻은 회원 엔티티를 기준으로 수행한다. +- `member.charge(...)`는 기존처럼 `+=` 기반 증가 로직을 그대로 사용한다. + +#### Edge Cases +- 이미 사용된 쿠폰이면 기존처럼 `can.coupon.already_used` 예외가 우선 발생해야 한다. +- 유효하지 않은 쿠폰 번호이면 기존처럼 `can.coupon.invalid_number_contact` 예외가 발생해야 한다. +- 회원 row lock 조회 결과가 없으면 쿠폰 사용과 캔 지급이 진행되지 않아야 한다. + +--- + +## 6. Technical Constraints +- Kotlin/Spring/JPA 기존 스타일을 따른다. +- `ChargeService`의 기존 트랜잭션 경계를 유지한다. +- 공개 API 스키마와 응답 DTO를 변경하지 않는다. +- 불필요한 리팩터링 없이 쿠폰 캔 지급 경로만 최소 수정한다. + +--- + +## 7. Metrics +- 쿠폰 캔 지급 성공률 +- 쿠폰 중복 사용 차단 건수 +- 쿠폰 캔 지급 중 회원 lock 조회 실패 건수 +- 동시 충전 상황에서 회원 캔 잔액 유실 재발 여부 + +--- + +## 8. Related Documents +- `docs/plan-task/20260518_쿠폰충전회원락보강.md` +- `docs/prd/20260518_충전이벤트보너스지급안정화_prd.md` diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt index 64a57f4e..192f50bf 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt @@ -188,9 +188,11 @@ class ChargeService( when (coupon.couponType) { CouponType.CAN -> { + val lockedMember = memberRepository.findByIdForUpdate(member.id!!) + ?: throw SodaException(messageKey = "common.error.bad_credentials") val couponCharge = Charge(0, coupon.can, status = ChargeStatus.COUPON) couponCharge.title = formatMessage("can.charge.title", coupon.can) - couponCharge.member = member + couponCharge.member = lockedMember val payment = Payment( status = PaymentStatus.COMPLETE, @@ -200,7 +202,7 @@ class ChargeService( couponCharge.payment = payment chargeRepository.save(couponCharge) - member.charge(0, coupon.can, "pg") + lockedMember.charge(0, coupon.can, "pg") return formatMessage("can.coupon.use_complete", coupon.can) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceTest.kt new file mode 100644 index 00000000..8249adfd --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceTest.kt @@ -0,0 +1,166 @@ +package kr.co.vividnext.sodalive.can.charge + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.can.CanRepository +import kr.co.vividnext.sodalive.can.coupon.CanCoupon +import kr.co.vividnext.sodalive.can.coupon.CanCouponNumber +import kr.co.vividnext.sodalive.can.coupon.CanCouponNumberRepository +import kr.co.vividnext.sodalive.can.coupon.CouponType +import kr.co.vividnext.sodalive.google.GooglePlayService +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.point.MemberPoint +import kr.co.vividnext.sodalive.point.MemberPointRepository +import kr.co.vividnext.sodalive.point.PointGrantLog +import kr.co.vividnext.sodalive.point.PointGrantLogRepository +import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobService +import okhttp3.OkHttpClient +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import java.time.LocalDateTime + +class ChargeServiceTest { + private lateinit var chargeRepository: ChargeRepository + private lateinit var canRepository: CanRepository + private lateinit var memberRepository: MemberRepository + private lateinit var couponNumberRepository: CanCouponNumberRepository + private lateinit var grantLogRepository: PointGrantLogRepository + private lateinit var memberPointRepository: MemberPointRepository + private lateinit var messageSource: SodaMessageSource + private lateinit var service: ChargeService + + @BeforeEach + fun setUp() { + chargeRepository = Mockito.mock(ChargeRepository::class.java) + canRepository = Mockito.mock(CanRepository::class.java) + memberRepository = Mockito.mock(MemberRepository::class.java) + couponNumberRepository = Mockito.mock(CanCouponNumberRepository::class.java) + grantLogRepository = Mockito.mock(PointGrantLogRepository::class.java) + memberPointRepository = Mockito.mock(MemberPointRepository::class.java) + messageSource = Mockito.mock(SodaMessageSource::class.java) + + Mockito.`when`(chargeRepository.save(Mockito.any(Charge::class.java))).thenAnswer { it.arguments[0] } + Mockito.`when`(messageSource.getMessage("can.charge.title", LangContext().lang)).thenReturn("%d 캔") + Mockito.`when`(messageSource.getMessage("can.coupon.use_complete", LangContext().lang)).thenReturn("%d 캔 충전 완료") + Mockito.`when`(messageSource.getMessage("can.coupon.use_complete_point", LangContext().lang)) + .thenReturn("%d 포인트 충전 완료") + + service = ChargeService( + chargeRepository = chargeRepository, + canRepository = canRepository, + memberRepository = memberRepository, + couponNumberRepository = couponNumberRepository, + grantLogRepository = grantLogRepository, + memberPointRepository = memberPointRepository, + objectMapper = Mockito.mock(ObjectMapper::class.java), + okHttpClient = Mockito.mock(OkHttpClient::class.java), + chargeEventJobService = Mockito.mock(ChargeEventJobService::class.java), + googlePlayService = Mockito.mock(GooglePlayService::class.java), + messageSource = messageSource, + langContext = LangContext(), + bootpayApplicationId = "bootpayApplicationId", + bootpayPrivateKey = "bootpayPrivateKey", + bootpayHectoApplicationId = "bootpayHectoApplicationId", + bootpayHectoPrivateKey = "bootpayHectoPrivateKey", + appleInAppVerifySandBoxUrl = "https://sandbox.example.com", + appleInAppVerifyUrl = "https://apple.example.com", + payverseMid = "payverseMid", + payverseClientKey = "payverseClientKey", + payverseSecretKey = "payverseSecretKey", + payverseUsdMid = "payverseUsdMid", + payverseUsdClientKey = "payverseUsdClientKey", + payverseUsdSecretKey = "payverseUsdSecretKey", + payverseJpyMid = "payverseJpyMid", + payverseJpyClientKey = "payverseJpyClientKey", + payverseJpySecretKey = "payverseJpySecretKey", + payverseHost = "https://payverse.example.com", + serverEnv = "test" + ) + } + + @Test + fun shouldUseLockedMemberWhenChargingCanCoupon() { + val originalMember = member(id = 10L) + val lockedMember = member(id = 10L) + val couponNumber = canCouponNumber(couponType = CouponType.CAN, can = 300) + + Mockito.`when`(couponNumberRepository.findByCouponNumber("COUPON1234")).thenReturn(couponNumber) + Mockito.`when`(memberRepository.findByIdForUpdate(10L)).thenReturn(lockedMember) + + service.chargeByCoupon("COUPON1234", originalMember) + + Mockito.verify(memberRepository).findByIdForUpdate(10L) + assertEquals(300, lockedMember.pgRewardCan) + assertEquals(0, originalMember.pgRewardCan) + } + + @Test + fun shouldSaveCouponChargeWithLockedMember() { + val originalMember = member(id = 11L) + val lockedMember = member(id = 11L) + val couponNumber = canCouponNumber(couponType = CouponType.CAN, can = 150) + val captor = ArgumentCaptor.forClass(Charge::class.java) + + Mockito.`when`(couponNumberRepository.findByCouponNumber("COUPON5678")).thenReturn(couponNumber) + Mockito.`when`(memberRepository.findByIdForUpdate(11L)).thenReturn(lockedMember) + + service.chargeByCoupon("COUPON5678", originalMember) + + Mockito.verify(chargeRepository).save(captor.capture()) + assertEquals(lockedMember, captor.value.member) + } + + @Test + fun shouldNotLockMemberWhenCanCouponAlreadyUsed() { + val member = member(id = 12L) + val couponNumber = canCouponNumber(couponType = CouponType.CAN, can = 100).also { + it.member = member(id = 99L) + } + + Mockito.`when`(couponNumberRepository.findByCouponNumber("USED1234")).thenReturn(couponNumber) + + org.junit.jupiter.api.assertThrows { + service.chargeByCoupon("USED1234", member) + } + + Mockito.verify(memberRepository, Mockito.never()).findByIdForUpdate(Mockito.anyLong()) + } + + @Test + fun shouldKeepPointCouponBehaviorWithoutMemberLock() { + val member = member(id = 13L) + val couponNumber = canCouponNumber(couponType = CouponType.POINT, can = 500) + + Mockito.`when`(couponNumberRepository.findByCouponNumber("POINT1234")).thenReturn(couponNumber) + + val result = service.chargeByCoupon("POINT1234", member) + + assertEquals("500 포인트 충전 완료", result) + Mockito.verify(memberRepository, Mockito.never()).findByIdForUpdate(Mockito.anyLong()) + Mockito.verify(grantLogRepository).save(Mockito.any(PointGrantLog::class.java)) + Mockito.verify(memberPointRepository).save(Mockito.any(MemberPoint::class.java)) + Mockito.verify(chargeRepository, Mockito.never()).save(Mockito.any(Charge::class.java)) + } + + private fun canCouponNumber(couponType: CouponType, can: Int): CanCouponNumber { + val coupon = CanCoupon( + couponName = "테스트 쿠폰", + couponType = couponType, + can = can, + couponCount = 1, + validity = LocalDateTime.now().plusDays(1), + isActive = true, + isMultipleUse = false + ) + return CanCouponNumber("COUPON").also { it.canCoupon = coupon } + } + + private fun member(id: Long): Member { + return Member(password = "password", nickname = "member$id").also { it.id = id } + } +}