
서비스 특성 상 매월 1일 특정시간에 선착순으로 충전 시 인센티브를 제공해, 트래픽이 짧은 시간 내 폭증하는 이벤트가 존재한다.
또한 민생회복 소비쿠폰 등의 대규모 이벤트에도 대비해야 되었다.
다만 코어 인프라에 인입되는 트래픽이 일정 수준 이상일 경우 서비스 장애가 발생하였고,
과거에 트래픽을 분 단위로 특정 수 만큼 인입되게 하는 대기열 솔루션 (트레이서라고 칭함)이 존재했다.
다만 그 트레이서(대기열 솔루션)도 일정 수 이상의 사용자가 인입되면 대기열 자체에도 장애가 발생해,
이를 해결하기 위해 직접 대기열 프로젝트를 구축하는 TF 팀에 참여하게 되었다.
구 대기열 솔루션 (트레이서)는 외부 솔루션이라, 설정 및 소스코드 분석이 제한적일 뿐 아니라 해당 개발사와도 연락이 되지 않는 상황이었다.
그래서 분석할 수 있는 최소한의 메트릭들을 분석하던 중,
아래와 같은 Linux 메트릭 정보를 확인해, TCP 통신 중 Socket Overflow에 대한 오류 원인 분석을
Socket Overflow 분석
에 정리해 두었다.
2063660 times the listen queue of a socket overflowed
2245967 SYNs to LISTEN sockets dropped

기술적으로 아래 기능에 대해 가장 성능이 중요한 것을 선택해야 했다.
이로 인해, 하나의 데이터에 경합을 최소화 하기 위해 Redis 를 선택.
-> Main Command 작업은 Single Thread 로 동작하므로 Lock 으로 인한 성능 저하가 일어나지 않는다.
-> 저수준 (C언어) 으로 구현되어 어셈블리 만큼의 성능을 발휘할 수 있다.
또한, Redis 는 다양한 자료구조를 제공한다.
Zone 별로 독립적인 물리 서버에서 연산하는 구조를 위해 Cluster 구조를 선택.
Redis 의 오픈소스 버전인 Valkey 를 사용하지 않은 이유
Redis 8.0 버전을 사용한 이유
AWS ElastiCache 를 사용하지 않은 이유

사내 기본 개발 환경인 Spring Boot 2.x 를 사용하면서, 항상 무거운 Thread Pool 으로 인한 성능 저하에 대해 항상 고민을 했었다.
이를 해결할 Stream 기반 Webflux 도 찾아보았으나, 기술 패러다임이 기존 MVC 구조와는 크게 달라 개발 생산성 및 유지보수에 문제가 있었다.
기술 검토 중, Java 21 부터 공식적으로 지원하는 Virtual Thread 를 찾아보며 기존 개발 구조를 유지하면서 개선이 가능할 것이라는 판단을 했다.
기존 Spring Boot 기본 MVC 모델의 한계

