최종프로젝트 BuySell의 선착순 쿠폰발급 기능구현을 위한 연습을 해보기로 했다.
요구사항
선착순 100명에게 쿠폰을 지급하는 이벤트이다.
101개 이상 지급되면 안된다.
순간적으로 몰린 트래픽으로 인해 다른 페이지의 성능에 지장을 주면 안된다.
Entity
@Entity
class Coupon(
val userId: Long,
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
}
Repository
interface CouponRepository: JpaRepository<Coupon, Long>
Service
@Service
class ApplyService(
private val couponRepository: CouponRepository
) {
fun apply(userId: Long) {
val count = couponRepository.count()
if (count > 100) {
return
}
couponRepository.save(Coupon(userId))
}
}
그럼 테스트코드를 작성해본다.
@SpringBootTest
class ApplyServiceTest {
@Autowired
private lateinit var applyService: ApplyService
@Autowired
private lateinit var couponRepository: CouponRepository
@Test
fun 응모한번() {
applyService.apply(1L)
val count = couponRepository.count()
assertThat(count).isEqualTo(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 {
applyService.apply(userId)
latch.countDown()
}
}
latch.await()
val count = couponRepository.count()
assertThat(count).isEqualTo(100)
}
}
여러번 응모했을 때의 결과값은 어떨까?
100개가 생성되어야 하는데 121개가 생성되었다. 이유가 무엇일까?
Race Condition이 발생했기 때문이다.
Race Condition이란
두개 이상의 쓰레드가 공유 데이터에 access를 하고 동시에 작업하려고 할 때 생기는 문제다.
coupon count가 99일 경우, 쓰레드 1이 생성된 쿠폰의 개수를 가져가고, 아직 100개가 아니므로 쿠폰을 생성하게 되고
쓰레드2가 100개째 생성된 쿠폰의 개수를 가져갔을 때 100개가 되었으므로 쿠폰을 생성하지 않을것이라 예상했다.
하지만 실제로 쓰레드 1이 생성된 쿠폰의 개수를 가져가고 쿠폰을 생성하기 전에, 쓰레드 2가 생성된 쿠폰의 개수를 가져가게 된다.
쓰레드2가 가져가는 쿠폰의 개수도 99개이므로 쿠폰을 생성하게 된다.
이렇게 되면 쓰레드1이 100개째 쿠폰을 생성했을 때, 쓰레드2는 101번째 쿠폰을 생성하게 되는것이다.
Race Condition을 해결하는 방법 중 Redis를 이용해 해결해보도록 하자
일단 기록용 레디스 이미지 다운 - 접속 명령어
docker pull redis
docker run --name myredis -d -p 6379:6379 redis
정상적으로 실행되었는지 docker ps로 확인 후
build.gradle에 redis 의존성 추가
Redis를 시작하기에 앞서, 레이스 컨디션은 2개 이상의 쓰레드에서 작업 할 시 발생하는 문제이므로 싱글쓰레드에서 작업하게 된다면
레이스 컨디션은 발생하지 않을 것이다. 그러나 싱글쓰레드를 사용한다면 당연히 성능이 좋지 않을 것이다.
그 이유는 먼저 요청한 사람의 쿠폰을 발급 후 그 다음사람의 요청을 처리하기 때문이다.
10:01분에 발급요청 -> 10:02분에 발급완료가 된다고 하면, 그 다음 발급요청건은 10시 2분 이후부터 발급이 가능하게 된다.
레이스 컨디션을 해결하기 위해 Synchronized 기능을 생각해 볼 수 있으나 서버가 여러대가 되는 경우에 다시 레이스 컨디션이 발생하게 된다.
또 다른 방법으로 SQL, Redis를 활용한 락을 구현해서 해결할 수도 있다. 하지만 우리가 원하는 건 쿠폰의 개수에 대한 정합성인데
락을 걸어버리면 쿠폰의 개수를 가져오고, 생성하는 과정까지 락을 걸어야한다.
이렇게 되면 락을 거는 구간이 길어져서 성능저하 우려가 있다.
핵심은 쿠폰 개수이기 때문에, 쿠폰 개수에 대한 정합성만 관리하면 될 것이다.
Redis에는 increment 라는 명령어가 있다. 이 명령어는 키에 대한 밸류를 1씩 증가시키는 명령어이다.
Redis는 싱글쓰레드 기반으로 동작하기 때문에 레이스 컨디션을 해결할 수 있고,incr 명령어의 성능도 빠른 편이라
이 명령어를 사용하면 빠르고 정확하게 쿠폰 개수에 대한 정합성을 지킬 수 있을것이다.
Redis 명령어를 실행할 Repository 작성
@Repository
class CouponCountRepository(val redisTemplate: RedisTemplate<String, String>) {
fun increment(): Long {
return redisTemplate
.opsForValue()
.increment("coupon_count")
?: throw IllegalStateException("Failed to increment coupon count")
}
}
Service에 CouponCountRepository 추가 및 메서드 수정
@Service
class ApplyService(
private val couponRepository: CouponRepository,
private val couponCountRepository: CouponCountRepository
) {
fun apply(userId: Long) {
val count = couponCountRepository.increment()
if (count > 100) {
return
}
couponRepository.save(Coupon(userId))
}
}
수정 후 다시 테스트 케이스 실행
테스트 성공
Redis는 싱글쓰레드 기반으로 동작하므로, 쓰레드1이 작업을 완료하기 전 까지 쓰레드2는 대기상태가 된다.
이렇게 되면 쓰레드2는 항상 최신의 값을 가져갈 수 있기 때문에 100개보다 많은 쿠폰을 생성할 수 없게 된다.
이후에 kafka를 이용해서 성능개선을 하는 부분은 일단 생략하기로 했다.
프로젝트에서 kafka를 사용하면 성능적으로 분명히 우수하겠지만 이해하지 못하고 사용하는건 기술 채택의 사유가 아니라고 생각한다.
'SpringBoot' 카테고리의 다른 글
Maven 이란? (0) | 2024.08.28 |
---|---|
Path Variable과 Request Param (0) | 2024.08.06 |
Oauth2.0 소셜로그인 구현 (0) | 2024.02.17 |
Spring 입문 - DI (의존성 주입), IoC (제어의 역전), Bean (0) | 2023.12.26 |