Operation/System Architecture

선착순 자원을 사용하기 위한 방법! (동시성, Lock, Isolation, ...)

JaeHoney 2023. 3. 8. 13:00

만약에 특정 기업에서 선착순 50명에게 맥북을 1만원에 파는 이벤트를 한다고 하자.

 

동시성 이슈를 어떻게 처리할 수 있을까?

문제 상황

'그냥 DB에 Select하고 50명이 없을 경우 Insert하면 되지 않나??' 라고 생각하면 안된다. 다수의 스레드가 SELECT를 수행한 시점에 재고가 있었다면 모두 Insert를 수행할 것이다.

 

이러한 실시간 선착순 이벤트에서 고려해야 하는 부분은 다음과 같다.

  • 동시성을 어떻게 보장할 지 (feat. 다수의 스레드, 다수의 서버)
  • 한 번에 쏠리는 트래픽을 어떻게 처리할 지

1. Queuing(Redis)

대표적인 솔루션으로 큐잉을 적용할 수 있다.

 

Redis의 경우 분산 처리가 가능하고 자원 낭비가 적고 효율적이라서 대용량 트래픽을 효과적으로 처리할 수 있고, 싱글 스레드로 동작하기 때문에 데이터가 Atomic하게 유지되어 동시성 이슈를 해결할 수 있다.

 

재고의 경우 String으로 저장하면 되지만, 요청을 저장할 수 있는 자료구조로는 아래 세 가지가 있다.

  • List
  • Set
  • Sorted Set

List

List의 경우 삽입에서 O(1)이 소요되지만, 자신의 존재 여부나 순서를 파악하기 위해서 순차 탐색을 하게 되므로 O(n)이 소요된다

 

즉, 중복으로 쿠폰이 발행되지 않는다는 요구사항이 있으면 List를 사용하는 것은 성능을 많이 낭비하게 되기에 적절하지 않다.

Set / Sorted Set

Set과 Sorted Set은 자신의 존재 여부를 파악하는 데 O(1)이 소요되기에 List의 중복 체크에서 많은 시간이 소요되는 문제를 해결할 수 있다.

 

Set의 경우는 삽입에서 O(log(1))이 소요되고, Sorted Set의 경우 삽입에서O(log(N))이 소요되지만 요청 순서대로 정렬하는 것이 가능하다.

 

그래서 나중에 Batch를 통해 정렬해둔 데이터에서 N 명을 뽑는 구조라면 Sorted Set이 적합하고, 실시간으로 바로 쿠폰을 발급해주는 등의 처리를 하는 데에는 Set이 적합할 수 있다.

Transaction

위에서 Redis (Set 자료구조)를 선택했더라면 아래 절차로 구현할 수 있을 것이다.

  • Redis에서 이벤트의 재고를 조회
  • 재고가 존재한다면 유저 중복을 검사(Set)
  • 지급 재고를 증가(INCR) 후 쿠폰을 발급

주의할 점은 재고 조회와 쿠폰 발급이 동일 트랜잭션에서 이뤄져야 한다는 점이다.

 

재고가 1개 남았는데, A 유저와 B 유저가 모두 이벤트의 재고 조회가 성공하면 쿠폰이 정해진 수량 이상 발급되는 문제가 발생한다.

 

Set에 쿠폰을 발급하는 것 까지 트랜잭션으로 묶어서 한개 요청씩 진입을 하도록 처리해야 한다.

  • multi() / exec() 명령어를 사용해서 구현이 가능하다.
  • 명령어들이 Queue에 쌓여서 일괄적으로 실행된다.

단, Cluster 환경에서는 다른 트랜잭션에서 커밋을 할 때까지 읽지 않도록 분산 Lock을 걸어야 한다. 다른 Redis를 통해 조회를 했을 때 재고가 0건나와서 전부 재고를 증가해버리면 트랜잭션도 의미가 없어진다.

유실

추가로 Redis는 휘발성 DB라서 데이터 유실을 고려하여 재고 데이터는 반드시 메인 DB에 싱크를 해야 한다.

결과

결과로 Redis를 활용해서 동시성 이슈를 해결했고, 선착순 등록에 실패한 경우 실제 DB에 영향을 주지 않을 수 있고, 분산처리 까지 가능하게 되었다.

 

Redis를 사용한 것은 사실 선착순 이벤트에서의 폭발적인 트래픽을 감당하기 위한 부분이 크다.

 

일반적인 자원 예약이라던지 쇼핑몰 등에서의 판매 처리 등은 Redis라는 추가 인프라를 활용하기가 설계가 복잡해진다는 문제가 있다. 폭발적인 트래픽이라는 요구 사항을 제외하면 어떻게 처리할까?

2. 락 (+ 트랜잭션 고립 수준)

