-
RedisTemplate으로 Set 자료구조 사용하기프로젝트/선착순 쿠폰 발급 시스템 2023. 6. 9. 00:01728x90
개요
선착순 쿠폰 발급을 위해 이벤트 쿠폰을 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/
사용자에게 쿠폰 발급
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/
테스트작성하기
@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개의 쿠폰이 할당되었는지 확인하였습니다.
'프로젝트 > 선착순 쿠폰 발급 시스템' 카테고리의 다른 글
Redis는 싱글스레드인가? (2) 2023.06.19 선착순 쿠폰 발급 시스템 성능테스트 (0) 2023.06.18 Spring Event 사용하기 (0) 2023.05.30 Kotlin JPA Update Query 작성하기 (0) 2023.05.28 Kotlin JPA 양방향 연관관계 매핑 (0) 2023.05.25