쿠폰 발급을 위한 기술적 구성요소 - Hot-stock/backend GitHub Wiki

디비의 접근을 줄이기 위한 Cache Aside 패턴

사용자는 eventId를 통해서 요청을 보내게 되는데 서버의 입장에서는 eventID가 있는지 검증을 해봐야 한다. 여기서 eventID를 확인하기 위해서 디비에 접속을 하지만 디비의 커낵션 풀로는 대량의 요청을 대응할 수가 없다. 따라서 TPS는 점점 낮아지게 되는데, 이 경우를 대비하기 위해서 처음 요청만 디비에 접근해서 event를 확인한 후 레디스에 값을 넣어주게 된다.

왜 더 빨라질까?

Redis와 데이터베이스는 모두 커넥션을 통해 데이터를 조회하지만, 차이가 발생하는 이유는 데이터 접근 방식과 처리 속도에 있습니다. Redis는 메모리에 저장된 데이터를 조회하므로 디스크 I/O를 피할 수 있어 데이터 접근이 매우 빠릅니다. 따라서 커넥션의 설정 및 반환이 매우 빠르게 이루어질 수 있습니다. 반면, 관계형 데이터베이스는 디스크 기반 연산을 수반하기 때문에, 데이터 조회에 시간이 더 걸리며 트랜잭션 처리와 같은 부가적인 작업도 필요합니다. 이로 인해 데이터베이스 커넥션의 반환이 상대적으로 느려질 수 있습니다따라서 레디스에 없을 때 데이터베이스에 요청을 보내고, 응답은 레디스에 넣어주는 방식을 사용하고, 그 이후의 요청은 레디스를 확인하면 처리량을 더 높일 수 있다.

flowchart LR
    Client -->|Request| Redis[Redis Cache]
    Redis -->|Cache Hit| Client
    Redis -->|Cache Miss| Database[Database]
    Database -->|Data Fetch| Redis
    Database -->|Data Fetch| Client
    Client -->|Subsequent Request| Redis

단점

이 패턴의 단점으로는 일관성 문제가 있습니다. 데이터베이스와 캐시의 데이터 상태가 맞지 않을 수 있는데, 제 구조에서는 이런 단점의 영향을 줄일 수 있습니다. 그 이유는 데이터 연산을 하는 것이 아닌 이벤트의 존재 여부만을 확인하기 때문에, 데이터 변경이 적어 캐시와 데이터베이스 사이의 일관성 문제를 걱정하지 않아도 됩니다.

쿠폰 발급 방법

중복 방지

중복 참여를 방지하기 위한 방법으로 레디스의 RSET을 사용했습니다. 요청을 받게 되면 event set 객체에 요청자의 PK를 확인해서 있으면 사용자가에게 '중복 요청이라고 알려주고', 아니면 sorted set에 요청 시간이랑 client PK와 session id를 저장해준다.

여기서 중요한 점은 위의 두 연산이 원자적으로 처리되어야 데이터의 일관성을 보장할 수 있음으로 REDIS의 MULTI 명령어를 사용해야 한다.

flowchart LR
    Client -->|Request| Redis[Check Set]
    Redis[Check Set] -->|Already Exists| Client["중복 요청"]
    Redis[Check Set] -->|Not Exists| Redis[Add to Set & Sorted Set]
    Redis[Add to Set & Sorted Set] --> Client["참여 성공"]

TPS를 높이기 위한 비동기 처리 방식

높은 TPS를 확보할 수 있는 이유

그 이유는 세 가지로 요약될 수 있는데, 쿠폰 발급 작업을 백그라운드로 전환한 것과, 각 서버가 무상태가 됐기에 scale-out이 쉽다는 점, Redis를 활요한 캐시 선택이 있습니다.

1. 메인 처리부에서 작업 분리

  • 즉각적인 응답 반환: 클라이언트의 요청을 받으면 메인 서버는 즉각적으로 Redis 큐에 데이터를 저장하고 클라이언트에게 응답을 반환합니다. 이렇게 하면 클라이언트는 빠르게 응답을 받을 수 있으므로, 요청 처리 시간이 줄어들어 더 많은 요청을 처리할 수 있습니다.
  • 백그라운드 작업 처리: 무거운 쿠폰 발급 작업은 스케줄러가 Redis 큐에서 데이터를 비동기적으로 가져와 처리합니다. 이 과정은 메인 서버의 자원을 소모하지 않으므로, 메인 서버의 TPS(초당 트랜잭션 처리량)를 크게 향상시킬 수 있습니다.

