내일배움캠프 프로젝트

BuySell - 선착순 쿠폰 발급기능 (3)

공부처음하는사람 2024. 3. 24. 15:17

전에 고민했던 발급 과정을 변경하기로 했다.

미리 생성해 둔 쿠폰을 발급하는 방법이 아닌, 쿠폰 발급 요청이 들어오면 생성하고 발급하는 방법으로 변경했다.

쿠폰을 생성 후 발급하는 방법은 쿠폰이 필요한 시점에서 생성이 되기 때문에 불필요한 쿠폰을 생성하는 것을 방지할 수 있고,

현재 제약사항에 쿠폰의 만료기한 기능이 있기때문에 발급시점에서 관리를 하게 된다면 조금 더 유연하게 쿠폰을 관리할 수 있다고 생각했다.

그리고 이미 Redis를 적용해 동시성 제어를 사용하려했기 때문에, 생성과 발급을 동시에 처리하는게 프로젝트에서

추구하는 방향성이 맞다고 생각한다.

Redis를 사용한 동시성 이슈를 학습했던 내용을 적용시켜서 코드를 작성해봤다.

RedisCouponRepository

@Repository
class RedisCouponRepository(
    private val redisTemplate: RedisTemplate<String, String>
) {
    fun increment() : Long {
        return redisTemplate
            .opsForValue()
            .increment("coupon_count")
            ?: throw IllegalStateException("Failed to increment")
    }
}

그리고 한가지 유저 1명당 1개의 쿠폰을 발급받기 위해 Redis Set collection을 적용해서 중복 응모를 막았다.

AppliedUserRepository

@Repository
class AppliedUserRepository(
    private val redisTemplate: RedisTemplate<String, String>
) {
    fun add(userId: Int): Long {
        return redisTemplate
            .opsForSet()
            .add("applied_user", userId.toString())
            ?: 0
    }
}

간단한 방법으로라면 유니크 키를 걸어서 1개만 생성하도록 해도 되지만 한 유저가 다른 쿠폰을 여러개 가질 수 있게되면 올바른 방법은

아니다. 락을 거는 방법은 락이 풀릴때 까지 로직에 접근하지 못하게 될테니 성능저하가 발생할 수 있다..

이 repository를 적용하면 SET에 존재하지 않는다면 쿠폰을 발급하게 되고, 존재한다면 발급하지 않게 된다.

CouponUtility

@Component
class CouponUtility {

    fun createCouponNumber(): String {
        val length = 4
        val charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"

        val coupon1depth = (1..length).map {charset.random()}.joinToString("")
        val coupon2depth = (1..length).map {charset.random()}.joinToString("")
        val coupon3depth = (1..length).map {charset.random()}.joinToString("")
        val coupon4depth = (1..length).map {charset.random()}.joinToString("")

        val coupon = coupon1depth + coupon2depth + coupon3depth +coupon4depth

        return coupon
    }
}

쿠폰은 숫자 + 대문자 영문으로 16자리로 생성한다.

Service

@Service
class CouponServiceImpl(
    private val couponRepository: CouponRepository,
    private val memberRepository: MemberRepository,
    private val couponUtility: CouponUtility,
    private val appliedUserRepository: AppliedUserRepository,
    private val redisCouponRepository: RedisCouponRepository
) : CouponService {
    @Transactional
    override fun createCoupon(request: CreateCouponRequest, memberId: Int): MessageResponse {
        val member = memberRepository.findByIdOrNull(memberId)
            ?: throw IllegalArgumentException("Invalid member")
        val apply = appliedUserRepository.add(memberId)

        if (apply != 1L) {
            return MessageResponse("계정당 1회만 참여 가능합니다.")
        }

        val count = redisCouponRepository.increment()

        if (count > request.couponCount) {
            return MessageResponse("준비된 쿠폰이 모두 소진되었습니다.")
        }

        val couponNumber = couponUtility.createCouponNumber()
        val coupon = Coupon(
            content = request.content,
            couponNumber = couponNumber,
            couponCount = request.couponCount,
            memberId = member,
            available = true
        )
        couponRepository.save(coupon)

        return MessageResponse("쿠폰이 발급되었습니다. $couponNumber")
    }
}

멤버를 검증하고, SET에 쿠폰응모내역에 존재한다면 MessageResponse로 반환하고,
increment를 사용해 count를 증가시킨다.
request의 count보다 많은수의 count가 될 경우에 MessageResponse로 메세지를 반환한다.
Coupon Entity에 관련 내용을 수정하고, 저장한다. (발급한다)
완료 시 쿠폰이 발급되는 메세지와 CouponNumber를 return 해준다.

중복신청이 허용되는지 테스트를 해보자

    @Test
    fun 한명당_하나의쿠폰만_발급받을수_있음() {
        val threadCount = 1000
        val executorService: ExecutorService = Executors.newFixedThreadPool(32)
        val latch = CountDownLatch(threadCount)

        repeat(threadCount) { i ->
            val userId = i.toLong()
            executorService.submit {
                couponService.createCoupon(CreateCouponRequest(
                    50,
                    "무료배송 쿠폰",
                    20250101),
                    1)
                latch.countDown()
            }
        }
        latch.await()
        val count = couponRepository.count()

        assertThat(count).isEqualTo(1)
    }

테스트 성공

쿠폰이 1개만 생성되는 것을 확인할 수 있다.

중복 신청에 대한 문제는 해결 했으니 이제 동시성 이슈가 해결이 되었는지 테스트를 해보겠다.

    @Test
    fun 응모를_여러번_시도() {
        val threadCount = 1000
        val executorService: ExecutorService = Executors.newFixedThreadPool(32)
        val latch = CountDownLatch(threadCount)

        repeat(threadCount) { i ->
            val userId = i.toLong()
            executorService.submit {
                couponService.createCoupon(CreateCouponRequest(20,"dd",5), 1)
                latch.countDown()
            }
        }
        latch.await()
        val count = couponRepository.count()

        assertThat(count).isEqualTo(20)
    }

테스트 성공

 

20개의 쿠폰만 발급받을 수 있고, 1000번의 요청을 시도해본 결과 성공했다.

 

테스트 케이스는 모두 성공을 했다.

 

 

이제 nGrinder에서 부하테스트를 해야하는데 테스트 스크립트를 작성하는게 보통일이 아닌것 같다.

이제 고작 4개월 남짓 코틀린으로 개발을 해왔는데.. 테스트 스크립트를 도저히 못짜겠어서 내일 튜터님께 도움좀 받아야겠다.

어떤 의존성이 추가되어야하는지 어떤식으로 작성을 해야하는지 글을 찾아봐도 이해가 안간다ㅠㅠㅠㅠㅠㅠ 너무어렵다진짜