티스토리 뷰

 

 

이 글에서는 동시에 많은 사용자가 접속하는 상황에도 대응 가능한 "선착순 이벤트" 시스템 설계 경험을 공유해 드리고자 합니다.

 




기술 스택 : Java 17, Sprint boot 3, Redis, RabbitMQ, AWS, Mysql 8, Pinpoint 3, nGrinder

1. 들어가며


선착순 이벤트는 무료로 이력서 피드백을 제공하는 사이트에서 이벤트를 진행했습니다. 


선착순 이벤트 요구사항은 다음과 같습니다.
1. 이벤트 기간 동안, 매일 특정 시간 오픈하며 총 신청 인원을 한정한다.
2. 신청 인원은 당일 정해진 양을 초과해서는 안된다.
3. 신청은 1인당 1번만 신청할 수 있다.

 



 

 

2. 동시성 이슈 문제


RDB에 의존하여 수량 체크를 하면, 동시성 이슈로 인하여 선착순 신청 인원이 초과될 위험이 있었습니다. 또한 수백에서 수천 명이 동시에 접속하여 이력서를 제출하려는 상황에서 일부 사용자들이 중복으로 이력서를 제출하는 경우가 있었습니다. 



동시성 이슈를 해결하고 위 요구사항을 충족하기 위해 Redis를 활용하기로 하였습니다. 그 이유는, Redis가 단일 스레드 기반 커맨드를 순차적으로 실행하고 결과를 전달하기 때문에 동시성 이슈를 해결할 수 있으며, 다양한 데이터 타입과 커맨드를 제공해 주기 때문에 모든 요구사항들을 만족시킬 수 있을 거라 생각했기 때문입니다.

 

 

 




3. Redis 를 활용한 선착순 이벤트! 고민의 흔적들 🕐


[ 방안 1. SET 자료형을 통한 선착순 인원 관리 ]

SET 자료형은 중복값을 제거해 주는 특성을 이용해, 3번째 요구사항인 중복 신청을 막을 수 있어서 선택하게 되었습니다.
또한 삽입 --> O(1), 삭제 --> O(1), 카운트 조회 --> O(1), 전체 조회 --> O(N) 이러한 상수 시간 복잡도 덕분에 대규모 데이터에 대해서도 빠른 처리가 가능합니다.


SET 자료형을 활용해 사용자의 유니크한 email를 value로 사용하여 기존에 이미 신청했는지 확인하여, 중복 신청을 막을 수 있게 되었습니다.

KEY 정보
- eventId : 이벤트 PK ID
- email : 사용자 이메일

이벤트 조회
redis> GET event::{eventId}

중복 신청 체크
redis> SISMEMBER event.request.eventId={eventId} {email}

이벤트 신청 인원 수 조회
redis> SCARD event.request.eventId={eventId}

신청 가능 여부 체크
if(이벤트 최대 신청자 수 < 신청 인원 수) {
  SADD event.request.eventId=1 {email} // 신청 확정 처리
}

SISMEMBER 커맨드를 통해서 중복 신청인지 확인합니다. 
SCARD 커맨드를 통해 이벤트 신청 인원을 조회하여 현재 몇 명 신청했는지 알 수 있습니다.
중복 신청을 하지 않았고 이벤트 신청 인원이 초과하지 않았다면, SADD 커맨드를 통해서 사용자의 이메일 값을 넣어서 신청 확정 처리를 합니다.


위와 같은 방식으로 개발하고 성능 테스트를 진행해 보았습니다. 그런데 이 방식에서는 트래픽이 몰릴 경우 동시성 이슈를 야기하며 아래 그림처럼 초과 신청이 발생하는 케이스를 확인했습니다.

실제로 nGrinder 툴을 이용하여 동시 1000건 요청을 하고 250명 신청할 수 있도록 테스트를 해보았습니다.
아래 그림처럼 성공한 테스트 263명으로 13명이 초과 신청이 되었습니다..!



이를 해결하고자 Redis Lock 을 활용하기로 하였습니다. Lock 은 여러 개의 동시 요청을 순차 처리를 보장합니다.


 

 

[ 방안 2. SET 자료형 + Redis Lcok 을 통한 선착순 인원 관리 ]

