Search
🚀

프로젝트로 경험하는 대용량 트래픽 - 선착순 쿠폰 발급 이벤트 (1)

목차

선착순 쿠폰 발급 서비스(First come, First served)

About

이 프로젝트는 유저에게 선착순으로 쿠폰을 발급해주는 기능이 구현되어 있다.
한정된 수량의 쿠폰을 유저에게 '신속하고', '정확하게' 발급하기란 쉬운 일이 아니다.
아무런 고려 없이 무작정 개발을 하다보면 필연적으로 '동시성 이슈' 또는 '성능 이슈'를 겪게 된다.

To do

이 프로젝트에서는 '동시성 이슈'와 '성능 이슈'를 개선해나가는 과정을 정리 한다.
전체 소스는 여기에서 확인할 수 있습니다

Project Setting

Version Info
Java 17
SpringBoot 3.1.9
MySql 8.0.36
Redis 7.2.4
Module Info
coupon-api : 쿠폰 발급 요청 서버
coupon-consumer : 쿠폰 발급 서버
coupon-core : 쿠폰 발급 로직
Test Info
Unit Test : JUnit
API Test : Intellij Http Client
Perform Test : Locust
Tables

 Simple Version

가장 기본적인 방식으로 아무것도 고려하지 않은 상태이다.
유저가 쿠폰 발급 요청을 하면
1. 쿠폰을 조회하고 (쿠폰 존재 유무 확인)
2. 쿠폰의 잔여 수량 및 일자를 체크한다 (쿠폰 유효성 확인)
3. 어떤 유저가 쿠폰을 발급했는지 저장한다. (쿠폰 발급 로그 적재)
와 같은 비즈니스 로직으로 수행된다. (소스 보기)

Issue

Unit Test와 API Test를 해보면 성공적으로 잘 동작한다.
하지만 정말 잘 동작하는 것이 맞을까?
쿠폰 발급 가능 수량을 500개로 설정 해놓고 1,000명의 유저들로 부하를 가해보자.
결과 데이터로만 보면 약 300,000개의 요청을 처리했고 3,738 RPS(Request Per Second)로 나쁘지 않은 성능을 보여주는 것 같다.
응답시간 또한 최초에 쿠폰을 발급하는 로직이 수행되어 일시적으로 느려졌으나
이후에는 쿠폰 발급을 하지 않아서인지 낮은 응답시간을 보여주고 있다.
이렇게만 보면 나름 그럴싸하다.
하지만 DB를 조회해보면 어떨까?
쿠폰의 수량은 500개 밖에 없는데 실제 발급된 수량은 500개를 훌쩍 넘은 4,998개가 발급되었다.
즉, 동시성 이슈가 발생하고 있다는 것을 알 수 있다.

 Synchronized Simple Version

우선은 간단하게 synchronized 키워드를 사용해서 동시성 이슈를 해결해보자.
issue 메서드를 호출하는 부분에서 synchronized 키워드로 critical section을 감싸준다.
그리고 다시 Locust를 돌려보자.
이번에는 정확하게 딱 500개만 발급되었다.
하지만 기존에 3,738 RPS가 나온것에 비해 현재 버전의 경우 1,144 RPS가 나왔다.
syschronized 키워드로 lock을 걸어서 순차적으로 Thread가 처리되기 때문에 속도 측면에서 차이가 있는 것이다.
이 방법은 처리량에서 조금 손해를 보긴하지만 로직의 안정성 측면에서는 탁월한 방법이었다.
하지만 단일 서버가 아닌 경우엔 어떻게 될까?
서비스의 처리량을 늘리기 위해 Auto-Scale을 하는 순간 synchronized로 쿠폰 수량을 제어하는 것은 굉장히 어려운 일이 된다.
또 어떤 방법이 있을지 다음으로 넘어가보자
그전에 !

 왜 issue 메서드 내부가 아닌 호출부에서 synchronized 키워드를 사용한건가요?

호출하는 곳에서 매 번 synchronized 키워드를 사용해야 한다는 불편함이 있고
실수로 작성하지 않은 경우 동시성 이슈가 발생하는게 명확한데 말이죠!
그렇다면 issue 메서드 내부에서 synchronized 키워드를 사용해보자.
무엇이 문제가 될까?
issue 메서드가 호출되는 과정을 파악해보자
(1) start transaction (2) lock 획득 (3) Coupon coupon = findCoupon(couponId); // DB 조회 발생 (4) coupon.issue(); (5) saveCouponIssue(couponId, userId); // DB 저장 발생 (6) lock 해제 (7) transaction commit
Plain Text
복사
위 순서로 로직이 실행될 것이다.
만약 두 스레드가 거의 동시에 해당 로직을 실행시켰다고 가정해보자.
그 중에서도 빠른 첫번째 스레드가 (1) 로직부터 실행하여 (2) lock을 획득하고 (3)~(5) 로직을 실행할 것이다.
이후에 (6) lock을 해제하고 (7)번을 실행하려는 그 찰나에
두번째 스레드가 lock을 획득할 수 있고 (3)~(5) 로직을 실행할 수 있다.
즉, 아직 DB에 제대로 반영이 되지 않은 상태에서 비즈니스 로직이 수행되어 원치 않은 결과를 초래할 수 있다는 말이다.
한번 더 synchronized로 감싼 메서드를 외부에 노출 시키면 안전하게 호출할 수 있지만
현재 맥락에서 크게 중요한 것은 아니니 넘어가도록 한다.

 Distributed Lock With Redis

