RedisTemplate으로 Set 자료구조 사용하기
개요
선착순 쿠폰 발급을 위해 이벤트 쿠폰을 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개의 쿠폰이 할당되었는지 확인하였습니다.