V2 서비스 확장 가정 및 V1의 한계 지점 - 100-hours-a-week/KTB4-3rd-CLOUD GitHub Wiki

목차

V2 배경

모여타는 초기 6인 스타트업으로 적은 자본과 실사용자가 없는 배경으로 V1 서비스를 제공하기 위해 빠른 배포와 적은 인프라 비용, 유지보수 및 운영 비용 절감을 위해 단일 구조로 설계하였다.

초기 타겟 시장으로 판교 출퇴근 직장인을 선택해 출퇴근 택시팟 매칭과 실시간 교통 정보의 커뮤니티를 안정적으로 활성화하는데 성공하며, 모여타는 출퇴근과 일상 이동에서 목적지·시간이 맞는 사람을 연결해 택시팟을 만들고, 매칭부터 채팅·정산까지 지원하는 B2C 목적형 모빌리티 플랫폼으로 성장했다.

이동이 필요할 때 찾는 서비스이면서, 반복 출퇴근과 팟 커뮤니티를 통해 지속적인 이용도 발생한다.

V1 사용자 요구사항

V1에서는 택시를 나눠타려는 의사가 있는 사람들의 매칭과 채팅방으로 연계까지 제공한다. 실사용자의 정산을 방장이 영수증이나 결제 내역을 가지고 직접 나눠서 공유하는 것이 불편하다는 요구사항이 있다.

따라서 모여타의 V2부터 AI 모델을 활용해 사용자들의 실시간 교통상황 커뮤니티 글과 댓글을 기반으로 혼잡도를 실시간으로 지도에 반영해 사용자들은 기존 타 교통서비스에서 확인하지 못하는 실시간 교통상황 정보를 얻을 수 있도록 한다. AI 이미지 인식을 기반으로 영수증이나 결제 내역 이미지 인증을 통해 자동으로 정산과 확인을 대신해주는 기능을 추가한다.

  • 실시간 혼잡도 정보
  • 영수증 이미지 기반 정산 AI

 

V2에 도입되는 기능이 활성화 되려면, 실시간 혼잡도 정보가 판교에서 그치지 않고 수도권 주요 업무 지구의 출퇴근 교통 혼잡도 정보를 수집하기 위해 적극적인 마케팅과 더불어 수도권 주요 업무 지구 6곳으로 확장한다.
확장된 주요 업무 지구의 직장인은 새로운 서비스 사용자 타겟이 되어서 서비스 내 활성 사용자가 증가하면, 실시간 교통정보 수집이 더 활발해지며 보다 정확한 혼잡도 정보를 제공하게 되며 서비스의 성장의 선순환을 기대할 수 있다.

 

V2 운영 목표 재산정

V2에서는 DAU가 V1 대비 24배 증가하고 서비스 지역도 6개 업무지구로 확대된다. 출퇴근 피크에는 최대 91 RPS, 비정기 행사 트래픽이 겹치면 최대 225.5 RPS와 WebSocket 1,800개를 동시에 처리해야 한다. 같은 비율의 장애라도 영향을 받는 사용자와 실패한 매칭·채팅 건수가 크게 증가하므로, V1의 초기 목표보다 가용성과 사용자 체감 품질을 상향한다.

V2 운영 목표는 V1의 핵심 사용자 여정 별로 SLI/SLO 정의에서 정의한 Good event, Bad event와 측정 구간을 그대로 사용한다. API 명세에 따른 세 CUJ를 기준으로 하며, 측정 기간은 rolling 28일이다. 인프라 CPU·메모리·커넥션 수는 SLO가 아니라 원인 분석과 자동 확장의 선행 지표로 사용한다. V2의 SLI/SLO 목표는 V1에서 정의한 지표와 동일하다.

SLI V2 운영 SLO 측정 대상과 판정 기준
핵심 REST API 기술 가용성 99.9% 세 CUJ의 유효 요청 중 5xx, timeout, connection failure, MATCH_BUSY가 아닌 요청 비율
조회 API latency p95 ≤ 1초, p99 ≤ 2.5초 핀·목록·상세·매칭 상태 조회의 서비스 경계 응답시간
쓰기·매칭 API latency p95 ≤ 1.5초, p99 ≤ 3초 참여·등록·택시팟 매칭 요청 수신부터 처리 결과 반환까지
WebSocket 연결 성공률 99.9% 인증된 CONNECT 시도 중 3초 안에 CONNECTED를 수신한 비율
채팅 메시지 저장 성공률 99.9% 유효한 고유 SEND 중 저장이 완료된 비율. 중복 재전송은 기존 메시지 반환 시 성공 처리
채팅 전달 latency p99 ≤ 2초 고유 SEND부터 발신자 클라이언트가 같은 메시지를 수신할 때까지
각 CUJ E2E 신뢰성 완료율 97.0% 의도 확정 후 서버가 처리 가능한 CUJ 시도 중 첫 채팅 MESSAGE까지 완료한 비율
참여·등록·매칭 정합성 100% 부분 생성, 정원 초과, 사용자별 복수 진행 매칭을 허용하지 않음
화면 LCP p75 ≤ 2.5초 모바일과 데스크톱을 분리해 실제 사용자 기준으로 측정
화면 INP p75 ≤ 200ms 모바일과 데스크톱을 분리해 실제 사용자 기준으로 측정

 

1. 서비스의 안정성

서비스 안정성은 단순히 인스턴스가 실행 중인 상태가 아니라 세 CUJ가 실제로 완료 가능한 상태로 판정한다. REST, WebSocket, 데이터 저장소와 AI 처리 경로의 장애 범위를 분리해 하나의 기능 장애가 탐색·매칭·채팅 전체로 전파되지 않도록 한다.

  • 핵심 REST와 WebSocket 연결은 각각 rolling 28일 99.9% 이상을 유지한다.
  • 전체 오류 예산은 약 20분이며, 28일의 절반이 지나기 전에 오류 예산의 50% 이상을 소진하면 신규 기능 배포를 제한하고 원인 제거를 우선한다.
  • 오류 예산을 75% 소진하면 고위험 변경을 중단하고, 100%를 소진하거나 SLO를 위반하면 신뢰성 개선 외 배포를 중단한다.
  • 참여·등록·매칭은 성공 응답뿐 아니라 관련 상태가 모두 반영된 경우에만 성공으로 집계하며 정합성 목표는 100%로 유지한다.
  • 장애 감지는 사용자 영향 기반 SLI로 수행하고, CPU·메모리·커넥션·대기열 지표는 영향 원인을 찾는 데 사용한다.

 