단일 서버에서는 synchronized 키워드로 동시성 문제를 해결할 수 있었지만
서버가 여러개로 증설되는 경우는 사용하기 힘들었다.
이런 경우 어떻게 동시성을 해결할 수 있을까?
Redis를 사용하는 분산락 방식을 알아보자.
자바 언어로 구현된 레디스 분산 락 클라이언트인 redisson을 이용해보았다.
DistributeLockExecutor 클래스에서 메소드 호출 시 분산 락을 획득한 경우에만 로직을 실행하도록 구현한다.
이후 호출부에서는 DistributeLockExecutor를 통해서 로직을 실행시킨다.
동일하게 1,000명의 유저로 실행시켜보자.
650 RPS 정도의 성능이 나온 것을 확인할 수 있었고
쿠폰도 정확하게 잘 발급이 되었다.
Redis 컨테이너의 CPU 사용량을 보면 9.89% 정도 사용하고 있었고
MySQL 컨테이너의 경우 13.2%를 사용하고 있었다.

 Record Lock With MySQL

두번째 방식은 MySQL의 Lock을 활용하는 방법이다.
쿠폰 발급 로직에서 동시성 이슈가 발생하는 것을 쿼리 관점에서 살펴보자.
# User 1 (1) start transaction; (2) select * from coupons where id = 1; (3) /* User 1 쿠폰 발급 로직 - update issuedQuantity of coupon - insert coupon_issue */ (4) commit;
SQL
복사
# User 2 (1) start transaction; (2) select * from coupons where id = 1; (3) /* User 2 쿠폰 발급 로직 - update issuedQuantity of coupon - insert coupon_issue */ (4) commit;
SQL
복사
딱 1개의 쿠폰만 발행하는 시나리오라고 가정하자.
유저 1이 (1)~(3) 로직을 잘 수행하고 변경 사항을 DB에 반영하려고 한다.
이때 쿠폰의 issuedQuantity 정보를 1로 업데이트를 해야 다음 유저에게 쿠폰을 발급하지 않을 수 있다.
하지만 (4)과정인 commit을 아직 하지 못한 상황이다.
이때 유저 2가 요청이 들어왔다.
유저 2가 쿠폰 정보를 조회했는데 issuedQuantity가 0이다.
마저 (2)~(3)의 로직을 수행한다.
이후 (4) commit을 한다.
뒤이어 유저 1도 commit을 한다.
이렇게 쿠폰은 1개밖에 없지만 2명에게 쿠폰이 발급되는 현상이 발생하는 것이다. (동시성 문제)
하지만 쿠폰의 정보를 조회할 때 쓰기 잠금을 걸어서 다른 유저가 쿠폰을 조회하는 것을 제한하면 어떨까?
# User 1 (1) start transaction; # for update 구문을 추가하여 record lock (2) select * from coupons where id = 1 for update; (3) /* User 1 쿠폰 발급 로직 - update issuedQuantity of coupon - insert coupon_issue */ (4) commit;
SQL
복사
# User 2 (1) start transaction; # for update 구문을 추가하여 record lock (2) select * from coupons where id = 1 for update; (3) /* User 2 쿠폰 발급 로직 - update issuedQuantity of coupon - insert coupon_issue */ (4) commit;
SQL
복사
이렇게하면 동시에 요청이 들어오더라도
유저 1의 transaction이 commit 되기 전까지 유저 2는 (2) 단계에서 대기를 하고 있을 것이다.
소스로 구현하면 아래와 같다.
JPA의 @Lock 어노테이션을 이용하여 구현할 수 있다.
동일하게 1,000명의 유저로 실행시켜 보자.
Redis의 분산 락을 사용하는 것보다 더 높은 처리량인 2,217 RPS 가 나왔다.
하지만 72.13%로 MySql의 부하가 상당했다.

동시성 이슈 해결 정리

동시성 문제가 발생하는 경우 다음과 같은 방법으로 해결할 수 있다.
첫번째로는 synchronized 키워드,
Java에서 제공해주는 키워드로 가장 빠르고 쉽게 적용할 수 있다.
하지만 서버가 여러 개 가동되고 있는 환경이거나 Auto Scale이 적용된 환경이라면 사용하기가 쉽지 않다.
두번째로는 Redis 분산락,
Redisson이라는 레디스 분산락 클라이언트를 활용한 방법이다.
확장성이 좋고 간단하게 사용할 수 있지만 Redis와 Redisson에 대한 이해도가 필요할 수 있으며 병목 현상이 발생할 수 있다.
또한 생각보다 성능이 좋지 않다. (이 부분에 대해서는 다른 글에서 좀 더 알아보기로 한다.)
세번째는 MySQL 레코드락,
SQL에 for update 구문을 활용하여 쓰기 잠금을 이용해 레코드락을 활용한 방법이다.
간단하게 사용할 수 있어서 좋지만 관련 기능이 아닌 다른 기능에서도 락이 걸릴 수 있으며 DB에 부하를 준다.
위 방식들은 동시성 문제를 해결하는 관점에서는 훌륭하다.
하지만 1,000명의 유저가 아닌 더 많은 유저가 몰린다고 가정하면
이정도 처리량으로는 원할한 서비스를 제공하기 힘들 수 있다.
또한 MySQL이나 Redis에 더 많은 부하가 생겨 병목현상이 생길 수 있다.
다음 내용에서는 성능적으로 어떻게 더 개선할 수 있을지 알아보자.
다음 글)