Virtual Thread 의 구조 및 장점
Virtual Thread 쓰레드 생성/스케줄 속도
| 대상 | 기본 Thread | Virtual Thread |
|---|---|---|
| 메모리 사이즈 | ~2MB | ~50 KB |
| 생성 시간 | ~1ms | ~10µs |
| 컨텍스트 스위칭 시간 | ~100µs | ~10µs |
Virtual Thread 사용 시 유의사항
참고 : 사용 버전
- JDK : 21
- Spring Boot : 3.4.0
- Kotlin : 2.1.0
- Redis : 8.0
Sliding Window Log는 특정 window 내에서 발생한 이벤트를 기록하고, 그 창이 시간에 따라 이동하면서 오래된 이벤트는 제외하는 방식이다.
Window 내 트래픽을 정밀하게 제어해 임계치 이상의 트래픽은 진입되지 못한다.
해당 알고리즘 자체의 단점은 진입되지 못하는 트래픽도 메모리에 저장되기 때문에 메모리 사용량이 높아질 수 있다는 것인데,
하지만 이 부분은 오히려 진입하지 못한 사용자들의 대기 순번을 지정해 예상 진입시간을 노출하는 요구사항에 오히려 부합한다.
즉 이 프로젝트에서는 단점 없이 효과적으로 구현할 수 있었을 뿐 아니라,
Window 의 사이즈를 운영자 설정사항인 1분 단위가 아닌 더 작은 단위(6초)로도 구현할 수 있어
1분 내에서도 특정 구간에서 사용자의 트래픽이 일순간 폭증하는(Burst) 상황에서도 Window 내 임계치에 막혀 트래픽이 비정상적으로 흘러가지 않는다.
또한 Redis Sorted Set 에서 특정 유저 Token 에 대한 값은 최초 진입 요청 Timestamp 값으로 정렬되어 저장할 수 있으므로,
Window 를 특정 분 혹은 구간으로 설정하면 유저 Token 값으로 해당 Window 안에 속하는지 빠르게 판별이 가능하다.
앞서 말했듯이 사용자의 순번을 조회하는 것은 Reids ZSET 내부의 Skip List 자료구조 덕분에 O(Log N) 시간복잡도로 조회가 가능하며,
예상 대기 시간은 Token Timestamp, 순번과 window size + 임계치를 조합해 사용자에게 응답한다.
여기서 끝이 아니라, 사용자 경험을 높이기 위한 여러 예외사항들을 처리해야 한다.
대기열 이탈자로 인한 후순위 사용자들의 무의미한 대기
- 만약 대기열 후순위에 추가된 사용자가 10,000 번에 위치했는데, 이 사용자가 진입 가능하기 전에 대기열에서 나가버려도 서버는 명시적으로 알 수 없다. (앱 강제 종료, 백그라운드 실행)
- 극단적으로 이러한 사용자들이 5,000 ~ 10,000 모두 대기열에서 이탈해버린다면 10,001 순번 사용자들은 이전 사용자들이 무의미한 순번을 가지고 있음에도 최초 예상 대기 시간보다 더 빨리 진입할 수 없게 되어버린다.
- 이러한 경우를 처리하기 위해, 사용자가 마지막으로 Polling 한 시간 을 별도 Hash 로 저장해, 특정 주기(ex: 30초)마다 최근 1분동안 Polling 하지 않은 사용자 Token 들을 대기열에서 삭제시켜 버린다.
- 이로 인해 사용자 입장에서는 최초 예상 대기 시간보다 더 빠르게 진입이 가능할 수 있다. 실제로도 운영 상 약 30~40% 의 사용자들이 대기열에서 이탈하는 것으로 확인되었다.
일시적인 사용자 개인 네트워크 지연(Wi-fi 등)으로 인한 Polling 중지 시간동안 현재 Window 가 이미 지나버린 경우
- 사용자의 App 에서 일시적으로 Polling 이 되지 않는 예외사항은 꽤 존재한다. (Wi-fi 순단, App Crash, 핸드폰 성능 문제, 전화로 인한 갑작스런 백그라운드 이동 등)
- 이 순간동안 사용자의 Token 이 현재 진입 가능한 Window 보다 지나버린 경우에도 진입할 수 있게 해주어야 사용자는 억울하지 않다.
- 그래서 꼭 Window 내의 Token Timestamp 값만 진입 가능한 것이 아닌, 지나버린 Token 도 진입할 수 있게 해준다.
- 물론 그 시간은 무제한이 아닌 위 1. 에서 언급한 마지막 Polling 시간 기준 1분이 지난 것들은 삭제시켜 불필요한 메모리 낭비는 방지한다.
대기열이 없어야 되는 평상시임에도, 일시적인 Burst 로 인해 Window 내 임계치에 도달해 대기열이 발생하는 경우
사내에서는 전자금융업 보안 상 Public Cloud 사용이 제한적이었다.
그래서 설계 초기 당시 온프레미스 서버에 가상화 솔루션을 설치해 사용하는 뉴타닉스 사용을 지시받았다. (사내에서도 사용 중인 솔루션)
다만 뉴타닉스의 한계는 아래와 같았다.
이로 인해 AWS 의 장점을 비교해 AWS 를 사용해야 하는 이유를 보고했고,
사용 허가를 받음에 따라 아직은 사내 운영이 미숙한 AWS 에 대해 직접 학습하고 설계 및 검증을 했다.
핵심 인스턴스는 모두 EC2 로 띄움
인스턴스 타입 선택 이유 (c7i.xlarge, c7i.2xlarge)
c7i 의 특징
대기열 코어 서버, Redis 만 2xlarge 선택 이유
보안 고려

