[Trouble Shooting] 대용량 트래픽에 적합한 대기열 처리 방법과 부하 테스트 환경 (티켓팅 서비스)
🎯 Spring MVC -> Spring WebFlux 로 입장 대기 기능 이전
티켓팅 서비스를 만들면서 발생한 문제는 Spring MVC 의 자원적 한계입니다. Spring MVC 에서 tomcat 은 사용자의 요청을 하나의 스레드가 완전히 처리하고 관리합니다. 하지만 이런 톰켓 스레드 개수를 조절하고, CPU 를 아무리 좋은걸 사용한다해도 수십, 수만건의 요청을 한번에 부담하기에 한계가 명확했습니다.
그래서 Spring WebFlux 도입을 결정했습니다. Spring MVC 와 달리 Spring WebFlux 는 사용자 요청을 논 블로킹 으로 처리해, 요청이 많아져도 서버의 리소스를 효율적으로 활용할 수 있습니다. 내부적으로 Netty 같은 논 블로킹 서버를 사용하며, 요청 하나당 스레드를 점유하는게 아니라, 이벤트 루프 기반으로 빠르게 많은 요청을 처리할 수 있는 아키텍처이고 짧은 시간에 많은 요청을 처리해야 하는 티켓팅 입장 기능에 적절하다 생각했습니다.
그래서 기존 Spring MVC 로 만들어진 하나의 모놀리틱 서버 구조에서 대기열 서버(Spring WebFlux) 와 예약 서버(Spring MVC) 로 분리를 결정했습니다.
✅ 대기열 입장 아키텍처(최종)
1. 사용자는 "입장" 버튼을 누른다.
2. 로드 밸런싱을 통해 요청을 분산시킨다(현재는 Round Robin 방식 사용중 추후 개선)
3. 입장 요청은 대기열 서버(WebFlux) 에서 사용자가 입장 했음을 Kafka 로 전송 한다.
4. 카프카에서 순차적으로 메시지를 꺼내와 사용자의 현재 대기 순서를 Redis ZSet 에 삽입한다. (Zset 의 정렬 기준은 입장 시간)
5. 사용자는 대기열 페이지에서 Web Socket 을 통해 실시간으로 현재 내 순번 Redis Zset 에 꺼내 조회한다.
6. 예약 서버 입장 여부를 Web Socket 을 통해 확인한 뒤, 예약 서버로 입장시킨다.
즉, 수십만건의 요청이 짧은 시간에 쏠릴 수 있는 입장 기능 API 는 카프카에 메시지를 전송만을 담당합니다. 이때 주의할점이 WebFlux 를 사용한다해서 무조건 고성능을 보장하는 것이 아닙니다. 만약 요청안에 블로킹 동작이 포함된 기능이 있으면 결국 논 블로킹의 이점을 온전히 살릴 수 없습니다.
특히 Kafka 클라이언트 라이브러리(KafkaProducer) 는 기본적으로 동기/블로킹의 전송 방식을 사용합니다. 그래서 어떤 방법이 있을까 찾아본 결과 메시징 전송 자체를 비동기로 감싸서 보내주는 라이브러리를 사용하는 방법과 직접 블로킹 요청을 기다리지 않고 별도의 스레드풀에서 메시징 전송을 처리하는 방법이 있었습니다.
그중 저는 별도의 스레드풀에서 메시징 전송을 처리하는 방법을 선택했습니다. (카프카 메시징 전송 기능은 여기서만 사용해서 굳이 라이브러리를 추가하지 않고 직접 커스텀해주는거 더 적절하다 생각함)
✅ 대기열 입장 요청 API
@RestController
@RequestMapping("/v1/api/queues")
public class QueueController {
private final WaitingLineProducer kafkaQueueProducer;
public QueueController(WaitingLineProducer kafkaQueueProducer) {
this.kafkaQueueProducer = kafkaQueueProducer;
}
@PostMapping("/enter")
public Mono<ResponseEntity<SuccessResponse>> enter(@RequestParam String email) {
return Mono.fromRunnable(() -> kafkaQueueProducer.sendQueueEnterMessage(email))
.subscribeOn(Schedulers.boundedElastic())
.thenReturn(ResponseEntity.ok(new SuccessResponse(true, "대기열 등록 완료", null)));
}
}
✅ 메시징 전송 Producer
@Component
public class WaitingLineProducer {
private final KafkaTemplate<String, Object> kafkaTemplate;
@Value("${kafka.topic-config.queue-enqueue.name}")
private String topic;
public WaitingLineProducer(KafkaTemplate<String, Object> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void sendQueueEnterMessage(final String email) {
kafkaTemplate.send(topic, email);
}
}
위 코드를 보면 Mono.fromRunnable 를 이용해 카프카 전송 동작을 별도의 스레드 풀(boundedElastic) 에서 실행해 WebFlux 이벤트 루프를 막지 않고 논블로킹 처리를 유지 합니다. 즉 클라이언트는 즉시 대기열 등록 완료 응답을 받을 수 있습니다.
이제, 대기열 입장 기능이 어느정도의 부하를 견딜 수 있을지 테스트를 해보다가 또 다른 문제가 발생했습니다.
🎯 로컬 환경에서 대용량 트래픽 부하 테스트는 적절하지 못함..
가장 처음에 진행했던 부하 테스트 환경입니다.
(1) 애플리케이션 로컬 도커로 띄우기
(2) 내 컴퓨터에서 K6 부하 발생
(3) Grafana 에서 부하 결과 모니터링
즉, 부하 발생부터 애플리케이션 실행까지 전부 로컬 컴퓨터에서 진행되었습니다.
K6 를 통해 API 엔트포인트로 초당 500개 정도의 요청을 보내봤지만 평균 응닶값이 20초, 절반이상의 요청들이 dropped 되는 것을 확인했습니다. 혹시 카프카 문제인가 싶어서 요청 후 바로 응답해주는 테스트용 API 를 만들어 진행해도 결과는 비슷했습니다.
그래서 애플리케이션 자체에서의 병목이 아니라 부하를 발생시키는 k6 와 이를 처리하는 서버가 같은 컴퓨터에서 실행되고 K6 에서 도커로 넘어가는 흐름이 문제일 수 있겠다라고 의심했습니다.(서버 CPU, 메모리 등은 매우 양호했음) 그래서 k6를 Docker 로 실행하고 같은 Docker 네트워크에서 API 서버로 요청을 보내본 결과 평균 응답 속도가 ms 수준으로 회복됨을 확인했습니다.
즉, 로컬 K6 -> 로컬 Docker 컨테이너 안의 서버로 요청을 보내면 로컬 커널을 거쳐 이동합니다. Docker는 기본적으로 bridge 네트워크를 사용하며, 이는 호스트 NIC와는 별개의 가상 인터페이스를 생성해 트래픽을 라우팅합니다.이 과정에서 NAT 변환, 패킷 복사, 커널 네트워크 스택 오버헤드가 발생해 기대했던 결과보다 느렸던 것 이었습니다.
결과적으로 컨텍스트 스위칭 비용, 네트워크 I/O 지연, 리소스 경합 등이 발생해 가벼운 논블로킹 요청이여도 부하 시나리오가 무거워지면 응답 시간이 늘어날 수 밖에 없는 환경이기에 대용량 트래픽에 대한 부하 테스트는 로컬 환경에서 적절하지 못함을 알게 되었습니다.
흠.. 그러면 어떻게 대용량 트래픽에 대한 부하 테스트를 진행하지 ? 다른 사람들은 높은 부하 테스트를 어떻게 하는지 찾아본 결과 부하 발생 인스턴스와 서버 인스턴스를 따로 분리해 같은 컴퓨터 내에서의 리소스 경합 과정을 배제하는 방식을 많이 사용했습니다. (아래 블로그 에서 많이 참고했습니다.)
https://vince-kim.tistory.com/39
대용량 트래픽은 어떻게 테스트해야할까? 60만 RPM 테스트하기 (K6, AWS, EC2, 테스트, 성능테스트)
Contents 이번 이야기 Definitions 테스트 Overview 1,000 RPS 테스트 : 설명 1,000 RPS 테스트 : 결과 2,000 RPS 테스트 : 설명 2,000 RPS 테스트 : 결과 점검의 시간 : What Application Load Balancer is doing for us 로드밸런싱 H
vince-kim.tistory.com
즉, 일반적으로 부하 테스트를 진행할때 다음과 같은 과정을 거칩니다.
1. 로컬 환경에서 빠르게 테스트 시나리오 확인
2. 별도의 클라우드 서버(EC2, 쿠버네티스 등)에서 정밀한 부하 테스트 진행
3. 부하 발생기는 별도의 서버에서 띄움
4. 네트워크 병목 최소화
이렇게 K6 부하 발생 인스턴스와 서버 애플리케이션 인스턴스를 분리하면
로컬 부하 -> 로컬 Docker 에서 발생하는 가상 NIC -> NAT -> docerk0(기본 도커 네트워크) -> 커널 네트워크 -> 컨테이너로 이어지는 흐름에서 일반 TCP/IP 통신으로 처리되어 NAT 와 기본 도커 네트워크, 가상 NIC 도 없어 더 빠른 성능을 확보할 수 있습니다.
그리고 가장 중요한 점은 같은 자원을 공유하지 않는다는 점 입니다. 또한 EC2 를 통해 분리하면 부하 테스트를 진행하다 병목이 생겼을때 서버 CPU 가 과부하면 서버 문제임을 알 수 있고, K6 가 느리면 네트워크나 K6 인스턴스에 문제가 있음을 정확히 알 수 있습니다.
그리고 여기서 한가지 더 의문점이 생겼던건, 서버는 EC2 에 올리고, 부하 발생은 내 컴퓨터에서 하면 안되나? 결론부터 말하면 가능은 하지만 좋은 방법은 아니였습니다. 내 컴퓨터에서 EC2 서버로 테스트할경우 테스트 트래픽이 실제 인터넷을 통해 요청이 보내지기 때문에 ISP -> 백본망 -> AWS 로 이동하고 이 과정에서 패킷 손실이나 지연이 발생할 수 있고 네트워크 품질에 따라 테스트 결과가 매번 달라져 테스트에 대한 신뢰도가 저하될 수 있습니다.
이러한 이유 때문에 대용량 트래픽에 대한 부하 테스트를 진행할때 부하 발생 인스턴스, 서버 인스턴스를 각각 배포하는 분리를 결정했습니다.
일단 하나의 대기열 서버가 어느 정도의 RPS를 견딜 수 있는지 테스트해보고, 그에 맞춰 적절한 EC2 인스턴스 유형을 선택하며 성능을 조정해나가는 것이 중요해 보입니다.