fix(charge): 쿠폰 충전 회원 락을 적용한다
This commit is contained in:
50
docs/plan-task/20260518_쿠폰충전회원락보강.md
Normal file
50
docs/plan-task/20260518_쿠폰충전회원락보강.md
Normal file
@@ -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` 통과를 확인했다.
|
||||||
69
docs/prd/20260518_쿠폰충전회원락보강_prd.md
Normal file
69
docs/prd/20260518_쿠폰충전회원락보강_prd.md
Normal file
@@ -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`
|
||||||
@@ -188,9 +188,11 @@ class ChargeService(
|
|||||||
|
|
||||||
when (coupon.couponType) {
|
when (coupon.couponType) {
|
||||||
CouponType.CAN -> {
|
CouponType.CAN -> {
|
||||||
|
val lockedMember = memberRepository.findByIdForUpdate(member.id!!)
|
||||||
|
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
val couponCharge = Charge(0, coupon.can, status = ChargeStatus.COUPON)
|
val couponCharge = Charge(0, coupon.can, status = ChargeStatus.COUPON)
|
||||||
couponCharge.title = formatMessage("can.charge.title", coupon.can)
|
couponCharge.title = formatMessage("can.charge.title", coupon.can)
|
||||||
couponCharge.member = member
|
couponCharge.member = lockedMember
|
||||||
|
|
||||||
val payment = Payment(
|
val payment = Payment(
|
||||||
status = PaymentStatus.COMPLETE,
|
status = PaymentStatus.COMPLETE,
|
||||||
@@ -200,7 +202,7 @@ class ChargeService(
|
|||||||
couponCharge.payment = payment
|
couponCharge.payment = payment
|
||||||
chargeRepository.save(couponCharge)
|
chargeRepository.save(couponCharge)
|
||||||
|
|
||||||
member.charge(0, coupon.can, "pg")
|
lockedMember.charge(0, coupon.can, "pg")
|
||||||
return formatMessage("can.coupon.use_complete", coupon.can)
|
return formatMessage("can.coupon.use_complete", coupon.can)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<kr.co.vividnext.sodalive.common.SodaException> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user