본문 바로가기

server/system design

쿠폰발급 서비스 구축하기 (실험용)

문제!!

쿠폰 발급 시스템을 만들어 볼 예정입니다. 쿠폰은 100개이며 선착순으로 발급됩니다.

대용량의 요청을 견디기 위해선 어떤 점을 고려해서 해야 하는가?

1. 10000개의 요청을 견디는 서비스 구조를 만들자!!

우선 하나의 ec2를 올려서 LB에 물려서 통신을 확인하자!! (Hello, world!)

LB 로그에서 통신 10개 성공 확인

 

 

성공 확인 (200)

 

이번엔 10000개를 테스트해보자!!

에러율 83%!!!! 하나의 서버로는 일반 요청(DB 요청 커넥션 없음)부터 에러가 발생한다.

gunicorn 로그를 봤더니 별다른 에러가 없다. 그냥 통신 자체가 서버까지 도달하지 못했다.

 

그러면 ec2를 LB(로드 밸런스)에 더 추가해보자. (target group에 추가하는 게 건강상 좋습니다.)

 

ec2가 두 개일 경우 jmeter 테스트 결과 68%의 에러율을 보인다…약 15% 정도가 추가적으로 성공한 것이다.

결국 서버 한대당 15% 정도가 높아지니 최소 5대를 추가해야 한다…….

 

혹시 몰라 gunicorn의 워커수를 4 →8로 늘려보고 테스트를 진행했다.

결과는 오히려 에러율이 늘었다. 서버가 감당할 수 있는 수보다 많은 요청을 처리 못하는 것일 뿐, 스레드 수가 문제가 아니었다.

 

 

이번엔 EC2의 성능을 높여봤다. t2.micro → t2.medium으로 변경하고 테스트를 진행했다.

 

 

- ec2 3개: 에러율 13.2%

 

- ec2 4개 : 에러율 0.86%

 

- ec2 5개가 되자 에러율이 없어졌다. 

 

이제 기본적인 서버의 구성이 끝났다.

오토스케일링을 안 한 이유는 요청 이벤트가 한꺼번에 오기 때문이다. 순차적으로 늘어난다면 오토스케일링이 효율적이지만 선착순으로 끝나는 요청이라면 미리 서버를 올려놓아야 한다.

 

2. 쿠폰 발급 로직을 구현해보자!!

일반 비즈니스를 통한 구현

  1. 클라이언트는 10000개의 요청을 한다
  2. 서버는 먼저 남은 쿠폰의 count를 구한다
  3. 쿠폰이 남아있다면 미사용→사용으로 디비를 업데이트 후 200을 리턴한다
  4. 남은 쿠폰이 없다면 400을 리턴한다.

100개의 요청 → 100개의 트랜잭션이 동작하는지 확인

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 구현 코드
def update_coupon(db):
    try:
        c = db.query(Coupon).filter_by(use=False).count()
        if 0 <= c:
                        raise Exception("쿠폰 남은게 없음요!!")
 
         data = db.query(Coupon).filter_by(use=False).first()
         data.use = True
         db.commit()
 
         return CouponSchema.from_orm(data)
            
    except Exception as e:
        raise Exception(str(e))
cs

 

테스트 실행 결과 100개의 사용 요청에 디비의 사용 가능 쿠폰은 83개!!!! 겨우 17개만 사용으로 데이터가 변경된 것이다!!

즉 트랜잭션 코드가 개판이었다는 소리다. (남은 쿠폰 개수)

코드를 변경해보자.

with_for_update()를 추가하여 select for update를 구현하였다. (동시성 제어를 위해 특정 데이터(ROW)에 대해 배타적 LOCK를 걸어준다)

먼저 count를 구하고 lock을 통해 해당 데이터를 업데이트해주도록 수정했다.