2. 사용자 경험 수준을 고려한 체감 속도와 품질 보장

출퇴근 시간대에는 특정 기능의 평균 응답시간이 아니라 사용자가 느끼는 꼬리 지연과 CUJ 완료 여부가 중요하다. 따라서 평균값 대신 p95·p99를 사용하고, 지도 조회·매칭·채팅이 동시에 실행되는 혼합 부하에서도 같은 목표를 적용한다.

  • 조회 API는 p95 ≤ 750ms, p99 ≤ 2초, 쓰기·매칭 API는 p95 ≤ 1.2초, p99 ≤ 2.5초를 만족한다.
  • 인증된 WebSocket 연결의 99.95%는 3초 안에 완료되어야 하며, 채팅 메시지는 99% 이상이 1.5초 안에 클라이언트까지 전달되어야 한다.
  • 세 CUJ의 E2E 신뢰성 완료율은 각각 98.5% 이상이어야 한다. 한 CUJ의 높은 성공률로 다른 CUJ의 실패를 상쇄하지 않고 참여자·모집자·택시팟을 분리해 평가한다.
  • 화면 체감 품질은 LCP p75 2.5초 이하, INP p75 200ms 이하를 유지한다.
  • AI 혼잡도와 영수증 분석은 핵심 탐색·매칭·채팅 경로와 별도 지표로 측정한다. AI 처리 지연이나 실패가 발생해도 마지막으로 확정된 결과 또는 처리 상태를 제공해 기존 CUJ의 가용성 분모에 연쇄 실패를 만들지 않는다.

 

3. 비정기적 이벤트 상황을 대비한 확장성

확장성의 합격 기준은 서버가 단순히 살아 있는지가 아니라 목표 부하에서 위 SLO를 유지하는지 여부다. V2 구조 변경 후 다음 조건을 부하테스트와 정기적인 용량 검증의 공통 기준으로 사용한다.

검증 시나리오 입력 부하 합격 기준
평시 출퇴근 피크 HTTP 91 RPS, 4시간 지속 REST 오류율 ≤ 0.05%, 조회·쓰기 p95·p99 목표 충족, 정합성 오류 0건
행사 단독 burst HTTP 180 RPS, 10분간 삼각형 유입 REST 오류율 ≤ 0.05%, timeout 0건, p99 목표 충족
출퇴근과 행사 중첩 HTTP 225.5 RPS 핵심 REST 가용성 ≥ 99.95%, 일반 조회와 매칭의 p99 목표 충족
채팅 연결 burst WebSocket 1,800개, 신규 연결 6개/초 연결 성공률 ≥ 99.95%, 3초 이내 연결, 비정상 연결 종료율 별도 기록
채팅 메시지 burst inbound 36건/초 저장 성공률 ≥ 99.99%, 전달 p99 ≤ 1.5초, 유실·중복 0건
혼합 핵심 경로 225.5 RPS + 1,800 연결 + 36 msg/s + 좌석 경합 CUJ별 완료율 ≥ 98.5%, 정합성 오류 0건, 한 기능의 부하가 다른 CUJ의 SLO를 위반하지 않음

 

V2의 잠재 이용자와 MAU 시나리오

대상 업무지구는 판교, 강남, 여의도, 광화문·종로, 구로·가산, 마곡이다. 주요 업무 지구의 6개의 거점에서 강남권 약 60만, 여의도 약 15만, 서울디지털산업단지(구로·가산) 약 15만, 마곡 약 10만, 광화문·종로 약 15만 명으로 산정한다.

구분 수치
잠재 이용자 1,150,000명
MAU 도달률 15.7%
MAU 약 180,000명

MAU는 단순 앱 실행이 아니라 혼잡도 조회, 팟 탐색·참여, 커뮤니티 작성·조회 등 서비스의 핵심 활동을 수행한 사용자로 정의한다.


DAU/MAU 비율과 벤치마크

Google Analytics는 DAU를 최근 24시간, MAU를 최근 30일의 활성 사용자로 정의하며 DAU/MAU를 이용자 재방문 비율로 제공한다. 모여타도 동일하게 고유 사용자 기준으로 집계한다. Google Analytics 공식 정의

Airship의 2023 Mobile Lifecycle Benchmarks는 2023년 1~6월 월간 활성 기기 10만 이상 앱 약 2.6B 기기 표본을 분석해 Travel & Local을 포함한 카테고리별 DAU/MAU를 비교한다. 앱의 목적과 이용 빈도에 따라 재방문 비율이 달라지는 것을 확인할 수 있다. Airship 벤치마크 보고서

Sendbird가 정리한 Travel 앱 벤치마크에서는 30일 동안 앱을 5회 이하 실행한 사용자가 70%, 10회 이상 실행한 사용자가 18%로 보고된다. 모빌리티 서비스의 이용 빈도가 사용자군에 따라 다르게 나타날 수 있음을 보여주는 참고 자료다. Sendbird 모바일 앱 참여 벤치마크

모여타는 이동 필요에 따라 이용하는 목적형 서비스다. 반복 출퇴근, 실시간 혼잡도 확인, 팟 채팅을 통해 재방문이 발생한다고 가정한다. 월간 활성 사용자의 20%가 월 20일 활동하고, 나머지 80%가 월평균 2.5일 활동한다고 가정하면 평균 활동일은 월 6일(0.2 × 20 + 0.8 × 2.5)이며, 30일 기준 DAU/MAU는 20%로 산정한다.

6개 업무지구의 약 115만 명 중 15.7%가 월간 활성 사용자라고 가정하면 MAU 약 18만 명, DAU 약 3만 6천 명이다.


V2 서비스 확장으로 성장하는 수치

1. DAU 24배 증가·RPS 세션 1회당 13건

