선착순 이벤트 아키텍처 구성
선착순 이벤트 인프라 아키텍처를 구성해보겠습니다.
선착순 이벤트는 짧은 시간에 많은 트래픽이 몰리고, 정확한 수량만큼만 제공되어야 합니다.
수량이 초과되면 마케팅 비용의 손실이 발생할 수 있고, 많은 트래픽으로 인해 서버가 다운될 수 있어 다음 조건에 맞춰 아키텍처를 구성해야 합니다.
여기서는 백엔드 이벤트 API 서버를 중점으로 설명합니다.
이벤트를 설계하기 위한 조건은 아래와 같습니다.
- 이벤트 수량을 정확히 제한해야 합니다.
- 높은 트래픽에서도 빠른 응답 속도를 유지해야 합니다.
이 글에서는 인프라 아키텍처를 먼저 소개한 뒤, 서비스 구성을 설명하겠습니다.
작업 절차
쿠폰 발급 이벤트를 예시로, 처리 절차는 다음과 같습니다.
- 사용자의 쿠폰 발급 요청을 받습니다.
- 중복 없이, 정해진 수량 내에서 쿠폰을 발행합니다.
- 쿠폰 발행 내역을 기록합니다.
- 발행된 쿠폰을 사용자에게 부여합니다.
- 사용자에게 이벤트 처리 결과를 응답합니다.
안정성과 성능을 위해 쿠폰 처리를 '발행'과 '부여'로 구분하여 처리합니다.
참여자가 적으면 단일 프로세스/스레드로 충분하지만, 수만 명이 동시에 요청하면 시스템이 멈출 수 있으므로 프로세스를 분리합니다.
인프라 아키텍처 구성
메인 비즈니스 서버와 이벤트 서버를 분리하여 장애 전파를 막습니다.
L4 로드밸런서
이벤트 서버의 이중화를 위해 앞에 L4 로드밸런서를 배치합니다.
Tomcat
요청부터 응답까지 빠르게 처리하여 더 많은 요청량을 커버하도록 합니다.
I/O bound 작업을 최대한 비동기(비동기 쓰레드)로 빼내기 위해, 이벤트 루프 기반 서버(Netty + Webflux) 대신 Servlet 방식의 Tomcat을 선택합니다. (더 가벼운 Jetty 서버도 고려해볼 수 있습니다.)
서버 내에서 사용자 세션에 붙어있는 쓰레드가 작동하는 시간을 줄여 더많은 사용자의 요청을 받을 수 있도록 만듭니다.
서버 대수는 부하 테스트 후 확정합니다.
Redis
이중화된 서버에서 쿠폰 발행 수량 정합성을 유지하기 위해 Redis를 사용합니다.
Redis는 멀티 프로세스 환경에서 Lua 스크립트를 통한 CAS-like 원자적 처리가 가능하여 적합합니다.
(CAS-like는 CPU 자체 원자적 명령어를 실행하므로써 동시성 문제를 해결하는 CAS 연산을 닮은 것을 의미하며, Lua 스크립트와 Redis의 특성인 싱글 스레드 엔드포인트 를 조합하여 강력한 기능을 만들어낼 수 있습니다.)
이외에는 다음과 같은 인프라들이 있습니다.
- 자체 JVM : Lock과 CAS 연산 그리고 동적 세마포어 모두 사용가능하여 성능상 가장 좋으나, 메모리를 공유하지 않기에 멀티 프로세스에서는 사용 불가
- RDB : 순간적으로 높은 트래픽 내에서 데이터 정합성을 고려하면 비관적 Lock을 사용해야 하며, 이에 따라 성능 저하 가능성 있음
- NoSQL(DynamoDB) : 명시적 락을 미지원하며, 원자적 처리에 어려움이 있음
- Kafka : 실시간 응답 처리에 어려움, 중복 방지 추가 설계 필요
Kafka
쿠폰 발행 후 지급은 별도 서버로 분리하며, 동기 처리가 필요 없으므로 HTTP 프로토콜 대신 비동기 처리 메시지 큐인 Kafka를 사용합니다.
비동기 처리도 좋으나, Kafka는 데이터 유실이 없고 이벤트 소싱을 통해 사후 분석(예외 추적 등)이 용이하여 선택하였습니다.
또한, stream api 의 peek 처럼 전송중인 데이터들을 모니터링용으로 소비가 가능합니다.
로직 분류
목적은 사용자 요청 세션에서 I/O bound 작업을 최소화 하기 위해서 비동기 작업과, 비동기가 불가능한 작업으로 분류합니다.
동기 : 사용자의 요청/응답, 쿠폰 잔여 수량 및 중복 확인
비동기 : 쿠폰 발급, 쿠폰 지급 처리
여기서 중요하게 보아야 할 것은 사용자의 이벤트 참여 요청이 오면 이벤트 상태와 사용자 상태를 조회하여 이벤트에 참여가 가능한지 확인 만 하고 응답을 준다는 것입니다.
사용자에게 '이벤트 참여가 완료되었습니다.' 라는 메시지를 제공한 후 이후에 쿠폰 발급 발행 이 시작됩니다.
Event api 코드 (샘플)
public class Sample {
private final ApplicationEventPublisher publisher;
private final RedisRepository redisRepository;
@PostMapping("/events/join")
public ResponseEntity<?> joinCouponEvent(@RequestBody User participant) {
/// 잔여 쿠폰 확인 + 중복 확인 + 쿠폰 개수 차감 + 쿠폰 발행 기록 (원자 처리)
EventJoinResult result = redisRepository.issueCouponIfAvailable();
if (result != SUCCESS) throw new EventParticipationException(result);
/// 쿠폰 발급 요청 이벤트 생성 (비동기 실행)
CouponIssuedEvent issuedEvent = new CouponIssuedEvent(participant);
publisher.publishEvent(issuedEvent);
/// 사용자 응답
return ResponseEntity.ok().body("이벤트에 참여 완료하였습니다.");
}
}
public class CouponEventListenerSample {
private final KafkaProducerService kafkaProducer;
@Async
@EventListener(CouponIssuedEvent.class)
public void handleCouponIssuedEvent(CouponIssuedEvent event) {
kafkaProducer.send(event);
log.info("Coupon Issued Event sent to Kafka topic: {}", event);
}
}
서비스 운영 시 정책에 맞는 적절한 예외처리들을 추가해야 합니다.
예) I/O 실패에 대한 5회 재시도 처리 후 요청 실패 처리 등
Redis는 Lua 스크립트로 중복 검사 및 쿠폰 수량 차감을 원자적으로 처리하여 데이터 정합성을 유지합니다.
issueCopuonIfAvailable 메서드는 4개의 작업을 하나의 Lua Script로 작성합니다.
Redis 내부 데이터 예시
couponCount |
998 |
participant |
1356889:true |
1533155 |
Batch Service 핵심 코드 (샘플)
쿠폰 지급 배치는 Kafka를 통해 비동기로 처리됩니다.
public class Sample {
private final BusinessDatabaseService businessDatabaseService;
private final RedisRepository redisRepository;
@KafkaListener(topics = "event-join-topic", groupId = "event-join-group-1")
public void eventJoinConsumer(CouponIssuedEvent event) {
Coupon coupon = createNewCoupon(EVENT_TYPE);
/// 쿠폰 지급
businessDatabaseService.grantCouponToUser(event.getUserId(), coupon);
/// 쿠폰 지급 완료 처리
redisRepository.markParticipationAsGiven(event.getUserId());
}
}
Redis 데이터 내부
couponCount |
998 |
participant |
1356889:true |
1533155:true |
마무리
이 아키텍처는 Scale-Out이 용이하며 Kubernetes와 함께 사용하면 처리량을 손쉽게 늘릴 수 있습니다.
아키텍처 설계 시에는 동기/비동기 처리 여부, Lock 필요성, 모니터링 중요도, I/O 또는 CPU Bound 여부 등을 고려해 적절한 기술과 인프라를 선택해야 합니다.
또한, 모든 상황에 정답이란 없으며, 도메인의 특성과 처리 정책, 개발팀 규모와 역량, 인프라 비용과 같은 비기술적 요소까지 함께 고려하여 설계해야 합니다.
추가로, 장기적으로 모놀리스를 SOA나 MSA로 분리하거나, 정형화된 로직을 미리 지나치게 최적화하는 것은 오히려 비효율적일 수 있습니다.
최적화가 필요하다면 반드시 모니터링 도구를 도입하여 일정 기간 측정된 데이터를 기반으로 진행하는 것이 바람직합니다.