만일 같은 객체를 수정하려 한다면 에러가 날것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def update_coupon(db):
    try:
        c = db.query(Coupon).filter_by(use=False).count()
        if 0 <= c:
                        raise Exception("쿠폰 남은게 없음요!!")
 
        data = db.query(Coupon).filter_by(use=False).with_for_update().first()
        data.use = True
        db.commit()
 
        return CouponSchema.from_orm(data)
        
    except Exception as e:
        raise Exception(str(e))
cs

이번 테스트 요청(100번 요청)엔 정상적으로 100개가 모두 소진되었음을 확인하였다. 야호!

 

그러면 100개의 쿠폰 중에서 200개의 요청하면 어떻게 될까?

정확히 50%만 성공한 것을 확인할 수 있다. (나머진 status code 400!!)

이번엔 10000번을 요청해보면?

로그상에서

200 ok : 100개

400, Bad Request : 9153개

502, Bad Gateway : 747개

발생하였다. 아마도 DB 커넥션 관리 때문에 502가 발생한 것으로 보인다. ㅜㅡㅜ

 

서버를 더 늘려보자! → target group에서 알맞게 분할해서 넣어줘야 한다.

 

 

3. 돈이 없어서 DB가 서럽다…

테스트 용으로 사용하고 있는 DB는 db.t3.small로 커넥션수가 170개로 제한되어 있다. 서버를 아무리 빵빵하게 해도 디비의 수용을 넘어버린 것.

디비의 커넥션이 적다면 해당 서비스는 동작하지 않는다. 사용자의 요청을 모두 한꺼번에 감당해야만 하는 서비스에서는 디비를 수용 가능한 커넥션수를 적당히 계산해서 스케일 업해줘야 한다.

rds small → large로 업 해줬다.

현재의 코드는 모든 요청 시 쿠폰이 발급 가능한지 확인하기 디비의 데이터를 확인한다.

이는 너무나 비효율적인 구조 인다. 디비의 커넥션이 무조건 요청마다 일어나기 때문이다.

우리는 이를 최적화하는 방법을 알 수 있다. 바로 캐시이다.

 

