V1의 핵심 사용자 여정 기반 부하테스트 시나리오 설계 - 100-hours-a-week/KTB4-3rd-CLOUD GitHub Wiki
4단계-V2 트래픽 성장에 따른 V1 인프라 한계 문서는 V2의 DAU·세션 행동·행사 유입 가정으로 성장 트래픽을 산정하고, 그 부하를 V1 구조에 적용해 한계 지점을 찾도록 제안한다. 이 문서는 그 목적에 맞춰 V2 성장 가정에서 유도한 사용자 여정 부하를 현재 V1 서버 API와 코드 경로에 넣었을 때, 어느 자원과 어느 부하 수준에서 먼저 지연·오류·정합성 문제가 나타나는지 확인하는 설계 부하테스트를 정의한다.
테스트에서 관측한 SLO 위반 시점과 자원 포화 지점은 V1 인프라의 설계 한계를 찾기 위한 근거다. V2 인프라의 목표 성능이나 실제 운영 SLO를 입증하는 결과로 해석하지 않는다.
1. 어떤 API를 호출해 부하를 만들 것인가?
2. 테스트 서버의 어느 지점에서 부하가 발생하는가?
3. 부하가 발생하는 동안 어떤 자원을 확인할 것인가?
4. 성장 가정의 어느 부하 수준에서 사용자 여정이 나빠지고, 먼저 포화되는 자원은 무엇인가?
위 질문에 답하도록 성장 가정 → 부하 모델 → V1 코드 경로 → 사용자 영향 지표와 자원 지표를 연결한다.
성장 모델
V2의 DAU 3만 6천 명, 한 세션 13건의 HTTP 요청, 출퇴근 피크와 행사 유입, 채팅 접속률·메시지 빈도를 부하 목표의 근거로 삼는다.
측정 대상
실제 V1 API를 단일 V1 인프라 경로(Nginx → Spring → 공용 HikariCP → MySQL)에 호출한다. 성장에 따라 증가한다고 가정한 조회량, 연결 수, 메시지 저장량, 동시 참여를 기능별로 분리한 뒤 혼합한다.
관측할 지점
V2 성장 부하 단계에 따라 p95·p99·오류율·CUJ 완료율이 변하는 지점과, 그때 먼저 나타나는 CPU·스레드·세션 메모리·커넥션 대기·쿼리·행 잠금을 찾는다. 결과는 V1 구조에서 어느 자원을 분리·확장하거나 어떤 코드를 개선해야 하는지 판단하는 설계 입력이다.
해석 범위
DAU와 이벤트 인원은 실측 트래픽이 아니라 성장 가정이다. 이벤트 15건/세션의 상세 요청 구성은 현재 V1 API 믹스로 근사한다. 혼잡도·AI·영수증 처리처럼 V1에 없는 V2 경로는 호출하지 않는다. 현재 코드는 다중 노드·N-1 장애·자동 확장 동작도 시험하지 않는다. 따라서 목표 RPS를 처리해도 V2 서비스 전체나 V2 SLO, 확장 정책을 검증한 것은 아니다.
SLO 적용
실행 코드의 기본 threshold는 V1 초기 SLO다. V1의 핵심 사용자 여정 별로 SLI SLO 정의의 99.9% 가용성·98.5% CUJ 완료율 등 상향 목표와 구별한다. 성장 부하를 V1 초기 SLO와 대조해 V1의 한계 부하를 찾는다.
부하는 운영과 같은 공개 진입점(Nginx)을 거쳐 들어가도록 발생시킨다. 내부 주소로 백엔드에 직접 요청하면 Nginx의 요청 제한, WebSocket 프록시, 연결 유지 시간 같은 실제 사용자 경로의 제약이 측정에서 빠지기 때문이다.
k6 부하 발생기 (로컬)
→ 공개 도메인 / Elastic IP
→ Nginx 컨테이너 (limit_req, WebSocket 프록시)
├─ /api/* → Spring Boot backend:8080 (cpus 1.0, mem 768MiB)
│ → Spring Security/JWT
│ → Controller → Service 트랜잭션
│ → HikariCP (기본 10개)
│ → MySQL 8.4 (cpus 0.5, mem 768MiB, buffer pool 384MB)
└─ /api/wss → Spring WebSocket(STOMP)
→ StompAuthChannelInterceptor (CONNECT 인증, SUBSCRIBE 참여자 확인)
→ ChatMessageSendService → MySQL
→ Spring Simple Broker → 구독자
REST API, WebSocket, 메시지 저장이 모두 같은 Spring 프로세스와 같은 HikariCP 풀을 사용한다. 따라서 시나리오는 기능별 단독 부하로 자원 점유를 먼저 확인하고, 이후 기능을 함께 실행해 공유 자원을 통한 지연 전파를 확인하는 순서로 구성한다.
| 구분 | 이전 (픽스처 서버) | 변경 (실제 서버) |
|---|---|---|
| 조회 |
/api/lab/posts, /api/lab/posts/{n}
|
GET /api/map-pins, GET /api/nearby-posts, GET /api/companion-posts/{id}, GET /api/community-posts/{id}
|
| 좌석 경합 |
/api/lab/runs/*/parties/*/approve (승인) |
V1에는 승인 단계가 없다. 동행 참여 POST /api/companion-posts/{id}/participants와 택시팟 매칭 POST /api/taxi-pots에서 경합이 발생한다. |
| 혼잡도 | /api/lab/runs/*/stats |
V1에 혼잡도 API가 없어 지도 이동 조회 GET /api/map-pins로 대체한다. |
| 채팅 |
/ws raw WebSocket echo |
/api/wss STOMP CONNECT → SUBSCRIBE /sub/chat/{room_id} → SEND /pub/chat/{room_id}
|
| 인증 | 토큰 수동 입력 | Kakao OAuth만 있으므로 테스트 서버 JWT_SECRET으로 HS256 토큰을 발급한다. |
| 판정 | 기대 상태코드 포함 여부 | SLI 문서 6장의 Good·Bad·분모 제외 기준과 V1 초기 SLO |
| 시나리오 | 스크립트 | V2 성장 근거 | V1에서 찾는 한계 |
|---|---|---|---|
| S1 조회 증가 | real/read.js |
하루 2회 세션 가정 91 RPS; 행사 단독 180 RPS | 공간 조회·상세·쓰기 믹스에서 최초 지연/오류 지점 |
| S2 WebSocket 연결 증가 |
real/ws-chat.js (MESSAGE_RATE=0) |
행사 채팅 접속률 50% → 1,800연결, 첫 5분 6연결/초 | Nginx·발생기·JVM 연결/메모리 한계 |
| S3 채팅 메시지 저장 |
real/ws-chat.js (MESSAGE_RATE=36) |
행사 피크 36 msg/s | 같은 HikariCP·MySQL에 저장 부하가 주는 영향 |
| S4 좌석 경합 | real/hot-row.js |
인기 경로의 같은 팟에 동시 요청 가능성; 동시성 수준은 감도 실험 변수 | 전체 RPS와 별개인 동일 행 잠금·커넥션 경합 |
| S5 혼합·행사 스파이크 | real/mixed.js |
조회 + 연결 + 저장 + 경합 동시 발생 | 개별 한계를 넘지 않는 부하가 공유 자원에서 전파되는 지점 |
| CUJ E2E | real/cuj.js |
참여자·모집자·택시 매칭 여정 각각의 완료 | 단계가 연결된 상태에서 첫 메시지까지 완수할 수 있는지 |
| V2 가정 | 산정 결과 | 부하 코드에서의 표현 | 해석 |
|---|---|---|---|
| DAU 1,500 → 36,000 (24배), 하루 세션 요청 13건 | 출퇴근 피크 45.5 RPS; 하루 2회 세션 가정이면 91 RPS |
read.js의 요청 믹스를 46/91 RPS로 실행 |
요청 비율은 V2 한 세션의 목록·상세·혼잡도·쓰기·기타 분포를 V1 API로 매핑한 합성 믹스 |
| 출퇴근 피크 4시간에 일간 세션 70% 집중, 순간 계수 2 | 45.5 RPS/세션 가정; 왕복 부하 91 RPS | 지속 처리량 단계 | 91 RPS는 V2 요청 분포 가정이지 동시 사용자 수를 뜻하지 않음 |
| 행사 사용자 3,600명, 사용자당 HTTP 15건, 10분 삼각형 유입 | 행사 단독 peak 180 RPS; 평시 피크와 중첩 시 약 225.5 RPS |
mixed.js PROFILE=event, PEAK_RPS=180 또는 226
|
코드는 V1의 13건 행동 믹스를 재사용해 행사 15건을 근사하므로 이벤트별 정확한 요청 구성은 재현하지 않음 |
| 행사 중 채팅 접속률 50%, 1,800연결을 5분에 형성 | 신규 연결 6개/초 |
ws-chat.js / mixed.js: TARGET_CONNECTIONS 또는 WS_CONNECTIONS, CONNECT_RATE
|
연결 누적과 초당 생성률을 별도 부하 축으로 측정 |
| 연결자당 10분간 평균 6건 → 전체 평균 약 18 msg/s, 순간 피크 계수 2 적용 | 피크 36 msg/s | MESSAGE_RATE=36 |
총 전송률 목표. 단일 발신자 echo로 저장·전달을 관측 |
| 평균 세션 10분, 출퇴근 피크 시작률 3.5 세션/초 | 활동 사용자 약 2,100명 | 직접 같은 수의 VU나 좌석 경합으로 바꾸지 않음 | 활동 사용자 추정치일 뿐, 동시 HTTP 요청·WebSocket 수·같은 팟 참여자 수와 같지 않음 |
| 같은 팟으로 모이는 사용자 비율은 성장 문서에서 특정되지 않음 | 동시 20/50/100 요청을 감도 단계로 둠 |
hot-row.js LEVELS=20,50,100; DISTRIBUTE로 비교 |
20/50/100은 V2 사용자 수에서 직접 도출한 예측치가 아니라 V1 잠금 민감도를 찾기 위한 통제 변수 |
CUJ는 어떤 사용자 행동과 완료 결과를 측정할지 정하고, V2 성장 가정은 그 행동에서 파생되는 요청·연결·메시지 규모를 정한다. 현재 코드는 이를 두 층으로 실행한다. read.js는 매 도착마다 요청 믹스의 API 하나를 호출하고, ws-chat.js와 hot-row.js는 연결·저장·행 잠금의 자원 비용을 분리해 측정한다. cuj.js는 참여자·모집자·택시팟의 실제 API 순서를 끝까지 실행해 E2E 완료율을 잰다. 그러므로 91/180/226 RPS 구간 전체에서 모든 가상 사용자가 매번 CUJ 전체를 밟는 모델은 아니다. 현재 부하 코드는 V2 규모의 구성 부하로 V1 병목을 찾고, 별도의 CUJ 표본 부하로 여정 완수 여부를 확인하는 방식이다.
시나리오 실행 전에 real/tools/make-users.py로 테스트 사용자와 토큰을 만들고, real/seed.js로 공개 API를 호출해 계좌 등록, 채팅방, 동행·커뮤니티 게시글을 생성한다. 시드 데이터는 V2 성장 가정의 수도권 업무지구 6개 거점(판교, 강남, 여의도, 광화문, 성수, 가산디지털단지)에 분산한다.
테스트 사용자는 용도별로 나눠 한 계정이 여러 시나리오의 상태를 오염시키지 않도록 한다.
| 사용자 풀 | 기본 인원 | 용도 |
|---|---|---|
| chat | 1,800 | 4명 단위 채팅방 멤버. 연결 유지·메시지 부하 |
| hotrow | 200 | 동행 참여·택시팟 동시 매칭 경합 |
| taxi | 200 | 택시팟 매칭 (계좌 등록 완료) |
| cuj | 300 | CUJ E2E 측정 (계좌 등록 완료) |
| writer | 100 | 조회 대상 콘텐츠 작성, 쓰기 트래픽 |
기본 풀 합계는 2,600명이다. real/lib/fixture.js의 POOL_CHAT, POOL_HOTROW, POOL_TAXI, POOL_CUJ, POOL_WRITER로 크기를 바꿀 수 있으며, make-users.py --count도 합계 이상으로 맞춰야 한다. seed.js는 택시·CUJ·hotrow 풀에 계좌를 등록하고, chat 풀로 4인 채팅방을 만든다. writer 풀은 조회 콘텐츠 작성과 일반 쓰기 트래픽에 사용한다. 시드 기본 콘텐츠는 동행 600건, 커뮤니티 600건이며 환경변수로 조정한다. 결과 픽스처는 FIXTURE_OUT으로 지정할 수 있다.
여기서 SLO는 성장 부하를 받는 현재 V1 설계의 한계점을 찾기 위한 사용자 영향 경계다. 실행 코드의 threshold는 V1 초기 SLO 기준이며, 이 값을 만족한 최대 부하를 V2 운영 성능 보장으로 보지 않는다. 응답은 SLI 문서 6장의 기준으로 분류하고, threshold가 처음 깨지는 성장 부하와 같은 시점에 포화되는 자원을 기록한다.
| 분류 | 응답 예시 | 가용성 분모 |
|---|---|---|
| Good |
2xx, 진행 중인 택시팟이 없을 때의 200 빈 응답 |
포함 |
| Bad |
5xx, timeout·연결 reset, 409 MATCH_BUSY, Nginx 429
|
포함 |
| 도메인 거절 |
ALREADY_PARTICIPATING, COMPANION_NOT_JOINABLE, BANK_ACCOUNT_REQUIRED, MATCH_ALREADY_IN_PROGRESS, COMPANION_POST_CANCELED
|
제외, 별도 집계 |
| 입력 오류 |
400 MALFORMED_REQUEST, 422 VALIDATION_ERROR
|
제외 |
| 테스트 데이터 오류 | 규칙에 없는 401·403·404
|
제외, 1건이라도 발생하면 실행 무효 |
MATCH_BUSY와 도메인 거절은 같은 409로 응답하므로 상태코드만으로는 구분할 수 없다. 응답 본문의 error.code를 읽어 분류한다.
지연시간은 유효 이벤트(Good·Bad)만 담은 api_duration으로 판정한다. 빠르게 반환되는 도메인 거절이 섞이면 p95가 실제보다 좋게 나오기 때문이다.
| 지표 | 정의 | 판정값 (V1 초기 SLO) | 대응 SLI |
|---|---|---|---|
sli_availability |
Good / (Good + Bad) | ≥ 99.9% |
P1·H2·T1 |
api_duration{sli_class:read} |
유효 조회 요청 지연 | p95 < 1초, p99 < 2.5초
|
P2 |
api_duration{sli_class:write} |
유효 쓰기·매칭 요청 지연 | p95 < 1.5초, p99 < 3초
|
T2 |
stomp_connected |
3초 안에 CONNECTED를 받은 연결 비율 |
≥ 99.9% |
P5 |
stomp_connect_duration |
WebSocket 시작 → CONNECTED
|
p99 < 3초
|
P5 |
chat_delivery_success |
보낸 메시지를 자신의 구독으로 다시 받은 비율 | ≥ 99.9% |
P6 |
chat_delivery_seconds |
SEND → 같은 메시지 MESSAGE 수신 |
p99 < 2초
|
P6 |
cuj_reliability{cuj} |
의도 확정 후 첫 메시지 수신까지 완료한 비율 | ≥ 97% |
P8·H6·T8 |
hot_row_consistency |
정원 초과·참여 수 불일치가 없는 비율 | 100% |
P4·T4 |
taxi_state_fresh |
매칭 응답과 현재 매칭 조회의 팟·채팅방 일치율 | ≥ 99.9% |
T6 |
참고 문서가 제시하는 V2 운영 목표는 가용성 99.95%, CUJ 완료율 98.5%, 조회 p95 750ms/p99 2초, 쓰기 p95 1.2초/p99 2.5초 등으로 V1 초기 SLO보다 높다. 현재 real/lib/slo.js는 이 상향값을 적용하지 않는다. 따라서 이 부하테스트는 성장 트래픽이 V1 경로에 주는 비용과 최초 위반 지점을 밝히며, V2 상향 SLO를 합격 조건으로 검사하는 절차는 별도로 추가해야 한다.
테스트 환경이 올바른지 확인하는 보조 지표도 함께 기록한다.
| 지표 | 의미 | 기준 |
|---|---|---|
nginx_rate_limited |
발생기 IP가 Nginx 요청 제한에 걸린 건수 | 0 |
test_data_errors |
토큰 만료·시드 누락으로 인한 401·403·404 | 0 |
ws_unexpected_close |
계획한 시간보다 먼저 끊긴 WebSocket 연결 | 0 |
dropped_iterations |
발생기가 목표 RPS를 만들지 못한 건수 | 0 |
business_rejections |
도메인 거절 건수 (error_code별) |
관찰용 |
taxi_match_total{match_result} |
기존 팟 합류 / 신규 팟 생성 | 관찰용 |
사용자 지표가 SLO를 벗어났을 때 원인을 찾기 위해 같은 구간의 서버 자원 지표를 함께 수집한다. 이 지표들은 SLO의 분자·분모로 사용하지 않는다.
| 계층 | 수집 항목 | 수집 위치 |
|---|---|---|
| Nginx | 상태코드, $request_time, $upstream_response_time, 활성 연결 수 |
access log, nginx exporter |
| Spring JVM | CPU, heap, GC pause, live thread, Tomcat busy thread | Actuator·Micrometer |
| HikariCP | active, pending, 커넥션 획득 시간, 점유 시간 | Actuator·Micrometer |
| WebSocket | 세션 수, inbound 채널 큐 | WebSocketMessageBrokerStats |
| MySQL | CPU, 쿼리 시간, row lock 대기, deadlock, rows examined | mysqld exporter, performance_schema
|
| 호스트·컨테이너 | 컨테이너 CPU·throttling·메모리, 호스트 CPU·메모리 | node exporter, cAdvisor |
모든 요청에 X-Load-Test-Run-Id 헤더를 붙여, 서버 로그와 k6 결과의 측정 구간을 맞춘다.
사용자는 지도와 주변 목록에서 출발 시간·목적지·검색 반경을 바꾸며 재검색한다. 조회 증가는 일시적인 급증이 아니라 피크 시간 동안 지속되는 처리량 부하다.
4단계 문서의 세션당 요청 비율(목록·검색 14 / 상세 14 / 혼잡도 10.5 / 쓰기 3.5 RPS, 45.5 RPS 기준)을 실제 V1 API로 옮겼다. 가중치 합이 91이므로 기준 91 RPS에서 가중치 1이 1 RPS에 해당한다.
| 문서 분류 | API | 인증 | 가중치 |
|---|---|---|---|
| 목록·검색 |
GET /api/nearby-posts 첫 페이지 |
불필요 | 20 |
| 목록·검색 |
GET /api/nearby-posts?cursor= 이어보기 |
불필요 | 8 |
| 상세 | GET /api/companion-posts/{companion_id} |
익명 10 / 로그인 10 | 20 |
| 상세 |
GET /api/community-posts/{post_id}, /comments
|
불필요 | 8 |
| 혼잡도 → 지도 이동 | GET /api/map-pins |
불필요 | 21 |
| 쓰기 |
POST /api/community-posts/{post_id}/comments, POST /api/companion-posts
|
JWT | 7 |
| 기타 |
GET /api/users/me, GET /api/chat-rooms
|
JWT | 7 |
기본 실행은 STEPS=46,91,180,226이며 소수도 지원해 45.5를 지정할 수 있다. k6 도착률은 10초 단위로 설정하므로 45.5 RPS가 가능하다. 첫 측정 전에 기본 2분 warm-up이 있고, 각 측정 단계는 기본 5분, 단계 간 회복 구간은 30초다. WARMUP, STEP_DURATION, STEP_GAP으로 조절한다. 이 짧은 단계는 최초 지연·오류 경계를 찾기 위한 것이다. 성장 가정의 출퇴근 피크 지속시간까지 확인하려면 STEPS=91, STEP_DURATION=4h로 별도의 장시간 실행을 한다. 단계별 dropped_iterations가 0이 아니면 목표 RPS를 발생기가 만들지 못한 것이므로 해당 단계를 서버 용량으로 해석하지 않는다.
| API | 서버 내부 경로 | 부하 특성 |
|---|---|---|
GET /api/map-pins |
HomeService.searchMapPins → 커뮤니티·동행 두 테이블에 MBRContains 공간 쿼리 → 병합 |
서버가 뷰포트 크기를 검증하지 않고 쿼리에 LIMIT이 없다. 500개 초과 여부는 모두 읽은 뒤 판단한다. |
GET /api/nearby-posts |
두 테이블에서 ST_Distance_Sphere 거리 계산·정렬 후 10개씩 병합, 커서 인코딩 |
뷰포트 안의 모든 행에 거리 계산이 발생한다. |
GET /api/companion-posts/{id} |
게시글 조회 + 로그인 사용자는 참여 여부 추가 조회 | 로그인 여부에 따라 쿼리 수가 다르다. |
| 쓰기 | 게시글 저장 + 채팅방·모집자 참여 행 생성 (한 트랜잭션) | 조회 부하 중 쓰기 트랜잭션이 커넥션을 점유한다. |
map-pins는 넓은 뷰포트 하나로도 테이블 전체를 읽을 수 있으므로, 시나리오에서는 모바일 지도 한 화면(위도 0.02° × 경도 0.025°)으로 뷰포트를 제한하고 매 요청마다 위치를 조금씩 옮긴다.
Nginx → 요청 처리 시간, upstream 응답 시간
Spring → CPU, Tomcat busy thread, JSON 직렬화 CPU
HikariCP → active, pending, 커넥션 획득 시간
MySQL → CPU, 공간 쿼리 시간, rows examined, buffer pool hit
단계별로 sli_availability{step_rps}와 api_duration{step_rps, sli_class:read}를 판정해 SLO가 처음 깨지는 RPS와 그 시점에 먼저 포화되는 자원을 기록한다.
매칭이 늘면 새로운 채팅방에 입장하는 사용자와 이미 연결된 상태에서 메시지를 주고받는 사용자가 함께 늘어난다. 연결 수와 메시지 수는 서로 다른 자원을 점유하므로 독립된 부하 축으로 측정한다.
| 단계 | 프로토콜 | 내용 |
|---|---|---|
| 연결 | WebSocket /api/wss
|
SockJS 없는 STOMP 엔드포인트 |
| 인증 | STOMP CONNECT
|
native header Authorization: Bearer <JWT>
|
| 구독 | SUBSCRIBE /sub/chat/{room_id} |
시드로 만든 채팅방의 실제 참여자만 구독한다. 비참여자의 구독은 서버가 오류 없이 버린다. |
| 전송 | SEND /pub/chat/{room_id} |
{ "client_message_id": UUID, "content": 고유 문자열 } |
| 수신 | MESSAGE |
같은 방 구독자 전원(발신자 포함)에게 브로드캐스트 |
| 조건 | S2 연결 | S3 메시지 |
|---|---|---|
| 동시 연결 | 1,800 | 1,800 |
| 신규 연결 속도 | 6연결/초 (첫 5분) | 6연결/초 |
| 유지 시간 | 10분 | 10분 |
| 메시지 | 없음 | 전체 36 msg/s (연결당 50초 간격, 4인 방이면 방마다 약 12.5초에 1건) |
브로드캐스트되는 메시지에는 client_message_id가 포함되지 않는다. 그래서 SLI 문서 P6의 "같은 client_message_id의 MESSAGE 수신"은 현재 구현으로 측정할 수 없다. 대신 메시지마다 UUID를 넣은 고유 content를 보내고, 발신자가 같은 content를 수신한 시점을 전달 완료로 판정한다.
연결 → Nginx 소켓·프록시 버퍼 → Spring WebSocket 세션 → JWT 디코딩 (CONNECT)
구독 → StompAuthChannelInterceptor → 참여자 조회 쿼리 (SUBSCRIBE마다 DB 1회)
메시지 → ChatMessageSendService
→ 참여자 조회 → 채팅방 조회 → 발신자 조회
→ messages INSERT → chat_rooms.last_message_id UPDATE (메시지 1건 = 커넥션 1개, 쿼리 4~5개)
→ Simple Broker → 방 구독자에게 팬아웃
유휴 연결은 DB 커넥션을 점유하지 않지만 세션 객체와 heap을 유지한다. 반대로 메시지 저장은 짧은 요청이어도 HikariCP 커넥션을 점유한다. 또한 연결 시점의 SUBSCRIBE도 DB를 조회하므로, 짧은 시간에 많은 사용자가 입장하면 구독 단계에서도 커넥션 경합이 발생할 수 있다.
| 부하 | 확인할 자원 |
|---|---|
| 연결 수 | Nginx 활성 연결, 파일 디스크립터, Spring 세션 수, heap·GC pause, 컨테이너 메모리 |
| 신규 연결 속도 | WebSocket handshake 시간(ws_connecting), stomp_connect_duration, 구독 시 DB 조회 |
| 메시지 | inbound 채널 큐, HikariCP active·pending, MySQL insert·commit 시간 |
좌석 경합은 전체 RPS가 낮아도 특정 행에 요청이 몰리는 hot-row 부하다. 활동 사용자 수가 아니라 같은 대상에 동시에 도착한 요청 수로 측정한다.
| 모드 | API | 동시 요청 | 대조군 |
|---|---|---|---|
동행 참여 (MODE=companion) |
POST /api/companion-posts/{companion_id}/participants |
서로 다른 사용자 20 / 50 / 100명이 같은 글에 동시 참여 |
DISTRIBUTE=10: 같은 동시성을 10개 글에 분산 |
택시팟 매칭 (MODE=taxi) |
POST /api/taxi-pots |
같은 출발·도착 좌표와 같은 출발 분으로 4 / 20 / 50명 동시 매칭 |
DISTRIBUTE=K: 경로를 K개로 분산 |
hot-row.js 기본값은 LEVELS=20,50,100, ROUNDS=3, DISTRIBUTE=1이다. 각 레벨과 라운드마다 새 대상을 준비하고, http.batch로 요청을 한 번에 보낸 뒤 검증하고 정리한다. DISTRIBUTE=K는 요청을 K개 대상/경로에 나눈다. 택시 경로는 출발지를 달리하고 도착 경도를 미세 조정해 경로를 구분한다.
| 단계 | 동행 참여 | 택시팟 |
|---|---|---|
| 대상 생성 |
POST /api/companion-posts (SUBWAY, 정원 10) |
없음 (매칭 요청이 팟을 생성) |
| 검증 |
GET /api/companion-posts/{id}의 current_count = 1 + 수락 수, ≤ capacity
|
성공 응답의 팟 ID와 current_count 기준으로 정원 및 팟 분산 확인 |
| 정리 |
DELETE /api/companion-posts/{id}/participants/me (참여자 → 모집자) |
DELETE /api/taxi-pots/{id}/participants/me |
동행 참여
참여 요청 → 활성 참여 여부 조회 → 사용자 조회
→ UPDATE companions SET current_count = current_count + 1
WHERE id = ? AND status = 'RECRUITING' AND current_count < capacity ← 같은 행 X-lock
→ 참여 행 INSERT → 채팅방 조회 → commit
정원이 최대 10명이므로 동시 50명 요청이면 9명만 수락되고 나머지는 COMPANION_NOT_JOINABLE로 거절된다. 거절된 요청도 행 잠금을 기다리는 동안 커넥션을 점유한다.
택시팟 매칭
매칭 요청 → users 행 FOR UPDATE → 진행 중 매칭 확인
→ companions FOR UPDATE (좌표·출발 분 완전 일치, 정원 미달)
├─ 있으면 참여 행 INSERT
└─ 없으면 팟·방장 참여 행·채팅방 생성
→ SYSTEM_JOIN 메시지 저장 → commit
→ 잠금 획득 실패 시 최대 3회 재시도 → 409 MATCH_BUSY
일치하는 팟이 없을 때 동시에 들어온 요청들이 각자 새 팟을 만들면 같은 조건의 팟이 여러 개로 쪼개진다. 이를 taxi_pot_fragmentation으로 기록한다. real/lib/api.js의 요청 timeout 기본값은 60초다. MySQL innodb_lock_wait_timeout은 테스트 대상의 실제 설정을 결과에 기록해야 하며, 여기서 기본값을 가정하지 않는다.
MySQL → row lock 대기 시간, lock wait 건수, deadlock 건수
HikariCP → active·pending, 커넥션 획득 시간 (잠금 대기 중 점유)
사용자 → 수락·거절·MATCH_BUSY 비율, 쓰기 p95·p99
정합성 → current_count ≤ capacity, 사용자별 진행 중 택시팟 1개 이하
같은 대상 집중(hot-row)과 분산(대조군)을 같은 동시성으로 비교해, 지연이 동시 요청 수 때문인지 같은 행 잠금 때문인지 구분한다.
각 기능을 단독으로 처리할 수 있어도 같은 시간에 발생하면 같은 JVM과 HikariCP에서 경합한다. 조회 단독 결과와 조회에 다른 부하를 더한 결과를 비교해 지연이 어떤 공유 자원을 통해 전파되는지 확인한다.
한 번의 실행 안에서 시간 창을 나눠 같은 조회 부하를 비교한다.
control (5분) → 조회만 READ_RPS
ramp → 간섭 부하가 목표치까지 증가 (판정 제외)
treatment (5분) → 조회 + 간섭 부하
간섭 부하 (INTERFERE) |
추가되는 부하 | 확인할 전파 경로 |
|---|---|---|
ws |
기본 1,600연결, 초당 20개 연결 시도 | JVM heap·GC → REST 지연 |
chat (기본) |
연결 + 메시지 기본 47 msg/s 저장, 초당 20개 연결 시도 | 공용 HikariCP 포화 → 조회 지연 |
hotrow |
30초마다 같은 글에 50명 동시 참여 | row lock + 커넥션 점유 → 조회 꼬리 지연 |
all |
위 세 가지 동시 | 복합 |
비교 프로필의 기본은 조회 50 RPS, 제어 5분, 연결 ramp 구간, 처리 5분이다. 기본 간섭 유형은 chat이다. CONTROL, TREATMENT, READ_RPS, WS_CONNECTIONS, CONNECT_RATE, MESSAGE_RATE, HOTROW_CONCURRENCY, HOTROW_INTERVAL로 변경할 수 있다. control 동안 연결은 시작하지 않고, ramp 동안은 조회 측정만 판정에서 제외하며, treatment에서 간섭과 조회를 함께 측정한다.
| 부하 | 조건 |
|---|---|
| 조회 | 기본 10분 동안 5분 ramp-up + 5분 ramp-down 삼각형 (행사 단독 180, 출퇴근 기준 중첩 226) |
| WebSocket | 6연결/초로 최대 1,800연결까지 ramp-up 후 유지, 테스트 종료 시 종료 |
| 메시지 | 기본 36 msg/s aggregate |
| 좌석 경합 | 기본 30초마다 동시 50명 참여 버스트 |
행사 프로필은 INTERFERE 값과 관계없이 all을 기본으로 잡아 채팅·연결·hot-row를 함께 실행한다. hot-row는 treatment 시작 시점부터 실행된다. CUJ 완료율은 real/cuj.js를 별도 프로세스로 실행해 함께 측정한다. 합산 기준 225.5 RPS를 정수로 표현할 때는 PEAK_RPS=226으로 올려 실행한다.
api_duration{window:control, sli_class:read}와 api_duration{window:treatment, sli_class:read}의 p95·p99를 비교한다. treatment 구간의 가용성, 조회·쓰기 지연, WebSocket 연결, 메시지 전달이 모두 SLO를 만족해야 해당 혼합 부하를 수용한 것으로 판정한다. HTTP RPS만 통과했다고 행사 상황을 수용했다고 판정하지 않는다.
개별 API가 SLO를 만족해도 여러 단계를 연속으로 거치는 여정은 실패할 수 있다. SLI 문서의 세 CUJ를 실제 API 순서대로 실행해 목적 달성 여부를 측정한다.
| CUJ | 호출 순서 | 의도 확정 시점 |
|---|---|---|
| 동행모집 참여자 |
GET /api/nearby-posts → GET /api/companion-posts/{id} → POST .../participants → GET /api/chat-rooms/{room_id} → GET .../messages → STOMP 첫 메시지 |
참여 요청 |
| 동행모집 모집자 |
POST /api/companion-posts → GET /api/companion-posts/{id} → GET /api/chat-rooms에서 방 찾기 → STOMP 첫 메시지 |
등록 요청 |
| 택시팟 매칭 |
POST /api/taxi-pots → GET /api/users/me/current-taxi-pot → GET /api/taxi-pots/{id} → STOMP 첫 메시지 |
매칭 요청 |
- 탐색만 하고 참여 가능한 글이 없어 떠난 경우는 신뢰성 분모(
cuj_reliability)에서 제외하고 제품 퍼널(cuj_funnel)에만 포함한다. - 참여 요청이 도메인 거절로 반환되면 다른 글로 최대 3회 다시 시도한다. 현재 코드는 세 후보가 모두 도메인 거절된 경우
intent를 확정하지 않아cuj_reliability분모에서 빠진다. 따라서 이 측정값은 참여 버튼을 누른 모든 사용자 기준이 아니라, 탐색 후보가 있고 요청 중 도메인 거절 없이 의도가 확정된 여정 기준이다. - 모집자의 게시글 등록 응답에는
chat_room_id가 없어 내 채팅방 목록에서 방을 찾는다. 이 때문에 H5 측정 구간에 요청이 1건 추가된다. - 택시팟은 좌표와 출발 분이 완전히 일치해야 합류하므로 6개 경로 × 10분 슬롯을 사용해 기존 팟 합류도 발생하도록 한다.
- 여정이 끝나면 참여를 정리해 같은 계정으로 반복 측정한다.
기본 JOURNEY=all은 세 여정을 각각 분당 12회 시작하며 기본 실행 시간은 10분이다. 전체 시작률은 분당 36회다. 이 CUJ 시작률은 코드의 기본 측정 부하로, V2 DAU·RPS 산식에서 직접 환산한 값은 아니다. JOURNEY_RATE는 여정별 rate이고, JOURNEY를 하나로 지정하면 해당 여정만 실행한다. CUJ 사용자 풀은 여정별로 나누며 실행률을 올릴 때는 POOL_CUJ도 늘려야 한다. 참여자 여정에서 탐색 후 유효 후보가 없으면 신뢰성 분모에서 제외하지만 퍼널 지표에는 남긴다. 기술 오류는 코드상 신뢰성 분모에 포함되지만, 도메인 거절과 MATCH_ALREADY_IN_PROGRESS는 의도 확정 전에 발생한 경우 제외된다. 단계별 cuj_step_total 및 REST SLI도 함께 해석한다.
현재 택시팟 hot-row 검증은 성공 응답의 팟 ID·current_count로 팟 분산과 응답 기준 정원을 검사한다. 실행 뒤 DB에서 참여자 수를 재조회하지 않으며, taxi_state_fresh는 CUJ에서 매칭 응답과 current-taxi-pot를 비교해 측정한다. 두 검증 범위를 구분해서 결과를 해석한다.
V1의 핵심 사용자 여정 별로 SLI/SLO 정의
V2 서비스 확장 가정 및 성장 지표
Grafana k6 - Scenarios and executors
Grafana k6 - Thresholds
Spring Framework - WebSocket Monitoring
Google SRE Workbook - Implementing SLOs\