fix(charge): 쿠폰 충전 회원 락을 적용한다

This commit is contained in:
2026-05-18 15:39:11 +09:00
parent fefb5c24eb
commit ddac78a666
4 changed files with 289 additions and 2 deletions

View 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` 통과를 확인했다.

View 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`

View File

@@ -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)
} }

View File

@@ -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 }
}
}