프로젝트/선착순 쿠폰 발급 시스템

RedisTemplate으로 Set 자료구조 사용하기

Junuuu 2023. 6. 9. 00:01
반응형

개요

선착순 쿠폰 발급을 위해 이벤트 쿠폰을 Set에 저장하고 사용자에게 발급할 때는 pop 하여서 발급해 주자

 

RedisTemplate 등록

@Bean
    fun redisTemplate(): RedisTemplate<String, CouponRedisEntity> {
        val objectMapper = ObjectMapper()
        objectMapper.registerModule(JavaTimeModule()) // Java 8 시간 타입 처리 모듈 등록
        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) // 날짜 포맷 설정

        val serializer = Jackson2JsonRedisSerializer(CouponRedisEntity::class.java)
        serializer.setObjectMapper(objectMapper)

        val redisTemplate = RedisTemplate<String, CouponRedisEntity>()
        redisTemplate.valueSerializer = serializer
        redisTemplate.setConnectionFactory(redisConnectionFactory())
        return redisTemplate
    }

이때 LocalDate를 CouponRedisEntity에서 다루고 있었습니다.

이에 따라 jsr310 의존성을 추가해 주고 직렬화를 따로 관리해 주었습니다.

 

jsr310 의존성

implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")

 

Redis에 EventCoupon적재

override fun saveEventCoupon(coupon: Coupon): Coupon {
        val opsForSet: SetOperations<String, CouponRedisEntity> = redisTemplate.opsForSet()
        opsForSet.add(coupon.name, CouponRedisEntity.toJpaEntity(coupon))
        return coupon
    }

event를 진행하는 coupon의 이름을 Set의 key로 갖고 coupon을 그대로 value로 저장해 줍니다.

 

opsForSet.add는 Redis의 SADD와 동일한 역할을 수행합니다.

https://redis.io/commands/sadd/

 

SADD

Adds one or more members to a set. Creates the key if it doesn't exist.

redis.io

 

사용자에게 쿠폰 발급

override fun saveCoupon(
        eventName: String,
        memberId: String,
    ): Coupon {
        val opsForSet: SetOperations<String, CouponRedisEntity> = redisTemplate.opsForSet()
        if (!redisTemplate.hasKey(eventName)) {
            println("$eventName 을 가진 redis set이 존재하지 않습니다.")
            throw NoSuchElementException("$eventName 을 가진 redis set이 존재하지 않습니다.")
        }
        val couponRedisEntity = opsForSet.pop(eventName) ?: throw NoSuchElementException("이벤트가 만료되었습니다.")
        val memberCouponsJpaEntity = couponRedisEntity.toMemberCouponsJpaEntity(memberId)
        memberCouponsJpaRepository.save(memberCouponsJpaEntity)
        return couponRedisEntity.toDomainEntity()
    }

1차적으로 eventName을 가진 set이 존재하는지 찾고 없다면 예외를 발생시킵니다.

2차적으로는 해당 set에서 쿠폰을 pop해오는데 더 이상 쿠폰이 존재하지 않는다면 선착순 쿠폰이 마감되었으므로 NoSuchElementException이 발생합니다.

 

만약 위의 조건에 걸리지 않는다면 사용자에게 쿠폰을 발급합니다.

 

opsforSet.pop은 Redis의 SPOP과 동일한 역할을 수행합니다.

https://redis.io/commands/spop/

 

SPOP

Returns one or more random members from a set after removing them. Deletes the set if the last member was popped.

redis.io

 

테스트작성하기

@SpringBootTest
@Transactional
@ActiveProfiles("local")
class CouponPersistenceAdapterTest @Autowired constructor(
    private val redisTemplate: RedisTemplate<String, CouponRedisEntity>,
    private val couponPersistenceAdapter: CouponPersistenceAdapter,
    private val memberJpaRepository: MemberJpaRepository,
    private val memberCouponsJpaRepository: MemberCouponsJpaRepository,
) {

    private val opsForSet: SetOperations<String, CouponRedisEntity> = redisTemplate.opsForSet()
    private val couponName = "test"

    @AfterEach
    fun cleanUp() {
        println("redis cleaning start")
        redisTemplate.connectionFactory!!.connection.flushAll()
    }

    @Test
    fun `100개의 event 쿠폰이 Multi-Thread 환경에서 동시성문제없이 잘 소모되어야 한다`() {
        //given
        val couponName = 쿠폰_N개를_미리_세팅해둔다(100)
        val memberId = 회원을_미리_세팅해둔다()

        //when
        runBlocking {
            GlobalScope.massiveUpdate {
                couponPersistenceAdapter.saveCoupon(couponName, memberId)
            }
        }

        //then
        val result = memberCouponsJpaRepository.findByMemberId(memberId)
        Assertions.assertEquals(result.size, 100)

    }

    suspend fun CoroutineScope.massiveUpdate(action: suspend () -> Unit) {
        val n = 10  // number of coroutines to launch
        val k = 100 // times an action is repeated by each coroutine
        val time = measureTimeMillis {
            val jobs = List(n) {
                launch {
                    repeat(k) { action() }
                }
            }
            jobs.forEach { it.join() }
        }
        println("Completed ${n * k} actions in $time ms")
    }

    @Test
    fun `10개의 event 쿠폰 존재할때 쿠폰이 한개 소모되면 9개가 남아있어야 한다`() {
        //given
        val couponName = 쿠폰_N개를_미리_세팅해둔다(10)
        val memberId = 회원을_미리_세팅해둔다()

        //when
        couponPersistenceAdapter.saveCoupon(couponName, memberId)
        val result = opsForSet.size(couponName)

        //then
        Assertions.assertEquals(result, 9)
    }

    @Test
    fun `1개의 event 쿠폰이 저장되어 있을때 2번째 요청은 NoSuchElementException이 발생한다`() {
        //given
        val couponName = 쿠폰_N개를_미리_세팅해둔다(1)
        val memberId = 회원을_미리_세팅해둔다()

        //when
        couponPersistenceAdapter.saveCoupon(couponName, memberId)

        //then
        Assertions.assertThrows(NoSuchElementException::class.java) {
            couponPersistenceAdapter.saveCoupon(couponName, memberId)
        }
    }

    private fun 쿠폰_N개를_미리_세팅해둔다(couponNumber: Int): String {
        val coupon = Coupon(
            grade = CouponGrade.NORMAL,
            expiredAt = LocalDate.MAX,
            name = couponName,
        )

        repeat(couponNumber) { opsForSet.add(coupon.name, CouponRedisEntity.toJpaEntity(coupon)) }
        return couponName
    }

    private fun 회원을_미리_세팅해둔다(): String {
        val memberId = "testId"
        val memberJpaEntity = MemberJpaEntity(
            memberId = memberId,
            password = "test",
            nickName = "test",
            fullName = "test",
        )
        memberJpaRepository.save(memberJpaEntity)
        return memberId
    }

}

간단하게 N개의 쿠폰과 회원을 세팅해 둔 후, 정상적으로 쿠폰이 발급되는지, 쿠폰이 없을 때는 예외가 발생하는지를 테스트하였습니다.

또한 코루틴을 통해 1000번의 쿠폰발급 요청 후, memberId로 정확히 100개의 쿠폰이 할당되었는지 확인하였습니다.