2. 무상태 서버

  • 서버 자원의 효율적 사용: 무상태 서버는 클라이언트의 상태 정보를 서버에 저장하지 않고, 각 요청은 독립적으로 처리됩니다. Redis와 스케줄러 간의 비동기 작업 처리는 이러한 무상태 특성과 잘 맞아, 서버 자원을 효율적으로 사용할 수 있게 합니다. 이는 메인 서버가 비동기적으로 요청을 처리하고, 이후 작업은 스케줄러가 Redis에서 큐 작업을 순차적으로 가져와 처리하기 때문입니다. 메인 서버의 부하를 줄이고, 대규모 요청을 처리하는 데 적합한 구조입니다.

  • 시스템의 유연성 증가: 무상태 서버는 상태를 서버가 아닌 외부 스토리지(예: Redis, Kafka 등)에 저장하기 때문에, 수평 확장이 용이합니다. Kafka를 통해 메시지를 처리하면 다른 서비스와의 통신도 비동기적으로 이루어져, 시스템의 확장성유연성이 증가합니다. 이는 쿠폰 발급이 완료되었을 때, 다른 서비스가 이를 구독하여 처리할 수 있도록 하기 때문에, 추가적인 부하 없이 기능을 확장할 수 있습니다. 여러 서버 인스턴스가 동일한 요청을 처리할 수 있어, 트래픽이 급증하는 상황에서도 시스템의 안정성을 유지할 수 있습니다.

3. Redis의 빠른 처리 속도

  • 메모리 기반의 빠른 큐 처리: Redis는 메모리 기반으로 동작하기 때문에 큐에 데이터를 삽입하거나 제거하는 속도가 매우 빠릅니다. 대량의 요청이 몰려도 Redis는 이러한 요청들을 빠르게 큐에 추가할 수 있어, 메인 애플리케이션 서버가 요청을 빠르게 수용할 수 있습니다.
  • TPS 향상에 기여: 메인 서버는 Redis에 데이터를 넣기만 하고, 실제 작업은 스케줄러가 처리하므로, 메인 서버의 부담이 줄어들어 TPS를 높이는 데 기여합니다.
flowchart LR
    Client -->|Request| Server[Application Server]
    Server -->|Immediate Response| Client
    Server -->|Enqueue| RedisSortedSet[Redis Sorted Set]
    Scheduler[Coupon Scheduler] -->|Fetch Data| RedisSortedSet
    Scheduler -->|Update| Database[Coupon Database]
    Scheduler -->|Publish| Kafka[Kafka - Coupon Issued Topic]

Outbox 패턴

디비의 저장은 트랜잭션으로 결합되어 보장이 되지만, 메세지는 트랜잭션이 없기 때문에 전송에 실패하면 복구 방법이 없게 된다. 이를 해결하기 위해서 Outbox 패턴을 도입한다

flowchart LR
    AppServer[Scheduler] -->|Store Data| Database[Database]
    AppServer -->|Write to Outbox| Outbox[Outbox Table]
    Processor[Outbox Processor] -->|Read from Outbox| Outbox
    Processor -->|Send to Kafka| Kafka[Kafka Topic]
    Kafka -->|Process Events| Consumer[Event Consumer]
    Processor -->|Update Outbox| Outbox

응답 결과 반환을 위한 카프카와 SSE

응답 결과를 받기 위해서 클라이언트는 SSE연결을 요청해야 한다. 그 이후에 API GW에서 카프카의 토픽을 구독하고 있다가 값이 전송되면 SSE에게 결과를 알려줘야 한다. 다만 SSE방식은 대용량 트래픽 방식에서 서버의 자원을 소모하기 때문에 문제점이 있다.

아직은 서비스가 크지 않기 때문에 SSE를 사용하지만 추후에 websocket을 사용하는 확장 포인트로 남겨 놓겠다

flowchart LR
    Client -->|SSE Connection| API_Gateway[API Gateway]
    API_Gateway -->|Subscribe to Topic| Kafka[Kafka Topic]
    Kafka -->|Receive Message| API_Gateway
    API_Gateway -->|Send Response| Client