V1의 판교 중심 서비스에서 V2의 6개 업무지구 서비스로 확장하면, 기준 DAU는 1,500명에서 3만 6천 명으로 24배 증가한다.

한 세션당 HTTP 요청 13건은 기능별 요청 흐름에서 도출한 내부 가정이다. 목록·검색 4건, 상세 4건, 혼잡도 3건, 쓰기 1건, 인증·기타 1건이다. 실제 프런트엔드의 중복 호출·재시도·캐시 적중률을 반영한 서버 수신 요청 수와 다를 수 있으므로, 배포 후 access log/APM으로 시나리오를 보정한다.

DAU당 하루 한 세션을 기준으로 하면 일일 HTTP 요청은 약 46만 8천 건이다.

출퇴근 피크 계산은 하루 세션의 70%가 4시간에 발생하고, 그 시간 내 순간 최고가 시간대 평균의 2배라는 부하 모델이다. 이 두 계수는 외부 통계가 아니라 부하테스트 설계 가정이다.

피크 HTTP RPS
= DAU × 일 세션/사용자 × 피크 4시간 세션 비중 70%
  ÷ (4×3,600초) × 순간 피크 계수 2 × 세션당 HTTP 13건
접속 가정 세션 시작률(순간 피크 포함) 피크 HTTP RPS
DAU당 하루 1세션 3.5세션/초 45.5
DAU당 하루 2세션 7세션/초 91.0

따라서 45.5 RPS는 기준 가정(DAU 36,000명, 하루 1세션) 이고, 91 RPS는 동일한 DAU에서 하루 2세션으로 늘어난 추가 부하 가정이다. HTTP 요청 수에는 WebSocket 프레임, 영수증 이미지 업로드/AI 처리, FastAPI·외부 AI 호출이 포함되지 않으므로 별도 자원 모델로 측정한다.

 

2. 세션 시간과 동시성 부하 증가

Airship 보고서는 앱 카테고리별 foreground session length가 다르며 앱이 제공하는 효용에 따라 기대 체류시간을 달리해야 한다고 설명한다. 모여타의 지도 확인은 짧을 수 있지만 팟 비교와 매칭 후 채팅은 체류를 길게 만들 수 있어, 단일 평균값만으로 동시 접속을 확정하지 않는다. 따라서 평균 세션 체류시간 10분으로 가정하고 10분이라는 수치는 실측치가 아닌 초기 부하테스트 가정임을 인지한다..

Little's Law를 적용한 동시 활동 세션 추정은 다음과 같다. DAU 36,000, 1일 1세션, 피크 4시간 동안 세션 시작이 균등하다는 계산이므로, 시간대 편중을 반영한 순간 피크 계수 2는 포함하지 않은 피크 시간대 평균이다.

피크 시간대 세션 시작률
= 36,000 × 70% ÷ 14,400초
= 1.75 세션/초

평균 동시 활동 세션
≈ 1.75 세션/초 × 600초(인당 세션 활성화 10분)
= 1,050 세션

순간 피크 계수 2를 적용한 계획 상한은 약 2,100개의 동시 활동 세션이다. 이는 화면을 열어 둔 세션 추정치이며, 동시에 처리되는 HTTP 요청 수나 실제 WebSocket 연결 수와 같지 않다. 세션 시간 5분/10분/15분, 평균·순간 피크 조건을 조합해 브라우저 점유와 연결 자원을 별도 검증한다.

 

3. 비정기적 스파이크성 트래픽 발생

행사 시나리오 역시 관측 데이터가 없는 계획 가정이다. 기준값은 DAU 36,000명(20% 시나리오)의 10%인 3,600명이 행사 구간에 유입되고, 각 사용자가 HTTP 요청 15건을 발생시키는 경우다. 기존 13건/세션보다 2건 많은 값은 행사 상황의 재조회·검색을 반영한 여유 가정이다. 10분 동안 요청 시작이 삼각형 분포(첫 5분 증가, 다음 5분 감소)라고 두면 평균은 90 RPS, 꼭짓점은 평균의 2배인 180 RPS다.

행사 HTTP 요청 = 3,600명 × 15건 = 54,000건
10분 평균 = 54,000건 ÷ 600초 = 90 RPS
삼각형 유입의 순간 최고치 = 90 × 2 = 180 RPS

기존 출퇴근 피크와 행사가 동시에 발생할 때의 합산 부하는 독립된 요청군이 단순 합산된다는 가정에서 20% 시나리오 기준 45.5 + 180 = 225.5 RPS다. 피크 시간 중복, 사용자 겹침, 캐시 적중률에 따라 실부하는 달라지므로 합산 부하는 용량 상한 시나리오로 쓴다.

행사 유입자의 50%가 채팅에 접속한다는 가정은 20% 시나리오에서 1,800개 WebSocket 연결을 만든다. 해당 비율 역시 검증 전 가정이다. 연결이 5분에 걸쳐 형성되면 초당 6개이며, 연결자당 10분간 메시지 6건을 보내는 균등 분포 가정은 평균 inbound 18건/초다. 36건/초는 순간 2배 계수를 적용한 계획 최고치로 정의한다.

행사 시나리오 지표(20% DAU/MAU) 산식 값
10분간 유입 사용자 36,000 × 10% 3,600명
HTTP 순간 최고치 3,600 × 15 ÷ 600 × 2 180 RPS
WebSocket 최종 연결 3,600 × 50% 1,800개
첫 5분 신규 연결률 1,800 ÷ 300초 6개/초
평균 inbound 메시지 1,800 × 6 ÷ 600초 18건/초
inbound 메시지 계획 최고치 18 × 2 36건/초
출퇴근 피크 중첩 HTTP 상한 45.5 + 180 225.5 RPS

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의 한계 부하를 찾는다.


V1 인프라 구조 및 측정할 API 정의

