kotlin

선착순 쿠폰 서비스에서 데이터 정합성 개선하기

DEV_GOLF 2023. 6. 15. 01:36
반응형

이번 포스팅은 인프런 강의를 보고 작성한 포스팅입니다. https://www.inflearn.com/course/%EC%84%A0%EC%B0%A9%EC%88%9C-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%8B%A4%EC%8A%B5/dashboard

 

실습으로 배우는 선착순 이벤트 시스템 - 인프런 | 강의

선착순 이벤트 시스템을 구현할 때 어떤 문제가 발생할 수 있고 어떻게 해결할 수 있는지 배워봅니다., - 강의 소개 | 인프런

www.inflearn.com

 

쿠폰 서비스 개발하기

100개의 쿠폰을 선착순으로 개발할 예정이고 1000명이 해당 쿠폰을 사려고 대기하고 있습니다. 다음 상황을 구현해봅시다. 

@Entity
class Coupon(
    var userId: Long
) {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null
}
@Transactional
fun apply(userId: Long) {
    val count: Long = couponRepository.count()

    couponCountCheck(count)

    couponRepository.save(Coupon(userId))
}

private fun couponCountCheck(count: Long) {
    if (count > 100) {
        throw IllegalArgumentException("이미 쿠폰 수량이 모두 소진 되었습니다.")
    }
}

간단한 쿠폰 발급 하는 서비스를 개발했습니다. 코드를 읽어보면 발급 된 티켓이 총 100개가 넘어가면 Exception이 발생하여 더 이상 발급할 수 없게 됩니다. 

 

그럼 테스트를 해봐야겠죠? 테스트 코드를 작성해보겠습니다. 

@SpringBootTest
internal class CouponServiceTest

@Autowired
constructor(
    private val couponService: CouponService,
    private val couponRepository: CouponRepository,
) {

    @Test
    @DisplayName("쿠폰을 사용자가 구매한다.")
    fun apply() {
        couponService.apply(1L)

        val count: Long = couponRepository.count()

        assertThat(count).isEqualTo(1L)
    }
}

 

단일 요청에 대한 발급이 되는지에 대한 테스트 입니다. Test 환경은 간단하게 MySQL DB를 사용했습니다. 

테스트 코드 실행 시 성공하는 것을 볼 수 있습니다. 

 

그럼 좀 더 상황을 구체적으로 부여해보죠 1000명의 가상 유저가 동시에 api를 요청합니다. 그리고 그 중에 900명은 실패하고 100명은 성공해야합니다. 

 

테스트 코드를 봅시다.

@Test
@DisplayName("여러 사용자가 쿠폰을 구매한다.")
fun applyManyPeople() {
    val threadCount = 1000
    val executorService = Executors.newFixedThreadPool(32)
    val latch = CountDownLatch(threadCount)

    for (i in 1 .. threadCount) {
        executorService.execute {
            try {
                couponService.apply(i.toLong())
            } finally {
                latch.countDown()
            }
        }
    }

    latch.await()

    val count: Long = couponRepository.count()

    assertThat(count).isEqualTo(100)
}

1000개의 가상 유저가 있고 32개의 Thread가 접근하여 쿠폰발급 요청을 할 것입니다. 그리고 멀티 스레드기 때문에 이 요청은 매우 빠르게 처리됩니다. 그럼 결과는 100개만 발급이 되어야합니다. 

 

결과를 봅시다.

100개의 쿠폰만 생성되야 했지만 10개가 더 생성 되었습니다. 10개가 더 발급 되면서 괜히 플랫폼 입장에선 쿠폰을 줬다 뺏어야하는 상황이 생깁니다. 또한 고객 입장에선 쿠폰을 빼앗기는 안좋은 경험을 하게 됩니다. 

무엇이 문제인가?

현재 테스트 상황은 Multi-Thread 환경입니다. Multi-Thread환경에서는 여러 Thread들이 경합을 할 수 있으며 이 과정에서 공유자원의 데이터가 업데이트 된 내용이 반영 되기 전에 다른 Thread가 데이터를 읽어와 실제 100개 보다 더 많은 데이터가 저장될 수 있습니다. 

 

