ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • RedisTemplate으로 Set 자료구조 사용하기
    프로젝트/선착순 쿠폰 발급 시스템 2023. 6. 9. 00:01
    728x90

    개요

    선착순 쿠폰 발급을 위해 이벤트 쿠폰을 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개의 쿠폰이 할당되었는지 확인하였습니다. 

     

    댓글

Designed by Tistory.