부하는 운영과 같은 공개 진입점(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 풀을 사용한다. 따라서 시나리오는 기능별 단독 부하로 자원 점유를 먼저 확인하고, 이후 기능을 함께 실행해 공유 자원을 통한 지연 전파를 확인하는 순서로 구성한다.

1. 부하 측정에 사용되는 API 정의

  • 조회
    GET /api/map-pins, GET /api/nearby-posts, GET /api/companion-posts/{id}, GET /api/community-posts/{id}

  • 동행 참여
    POST /api/companion-posts/{id}/participants와 택시팟 매칭 POST /api/taxi-pots에서 경합이 발생한다.

  • 혼잡도
    V1에 혼잡도 API가 없어 지도 이동 조회 GET /api/map-pins로 대체한다.

  • 채팅
    /api/wss STOMP CONNECT → SUBSCRIBE /sub/chat/{room_id} → SEND /pub/chat/{room_id}

  • 인증 Kakao OAuth만 있으므로 테스트 서버 JWT_SECRET으로 HS256 토큰을 발급한다.

 

2. 부하 측정 시나리오 구성

시나리오 스크립트 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 성장 지표에서 부하 목표로의 변환

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 잠금 민감도를 찾기 위한 통제 변수

 

3. 부하 측정의 사용자 생성

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 조회 대상 콘텐츠 작성, 쓰기 트래픽

 

4. SLI 판정 기준

여기서 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가 실제보다 좋게 나오기 때문이다.

 

5. 사용자 관점 지표와 SLO 판정값

지표 정의 판정값 (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} 기존 팟 합류 / 신규 팟 생성 관찰용

 

6. 원인 분석 지표

사용자 지표가 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 결과의 측정 구간을 맞춘다.

   

1. 단순 게시글 조회 증가

사용자는 지도와 주변 목록에서 출발 시간·목적지·검색 반경을 바꾸며 재검색한다. 조회 증가는 일시적인 급증이 아니라 피크 시간 동안 지속되는 처리량 부하다.

1) 호출되는 API

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를 발생기가 만들지 못한 것이므로 해당 단계를 서버 용량으로 해석하지 않는다.

2) 예상 부하 지점 및 확인할 자원

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와 그 시점에 먼저 포화되는 자원을 기록해 한계 지점을 정의한다.


부하 측정 과정 및 결과

1) 부하 측정 중 포화되는 과정

연결 수가 1,000개를 넘으면 연결당 수십 KB만 잡아도 64MiB를 넘기 때문에 nginx 컨테이너가 메모리 한도에 걸려 worker 프로세스가 죽게 된 것이다. 컨테이너(cgroup) 단위 OOM이 발생한다.

백엔드도 기본 한도의 80% 이상으로 배치했는데, heap이 증가하고, 스레드 스택과 버퍼가 더해지면 한도 768MiB가 넘어서 백엔드가 죽고 재시작하는 사이에 502가 대량 발생한다.또한 컨테이너 한도도 V1 기준에서 여유 공간이 거의 없는 수준으로 구성했기 때문에, 부하 발생하는 과정에서 컨테이너 메모리 합이 초과하게 되면서 호스트 OOM kill이 발생하게 된다.

  1. 91 RPS 단계에서 nearby 공간 쿼리와 LIMIT 없는 map-pins 요청이 2배로 늘면서, MySQL의 쿼리 시간이 늘어난다.
  2. 커넥션 점유 시간이 15ms에서 110ms 이상으로 늘고, Hikari 커넥션 10개가 다 찬다.
  3. 이 시점에서 아직까지 Nginx의 연결 수립·TLS 시간은 약 0ms로 정상이다.
  4. 응답 대기 중앙값은 1.2초에서 7초까지 증가한다.
  5. 커넥션을 못 얻은 요청이 Hikari 대기열에 쌓여 최대 191개에 도달한다. (Tomcat 스레드 200개에서 사용 중 10개를 뺀 값과 동일하다.)
  6. 클라이언트가 이미 떠난 요청이 스레드와 커넥션 대기열을 계속 차지하고 있어 (Spring의 Hikari 대기 제한 기본 30초) 새 요청의 대기도 늘게 된다.
  7. 응답이 오지 않으니 nginx는 연결마다 클라이언트 쪽 소켓, TLS 상태, upstream 소켓, 프록시 버퍼를 계속 들고 있게 된다.

2) 부하 측정 결과

목표 부하 실제 요청률 SLI 가용성 조회 p95 / p99 실행 못 한 요청 판정
45.5 RPS 45.5 RPS 100% 301 / 1,035 ms 0건 가용성 99.9%, 조회 p95 1초·p99 2.5초 기준 충족
91 RPS 88.4 RPS 6.93% 약 10.0 / 10.0초 794건 실패
180 RPS 153.6 RPS 0% 약 10.0 / 10.0초 7,925건 실패
226 RPS 157.0 RPS 0% 약 10.0 / 10.0초 20,695건 실패

측정 구간 전체 가용성은 11.61%, dropped iteration은 29,414건으로 91 RPS에서는 상태 0 응답 24,485건이 대부분이며 원시 기록에 request timeout이 있다. 180·226 RPS에서는 상태 0과 502가 대부분인데 이는 약 10초의 p95는 스크립트의 요청 타임아웃에 도달한 값이다.


Prometheus 지표 45.5 RPS 91 RPS 180 RPS 226 RPS
호스트 CPU 평균 35.7% 53.7% 83.0% 75.7%
백엔드 CPU 평균 0.28 core 0.22 core 0.42 core 0.48 core
백엔드 working set 평균 407 MiB 622 MiB 420 MiB 662 MiB
HikariCP active 평균 / 최대 풀 0.7 / 10 8.9 / 10 10 / 10 9.9 / 10
HikariCP pending 평균 (최대) 0 (0) 162 (191) 184 (189) 157 (191)
Spring exporter 정상 응답 / 15초 관측 21회 21/21 10/21 4/21 7/21

91 RPS에서 HikariCP 대기가 급증했고, 고부하 단계에는 Spring exporter의 수집 공백이 커지게 되었는데 이는 호스트 OOM Kill 발생으로 exporter가 다운되었기 때문이다.


1.1. OOM 원인 확인: nginx 컨테이너 제한과 호스트 메모리 부족

서버의 커널 로그에는 서로 다른 두 종류의 OOM kill이 명시되어 있는데 nginx가 38건, Java가 2건, nginx cgroup 내 'runc' 8건이 기록됐다.