Redis에서 Lock 을 구현하는 대표적인 Named Lock, Lettuce Lock, Redisson Lock 이 있습니다.

  개념 특징
Named Lock 특정 키 이름으로 락을 설정하는 방식 락을 생성하고, 해당 키가 존재할 경우 다른 클라이언트가 락을 얻지 못하도록 방지
Lettuce Lcok SETNEX 명령어를 사용하여 분산 환경에서 락을 처리 Spinlock 방식으로 락 획득 요청을 하기 때문에 Redis에 많은 부하를 줄 수 있음
Redisson Lcok pub/sub 기능을 활용하여 분산 환경에서 안전하게 락을 처리 tryLock()과 같은 메서드를 사용하여 락을 쉽게 획득하고 해제할 수 있으며, 자동 만료 시간 설정 등도 지원

이 3개 중에서 Redisson Lcok 선택하였습니다. 그 이유는 다음과 같습니다.


Named Lock 은 특정 자원에 대해 하나의 락을 설정합니다. 선착순 이벤트의 대규모 동시성 처리빠른 응답이 요구되는 상황에서는 성능 및 공정성 측면에서 한계가 있기 때문에 부합하지 않습니다.


Lettuce Lcok 은 분산락을 사용하기 위해서는 setnx, setex 등을 이용해 분산락을 직접 구현해야 합니다. 또한 retry, timeout과 같은 기능을 구현해 주어야 한다는 번거로움이 있습니다. 이에 비해 Redisson 는 Lock Interface 지원하기 때문에 락을 보다 안전하게 사용할 수 있습니다.


락을 획득하는 방식에도 차이가 있습니다. Lettuce Lcok 은 지속적으로 Redis에게 락이 해제되었는지 요청을 보내는 스핀락 방식으로 동작합니다. 요청이 많을수록 Redis가 받는 부하는 커지게 됩니다. 이에 비해 Redisson은 Pub/Sub 방식을 이용하기에 락이 해제되면 락을 subscribe 하는 클라이언트는 락이 해제되었다는 신호를 받고 락 획득을 시도하게 됩니다.




RDB 를 의존하는 기본 방식에서 SET 자료형과 Redisson Lcok 바꾸어서 아래 그림처럼 적용하였습니다.

락 범위는 트랜잭션 범위보다 큰 범위에서 진행하였습니다.
그 이유는 데이터 정합성을 맞추기 위해서입니다.
락의 해제가 트랜잭션 커밋보다 먼저 이뤄지면 데이터 정합성이 깨질 수 있습니다.

 






4. 동시성 이슈는 해결 완료! 성능은 어떨까? 🤣


성능 테스트는 nGrinder를 통해 진행하였습니다. 이 테스트를 진행하며 모니터링한 결과... 2가지 문제점이 있었습니다.

 

[ 문제점 1. 락 경쟁으로 인한 TPS 감소 및 병목 현상 ]

동시 요청은 1875번, 신청 가능한 인원은 300명으로 설정하고 테스트를 해보았습니다.
하지만 성공 테스트 수가 300명이 아닌 126명 테스트에 성공하였습니다. 
그 이유는 다음과 같습니다.
1. 트랜잭션 범위보다 큰 Lcok 으로 인해 다른 스레드가 Blocking 되어 TPS 감소 및 성능 저하

실제 응답 속도 6000ms 성능 저하

2. 트랜잭션의 범위가 길어서 MySQL 부하 증가
3. 트랜잭션 롤백 시 이벤트 조회부터 다시 수행해야 하는 비효율성 발생



 

 

[ 문제점 2. DB 부하 ]

AWS RDS CloudWatch

실제 선착순 이벤트 API 비즈니스 로직 내에서 DB 에 저장하는 Insert Query가 부하가 발생하는 점을 확인했습니다.


DB 에 부하가 발생하여 장애가 발생하면 전체 시스템에 큰 영향을 줄 수 있는 문제점을 파악하였습니다.
Read DB와 Write DB 분리해서 진행해 볼까? 생각해 보았는데, Read DB의 경우 API의 트래픽이 몰리는 경우를 대비하여 Scale-out을 통해 트래픽 분산 및 대응이 가능하지만, Write DB의 경우 Scale-up을 통해 대비를 하더라도 Single Point of Failure가 발생할 경우 서비스 전체에 영향을 주게 됩니다.