4. Redis. 우린 이것을 캐시라 부르기로 했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def update_coupon(db, rd):
    try:
        c = rd.get("coupon_count")  # get
        
        # DB transaction
        if not c or int(c) <= 0:
            raise Exception("쿠폰 남은게 없음요!!")
        
        data = db.query(Coupon).filter_by(use=False).with_for_update().first()
        data.use = True
        db.commit()
 
        rd.decr("coupon_count"# set
        return CouponSchema.from_orm(data)
    
    except Exception as e:
        raise Exception(str(e))
cs

코드를 위와 같이 바꿨다.

레디스에서 coupon_count로 100을 저장한다.

요청이 올 때마다 해당 키값을 확인하고, 만일 1 이상 값이라면 디비에서 값을 읽어오도록 수정했다.

그다음 db 커밋이 완료가 되었다면 레디스의 값을 1 감소한다.

해당 테스트는 locust로 진행했다. 2만 번의 요청 중에서 딱 100개를 빼고 모두 실패(400)가 되었다!( 성공 )

 

레디스의 해당 값도 정확히 0이 되어 제대로 동작한 것을 확인할 수 있다.

 

재밌는 점은 400 에러 약 2만 개 중 323개가 트랜잭션 충돌이 일어나서 에러가 발생한 점이다. 결국 100개가 성공으로 가는 도중에 323개는 디비상에서 트랜잭션에 의해서 에러가 발생했다고 보면 된다.

일단 대략적인 요청에 대해서 트랜잭션과 캐싱만으로 서비스를 만들어보았다. 그다음 스탭은 무엇일까?

 

번외.  1초에 100000번의 요청을 견디는 서비스를 만들어 보자!

현재는 ec2는 6대를 사용 중이다. 거의 최소한으로 사용 중으로 만일 요청이 늘어난다면 ec2의 개수는 기하급수적으로 늘어날 것이다. (보통 다른 스타트업에서의 예를 들어 본 결과 100000번의 이벤트 요청에 50~100대 정도는 운영해야 한다고 한다..) 그 정도의 테스트는 저의 지갑을 초과하는 것으로 여기선 상상만으로 해야겠다.

당연히 해결 방법은 스케일 아웃 / 스케일 업이다. 돈은 무한하지 않으므로 둘 다 적절하게 필요하다. (물론 돈이 많으면 그냥 냅다 둘다 늘리면 된다. )

 

앞서 말한 대로 디비의 커넥션 수를 감당하기 위해서 large로 테스트했다.

 

위에서 성공 100개 + 충돌 323 개로 상상해보면 한꺼번에 430개 정도의 커넥션이 맺어졌다는 결론이 나온다. 즉 처음 성공까지의 커넥션 수로 계산로 계산하면 대략적인 디비의 사이즈를 가늠할 수 있다.

나머지는 디비가 아닌 캐시에서 알아서 커트가 가능하기 때문에 오히려 캐시의 성능이 중요해진다.

 

번외 2. 더욱 견고한 서비스 만들기

API 서버 나누기

만일 쿠폰 이벤트 시 해당 쿠폰 발급 이외의 api들은 모두 엄청난 대기열 혹은 504 타임아웃이 발생할 것이다.

해당 쿠폰 API만을 위한 서버를 구성하고, 다른 API들은 기존의 서버를 이용하도록 조치해둬야 할 것이다.

 

fail over 지역은?

해당 서비스에서의 실패 지점은 두 곳이다. 캐시의 다운과 디비의 다운이다.

먼저 캐시가 다운되었다면 캐시 커넥션에서 알 수 있을 것이고, 해당 로직을 통과할지 에러를 뱉을지에 대한 코드상에서의 커버가 있어야 할 것이다.

디비가 다운되었을 경우엔 답이 없다. 복구할 때까지 해당 서비스는 멈출 것이다.

디비가 다운이 아닌 트랜잭션 에러일 때는 롤백에 관해서도 나름의 정책을 세워야 한다. 사실 위에 구현한 코드는 디비의 접근 정책 중 최상위인 Serializer와 비슷하다. 한 명이 하나의 쿠폰만을 발급하기 때문에 하나의 요청에 하나의 트랜잭션이 걸리기 때문이다. 롤백에 대한 로직이 필요한 경우 코드상에서의 구현이 필요하다. (해당 비즈니스 로직에서는 딱히 필요치가 않을 것이다.)

 

 

프롤로그.

사실 이번 포스팅은 내가 4번이나 면접에서 질문받았던 질문이었다. 많은 블로그 글을 봐서(참고사항에 넣어두었다) 대략적인 설계는 알고 있었지만, 사실 직접 만들어본 적 없는 반쪽자리 경험이었다.

다른 분들도 혹시나 이 글을 읽는다면 꼭 한번 만들어 보는 것을 권해드린다.

보는 것과 직접 만지는 것의 경험은 다르다.

 

 

참고

https://dev-jj.tistory.com/entry/커머스동접자-대응을-위한-REDIS-도입기

https://woodcock.tistory.com/33

https://dev4us.github.io/2019/12/05/handle-traffic-jam/

https://velog.io/@hgs-study/redis-sorted-set

https://hyperconnect.github.io/2019/11/15/redis-distributed-lock-1.html

https://velog.io/@hgs-study/redisson-distributed-lock

https://d2.naver.com/helloworld/5048491

https://techblog.woowahan.com/2514

https://velog.io/@ddhyun93/Kotlin-Spring-Boot-Redis-Distributed-Lock-활용-선착순-응모-시스템-개발

https://www.youtube.com/watch?feature=share&v=MTSn93rNPPE

https://techblog.gccompany.co.kr/redis-kafka를-활용한-선착순-쿠폰-이벤트-개발기-feat-네고왕-ec6682e39731

https://csy7792.tistory.com/350

https://velog.io/@dvmflstm/redis의-분산락을-이용한-공유-자원-관리