커널 로그 종료된 프로세스 해석
constraint=CONSTRAINT_MEMCG, oom_memcg=...docker-551101d...scope nginx worker 반복 종료 nginx 컨테이너의 64 MiB 제한에 걸림. 첫 발생은 91 RPS 단계
constraint=CONSTRAINT_NONE, global_oom Java PID 439770 호스트 전체 메모리 부족으로 백엔드 종료
constraint=CONSTRAINT_NONE, global_oom Java PID 447350 호스트 전체 메모리 부족으로 백엔드 재차 종료
Oct 02 09:28:17 kernel: Memory cgroup out of memory: Killed process 405568 (nginx)
Oct 02 09:34:12 kernel: oom-kill:constraint=CONSTRAINT_NONE,...,global_oom,...,task=java,pid=439770
Oct 02 09:34:12 kernel: Out of memory: Killed process 439770 (java)
Oct 02 09:38:30 kernel: oom-kill:constraint=CONSTRAINT_NONE,...,global_oom,...,task=java,pid=447350
Oct 02 09:38:30 kernel: Out of memory: Killed process 447350 (java)

node_vmstat_oom_kill이 46→89, nginx container_oom_events_total이 28→74로 증가했다. 두 번의 Java 종료는 백엔드 컨테이너의 메모리 제한 OOM이 아닌 호스트 global OOM이기 때문에 발생한 것이라고 정의할 수 있다.

V1 인프라 구조에서 45.5 RPS 단계까지 정상적으로 처리되었지만, 하루 세션 2회가 발생하는 가정으로 91 RPS부터 nginx 메모리 제한 OOM과 HikariCP 대기가 관측됐고, 이벤트 피크와 평시 RPS와 이벤트가 겹친 180·226 RPS 단계에는 호스트 global OOM으로 백엔드가 두 번 종료됐다.


결론: Nginx 단일 진입점 포화 및 장애 전파

V2 성장 가정에서 단순 게시글 조회 수 증가 시나리오의 부하 지점은 단일 진입점인 nginx 메모리 한도와 호스트 전체 메모리 한도가 발생했고, Nginx 컨테이너의 장애가 호스트 전체 장애로 전파된다는 것을 확인할 수 있다.

따라서 단일 진입점 문제와 V1에 비해 약 36배 증가한 평시 피크 RPS를 감당하기엔 현재의 단일 인스턴스 내 컨테이너 구성은 문제를 해결할 수 없다고 판단된다.

단일 진입점이 되지 않을 가용성과 유동적인 트래픽량을 처리할 확장성, 인프라 운영 비용 및 관리 비용을 고려해 CDN + ALB + ECS 구조를 선택한다. V2의 Nginx 인프라 설계 문서에 구조 선정 이유과 결정 과정이 정리되어 있다.



1.2. HikariCP 대기 요청 평균 0 → 162까지 상승

지표 45.5 RPS 91 RPS 180 RPS 226 RPS
JVM heap 사용량 관측 최대 95 MiB 310 MiB 281 MiB 335 MiB
백엔드 컨테이너 working set 평균 407 MiB 622 MiB 420 MiB 662 MiB
HikariCP 사용 중 커넥션 평균 / 최대 풀 0.7 / 10 8.9 / 10 10 / 10 9.9 / 10
HikariCP 대기 요청 평균 (관측 최대) 0 (0) 162 (191) 184 (189) 157 (191)
Spring exporter 정상 응답 / 15초 관측 21회 21/21 10/21 4/21 7/21

45.5 RPS에서는 대기 요청이 관측되지 않았지만, 91 RPS에서는 HikariCP 대기 요청 평균이 162개, 관측 최대가 191개로 증가했다. 이는 커넥션을 기다리는 Spring 요청 스레드가 크게 늘었다는 뜻이다. 처리 중인 요청의 upstream 연결이 오래 유지되면 Nginx가 보유하는 연결 수도 함께 늘 수 있어 메모리 압박에 기여했을 가능성이 있다.

다만 HikariCP 지표만으로 Nginx 메모리 사용량이나 OOM의 직접 원인을 증명할 수는 없다. 앞의 커널 로그는 Nginx cgroup 메모리 제한에 따른 worker 종료와, 호스트 전체 메모리 부족으로 Java 프로세스가 종료된 global_oom을 각각 보여준다. 이를 하나의 OOM이 자동 전파된 것으로 단정하기보다, 같은 부하에서 발생한 컨테이너 메모리 제한 초과와 호스트 전체 메모리 부족으로 나누어 해석한다. HikariCP 포화는 관측 사실이고, DB 처리 지연이 그 포화를 일으킨 구체 원인인지는 MySQL 쿼리·잠금 지표로 추가 확인해야 한다.

1) HikariCP 대기열 포화

1-1) 요청 흐름과 대기열 위치

부하 발생기
  → Nginx (HTTP 연결·upstream 연결 유지)
  → Spring MVC 요청 처리 스레드
  → Spring Data JPA / Hibernate
  → HikariCP (Spring 프로세스 내부 커넥션 풀, 관측 최대 10개)
  → JDBC 연결
  → MySQL (쿼리 실행·잠금 처리)

HikariCP 대기열은 백엔드 Spring 프로세스 안에 있고, MySQL은 HikariCP 대기열을 관리하는 주체가 아니다. Spring 요청 스레드가 JDBC 커넥션을 요청했을 때 풀이 허용한 커넥션을 모두 사용 중이면, 그 스레드는 HikariCP에서 대기한다. hikaricp_connections_pending은 커넥션을 기다리는 스레드 수를 나타내는 게이지로 RPS나 MySQL 내부 대기 요청 수가 아니다.

커넥션을 빌린 뒤 실행 중인 SQL과 트랜잭션은 MySQL에서 처리된다. 쿼리가 느리거나 잠금 대기·긴 트랜잭션이 있으면 커넥션 반환이 늦어질 수 있고, 그 결과 Spring 쪽 HikariCP 대기자가 늘어난다. 반대로 풀 크기가 작거나 애플리케이션이 커넥션을 불필요하게 오래 잡아도 대기열이 생길 수 있다.

1-2) 코드에서 커넥션을 오래 점유할 수 있는 경로