동시성 제어를 위한 가장 보편적인 방법은 락이다.

 

락을 통해 트랜잭션들을 줄세우고 처리하면 동시성 이슈를 해결할 수 있다. 해당 방법은 불필요한 대기 상태를 만드는 것을 최소화하는 것이 중요하다.

  • (MySQL에서는 특히나 인덱스를 잠그기 때문에 WHERE 조건에 따라 락의 영향 범위가 커질 수 있다.)
    • 해당 작업으로 인해 다른 쿼리가 대기하게 되진 않을 지 고려해야 한다.

2-1. 비관적 락

락을 이용하면 해당 이슈를 해결이 가능하다.

 

재고를 조회하는 트랜잭션에 비관적 락을 건 후, 쿠폰을 발급하는 트랜잭션의 고립 수준을 READ COMMITTED 이상으로 적용하면 동시성 이슈를 해결할 수 있다.

  • (다른 트랜잭션에서 자원의 재고에 접근하지 못하도록 락을 걸어서 대기시키기 때문)

여기서 RDB만으로 사용하고 있다면 중복 발급 체크 로직이 실패할 수 있는 지 검증해야 한다.

 

유저 id에 UNIQUE 인덱스가 없다면 중복 체크(한 유저가 쿠폰이 이미 존재하는 지 체크) 시 Phantom Read로 인해 동시성 이슈가 발생할 수 있다.

  • InnoDB를 사용하고 있다면 유저 기반의 인덱스가 걸려있을 때 Record 락이 걸리므로 해당 이슈가 발생하지 않을 것이다.
  • 트랜잭션 격리 수준을 SERIALIZABLE로 해서 그냥 다른 트랜잭션에서 접근 조차 못하게 막는 방법도 있다.

비관적 락의 경우에는 레코드 자체에 락을 걸기 때문에 대기 중인 쿼리가 쌓여서 성능상 이슈가 있다는 단점이 있다.

2-2. 낙관적 락

장점

낙관적 락을 사용하면 레코드에 대해 Versioing(버저닝)을 해서 자원의 재고를 비관적 락처럼 요청들을 줄 세우지 않고 한꺼번에 처리할 수 있다.

 

추가로 비관적 락의 경우 DB의 자원을 사용하는 데 반해 낙관적 락의 경우 어플리케이션의 자원을 사용하므로 DB의 부담을 덜어줄 수 있다.

 

단점

경합이 발생하면 후속 쿼리들은 전부 실패로 처리하기 때문에 폭발적인 트래픽이 들어오는 경우 재고가 있음에도 많은 요청들이 대부분 실패하게 된다.

  • 실시간 처리의 경우 사용자 경험에 영향을 미칠 수 있다.

버저닝을 위해 사용하면 DB에 컬럼 하나가 추가로 필요하게 된다. 추가로 여전히 동일한 사람이 중복으로 요청을 보낼 수 있는 문제는 존재한다.

  • 이때는 Unique Index / 비관적 락이 추가로 필요하게 된다.

실패 처리도 직접 구현해야 하고 롤백 기반으로 동작하기 때문에 동시 요청을 보낸 사용자가 많을 수록, 로직이 복잡할 수록 롤백 작업도 많아지므로 자원 소모가 크게 된다.

3-3. 분산 락

증권에서 고객의 잔고를 관리하는 서비스를 생각해보자.

 

비관적 락을 걸어 버리면 잔고, 증거금, 주문 등 많은 테이블에 락을 걸게 되어 부하가 크게 발생한다. 추가로 분산 DB 환경에서는 동시성 제어가 어려울 수 있다.

 

낙관적 락을 사용하면 동시에 발생하는 트랜잭션들이 대기없이 실패하게 되어 별도의 재시도 구현이 필요하게 된다는 단점이 있다. 이는 코드의 복잡도 상승으로 이어질 수 있다.

 

그래서 별도의 락 테이블을 구성해서 사용한다.

문제는 MSA에서 해당 방식은 모듈의 데이터베이스를 강제하게 되고, 서비스 간 높은 결합도가 필요하게 되어 비효율적인 자원 사용을 야기할 수 있다.

 

그래서 Redis 기반의 분산 락을 사용하는 것이 좋다. Redis는 Memory기반의 저장소를 활용하기 때문에 높은 처리량을 얻을 수 있다. 추가로 기존의 RDB를 강제하지 않아도 된다는 장점이 있다.

 

Redis Cluster와 Redlock 알고리즘을 이용하면 폭발적인 트래픽이 발생하는 상황에서 실제로 락을 획득한 요청만 메인 DB에 접근하고, 전면에서 Scale out이 가능한 Redis Cluster에서 트래픽을 감당하면서 폭발적인 트래픽을 안정적으로 처리하는 방법도 있다.

참고