개요
MSA로 구축된 환경에서 선착순 쿠폰 발급 시스템을 설계해본다. 설계는 프로모션을 담당하는 서버 입장에서 진행하며 실제 쿠폰을 발급하는 서버와 기타 검증을 수행할 수 있는 서버가 따로 존재하는 상황을 가정한다. 또한 휴먼 리소스가 부족한 제한된 상황에서 최소한의 작업만을 통해 기능을 구현해야 한다.
쿠폰 잔여 재고 확인, 쿠폰 발급, 발급 대상 검증 등을 프로모션 서버에서만 진행할 수 있다면 굉장히 간단하게 풀어낼 수 있는 문제다. (관련 포스팅 링크) 하지만 프로모션 서버가 해당 책임을 가지고 있지 않은 상황이기에 까다로운 상황이 몇가지 존재한다. 본 포스팅은 이를 해결하기 위한 설계 과정에 대해 기술한다.
최초 아키텍처
각 서버의 역할을 다음과 같다.
- Promotion Server: 프로모션 서버는 클라이언트로 부터 쿠폰 발급 요청을 받는다. 주문 서버, 유저 서버에게 요청을 보내서 검증을 진행한 후 쿠폰 서버로 실제 쿠폰 발급을 요청한다.
- Order Server: 주문 서버는 유저의 주문 내역을 가지고 있다. 예를 들어 첫 주문 대상 유저에게만 쿠폰을 발급하려면 주문 서버에게 특정 유저의 주문 내역을 요청하여 받아올 수 있다. 그리고 해당 정보를 통해 프로모션 서버에서 검증을 수행한다.
- User Server: 유저 서버는 유저의 정보를 가지고 있다. 예를 들어 특정 나이대의 유저에게만 쿠폰을 발급하려면 유저 서버에게 특정 유저의 정보를 요청하여 받아올 수 있다. 그리고 해당 정보를 통해 프로모션 서버에서 검증을 수행한다.
- Coupon Server: 쿠폰 서버는 실제 쿠폰 발급을 담당하는 서버이다. 타 서버에서 이미 굉장히 많은 트래픽을 받고 있기 때문에 최대한 적은 부하를 줘야 한다.
제한 사항
- 다른 마이크로 서비스에게 코드 작업을 요청할 수 없다.
- 클라이언트에게 코드 작업을 요청할 수 없다.
- 오직 프로모션 서버의 코드만 작업할 수 있다.
- 쿠폰 서버는 레거시 서버이고 기존 요청량이 굉장히 많기 때문에 최대한 부하를 줄여야 한다.
- 프로모션 서버는 최대 발급 가능 쿠폰 개수만 알고 있고 잔여 개수는 알 수 없다.
- 실시간을 보장하진 않아도 된다. 유저가 발급 요청을 하자마자 발급 성공여부를 알 필요까진 없다는 것이다. 다만, 최대한 빠르게 작업을 완료해야 한다.
- 검증을 위해 요청하는 주문, 유저 서비스 API의 경우 단건의 요청만 허용한다. 한번의 요청에 한명의 유저 정보만을 얻어올 수 있음을 뜻한다. 한번의 요청에 여러명의 유저 정보를 얻어오는 Bulk API는 존재하지 않는다.
고려 사항
먼저 요청을 어떻게 다룰 지 고민해보자.
첫 번째는 Rate limit을 걸고 특정 임계치 이상의 요청이 들어오는 경우 이후 요청은 버리는 것이다. 하지만 이 방법은 문제가 있다. 예를 들어 이벤트 쿠폰 수량이 100개인데 Rate limit을 200개로 걸어뒀다고 가정해보자. 이 때 200개의 요청에서 검증을 거친 후 실제 발급된 쿠폰의 개수가 50개라면 나머지 50개는 발급이 되지 않는다. 특정 시간이 지나고 Rate limit이 풀리면 다시 쿠폰 발급을 요청할 수 있지만, Rate limit 활성화 후 요청건들은 버려지게 되므로 선착순이라고 볼 수 없다.
두 번째로 요청을 받으면 별도의 메시지 큐에 보관하고 유저에게는 응모 완료 응답을 주는 것이다. 그리고 별도의 워커/컨슈머를 통해 쿠폰을 받을 수 있는 유저인지 검증하고 쿠폰 발급을 진행한다. 이렇게 하면 정해진 수량에 맞춰 정확하게 쿠폰을 발급해줄 수 있다. 또한 프로모션 서버 입장에서 Bulk head 패턴등을 통해 타 서버로 가는 요청 배압을 조절할 수 있다. 하지만 최종적 일관성이기에 유저가 쿠폰 발급에 성공/실패했는지 즉시 알 수 없다.
정해진 개수에 맞춰 정확하게 발급이 되어야하는 요구사항에 초점을 맞춰 본 포스팅에서는 두 번째 방법을 통해 설계를 진행해본다.
초기 설계
전체적인 흐름
- 클라이언트가 프로모션 서버에게 쿠폰 발급 요청을 보낸다.
- 프로모션 서버는 SQS로 메시지를 적재하고 클라이언트에게 즉시 응답을 반환한다.
- 컨슈머들은 SQS에서 메시지를 컨슘한다.
- 프로모션 서버의 레디스를 조회하여 발급 개수를 확인한다.
- 잔여 쿠폰 개수가 0이라면 프로모션 서버로 요청을 보내서 선착순 쿠폰 이벤트가 종료되었음을 알린다.
- 주문, 유저 서버에 요청을 보내고 받아온 값을 통해 유저가 쿠폰을 발급받을 수 있는 상태인지 검증한다. 만약 두 API 요청간 의존성이 없다면 비동기로 수행하는것이 좋겠다.
- 쿠폰 발급 대상이 아니라면 아무런 작업을 수행하지 않는다.
- 쿠폰 서버에 쿠폰 발급 요청을 보낸다.
- 레디스에 저장된 잔여 쿠폰 개수를 감소시킨다.
- 프로모션 서버의 레디스를 조회하여 발급 개수를 확인한다.
만약 3번에서 쿠폰 서버에게 발급 요청을 완료했는데 레디스에 저장된 잔여 쿠폰 개수를 감소시키지 못하는 경우 문제가 될 수 있다. 이런 상황을 방지하기 위해 다음과 같은 방법을 사용할 수 있을 것 같다.
- 쿠폰 서버에게 발급 요청하는 것과 레디스 잔여 개수 감소 순서를 바꾼다. 이렇게 하면 최대 발급 개수 미만으로 발급될 순 있어도 초과 발급되진 않는다.
- 어느정도 추가 수량이 발생할 수 있음을 정책으로 가져간다.
메시지 큐
큐로 SQS를 사용했다. SQS는 매니지드 서비스로써 AWS 자체적으로 가용성을 보장해준다. 또한 하나의 토픽을 대상으로 여러개의 컨슈머가 병렬로 메시지를 읽어갈 수 있다. ack, nack를 통해 메시지에 대한 처리를 하지 않아도 다른 컨슈머들은 설정한 Visibility time이 지나기전까지 메시지를 읽을 수 없기 때문이다.
카프카는 고려하지 않았다. 선착순 이벤트 자체가 빈번하게 발생하는 이벤트가 아니기 때문에 별도의 클러스터와 파티션을 구성해두면 비용이 나가기 때문이다. 또한 선착순 이벤트마다 트래픽이 다른데, 파티션은 한번 늘리면 줄이기 힘들기 때문에 부합하지 않다고 생각했다.
레디스를 사용해보면 어떨까? 레디스 리스트 자료구조를 사용하면 메시지 큐와 같이 FIFO방식을 통해 메시지 큐로 사용할 수 있다. 하지만 메시지의 유실 위험이 있고 추가적인 관리 포인트가 늘어나는 이유로 인해 선택하지 않았다.
(참고로 요즘은 레디스 Streams도 많이 사용한다고 한다. 하지만 이 기능은 사용해본적이 없기 때문에 본 포스팅에서는 고려 대상에서 제외시켰다)
문제점
현 설계에서의 문제점은 완전한 선착순을 보장하지 못한다는 것이다. 예를 들어 다음과 같은 상황이 발생한다고 가정한다.
- 쿠폰 최대 발급 개수는 2이다.
- A유저가 12:00:00초에 쿠폰 발급을 요청했고 B유저가 12:00:01초, C유저가 12:00:02초에 발급을 요청했다.
- 메시지 큐에는 A, B, C 유저 순으로 메시지가 저장되었다.
- 각 컨슈머가 A, B, C의 메시지를 순차적으로 읽어간다.
- A유저의 발급 요청을 처리하는 순간 주문 서버에서 지연이 발생했다.
- B유저의 발급 요청은 지연없이 처리됐다.
- C유저의 발급 요청은 지연없이 처리됐다.
이런 케이스가 발생하는 경우 실제로는 A유저가 가장 먼저 쿠폰 발급을 요청했지만 B, C 유저의 발급이 먼저 진행됐고 결과적으로 A유저는 쿠폰을 발급받지 못한다. 이런 문제는 어떻게 처리하는게 좋을까?
개선된 설계
전체적인 흐름
- 클라이언트가 프로모션 서버에게 쿠폰 발급 요청을 보낸다.
- 쿠폰 최대 발급 개수보다 특정 개수만큼 더 요청을 받도록 한다. 예를 들어 최대 발급 개수가 100개라면 1000개의 발급 요청까지 수용한다.
- 최대 발급 개수 + 추가 요청 개수에 도달했다면 쿠폰 이벤트를 종료한다.
- 프로모션 서버는 SQS로 메시지를 적재하고 클라이언트에게 즉시 응답을 반환한다. 메시지를 적재할 때는 요청 당시의 타임스탬프값을 같이 보낸다. 이 때 보다 정밀한 시간 계산을 위해 Millisecond보다 Nanotime을 사용하는것이 좋을 수 있겠다.
- 컨슈머들은 SQS에서 메시지를 컨슘한다.
- 프로모션 서버의 레디스를 조회하여 현재 발급 요청 개수를 확인한다.
- 발급 요청 개수가 0이라면 프로모션 서버로 요청을 보내서 선착순 쿠폰 이벤트가 종료되었음을 알린다.
- 주문, 유저 서버에 요청을 보내고 받아온 값을 통해 유저가 쿠폰을 발급받을 수 있는 상태인지 검증한다. 만약 두 API 요청간 의존성이 없다면 비동기로 수행하는것이 좋겠다.
- 쿠폰 발급 대상이 아니라면 아무런 작업을 수행하지 않는다.
- 레디스 Sorted Set에 키: 유저ID / 값: 타임스탬프 형태로 값을 저장한다.
- 레디스에 저장된 발급 요청 개수를 감소시킨다.
- 프로모션 서버의 레디스를 조회하여 현재 발급 요청 개수를 확인한다.
- 별도 컨슈머가 Sorted Set을 0번째부터 특정 개수까지 Chunk 단위로 조회한다. 이렇게 가져오는 경우 먼저 발급 요청을 한 유저의 정보를 먼저 가져올 수 있다.
- 조회한 유저는 이미 검증이 끝난 발급 대상 유저들이다.
- 따라서 쿠폰 서버로 쿠폰 발급 요청을 보낸다. 이또한 비동기로 수행하는것이 좋겠다.
- 쿠폰 발급이 끝난 유저를 Sorted Set에서 제거한다.
2번을 보면 최대 발급 개수보다 어느정도 추가적인 요청을 받는 모습을 볼 수 있다. 이는 4-2에서 검증을 수행한 후 발급 대상이 아닌 유저는 제외되기 때문이고 이를 발급 요청 당시 알 수 없기 때문이다. 본 예시에는 최대 발급 수량 100개일 때 1000개까지 발급 요청을 수용한다고 했다. 물론 최악의 경우에는 1000명 모두 검증에 걸려 발급되지 않을수도 있다. 따라서 이는 기존 이벤트 진행 시 들어온 쿠폰 발급 요청과 실제 발급된 쿠폰의 개수 데이터를 확인하여 적정한 버퍼를 찾아야 한다.
더 나아가기
포스팅 초반에 여러가지 제한사항이 존재하였기에 그에 맞춰 설계를 해보았다. 추가적으로, 만약 이러한 제한사항들이 없다면 어떻게 설계를 바꿔나갈 수 있을 지 간단하게 생각해보자.
CDC
주문, 유저 서버로부터 검증에 필요한 정보를 CDC해오는 방안이 있다. 이렇게 한다면 외부 API호출이 줄어들기 때문에 지연시간을 감소시킬 수 있다. 하지만 쿠폰 발급이라는 기능만을 위해 데이터를 중복으로 저장하는것은 오버 엔지니어링일수도 있기에 정책과 상황을 판단하여 결정해야 한다.
Bulk API
주문, 유저 서버에 정보를 요청할 때 벌크로 요청하고 응답을 받아볼 수 있도록 각 서버에 API를 추가 개발하는것도 하나의 방법이다. 이렇게 한다면 현재처럼 한번의 요청에 유저 하나만의 정보를 얻어오는것이 아닌, 여러 유저의 정보를 한번에 받아올 수 있기 때문이다.
캐싱 처리
선착순 쿠폰 이벤트 진입 또는 그 전 유저 플로우에서 미리 프로모션 서버로 요청을 보낸 후 해당 유저가 발급 대상인지 미리 검증하고 결과를 캐싱 처리 해두는것도 하나의 방법이 될 수 있다. 이렇게 한다면 쿠폰 발급 플로우 중 가장 지연시간이 많이 걸리는 주문, 유저 서버로의 API 호출이 발급 당시에는 사라지기 때문에 오히려 간단하게 해결될수도 있을 것이다.
SSE 또는 웹 소켓
준실시간성을 보장하고 싶다면 SSE(Server Sent Event)나 웹 소켓을 사용하는것도 좋을 것 같다. 프로모션 서버에 쿠폰 발급 요청 API를 보낸 후 클라이언트에서 다시 프로모션 서버와 SSE/웹 소켓 통신을 하며 발급이 완료되었는지 확인하는 것이다. 상황에 따라 달라지겠지만 발급 요청 후 5분 내로 쿠폰함에 발급된다는 정책/알림이 있는 경우 필요없을수도 있겠다.
알림
위 내용처럼 발급 요청 -> 특정 시간 이후 발급 이라는 정책이 있다면 발급 완료 후 유저에게 노티를 보내는것도 좋을 것 같다. 예를 들어 특정 서비스의 경우 어떠한 기능을 요청하면 앱에서 "완료된 후 노티를 보내드릴게요"와 같은 메시지를 보여준다. 이를 통해 유저에게 인지시켜줄 수만 있다면 큰 문제는 없을 것 같다.
'Coding > 설계 | 경험' 카테고리의 다른 글
광고 시스템 퍼포먼스 튜닝 회고록 (1) | 2023.07.19 |
---|---|
SQLAlchemy와 AsyncIO를 사용할 때 발생한 문제점 (0) | 2022.11.17 |
동시성을 고려한 쿠폰 재고 시스템 설계 (0) | 2022.07.28 |
FastAPI에서 SQLAlchemy Session 다루는 방법 (1) | 2022.03.23 |
당근 서버 밋업 1회 정리 (0) | 2021.12.21 |