이번 45.5·91·180·226 RPS 실험은 게시글 주변 조회 부하다. 따라서 택시팟 생성의 PESSIMISTIC_WRITE 잠금 경로와는 구분해야 한다. 요청 경로는 HomeController에서 HomeService를 거쳐 JPA repository로 이어지며, 조회 서비스 메서드는 @Transactional(readOnly = true)로 실행된다. DB 조회에 사용한 커넥션은 트랜잭션이 끝날 때 HikariCP로 반환된다.

요청 경로 코드에서 확인한 동작 대기열에 주는 영향
GET /api/map-pins HomeService.searchMapPins()가 커뮤니티 글과 동행 글을 각각 공간 쿼리로 조회한다. MBRContains로 뷰포트를 찾은 뒤 두 결과를 합친다. 응답 상한 500개 검사는 조회가 끝난 뒤 Java 코드에서 수행한다. repository SQL에는 해당 500개 상한 LIMIT이 없다. 넓은 뷰포트나 많은 후보가 있으면 DB가 많은 행을 읽고 반환할 수 있다. 응답 제한이 DB 작업량 제한과 같지 않다.
GET /api/nearby-posts HomeService.searchNearbyPosts()가 커뮤니티·동행 repository 쿼리를 각각 호출한다. 쿼리는 공간 필터 뒤 ST_Distance_Sphere, 사용자 join, 거리 정렬을 수행하고 각 결과를 최대 10개 반환한다. 서비스는 두 목록을 다시 합치고 정렬한다. 뷰포트 후보 행이 많을수록 거리 계산·정렬 비용이 커질 수 있다. LIMIT 10이 후보 계산 비용을 항상 10행으로 제한하는 것은 아니다.
상세·사용자·채팅방 조회 상세 서비스 등도 JPA repository를 호출하고 @Transactional(readOnly = true)를 사용한다. 단일 호출의 쿼리 수와 크기는 다르지만 같은 HikariCP 풀을 공유한다.

특히 map-pins는 애플리케이션 응답을 500개로 제한하지만 SQL 결과는 먼저 전부 가져온다. 실제 비용은 공간 인덱스 사용 여부, 뷰포트별 후보 행 수, 실행 계획에 따라 달라지므로 운영 DB의 EXPLAIN ANALYZE와 rows examined로 확인해야 한다. nearby-posts도 주변 후보가 늘면 거리 계산과 정렬 비용이 커져 커넥션 점유 시간이 길어질 수 있고, 이는 HikariCP pending을 높이는 한 가지 가설이다. 다만 이 부하 측정의 HikariCP 지표만으로 해당 쿼리 비용이 대기열의 원인이라고 확정할 수는 없다.

목표 RPS Hikari active 평균 / 최대 Hikari pending 평균 (관측 최대) Spring exporter 정상 응답 해석
45.5 0.7 / 10 0 (0) 21/21 표본에서 풀이 여유 있고 대기자는 없었다.
91 8.9 / 10 162 (191) 10/21 시험한 단계 중 처음 대기자가 관측됐다.
180 10 / 10 184 (189) 4/21 관측 표본에서 풀이 가득 차고 대기자가 확인됐다. 계측 누락이 크다.
226 9.9 / 10 157 (191) 7/21 관측 표본에서 풀 포화와 대기자가 확인됐다. 계측 누락이 크다.

hikaricp_connections_pending은 각 관측 시점에 풀에서 커넥션을 기다린 스레드 수다. 45.5 RPS에서는 대기자가 관측되지 않았고, 목표 91 RPS 단계에서는 active가 최대 풀 크기 10에 도달한 표본과 다수의 pending이 관측됐다. 따라서 이 조회 부하에서 현재 HikariCP 풀에 대기열이 생겼다는 점은 확인됐지만, 시험 단계가 45.5·91 RPS로 나뉘므로 포화가 시작된 정확한 RPS는 특정할 수 없다. 또한 Spring exporter 응답이 91 RPS에서 10/21회, 180 RPS에서 4/21회, 226 RPS에서 7/21회이므로 평균과 최댓값은 응답이 수집된 표본 기준이다.

이 지표만으로 원인을 풀 크기, 커넥션 점유 시간, SQL 실행 시간 또는 MySQL 내부 대기 중 하나로 확정할 수는 없다. 특히 이 실험은 게시글 주변 조회 경로이므로 택시팟 생성의 PESSIMISTIC_WRITE 경합 결과로 해석하면 안 된다. 원인을 분리하려면 동일 부하에서 커넥션 획득·사용 시간, 쿼리 지연·rows examined, MySQL 잠금 및 실행 계획을 함께 확인해야 한다. 따라서 이 결과만으로 HikariCP 풀을 키우거나 Spring 태스크의 CPU·메모리 스펙을 결정하지 않는다.


1.3. Spring Boot의 가용성

가용성 문제 정의

HikariCP 대기열의 구체적인 원인은 추가 계측이 필요하지만, Spring Boot의 가용성 문제는 별도의 근거로 확인된다. 커널 로그에서 Java 프로세스가 호스트 전체 메모리 부족에 따른 global_oom으로 두 차례 종료되며 다른 컨테이너의 제한 초과 장애 여파로 Spring 프로세스 종료가 되는 문제가 발생했다.

Spring 인스턴스가 하나뿐인 V1 구조에서는 그 프로세스가 종료된 동안 대체 Spring 인스턴스로 요청을 보낼 수 없고, 재시작이 끝날 때까지 백엔드 처리 용량이 사라진다.


결론: 별도의 Spring 프로세스로 Scale-out: ECS on EC2

따라서 HikariCP 대기의 원인을 확정해서가 아니라, 단일 Spring 프로세스와 호스트 장애가 곧 백엔드 용량 상실로 이어지는 가용성 위험을 줄이기 위해 Spring REST 서비스를 여러 인스턴스로 scale-out해야 한다. 로드밸런서의 헬스체크로 비정상 인스턴스를 제외하고, 한 인스턴스가 OOM 종료·재시작 중이어도 나머지 인스턴스가 트래픽을 처리할 최소 여유 용량을 유지해야 한다. EC2 기반이라면 인스턴스를 서로 다른 호스트와 가용 영역에 배치해야 하며, 같은 호스트의 global OOM이 모든 태스크를 함께 종료할 수 있는 배치는 장애 격리가 되지 않는다.