이를 해결하기 위해 EDA 기반 RabbitMQ 를 활용하여 RabbitMQ 애플리케이션에서 신청 완료 처리하도록 변경하였습니다.

 

 




5. 문제를 해결할 RabbitMQ 도입! 😊


검증이 끝난 요청을 Queue 저장하여 RabbitMQ 애플리케이션에서 Consume 하여 신청 완료 처리하도록 작업하였습니다.
이렇게 함으로써, 첫 캐싱 작업 외에 MySQL Access 없어지게 되고 트랜잭션과 락이 분리가 되어 범위가 적어지게 되었습니다.



다시 성능 테스트를 해보았습니다.
- 동시 요청 : 3000건
- 신청 가능한 인원 : 1000명

TPS는 45.3으로 개선 전의 13.1 보다 245.8% 증가하였습니다.
성공한 테스트 수도 1000개 정상적으로 요청이 되었습니다.
이렇게 첫 번째 문제점인 락 경쟁으로 인한 TPS 감소 및 병목 현상 문제를 해결할 수 있었습니다.

 


두 번째 문제인 DB 부하를 확인해 보겠습니다.

AWS RDS CloudWatch

RabbitMQ 적용하기 전에는 13% CPU 사용률이 보였는데, 적용 후에는 8% 로 떨어진 것을 확인할 수 있습니다.
이렇게 두 번째 문제점인 DB 부하 문제를 10% 아래로 감소시키는 데 성공하였습니다.



이를 통해 실제 이벤트 서비스 API가 DB에 접근하지 않고 신청 가능 여부만 판단하여 고객에게 응답을 주기 때문에 처리 속도가 개선되었고, 사용자 경험 또한 향상되는 결과를 얻을 수 있었습니다.

 

 


 

 

6. 오류 케이스


[ 비동기 스레드 풀의 최대 크기 초과 ]

nGirnder 성능 테스트를 하다가 50건까지는 정상적으로 동작하다가 50건 이후로는 아래 사진과 같이 TaskRejectedException 예외가 발생하였습니다.

Pinpoint Transaction

TaskRejectedException: ExecutorService in active state did not accept task 에러는 Spring에서 비동기 작업을 처리하기 위해 사용하는 ExecutorService가 더 이상 새로운 작업을 수락할 수 없는 상태일 때 발생하는 에러입니다.


애플리케이션에서 신청이 완료되면 메일을 보내고 있었습니다. 이때 비동기로 보내고 있었는데 동시 요청 1000건 성능 테스트 하던 중에  스레드 풀의 작업 대기열이 가득 차서 TaskRejectedException 발생되고 있었습니다.


해결 방법으로는
1. 스레드 풀(CorePoolSize, MaxPoolSize)과 대기열 크기(QueueCapacity) 크기 증가
2. 부하 분산
3. 메세지 큐 사용


이 3개 중에서 메세지 큐 사용 선택하였습니다. 그 이유는 다음과 같습니다.


1번 방법은 스레드 풀과 대기열 크기를 증가하여도 대용량 트래픽을 감당하기에는 위험할 수 있습니다. 만약 너무 높게 설정하면 시스템 리소스(CPU, 메모리) 사용량이 과도하게 증가할 수 있습니다.


2번 방법은 애플리케이션을 수평적으로 확장(서버나 인스턴스 추가)하여 병목을 줄일 수는 있지만, 이메일 기능만을 처리하기 위해 부하 분산을 하기에는 오버헤드가 발생할 수 있으며, 리소스 낭비와 관리 비용 증가로 이어질 수 있습니다.


3번 방법은 기존에 RabbitMQ 사용하고 있어서 선택하였습니다. Queue 추가하여, 소비자(consumer)가 이를 하나씩 처리하면 스레드 풀에 대한 부담을 줄일 수 있습니다.



 



 

 

 

[출처]
풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson 
쿠폰 발급에 대한 동시성 처리
MySQL을 이용한 분산락으로 여러 서버에 걸친 동시성 관리