이에 대한 자세한 내용은 다음을 참고해보시기 바랍니다.

https://golf-dev.tistory.com/46

 

[Thread chapter.02] Thread Concurrency & Parallel Programming

최근 회사에서 작업을 하면서 문제가 생긴 이력이 있다. 발생한 이슈는 50000개 이상의 데이터를 고객에게 전달해줘야하는 배치 프로그램에서 약 400 ~ 500개의 데이터가 손실된 것이었다. 재빨리

golf-dev.tistory.com

 

해결 방법 - Redis INCR 이용하기

redis는 연산의 원자성을 보장해줍니다. increment를 하더라도 Single-Thread라는 특성 때문에 연산을 하더라도 다른 Thread들은 wait Queue에서 대기할 수 밖에 없습니다. 또한 Redis는 Single-Thread 라도 Netty 기반의 multiplexing I/O를 지원하기 때문에 초당 10만 요청처리가 가능합니다. 

 

자세한 글은 다음을 참고 하시기 바랍니다.

https://golf-dev.tistory.com/74

 

Redis가 Atomic한 연산을 할 수 있는 이유는 뭘까?

https://golf-dev.tistory.com/53 Redis INCR을 이용한 분산환경에서의 동시성 제어하기 문제 상황 회사에서 분산환경에서 하루에 한 번만 요청이 가능한 기능이었지만 한 사람이 3번 이상 요청을 보낸 기록

golf-dev.tistory.com

이러한 특성을 이용하여 동시성을 해결할 수 있습니다. 그럼 코드를 개선해봅시다.

@Repository
class CouponCountRepository(
    private val redisTemplate: RedisTemplate<String, String>
) {

    fun increment(userId: Long): Long? {
        return redisTemplate.opsForValue().increment("COUPON")
    }
}
@Transactional
fun applyVer2(userId: Long) {
    val count: Long = couponCountRepository.increment(userId)
        ?: throw IllegalArgumentException("쿠폰 발급 수량 정보를 가져오는데 실패했습니다.")

    couponCountCheck(count)

    couponRepository.save(Coupon(userId))
}

private fun couponCountCheck(count: Long) {
    if (count > 100) {
        throw IllegalArgumentException("이미 쿠폰 수량이 모두 소진 되었습니다.")
    }
}

 

자 이제 테스트를 해보겠습니다. 

Atomic한 연산을 통해 데이터 정합성이 깨졌던 기존 코드가 해결된 모습을 볼 수 있습니다. 실제 Redis-CLI를 살펴보면 

1000개가 Redis에 정확히 저장되어있는 것을 볼 수 있다. Race condition이 발생하지 않았다는 것을 뜻합니다.

 

그럼 정말 모든 것이 해결된 것일까?

그렇진 않다. 현재 상황을 되짚어 봅시다. 가상 유저 1000명의 요청만을 처리했다. 또한 이건 테스트 코드를 이용한 요청일 뿐이었고 실제로 네트워크를 타는 상황도 아닙니다.

만약 실제 운영 상황이고 DAU가 40만 이상의 서비스 쿠폰 발급을 최소 1만 5천개 정도를 해줘야한다고 생각해보면 이는 DB에도 큰 부담을 줄 것이고 Redis에도 부담이 될 수 있는 상황입니다. 

(실제로 필자는 티켓팅을 하던 도중에 해당 서비스가 죽어버려서 티켓을 결국 못사고 불만이 생겼던 경험이 있습니다.) 

 

또한 Redis가 처리량이 빠른 만큼 더 빠르게 DB까지 많은 thread가 동시에 접근할 것이고 이는 DB connection Pool도 고민해 봐야합니다. 

 

이 상황을 어떻게 해결할 수 있을지에 대해선 다음 포스팅에 추가적으로 쓸 예정입니다. 

 

마침.