하지만 Scale-out은 HikariCP나 MySQL 병목을 자동으로 해결하지 않는다. 인스턴스를 늘리면 총 DB 커넥션 상한도 늘어나므로, 각 태스크의 풀 크기 합계가 MySQL의 연결 용량과 쿼리 처리 능력을 넘지 않도록 관리해야 한다. 분리 후에는 같은 조회 시나리오로 단계별 RPS, p95·p99, 오류율, HikariCP 대기, DB 지표를 비교하고, SLO를 만족하는 최소 태스크 수와 태스크 스펙을 정해야 한다.

결과적으로 Spring 프로세스는 ECS on EC2로 task t3.small 인스턴스 2대에 비정기적 트래픽에 대해 spot 인스턴스로 대응하는 구조로 결정한다. 자세한 태스크 스펙과 Scale-out 방법에 대해서 [V2의 백엔드 애플리케이션 인프라 설계] 문서에서 자세히 다룬다.




2. WebSocket 연결과 조회 혼합 부하

매칭이 늘면 새로운 채팅방에 입장하는 사용자와 이미 연결된 상태에서 메시지를 주고받는 사용자가 함께 늘어난다. 각 기능을 단독으로 처리할 수 있어도 같은 시간에 발생하면 같은 JVM과 HikariCP에서 경합한다. 조회 단독 결과와 조회에 다른 부하를 더한 결과를 비교해 지연이 어떤 공유 자원을 통해 전파되는지 확인한다.

V1에서는 REST API와 WebSocket이 하나의 Spring Boot JVM에서 동작한다. WebSocket 연결이 증가하면 세션 객체와 버퍼가 heap을 점유하고, REST 요청의 직렬화와 같은 GC를 사용한다. 따라서 조회량 자체는 처리할 수 있어도 WebSocket과 REST가 동시에 발생할 때 응답 지연과 오류가 증가할 수 있다.

한 번의 실행 안에서 시간 창을 나눠 같은 조회 부하를 비교한다.

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에서 간섭과 조회를 함께 측정한다.


Spring·WebSocket 분리: GC pause p95 82배 증가와 heap 메모리 증가

항목 REST 단독 WebSocket 1,642개 + REST 변화
조회 p95 11.0 ms 167.2 ms 15배
조회 p99 33.4 ms 914.8 ms 27배
조회 실패율 0% 4.54% 증가
backend CPU 0.032 cores 0.061 cores 1.9배
heap 최대 123 MB 277 MB 2.3배
GC pause p95 3.1 ms 257 ms 82배
HikariCP 대기 0 0 변화 없음
MySQL CPU 0.017 cores 0.018 cores 변화 없음

WebSocket 연결당 heap은 28.2 KB, 1,000개당 CPU는 0.0092 cores로 측정됐다. 연결 단가 자체는 작지만, 연결이 누적된 상태에서 heap과 GC pause가 증가하면서 REST 응답의 꼬리 지연으로 전파됐다. HikariCP와 MySQL 지표는 변하지 않고 Spring heap과 GC pause가 함께 증가했다. 이 결과는 REST와 WebSocket이 프로세스 안에서 공유하는 heap·GC가 핵심 경합 지점인 것은 같은 JVM 자원을 공유하는 구조에서 지연이 전파되어 자원 경합이 발생했다는 걸 조회 p95·p99와 실패율에서 확인할 수 있다.


결정: REST·WebSocket 서버 분리

V2에서는 REST와 WebSocket을 별도 Spring Boot 프로세스 및 ECS 서비스로 분리한다. WebSocket 연결 증가가 REST JVM의 heap·GC에 직접 영향을 주지 않고, 두 서비스의 자원 배분·확장·배포도 각각 조정할 수 있습니다.


3. 인기 팟 집중

좌석 경합은 전체 RPS가 낮아도 특정 행에 요청이 몰리는 hot-row 부하다. 활동 사용자 수가 아니라 같은 대상에 동시에 도착한 요청 수로 측정한다.

1) 호출되는 API

모드 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

2) 부하 지점 및 확인할 자원

동행 참여

참여 요청 → 활성 참여 여부 조회 → 사용자 조회
         → 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)과 분산(대조군)을 같은 동시성으로 비교해, 지연이 동시 요청 수 때문인지 같은 행 잠금 때문인지 구분한다.

3) MySQL 컨테이너 부하 측정 결과

측정 대상은 0.5 vCPU·768 MB MySQL 컨테이너이며, 실서버 API 경로는 k6 → HTTPS/Nginx → Spring Boot → HikariCP → MySQL이다. 좌석 승인과 택시팟 생성은 서로 다른 트랜잭션 경로이므로 결과를 나눠 해석한다.

초기 픽스처 기반 동일 행 좌석 경합은 dev 실서버의 동행 글 승인 측정과 별도 실행이다. 환경이 다르므로 두 결과의 실패율이나 지연값을 이어 붙여 하나의 추세로 해석하지 않는다.

동시 요청 실패율 HikariCP pending 커넥션 획득 p95 좌석 lock p95 정합성
5 0% 0 2.3 ms 425 ms 오류 없음
20 4.76% 10 934 ms 971 ms 오류 없음
50 59.5% 32 1,061 ms 976 ms 오류 없음
100 79.9% 84 1,067 ms 1,046 ms 오류 없음

동시 20건부터 Hikari 대기가 시작되고, 50건 이상에서 커넥션 획득 timeout과 실패가 급증했다. 정원 초과·중복 참여·잔여 좌석 음수는 없었다. 이 실행은 hot-row 직렬화 중 커넥션을 점유하는 것이 해당 구성의 가용성 병목이 될 수 있음을 보였지만, dev 실서버 측정 결과와 실패율을 직접 비교할 수는 없다.

동행 좌석 경합

