Home 레디스를 사용해 선착순 요청 처리하기
Post
Cancel

레디스를 사용해 선착순 요청 처리하기

개인적으로 Redis를 잘 사용하게 되면 서비스의 읽기 성능을 개선할 수 있을 것 같아서 공부를 하던 중,

선착순 이벤트와 같은 경우 Redis의 분산락을 활용해서 처리하는 것이 가능하다는 생각이 들어 공유하게 된 게시글입니다.

분산락

예를 들어 100장의 쿠폰이 선착순으로 특정 시간에 구매할 수 있다고 했을 때, 100명 이상이 동시에 구매를 요청하게 되었을 때 마주하는 동시성 문제를 해결할 수 있도록 로직을 구현해야합니다.

그래서 저는 분산락의 개념을 활용한다면 위 문제를 해결할 수 있을 것이라고 생각했습니다.

위와 같이 여러 요청이 하나의 자원을 공유할 때, 각 분산 DB의 동기화가 여러 요청의 동기화 속도를 못 따라가는 상황이 발생합니다.

이 때문에 데이터 정합성을 보장받지 못하게 되고, 결국 데이터 동시성 문제가 발생하는 것입니다.

분산락설명1

여러 요청이 하나의 자원을 공유하는 경우, 위 예시에서는 수량이 공유 자원이 됩니다. 수량이라는 자원을 동시에 사용할 경우 여러 수량이 커밋되거나 롤백되는 수량의 동기화가 다른 서버가 따라가지 못해서 정합성이 깨지는 것입니다.

그래서 저희는 락이라는 것을 이용해야 합니다.

락을 사용하면 동시에 여러 요청이 들어와도, 한 요청이 자원을 사용중일 때에는 다른 요청은 사용하지 못하게 됩니다.

그래서 저는 이러한 문제를 레디스를 사용해서 해결해보고자 합니다.

분산락설명2

위처럼 공유 자원을 레디스에 올려놓고, 모든 요청에 대해서 자원을 레디스를 통해서 사용하도록 하는 것입니다.

그리고 레디스에서 지원하는 분산락을 활용한다면 경쟁 상태에서 마주하는 동시성 문제를 해결할 수 있습니다.

AS-IS: 락을 사용하지 않을 경우 동시성 문제 발생

락을 사용하지 않을때 동시성 문제가 어떻게 발생하는지 살펴보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Test
fun test() {
    val people = 100
    val count = 2
    val initialTotalCoupons = couponService.getTotalCoupons()
    val soldOut = AtomicInteger(0)
    val countDownLatch = CountDownLatch(people)

    val executor = Executors.newCachedThreadPool()

    for (i in 0 until people) {
        executor.submit {
            if (couponService.buyCoupon(count)) {
                soldOut.addAndGet(count)
            }
            countDownLatch.countDown()
        }
    }

    countDownLatch.await()
    executor.shutdown()

    val totalSoldOut = soldOut.get()
    val remainingCoupons = couponService.getTotalCoupons()
    assertEquals(initialTotalCoupons, totalSoldOut + remainingCoupons, "Total coupons do not match")
}

150장의 쿠폰을 100명의 사용자가 인당 2장씩 구매하는 상황을 구현한 테스트 코드입니다.

만약 150장이 다 판매되면 판매가 중지되어야하지만, 테스트는 실패합니다.

동시성 문제가 발생했기 때문입니다.

분산락 사용하기

이제, 레디스의 분산락을 사용해서 문제를 해결해봅시다.

저는 JWT 토큰 중, Refresh Token을 관리하기 위해 Redis를 사용했던 적이 있습니다.

이때 사용했던 Lettuce라는 Redis Client를 사용해서 락을 통해 해결해보려고 했지만, 결국 Redisson을 사용하게 되었습니다.

그 이유는 Lettuce의 경우는 setnx 메서드를 이용해 사용자가 직접 스핀 락 형태로 구현하게 됩니다.

락 점유 시도를 실패했을 때, 계속 락을 점유하려는 시도가 요청되고, 이로 인해 레디스는 계속 부하를 받게 되며 응답 시간이 지연되게 됩니다.

추가적으로 만료 시간을 제공하고 있지 않기 때문에, 락을 점유한 서버가 장애가 생길 경우 다른 서버들도 해당 락을 점유할 수 없는 상황이 발생합니다.

그리고 Redisson은 분산 락을 지원하며, Redis 공식 홈페이지에서 Redisson의 분산 락을 다음과 같이 설명하고 있습니다.

Distributed locks are a very useful primitive in many environments where different processes must operate with shared resources in a mutually exclusive way.

분산 락은 서로 다른 프로세스가 상호 배타적인 방식으로 공유 리소스를 사용하여 작동해야 하는 많은 환경에서 매우 유용한 기본 요소이다.

또한, RedissonLock 클래스의 tryLockInnerAsync() 메서드를 보면, 아래와 같이 되어있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
        return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
                "if (redis.call('exists', KEYS[1]) == 0) then " +
                        "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        "return nil; " +
                        "end; " +
                        "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                        "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        "return nil; " +
                        "end; " +
                        "return redis.call('pttl', KEYS[1]);",
                Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
	}
  • Lua Script를 사용해서 자체 TTL을 적용하는 것을 확인할 수 있습니다.
  • hincrby 명령어는 해당 field가 없으면 increment 값을 설정합니다.
  • pexpire 명령어는 지정된 시간(milliseconds) 후 key 자동 삭제합니다.

레퍼런스

https://github.com/redis/redis

https://redis.io/

레디스(Redis) 와 싱글스레드(Single Thread)

Redis로 분산 락을 구현해 동시성 이슈를 해결해보자!

This post is licensed under CC BY 4.0 by the author.

여러 요청에 대해 비동기적으로 처리해보기

[LeetCode] valid-palindrome