현재 저는 유레카(유플러스 채용 연계 부트캠프)에서, 종합 프로젝트를 진행 중입니다.
그중에 아이 성향을 주제로 하는 응모 시스템을, 대규모 트래픽(1초에 10만 트래픽을 10초간 버티는 안정성)을 안정적으로 버티게 구축하는 역할을 맡았습니다.
해당 기능을 포함해서 Naviya 프로젝트를 진행하며 고민했던 점들을 회고하며 글을 작성해 보겠습니다.
필수 요구사항
해당 시스템을 구축하기 위해서 주어진 최소한의 요구사항은 다음과 같습니다.
자녀 성향을 응모하는 100명 한정 선착순 이벤트 페이지 시스템
- 응모 페이지는 회원의 이름과 전화번호를 받는다
- 중복으로 응모는 되지 않는다
- 선착순 응모 페이지는 매일 오후 1시 정각에 가장 높은 트래픽을 받는다 -> 1분에 10만 요청을 10분간 받는다
- 응모 결과는 다음날 오후 1시에 발표한다.
평가 기준은 개인 맞춤형 서비스와 대규모 트래픽 처리
팀원들이 진행하는 자녀 성향 진단 시스템, 콘텐츠 추천 시스템에서 구현하는 개인 맞춤형 서비스와 달리,
제가 맡은 부분이 대규모 트래픽 처리가 가능한 지를 보여줄 수 있는 부분이라고 생각했습니다.
다른 팀들은 그럼에도 대용량 트래픽 처리보다는 여러 이벤트를 확장성 있게 개설하고, 질문 문항을 수정할 수 있는 식으로 구현하였지만, 저의 경우 직접 K6를 통해 성능 테스트를 진행하고, 하나씩 병목 지점을 찾아 개선해 가며 최종적인 로직과 아키텍처를 완성해 나가는 것을 목표로 프로젝트를 진행했습니다.
초기 설계
대규모 응모 시스템 구축만 신경 쓰려면, 사실 대단히 많은 엔티티가 필요하지 않습니다.
추가로 기존의 여러 프로젝트 경험을 미루어보았을 때, 꼭 필요하지 않은 연관관계를 굳이 부여하면 오히려 여러 부가적인 문제(N+1 문제, 테스트 시나리오 복잡성 증가 등등)가 발생합니다.
항상 프로젝트를 할 때 제일 중요하게 생각하는 것은
이 프로젝트를 왜 하는가?
이 프로젝트를 통해서 어떤 걸 얻고 싶은가?
등의 질문에 확실한 결론을 짓고, 모든 결정을 거기에 맞춰서 진행하는 것입니다.
실제로 유저에게 서비스를 할 목적으로 개발했던 Now, Here의 경우에는 그에 맞춰서 개발했지만
지금 개발하는 Naviya의 주된 목표는 아래와 같았습니다.
1. 대규모 부하테스트
2. Redis기반 실시간 대규모 트래픽 처리
3. 메시지 큐 시스템을 통한 안정적인 트래픽 처리 데이터 파이프라인 구축
그래서 초기 ERD는
Redis : 전화번호(PK), 이름, 선착순 100명을 측정할 수 있는 count
DBMS : Id(PK), 이름, 전화번호, 생성날짜(응모 성공자 파악을 위함)
과 같이 매우 간단하게 구현했습니다.
(최종에는 살짝 수정)
우선 초기 설계에서 Redis를 사용한 이유는, Redis는 NoSQL 기반의 인메모리 데이터 저장소로써 데이터를 디스크에 저장하지 않고 메모리에 저장하기 때문에, 매번 디스크 I/O보다 훨씬 빠른 속도로 조회 및 사용이 가능하기 때문입니다.
그래서 중복 응모 방지를 할 때 Redis를 사용하면 키 값인 번호를 메모리에서 조회해 매우 빠른 속도로 중복을 방지할 수 있습니다.
또한 선착순 100명을 세기 위한 count를 중복 아닌 경우에 추가하고, 이후에 100명이 된다면 저장이 아니라 바로 마감 응답을 보낼 수 있게 처리하였습니다.
앞으로 어떤 테스트 결과와 최적화를 통해 최종 아키텍처로 향하는지 같이 확인하시죠.
부하 테스트 K6
부하 테스트 툴로도 굉장히 다양한 툴이 많습니다.
이전 프로젝트의 경우 Postman의 유료 기능에 있는 성능 테스트 기능을 통해서 진행하였습니다.
이번에는 좀 더 범용적으로 많이 사용하는 K6, nGrinder, Jmeter 중에서 선택을 하고 싶었습니다.
유플러스 현직자 분들과의 멘토링에서 현재 유플러스에서는 nGrinder를 많이 사용하지만 각 프로젝트의 상황에 맞춰서 툴을 선택하는 것이 좋겠다고 조언을 들었습니다.
그래서 조사한 각 테스트 툴의 장단점은 아래와 같습니다.
K6
장점
1. Go언어 기반으로 높은 성능과 낮은 자원 소비
2. Js기반 테스크 스크립트 작성을 통해 상대적으로 시나리오 테스트 코드를 쉽게 구현
3. 다른 도구에 비해 VU(가상 유저) 생성 시 낮은 하드웨어 리소스를 사용
단점
1. 단일 스레드 실행
2. GUI 없음
nGrinder
장점
1. Jython 또는 Groovy로 작성
2. 분산테스트 가능
3. 웹 인터페이스를 제공해 테스트 관리와 모니터링이 쉬움
단점
1. 높은 자원 사용량
2. 복잡한 설정
JMeter
장점
1. Java로 작성
2. GUI 제공
3. 오랜 역사로 큰 커뮤니티 보유와 다양한 정보 제공
단점
1. 높은 자원 사용량
이번 프로젝트의 경우 단일 로컬 서버에서 1초당 10만 트래픽을 10초간 처리하는 테스트를 작성하는데, K6도 로컬에서 작동하고 서버도 로컬에서 띄우다 보니 셋 중에서 가장 하드웨어 리소스가 적게 드는 K6를 선택하였습니다.
GUI가 없는 것이 편의성 측면에는 문제였지만, 저와 같은 상황에서는 오히려 리소스가 적게 드는 희소식이었습니다.
그래서 도커를 통해서 간편하게 K6를 세팅하여 사용했습니다.
크게 선착순 100개가 순차적으로 잘 들어가는지 확인하는 테스트와, 원래 프로젝트의 목표인 대규모 트래픽 테스트 두 가지를 진행했습니다.
이번 프로젝트는 여러 편의 분량이 되는 글들을 그냥 한 편으로 작성할 예정이라, 이 부분도 할 말이 많지만 대규모 트래픽 테스트에 관해서만 작성하겠습니다.
대규모 트래픽 테스트의 시나리오는 대규모 사용자 수와 높은 요청 빈도를 기반으로 한 부하 테스트 시나리오로, Redis와 포함한 응모 이벤트 시스템이 대규모 트래픽 상황에서 얼마나 안정적으로 작동하는지를 평가합니다.
이 시나리오는 점진적으로 가상 유저 수(VUs)를 증가시키며, 최종적으로 500명의 유저가 동시에 초당 200번씩 10초간 시스템에 접근하는 상황을 재현합니다.
각 유저는 고유한 응모 데이터를 사용해 서버에 반복적으로 요청을 전송하며, 이를 통해 시스템의 최대 처리 능력과 응답 시간, 실패율을 측정합니다.
테스트 구성 요소
- 부하 단계 (stages):
- 첫 번째 단계: 5초 동안 VUs를 50명까지 점진적으로 증가시켜 초기 부하를 가함.
- 두 번째 단계: 5초 동안 100 VUs를 유지해 서버가 일정한 부하를 처리할 수 있는지 확인.
- 세 번째 단계: 10초 동안 500 VUs를 유지하여 대규모 동시 접속 시나리오를 재현.
- 임계값 설정 (thresholds):
- 응답 시간: 95% 이상의 요청이 3000ms 이내에 완료되어야 함.
- 실패율: 전체 요청의 1% 미만이어야 함.
테스트 로직
- 요청 데이터: 각 VU는 자신의 고유 userId와 전화번호 정보를 포함한 JSON 형식의 요청 데이터를 생성
- 요청 반복: 각 VU는 200번의 POST 요청을 서버에 전송하며, 요청 사이에 0.1초 간격을 두어 부하 분포를 조정
- 성공 여부 확인: 각 요청의 응답 상태와 메시지 형식을 검사하여 성공적으로 처리된 응모만 카운트
추후에 체험형 이벤트 선택 기능을 추가하고 나선 약간의 수정이 있었지만, 우선 큰 흐름은 위와 같은 형식으로 진행했습니다.
그리고 처음에 테스트 결과로는
✗ response is a string
↳ 72% — ✓ 43438 / ✗ 16120
checks.........................: 72.93% 43438 out of 59558
data_received..................: 14 MB 285 kB/s
data_sent......................: 9.4 MB 193 kB/s
http_req_blocked...............: avg=176.82ms min=0s med=0s max=19.65s p(90)=13.03µs p(95)=910.79ms
http_req_connecting............: avg=158.83ms min=0s med=0s max=18.09s p(90)=0s p(95)=904.81ms
✗ http_req_duration..............: avg=767.64ms min=0s med=0s max=27.25s p(90)=2.13s p(95)=3.03s
{ expected_response:true }...: avg=1.56s min=31.7ms med=1.21s max=27.25s p(90)=3.04s p(95)=4.17s
✗ http_req_failed................: 50.89% 45176 out of 88761
http_req_receiving.............: avg=966.78µs min=0s med=0s max=2.62s p(90)=58.89µs p(95)=91.84µs
http_req_sending...............: avg=7.09ms min=0s med=0s max=3.44s p(90)=85.5µs p(95)=6.67ms
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...............: avg=759.57ms min=0s med=0s max=27.25s p(90)=2.11s p(95)=3.01s
http_reqs......................: 88761 1828.145604/s
iteration_duration.............: avg=7.2s min=1.07s med=3.73s max=41.32s p(90)=19.48s p(95)=24.49s
iterations.....................: 54249 1117.327102/s
vus............................: 1207 min=0 max=46613
vus_max........................: 100000 min=12122 max=100000
라고 나왔는데, 이중 중요한 지표만 요약해서 설명드리면 아래와 같습니다.
- response is a string: 72.93%
- 테스트에서 응답이 문자열임을 확인하는 검사가 약 73% 성공
43,438개의 요청에서 응답이 문자열이었지만, 16,120개의 요청에서는 문자열로 확인되지 않음
- 테스트에서 응답이 문자열임을 확인하는 검사가 약 73% 성공
- http_req_failed: 50.89%
- 설명: 전체 요청의 약 51%가 실패했습니다. 88,761개의 요청 중 45,176개가 실패
- 해석: 서버가 부하를 감당하지 못해 절반 이상의 요청이 실패, 타임아웃 또는 서버 자원 부족으로 인한 문제
- http_req_duration: avg=767.64ms
- 설명: 요청의 평균 응답 시간이 약 768ms
- 해석: 일부 요청의 응답 시간이 매우 길어졌고, 27초 이상의 응답 지연이 발생
- http_reqs: 88,761
- 설명: 총 88,761개의 요청이 이루어졌으며, 초당 약 1,828건의 요청이 발생
- 해석: 트래픽은 잘 발생했으나, 요청의 절반 이상이 실패하여 실제 성공한 요청은 상대적으로 적음
물론 맨 처음에는 큐 시스템 도입을 하지 않았기도 하였지만 결과와 같이 다소 많이 구린 서버 성능이었는데 덕분에 최적화할 거리가 많아져서 좋았습니다.
수백 명의 유저가 동시에 수백 건의 요청을 보내다 보니, 성능 문제뿐만 아니라 동시성 문제도 발생하였습니다.
아직 A의 요청이 진행 중인데 B의 요청이 반영돼 COUNT 가 100이 되어서 A의 요청에 영향을 미친 다던지와 같은 문제들 말입니다.
그래서 제가 생각한 방안은 아래와 같습니다.
1. 스레드 풀, DB 커넥션 풀, Redis 커넥션 풀 수 조정, 타임아웃 시간 증가
2. Redis 캐시 형태 list -> set형으로 최적화
3. Lua 스크립트를 통해 count 증가와 응모 처리를 원자적 단위로 처리
4. 큐 시스템 도입으로 응답은 빠르게, 처리는 순차적으로 진행
1번의 경우 단순히 config 파일을 수정하거나, properties파일을 수정하는 것이니, 2~4번에 해당하는 내용을 설명하겠습니다.
최적화 - 캐시 자료형 Set로 전환
알고리즘 문제를 풀 때 처음에는 완전 탐색으로 풀고 추후에 하나씩 로직을 개선해 나가는 것처럼, 이번 프로젝트에서도 처음에 빠르게 기능을 구현할 때는 List 형태로 구현했습니다.
@Override
@Transactional
public String submitLotteryEntry(LotteryEntryRequest request) {
try {
String phone = request.getPhone();
String name = request.getName();
String selectedProgram = request.getSelectedProgram();
String entryData = String.format("%s:%s:%s", name, phone, selectedProgram);
log.info("새로운 응모 요청 처리 중 - 이름: {}, 전화번호: {}, 프로그램: {}",
name, phone, selectedProgram);
// Redis에서 중복 체크
List<String> winnersList = redisTemplate.opsForList().range(LOTTERY_WINNERS_LIST, 0, -1);
if (winnersList != null) {
boolean isDuplicate = winnersList.stream()
.map(data -> data.split(":")[1]) // 전화번호 추출
.anyMatch(p -> p.equals(phone));
if (isDuplicate) {
log.warn("중복 응모 시도 - 전화번호: {}", phone);
return "이미 응모한 전화번호입니다.";
}
}
// 응모 카운트 확인
Long currentCount = redisTemplate.opsForValue().get(LOTTERY_COUNT_KEY) != null
? Long.parseLong(redisTemplate.opsForValue().get(LOTTERY_COUNT_KEY))
: 0;
if (currentCount >= 100) {
log.info("응모 마감 - 현재 응모자 수가 100명을 초과하였습니다.");
return "응모가 마감되었습니다.";
}
// 성공한 응모 처리
redisTemplate.opsForList().rightPush(LOTTERY_WINNERS_LIST, entryData);
redisTemplate.opsForValue().increment(LOTTERY_COUNT_KEY);
log.info("응모가 접수되었습니다 - 이름: {}, 전화번호: {}, 프로그램: {}",
name, phone, selectedProgram);
return "응모가 접수되었습니다. 결과는 내일 오후 1시에 발표됩니다.";
} catch (Exception e) {
log.error("응모 처리 중 오류 발생 - 이름: {}, 전화번호: {}, 프로그램: {} 오류: {}",
request.getName(), request.getPhone(), request.getSelectedProgram(), e.getMessage());
return "응모 처리 중 오류가 발생했습니다. 다시 시도해주세요.";
}
}
List 형으로도 현재 요구사항은 잘 만족할 수 있지만, List형으로 구현할 경우 중복된 응모 방지를 위해서 전체 리스트를 순회하며 비교해야 합니다. 이는 시간복잡도가 O(n)입니다.
하지만 Hash 구조를 이용하면 Key-Value 상으로 저장되기 때문에 응모자의 전화번호를 고유한 키로 두어 O(1)의 시간 복잡도로 찾을 수 있습니다.
또한 Hash는 각 키가 고유한 값을 가지므로, 중복된 전화번호로 응모하는 경우 즉시 중복 응모를 방지할 수 있는 이점이 있습니다.
마지막 이점으로는, Value값을 이용하면 MySQL로 이전할 때 전체 응모 데이터를 상대적으로 values() 메서드를 통해 가져오고 별다른 변환을 하지 않아 이전 작업이 간단해집니다.
서비스 안의 다른 메서드들을 포함해서, 응모를 요청하는 메서드를 아래와 같이 set() 자료형을 이용하게끔 수정하였습니다.
@Override
@Transactional
public String submitLotteryEntry(LotteryEntryRequest request) {
try {
String phone = request.getPhone();
String name = request.getName();
String selectedProgram = request.getSelectedProgram();
String entryKey = phone;
String entryData = String.format("%s:%s:%s", name, phone, selectedProgram);
log.info("새로운 응모 요청 처리 중 - 이름: {}, 전화번호: {}, 프로그램: {}",
name, phone, selectedProgram);
Boolean isDuplicate = redisTemplate.opsForHash().hasKey(LOTTERY_HASH_KEY, entryKey);
if (Boolean.TRUE.equals(isDuplicate)) {
log.warn("중복 응모 시도 - 전화번호: {}", phone);
return "이미 응모한 전화번호입니다.";
}
String countStr = redisTemplate.opsForValue().get(LOTTERY_COUNT_KEY);
Long currentCount = 0L;
if (countStr == null) {
redisTemplate.opsForValue().set(LOTTERY_COUNT_KEY, "0");
} else {
try {
currentCount = Long.valueOf(countStr);
} catch (NumberFormatException e) {
log.warn("Invalid count value in Redis. Resetting count to 0.");
currentCount = 0L;
redisTemplate.opsForValue().set(LOTTERY_COUNT_KEY, "0");
}
}
if (currentCount >= 100) {
log.info("응모 마감 - 현재 응모자 수가 100명을 초과하였습니다.");
return "응모가 마감되었습니다.";
}
redisTemplate.opsForHash().put(LOTTERY_HASH_KEY, entryKey, entryData);
redisTemplate.opsForValue().increment(LOTTERY_COUNT_KEY, 1);
log.info("응모가 접수되었습니다 - 이름: {}, 전화번호: {}, 프로그램: {}",
name, phone, selectedProgram);
return "응모가 접수되었습니다. 결과는 내일 오후 1시에 발표됩니다.";
} catch (Exception e) {
log.error("응모 처리 중 오류 발생 - 이름: {}, 전화번호: {}, 프로그램: {} 오류: {}",
request.getName(), request.getPhone(), request.getSelectedProgram(), e.getMessage());
return "응모 처리 중 오류가 발생했습니다. 다시 시도해주세요.";
}
}
최적화 및 동시성 문제 해결 - Lua 스크립트, 중복 및 응모 처리 메시지 캐싱
대용량 트래픽을 처리하는 상황에서, 최대한 서버를 거치지 않고 Redis에서 처리하기 위해서 중복 및 응모 처리 메시지 자체도 Redis에 캐싱을 하여 사용했습니다.
또한 위에서 작성한 것처럼 set() 자료형을 통해서 구현했던 응모 처리 메서드에서 Lua 스크립트를 통해
- 중복 응모 여부 처리
- 응모 처리 - 성공 / 실패
- 성공 시 count 증가
- ++ value 값을 string이 아니라 json 형태로 파싱 하는 걸로 변경
위 작업을 하나의 원자적 단위로 처리하게끔 수정하였습니다.
그래서 아래와 같이 기존의 set() 자료형에서 생길 수 있는 문제들을 한 번에 해결했습니다.
- 데이터 일관성 : 응모자가 많은 경우 중복 체크, 데이터 저장, 카운트 증가 등의 상황에서 race condition 문제가 발생
- 성능 저하 : redis의 네트워크 호출이 많아질수록 네트워크와 Redis의 부하가 높아지고 성능이 저하
@Override
@Transactional
public String submitLotteryEntry(LotteryEntryRequest request) {
String script =
"local count_key = KEYS[1] " +
"local hash_key = KEYS[2] " +
"local entry_key = ARGV[1] " +
"local entry_data = ARGV[2] " +
"local max_entries = 100 " + // 선착순 100명 제한
"local current_count = tonumber(redis.call('GET', count_key) or '0') " +
// 중복 체크
"if redis.call('HEXISTS', hash_key, entry_key) == 1 then " +
" return redis.call('GET', KEYS[3]) " + // 중복 응모 메시지
"end " +
// 선착순 제한 체크
"if current_count >= max_entries then " +
" return '응모가 마감되었습니다.' " + // 선착순 마감 메시지
"end " +
// 응모 데이터 저장 및 카운트 증가
"redis.call('HSET', hash_key, entry_key, entry_data) " +
"redis.call('INCR', count_key) " +
"return redis.call('GET', KEYS[4])"; // 응모 완료 메시지
String entryKey = request.getPhone();
String entryData = String.format("{\"name\":\"%s\", \"phone\":\"%s\"}", request.getName(), request.getPhone());
try {
byte[] resultBytes = redisTemplate.execute((RedisCallback<byte[]>) connection ->
connection.scriptingCommands().eval(
script.getBytes(),
ReturnType.VALUE,
4,
LOTTERY_COUNT_KEY.getBytes(),
LOTTERY_HASH_KEY.getBytes(),
DUPLICATE_RESPONSE_KEY.getBytes(),
ENTRY_PROCESSED_RESPONSE_KEY.getBytes(),
entryKey.getBytes(),
entryData.getBytes()
)
);
String result = resultBytes != null ? new String(resultBytes) : "오류 발생";
log.info("응모 처리 결과: {}", result);
return result;
} catch (Exception e) {
log.error("응모 처리 중 오류 발생 - 이름: {}, 전화번호: {} 오류{}", request.getName(), request.getPhone(), e.getMessage());
return "응모 처리 중 오류가 발생했습니다. 다시 시도해주세요.";
}
}
최적화 - Redis Streams
마지막으로 최적화하기 위해서 도입한 부분은 바로 비동기 큐 시스템입니다.
우선, 대규모 트래픽 처리를 위해서 주로 어떤 선례가 있는지 확인해 봤고, 크게 나온 부분이
비동기 큐 시스템 vs Spring Framework Reactive Stack
이렇게 2가지였습니다.
저의 경우에는 로컬 단일 서버에서 프로그램을 돌리고, 현재 하드웨어 리소스를 수평 확장 시키기 어려운 상황이기 때문에 고성능 병렬 처리를 좋게 만들지만 과도한 리소스를 사용할 우려가 있는 Spring Framework Reactive Stack 보다는 비동기 큐 시스템을 사용하기로 결정했습니다.
그럼 비동기 큐 시스템은 뭐고, 또 어떤 종류가 있을까요?
간단하게 비동기 큐 시스템에 대해 설명드리면, 이는 메시지를 큐에 넣고 consumer가 비동기적으로 처리하여 고부하를 분산시켜 처리하는 방식입니다. 주로 RabbitMQ와 Kafka 같은 메시지 큐 시스템은 특정 작업을 별도의 큐에 담아 소비자가 처리할 수 있도록 합니다.
이것 외에도 아래 표와 같은 큐 시스템이 있습니다.
이 중에서 많은 기업들의 JD에도 적혀있는 kafka와 지금 제가 사용하는 Redis에 포함된 Redis Streams에 대해 조사해 보았습니다.
현재 서비스에서 Redis를 기반으로 구현 중이기도 하여서 설치와 설정이 간편하다는 장점, 지연 시간이 짧고 실시간 응답이 필요할 때 유리하다는 점, 현재 저의 부하테스트에는 초당 수십만 메시지까지의 성능이면 충분하다는 점을 생각해서 Redis Streams를 사용하기로 결정했습니다.
그런데 여기서 살짝의 문제가 있었습니다..!
제가 윈도우 운영체제를 쓰고 있고, 윈도우 운영체제에서는 다운로드하여서 사용할 수 있는 Redis의 버전이 3.0.0대의 버전이 가장 최근이었습니다.
근데 현재 Redis는 7 버전까지 나온 상황이고, Redis Streams은 5 버전 이상부터 사용할 수 있다는 점이었습니다.
이를 해결하기 위해서 구글링 해본 결과 WSL2를 통해서 Ubuntu를 다운로드하여서 사용하는 방법이 있었습니다.
그래서 Ubutu에 redis를 다운로드하고 이후에 이를 로컬 서버에 연결해서 사용하는 방식으로 구현했습니다.
여기까지 구현하고 위에서 작성한 테스트 시나리오로 테스트한 결과는 어땠을까요?
참고로 한 번에 위와 같은 과정들을 한 것이 아니라, 매번 최적화하고 K6를 통해서 테스트하는 과정들을 수십 번 반복했습니다.
하지만 앞으로도 글의 분량이 많은데, 매번 테스트한 결과를 모두 첨부하면 글이 너무나도 길어질 것 같아서 처음 테스트 결과와 마지막 테스트 결과만 첨부하였습니다.
✓ is success or correct message
checks.........................: 100.00% 98748 out of 98748
data_received..................: 31 MB 591 kB/s
data_sent......................: 20 MB 372 kB/s
http_req_blocked...............: avg=137.94µs min=1.39µs med=7.62µs max=124.54ms p(90)=11.19µs p(95)=13.06µs
http_req_connecting............: avg=127.33µs min=0s med=0s max=124.31ms p(90)=0s p(95)=0s
✓ http_req_duration..............: avg=54.15ms min=2.26ms med=41.85ms max=1.33s p(90)=110.63ms p(95)=137.1ms
{ expected_response:true }...: avg=54.15ms min=2.26ms med=41.85ms max=1.33s p(90)=110.63ms p(95)=137.1ms
✓ http_req_failed................: 0.00% 0 out of 98748
http_req_receiving.............: avg=93.4µs min=10.97µs med=65.37µs max=295.03ms p(90)=139.73µs p(95)=187.97µs
http_req_sending...............: avg=35.29µs min=3.8µs med=23.7µs max=8.72ms p(90)=66.33µs p(95)=98.33µs
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...............: avg=54.02ms min=2.2ms med=41.72ms max=1.33s p(90)=110.47ms p(95)=136.97ms
http_reqs......................: 98748 1861.989901/s
iteration_duration.............: avg=31.48s min=24.86s med=31.99s max=33.95s p(90)=33.03s p(95)=33.19s
iterations.....................: 421 7.938366/s
vus............................: 79 min=10 max=499
vus_max........................: 500 min=500 max=500
여러 노력 끝에 얻은 최종 결론이니, 좀 자세히 해석을 해보겠습니다.
1. 전체적인 성공률과 처리량
✓ checks: 100.00% (98,748 out of 98,748)
- 모든 요청이 성공적으로 처리됨
- 총 98,748개의 요청이 처리됨
http_reqs: 98,748 (1,861.99/s)
- 초당 약 1,862개의 요청을 처리
- 하드웨어 리소스의 영향으로 10만 개의 요청을 10초 안에 보내지는 못했지만, 보낸 모든 요청을 100% 성공적으로 응답했습니다.
2. 응답 시간 분석
http_req_duration (중요 지표):
- 평균: 54.15ms
- 최소: 2.26ms
- 중간값: 41.85ms
- 최대: 1.33s
- 90% 요청: 110.63ms 이하
- 95% 요청: 137.1ms 이하
- 보다시피 원래 목표했던 95%의 요청이 3초 안에 들어올 뿐만 아니라 최대로 올린 시간 역시 1.33초 굉장히 많이 줄었습니다.
결론적으로
성공률 73% -> 100%
최대 응답시간 27초 -> 1.3초로
각각 37%, 95% 개선되는 성과를 보였습니다.
(테스트 실행 동영상 / 4배속)
부가 기능 - 체험형 이벤트 선택 기능 및 프론트 수정
이제 기존의 목표인
1. 대규모 부하테스트
2. Redis기반 실시간 대규모 트래픽 처리
3. 메시지 큐 시스템을 통한 안정적인 트래픽 처리 데이터 파이프라인 구축
를 다 완료했습니다.
근데 '아이의 성향을 가지고 응모 페이지 구현'하라는 요구사항을 만족해야 하고, 아이를 위해서 또는 아이가 사용하는 서비스인 만큼 이런 부분을 기획과 디자인에 반영해서 개발을 진행했어야 했습니다.
그래서 생각한 아이디어는 아이의 성향에 따라서 체험할 수 있는 프로그램을 4가지 고르고, 해당 프로그램과 이름, 전화번호를 선착순으로 입력한 100명이 체험형 프로그램을 무료로 진행할 수 있는 이벤트를 기획했습니다.
또한 디자인을 팀원과 논의하여 노란색의, 아이에게도 친화적인 디자인으로 최종 수정했습니다.
최종 아키텍처 및 결론
최종적으로 위와 같이 10만 * 10초의 트래픽을 감당하기 위한 아키텍처를 설계했습니다.
이번 대규모 응모 처리 시스템을 구현하면서 좋았던 점은
1. Redis 캐시를 사용해 본 경험
2. 대용량 트래픽을 위한 큐 시스템 적용 경험
3. K6를 통한 테스트 시나리오 작성 및 성능 테스트 경험
4. 실질적인 성능 향상 경험
이렇게 4가지였습니다.
그리고 아쉬운 점은 하드웨어의 한계로 10만 * 10초의 트래픽을 실제로 테스트하지 못하고, 1만 * 10초의 트래픽으로 테스트한 것입니다.
물론 클라우드 서비스를 이용해서 해도 되지만, 사이드프로젝트에서 프리티어를 이용하려고 하다 보면 훨씬 안 좋은 하드웨어 자원을 사용한다는 것을 알아서 그냥 로컬에서 진행했습니다.
마무리하며
나중에 현업에 간다면, 실제로 경험하게 되는 수십, 수백만의 트래픽을 가지고 최적화해 보는 경험을 꼭 해보고 싶고, 이번 경험이 그 경험을 위한 초석이 될 거라고 생각합니다.
그리고 개발을 시작한 지 얼마 안 됐을 때는 쉘 프롬프트만 봐도 머리가 쥐 날 것 같았는데, 이제는 하나의 컴퓨터에서 우분투에 Redis, 윈도우 쉘에 도커 띄워서 K6 부하테스트, 로컬에 서버 올려서 작동하는 걸 한 번에 수월하게 다루는 것을 보고 그래도 나름대로 개발 실력이 계속 늘고 있다는 게 느껴져서 좋았습니다.
이제 코테랑.. 다른 부분도 매우 좋아지기를 바라면서 글을 마칩니다.
😊 긴 글 읽어주셔서 감사합니다.