같은 동행 글에 서로 다른 사용자의 참여 요청을 동시 20·50·100건씩 각 3라운드 보냈다. 대상 글은 방장을 포함해 정원 10명으로 생성했다. 각 대상에 남은 좌석은 9개였고, 전체 9개 대상에서 각각 참여 성공 9건이 반영됐다. 이 API는 방장이 신청을 승인하는 흐름이 아니라, 참여 요청 자체가 자리를 확보하고 채팅방에 합류하는 흐름이다.

동시 요청 참여 성공 / 정상 거절 SLI 실패율 참여 성공 p95 / p99 전체 p95 (거절 포함) 커넥션 획득 평균 / 최댓값 MySQL row-lock 대기 정합성
20 27 / 33 0% 437 / 472 ms 486 ms 40 / 423 ms 47회 · 누적 5.3초 3/3 라운드 통과
50 27 / 123 0% 329 / 381 ms 615 ms 176 / 546 ms 156회 · 누적 9.8초 3/3 라운드 통과
100 27 / 273 0% 464 / 480 ms 960 ms 326 / 821 ms 221회 · 누적 9.8초 3/3 라운드 통과

코드 흐름상 POST /api/companion-posts/{id}/participants는 ChatParticipationService.participate()를 트랜잭션으로 실행한다. 활성 참여 여부와 사용자를 조회한 뒤 다음 조건부 UPDATE를 호출한다.

@Modifying
@Query("""
    UPDATE Companion c
    SET c.currentCount = c.currentCount + 1
    WHERE c.id = :id
      AND c.status = :status
      AND c.currentCount < c.capacity
    """)
int increaseCurrentCountIfRecruitingAndNotFull(...);

UPDATE 결과가 0행이면 COMPANION_NOT_JOINABLE(HTTP 409)로 거절한다. 좌석이 남아 있으면 참여 행을 저장하고 채팅방과 입장 시스템 메시지를 처리한 뒤 트랜잭션을 커밋한다. 참여 테이블의 (companion_id, user_id) 고유 제약과 활성 참여 사전 확인은 같은 사용자의 중복 참여를 막고, 조건부 UPDATE는 같은 순간에 여러 요청이 와도 current_count가 정원을 넘지 않게 한다.

부하 생성 코드도 HTTP 201만 참여 성공으로 집계하고 COMPANION_NOT_JOINABLE·ALREADY_PARTICIPATING은 정상 비즈니스 거절로 분류해 SLI 실패율의 분모에서 제외한다. 정원 10명 중 방장 1명을 제외하면 대상당 참여 성공 상한은 9명이다. 그래서 세 라운드 동안 각 동시성 단계에서 참여 성공은 27건으로 제한되고, 나머지는 주로 좌석 소진에 따른 정상 거절이다.

정합성 오류가 없었던 것은 이 조건부 UPDATE, 참여자 고유 제약, 부하 스크립트의 current_count = 1 + 성공 요청 수 ≤ capacity 사후 검증과 일치한다. 다만 이것은 이 데이터와 경로에서의 통과 결과이며 모든 경쟁 조건을 보편적으로 증명하는 것은 아니다.

Hikari active는 10/10, pending은 최대 8, timeout은 0이었다. MySQL questions/s는 평균 40·최대 71, threads running은 2, MySQL CPU는 단계 최대 0.09 cores(할당 0.5 vCPU의 18%)였다. 실제 경로는 같은 companions 행을 갱신하므로 InnoDB row lock 대기와 커넥션 점유가 생겼고, pending 최대 8 및 row-lock 누적 대기가 이를 보여준다. 이 부하에서는 한 행 UPDATE가 직렬화되고 대부분의 요청이 정원 소진으로 거절되어, CPU 연산보다 잠금 대기 비중이 커졌다. 그러므로 DB CPU는 포화되지 않았지만 DB 경합은 존재했다.


택시팟 생성 경합

같은 출발 좌표·출발 분으로 동시 4·20·50건을 각 3라운드 보냈다. 생성 전 사용자 잠금과 기존 팟 탐색/생성 경로가 경합했다.

동시 요청 성공 / HTTP 500 SLI 가용성 요청 p95 / p99 커넥션 획득 평균 / 최댓값 MySQL row-lock 대기 MySQL threads running 최댓값
4 6 / 6 50.0% 333 / 345 ms 0.9 / 5 ms 11회 · 누적 0.1초 2
20 15 / 45 25.0% 1,138 / 1,141 ms 25 / 203 ms 49회 · 누적 5.6초 10
50 43 / 107 28.7% 904 / 948 ms 214 / 688 ms 126회 · 누적 15.6초 11

전체 SLI 가용성은 64/222 = 28.8%였고, 158건이 HTTP 500으로 반환됐다. 의도된 409 MATCH_BUSY는 0건이었다. Hikari timeout은 0, 호스트 CPU 평균 6.9%·최대 9.7%, Spring CPU 최대 0.117 cores였다. 정합성 검사는 21/21 통과했고 팟 쪼개짐은 0건이었다. MySQL CPU는 이 결과표에 없어 DB CPU 포화 여부를 확정할 수 없지만, 현재 기록만으로는 Hikari 연결 한도 고갈이 500의 원인으로 드러나지 않았다. 우선 확인할 문제는 lock 충돌/데드락 예외가 재시도 뒤에도 MATCH_BUSY로 변환되지 않고 500으로 반환되는 애플리케이션 경로다.

두 단계 모두 정합성 검사는 통과했지만 사용자가 겪는 가용성은 달랐다. 동행 좌석 단계에서는 정상 거절을 제외한 SLI 실패율이 0%였고, 택시팟 생성 단계에서는 충돌 응답 처리 실패로 가용성이 28.8%에 그쳤다. 따라서 정합성 보장, hot-row 처리량, 오류 응답은 별도 지표로 판단한다. RDS 같은 DB 배치 변경만으로 충돌·데드락 처리 문제가 해결된다고 보지 않는다.

계측은 Grafana/Prometheus에서 호스트·Spring CPU, 컨테이너 메모리·CPU throttling, JVM heap·GC, Hikari active/pending·획득시간, MySQL questions·threads·row lock을 관찰했다. DB 배치 방식 및 대안의 비교 기준은 V2의 DB 인프라 설계를 참조한다.




근거 문서

⚠️ **GitHub.com Fallback** ⚠️