Prometheus 는 어떻게 Auto Scaling 되는 대기열 코어 서버의 각각의 인스턴스들의 Metric 들을 수집하는지
- 일정 주기(30s)마다 AWS 에 특정 이름 또는 Auto Scaling Group 에 속한 인스턴스들의 Private IP 들을 조회
- 조회되는 Private IP 들에게 Metric 수집 요청


Java 의 JIT(Just-In-Time) 컴파일 캐싱은 JVM에서 실행 성능을 최적화하기 위해 사용하는 기술이다.
이 기술은 자주 사용되는 바이트코드를 네이티브 머신 코드로 변환하여 heap-off 메모리에 코드 캐시로 저장한다.
이는 코드의 수행 횟수가 많을 수록 Tier 가 높아져 더 최적화된 수행을 가능하게 만든다.
이 프로젝트에서는 갑작스런 트래픽 급증(Burst)에도 컴파일 캐싱이 늦게 되는 것을 조금이나마 방지하기 위해,
컴파일 최적화 Tier 임계치 옵션을 튜닝한다.
java -XX:+PrintFlagsFinal -version | grep Threshold | grep Tier
java version "21.0.2" 2024-01-16 LTS
Java(TM) SE Runtime Environment (build 21.0.2+13-LTS-58)
Java HotSpot(TM) 64-Bit Server VM (build 21.0.2+13-LTS-58, mixed mode, sharing)
uintx IncreaseFirstTierCompileThresholdAt = 50 {product} {default}
intx Tier2BackEdgeThreshold = 0 {product} {default}
intx Tier2CompileThreshold = 0 {product} {default}
intx Tier3BackEdgeThreshold = 60000 {product} {default}
intx Tier3CompileThreshold = 2000 {product} {default}
intx Tier3InvocationThreshold = 200 {product} {default}
intx Tier3MinInvocationThreshold = 100 {product} {default}
intx Tier4BackEdgeThreshold = 40000 {product} {default}
intx Tier4CompileThreshold = 15000 {product} {default}
intx Tier4InvocationThreshold = 5000 {product} {default}
intx Tier4MinInvocationThreshold = 600 {product} {default}
임계치 설명
JIT 컴파일 캐싱 Tier
Tier0 : 인터프리터 (Interpreter) - 바이트코드를 한 줄씩 해석해서 실행.
Tier1 : C1 컴파일러 (Simple C1 compiled code) - 프로파일링 정보 없이, 매우 기본적인 최적화만 수행하여 빠르게 컴파일.
Tier2 : C1 컴파일러 (Limited C1 compiled code) - 일부 프로파일링 정보 수집.
Tier3 : C1 컴파일러 (Full C1 compiled code) - 모든 프로파일링 정보를 수집하여 C2 컴파일러가 사용할 수 있도록 준비.
Tier4 : C2 컴파일러 (C2 compiled code) - C1이 수집한 프로파일링 정보를 바탕으로 가장 높은 수준의 최적화를 수행. 컴파일 속도는 느리지만 실행 속도는 가장 빠름.
일반적으로 Tier0 -> Tier3 -> Tier4 단계로 상승함.
Tier1, Tier2 는 특수한 경우에 사용되는데,
Tier1 ~ Tier4 임계치 일괄 조정
# Threshold 를 일괄 0.5배로 조정. 제일 간단한 설정 방법
-XX:CompileThresholdScaling=0.5
Tier 임계치 조정 시 유의사항
차후 개선 고려사항
AOT (Ahead-of-Time) 컴파일 :
부하테스트 도중,
Redis Lua Script 제한으로 인해 ZRANGE 등의 unpack() 함수는 8,000 개 이상의 범위를 한꺼번에 처리할 시 아래와 같은 에러 발생
(error) ERR Error running script (call to f_xxx): user_script:line_number: too many results to unpack
이로 인해 5,000 개의 chunk size 조절로 반복문 처리.
import http from 'k6/http'; import { sleep, check, group } from 'k6'; import { Counter } from 'k6/metrics';
// 외부 환경변수로부터 stages 값 주입
const stage1_duration = __ENV.STAGE1_DURATION || '10s';
const stage1_target = Number(__ENV.STAGE1_TARGET || 10000);
const stage2_duration = __ENV.STAGE2_DURATION || '110s';
const stage2_target = Number(__ENV.STAGE2_TARGET || 20000);
const stage3_duration = __ENV.STAGE3_DURATION || '30s';
const stage3_target = Number(__ENV.STAGE3_TARGET || 20000);
const stage4_duration = __ENV.STAGE4_DURATION || '10s';
const stage4_target = Number(__ENV.STAGE4_TARGET || 0);
// 테스트 설정
export let options = {
stages: [
{ duration: stage1_duration, target: stage1_target },
{ duration: stage2_duration, target: stage2_target },
{ duration: stage3_duration, target: stage3_target },
{ duration: stage4_duration, target: stage4_target },
],
tags: {
team : 'server',
test_name: 'basic-test'
},
};
// 커스텀 메트릭 정의
const waitRequests = new Counter('wait_requests_total');
const entryRequests = new Counter('entry_requests_total');
const canEnterFalse = new Counter('can_enter_false_count');
const canEnterTrue = new Counter('can_enter_true_count');
export function setup() {
console.log('Setup: Initializing test setup...');
// 공통으로 사용할 헤더 초기화
let headers = { 'accept': '/', 'Content-Type': 'application/json', };
const waitPayload = JSON.stringify({
"zoneId": "TEST_ZONE",
"clientIp": "127.0.0.1",
"clientAgent": "WEB"
});
return {
headers: headers,
waitPayload: waitPayload
}
}
export default function (data) {
const randomSleepTime = Math.floor(Math.random() * 3000) + 1;
sleep(randomSleepTime / 1000);
let token = null; let canEnter = false;
group('POST /traffic/wait', function () {
let res = http.post('http://spring.abc.com:xxxxx/abc/api/test1', data.waitPayload, {headers: data.headers});
waitRequests.add(1);
check(res, {'is WAIT status 200': (r) => r.status === 200 });
let resBody = res.json();
canEnter = resBody.canEnter;
// console.log(`WAIT - canEnter: ${canEnter}, Status: ${res.status}, Body: ${res.body}, Duration: ${res.timings.duration}ms`);
token = resBody.token;
if (canEnter) {
canEnterTrue.add(1);
} else {
canEnterFalse.add(1);
const pollingPeriod = resBody.waiting?.pollingPeriod || 3000;
sleep(pollingPeriod / 1000);
}
});
if (!canEnter) {
group('POST /traffic/entry', function () {
const entryPayload = JSON.stringify({ "zoneId": "TEST_ZONE", "token": token });
while (!canEnter) {
entryRequests.add(1);
let res = http.post('http://spring.abc.com:xxxxx/abc/api/test2', entryPayload, {headers: data.headers});
let resBody = res.json();
canEnter = resBody.canEnter;
check(res, {'is ENTRY status 200': (r) => r.status === 200 });
// console.log(`ENTRY - Status code: ${res.status}, Body: ${res.body}, Duration: ${res.timings.duration}ms`);
if (canEnter) {
canEnterTrue.add(1);
break;
} else {
canEnterFalse.add(1);
const pollingPeriod = resBody.waiting?.pollingPeriod || 3000;
sleep(pollingPeriod / 1000);
}
}
});
} else { console.log("Skipping ENTRY request because canEnter was not true or token was not obtained."); }
console.log("1 user entered!\n\n") }
Client 입장의 API
대기열 코어 서버
Redis
요약
Redis Node 하나 당 VUs = 600,000 명 가량일 때까지 지연 발생 없음. (그 이상부터는 지연 발생)
특이사항 : Redis CPU 중간에 중간에 peak 500% 는 BGSAVE 시 발생한 것으로 예상.
Metric 캡처




ALB (CloudWatch )
대기열 서비스
Redis
대기열 코어 서버 Application
대기열 코어 서버 OS




