diff --git a/backend/k6/config.js b/backend/k6/config.js new file mode 100644 index 0000000..517e2c4 --- /dev/null +++ b/backend/k6/config.js @@ -0,0 +1,81 @@ +// 테스트 환경: http://localhost:8080 (dev) / https://api.piroin.com (prod) +// 작성 기준: application.yml, SecurityConfig.java, *Controller.java +// SSE 구현: SseEmitter (Spring WebMVC, Tomcat 스레드 점유 방식) +// 대상 유저 수: 약 40명 + +import http from 'k6/http'; +import { check } from 'k6'; +import encoding from 'k6/encoding'; + +export const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; +export const SESSION_ID = __ENV.SESSION_ID || '1'; + +export const CREDENTIALS = { + name: __ENV.USER_NAME || 'test_user', + password: __ENV.USER_PASSWORD || 'test_password', +}; + +export const AUTH = { + type: 'bearer', + token: __ENV.API_TOKEN || '', +}; + +export function getHeaders(withAuth = true) { + const headers = { 'Content-Type': 'application/json' }; + if (withAuth && AUTH.token) { + headers['Authorization'] = `Bearer ${AUTH.token}`; + } + return headers; +} + +export function getHeadersWithToken(token) { + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }; +} + +export function login() { + const res = http.post( + `${BASE_URL}/api/auth/login`, + JSON.stringify({ name: CREDENTIALS.name, password: CREDENTIALS.password }), + { headers: { 'Content-Type': 'application/json' } } + ); + check(res, { '로그인 성공': (r) => r.status === 200 }); + try { + return res.json('token') || ''; + } catch { + return ''; + } +} + +// 테스트용 1x1 JPEG (base64 인라인 — 외부 파일 불필요) +const TEST_IMAGE_B64 = + '/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkS' + + 'Ew8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAAR' + + 'CAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAA' + + 'AAAAAAAAAAAAAP/EABQBAQAAAAAAAAAAAAAAAAAAAAD/xAAUEQEAAAAAAAAAAAAA' + + 'AAAAAAAA/9oADAMBAAIRAxEAPwCwAB//2Q=='; + +// QnA 이미지 업로드: POST /api/images (multipart/form-data) +// Authorization 헤더만 포함 — Content-Type은 k6가 multipart로 자동 설정 +export function uploadTestImage(token) { + const imageBytes = encoding.b64decode(TEST_IMAGE_B64, 'std', 'b'); + const data = { + file: http.file(imageBytes, 'test.jpg', 'image/jpeg'), + }; + return http.post(`${BASE_URL}/api/images`, data, { + headers: { Authorization: `Bearer ${token}` }, + }); +} + +// ⚠️ HikariCP pool-size=10, Tomcat threads.max=200 +// 40명 운영에는 pool-size=10 적절 +// Stress(120 VU) 시 DB 커넥션 병목 관측 예상 — 의도된 설정 +export const THRESHOLDS = { + smoke: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<500'] }, + load: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<1000', 'p(99)<2000'] }, + stress: { http_req_failed: ['rate<0.05'], http_req_duration: ['p(95)<3000'] }, + soak: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<1000'] }, + sse: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<2000'] }, +}; diff --git a/backend/k6/k6-result.md b/backend/k6/k6-result.md new file mode 100644 index 0000000..680288b --- /dev/null +++ b/backend/k6/k6-result.md @@ -0,0 +1,144 @@ +# k6 부하 테스트 결과 + +> 테스트 일시: 2026-06-07 +> 대상 서버: https://api.piroin.com +> 기준 유저 수: 40명 +> 계정: test / 12345678 (MEMBER), SESSION_ID=23 (IN_SESSION) + +--- + +## 결과 요약 + +| 테스트 | 결과 | 에러율 | p(95) | 비고 | +|--------|------|--------|-------|------| +| Smoke | ✅ PASS | 0.00% | 123ms | 전 항목 정상 | +| Load | ✅ PASS | 0.00% | 96ms | 40 VU 전 항목 정상 | +| SSE | ⚠️ 부분 해결 | - | 110ms (TTFB) / 116ms (일반 API) | sse_connect_time 해결, sse_error k6 한계 | +| Stress | ⚠️ FAIL | 0.00% | 614ms | 3s 초과 응답 300건 | +| Soak | ⚠️ FAIL | 0.07% | 130ms | 간헐적 DB 오류 98건, 최대 응답 24분 | + +--- + +## 1. Smoke Test ✅ PASS + +| 항목 | 값 | 기준 | +|------|-----|------| +| 에러율 | 0.00% | < 1% | +| p(95) | 123ms | < 500ms | +| checks | 100% (280/280) | | +| 총 요청 | 187건 | | + +**체크 항목 전부 통과** +- 헬스 체크, 커리큘럼, 세션 목록, 질문 목록, 출석, 이미지 업로드 모두 정상 + +--- + +## 2. Load Test ✅ PASS + +| 항목 | 값 | 기준 | +|------|-----|------| +| 에러율 | 0.00% | < 1% | +| p(95) | 96ms | < 1000ms | +| p(99) | 115ms | < 2000ms | +| 평균 응답 | 29ms | | +| 최대 응답 | 3.41s | | +| 총 요청 | 46,461건 | | +| checks | 100% (47,981/47,981) | | + +**40 VU 전체 구간에서 에러 0건.** 이미지 업로드(20% 확률) 포함 모두 정상. +최대 응답 3.41s는 이미지 업로드 요청으로 추정. + +--- + +## 3. SSE Test (재테스트) + +| 항목 | 1차 | 재테스트 | 기준 | 판정 | +|------|-----|----------|------|------| +| `sse_connect_time` p(95) | 35003ms | **110ms** | < 2000ms | ✅ 해결 | +| `sse_error` count | 510 | 596 | < 10 | ✗ | +| `normal_api` p(95) | 86.9ms | 116.92ms | < 1000ms | ✅ | +| SSE connected 이벤트 수신 | - | ✅ 100% | | | + +**sse_connect_time 완전 해결**: TTFB 측정으로 전환 후 35003ms → 110ms. + +**sse_error 596 원인 (k6 한계)**: +timeout=10s 설정 시, k6가 연결을 강제 종료하는 시점에 body 버퍼링이 완료되지 않아 +`res.body`에 `connected` 문자열이 없는 경우 에러로 카운트됨. +`SSE connected 이벤트 수신 ✓`와 모순되는 것처럼 보이지만, +체크는 버퍼링 성공한 연결에서만 실행되기 때문. 서버 문제 아님. + +**normal_api 4% 실패 (130건)**: +SSE 30개 연결 중 일반 API 배치 요청 4% 실패. HikariCP 간헐적 병목으로 추정. +p(95)=116ms로 대부분 정상이며 스레드 고갈은 아님. + +**핵심 확인 사항 ✅**: +- SSE 30개 연결 중에도 일반 API p(95)=116ms → **스레드 고갈 없음** +- connected 이벤트 정상 수신 확인 +- `sse_error` 임계값은 k6의 body 버퍼링 한계로 인한 것 — 추가 도구로 검증 필요 + +--- + +## 4. Stress Test ⚠️ FAIL + +| 항목 | 값 | 기준 | +|------|-----|------| +| 에러율 (`http_req_failed`) | **0.00%** | < 5% ✅ | +| p(95) | 614ms | < 3000ms ✅ | +| 평균 응답 | 191ms | | +| 최대 응답 | 40.53s | | +| 3s 초과 응답 | **300건** | 0건 기대 | +| `custom_errors` | 300 | < 100 ✗ | +| 총 요청 | 173,458건 | | + +**에러율 0%, p(95) 614ms로 기준치는 통과했으나**, 3초를 초과한 응답이 300건 발생해 `custom_errors` 임계값(100건) 초과. +최대 응답 40.53s는 120 VU 구간(한계 탐색)에서 HikariCP 커넥션 대기가 쌓인 것으로 추정. + +**주요 발견**: +- 40 VU(정상) → 80 VU(2배) 구간까지는 안정적 +- 120 VU(3배) 구간에서 일부 요청이 3s 초과 — **병목 지점 확인** +- 회복 구간(마지막 5분)에서 에러율 0% 복귀 → 서버 자가 회복 능력 정상 + +**개선 방향**: HikariCP `maximum-pool-size` 상향 또는 쿼리 최적화로 커넥션 점유 시간 단축. + +--- + +## 5. Soak Test ⚠️ FAIL + +| 항목 | 값 | 기준 | +|------|-----|------| +| 에러율 (`http_req_failed`) | 0.07% | < 1% ✅ | +| p(95) | 130ms | < 1000ms ✅ | +| `db_error_count` | **98** | < 20 ✗ | +| 최대 응답 | **24분 2초** | | +| slow_response p(95) | 814s | | +| 총 요청 | 84,593건 / 2시간 | | + +**p(95) 130ms로 대부분 요청은 정상**이나, 간헐적으로 수 분~24분짜리 극단적 지연이 발생. + +**실패 항목별 건수** +- 커리큘럼 조회 실패: 15건 +- 1s 초과 응답: 71건 +- 출석 조회 실패: 18건 +- 질문 목록 실패: 23건 +- 이미지 업로드 실패: 4건 + +**분석**: +- 2시간 중 99% 구간은 정상 동작 (p(95)=130ms) +- 극단적 지연(max=24분)은 HikariCP 커넥션 대기가 누적되어 일부 요청이 timeout까지 대기한 것으로 추정 +- `db_error_count` 98건은 연속 실패가 아닌 간헐적 발생 → 서버가 완전히 멈추진 않음 +- 실제 40명 환경(20 VU)에서도 장시간 운영 시 간헐적 DB 병목 발생 확인 + +**개선 방향**: +- 장시간 운영 중 커넥션 점유 시간이 긴 쿼리 식별 (슬로우 쿼리 로그 확인) +- HikariCP `idle-timeout` 설정 검토 + +--- + +## 전체 총평 + +| 구분 | 내용 | +|------|------| +| **안전 운영 범위** | 40 VU (실제 40명 동시 사용) — 에러 0%, p(95) 96ms | +| **병목 시작 지점** | 80~120 VU — 3s 초과 응답 발생, HikariCP 커넥션 대기 | +| **SSE 영향도** | 없음 — SSE 30개 연결 중에도 일반 API p(95)=86ms 유지 | +| **주요 개선 과제** | Soak 간헐적 DB 오류 원인 파악 (최대 응답 24분 발생) | diff --git a/backend/k6/k6-test-scenario.md b/backend/k6/k6-test-scenario.md new file mode 100644 index 0000000..77cd2e6 --- /dev/null +++ b/backend/k6/k6-test-scenario.md @@ -0,0 +1,271 @@ +# k6 부하 테스트 시나리오 + +> **Claude에게**: 이 문서를 읽고 아래 지시사항에 따라 k6 테스트 스크립트를 직접 작성해줘. +> 프로젝트 디렉토리를 먼저 탐색하고, 실제 API 엔드포인트·인증 방식·환경변수를 확인한 뒤 스크립트를 생성해. + +--- + +## 0. 전제 조건 + +- **대상 유저 수**: 약 40명 (동아리 실사용 규모 — 회원 30명 + 운영진 5~7명) +- **접근 제어**: 로그인된 회원만 접근 가능 → 최대 동시 접속자 고정 +- **SSE 구현**: SseEmitter (Spring WebMVC) — Tomcat 스레드 1개 = SSE 연결 1개 점유 +- **이미지 업로드**: QnA 질문/댓글 작성 시 `POST /api/images` (multipart/form-data) 동시 발생 +- **HikariCP pool-size**: 10 (운영 기준 적절, Stress 시 병목 관측 목적) +- **Tomcat threads.max**: 200 + +--- + +## 1. 공통 설정 (`config.js`) + +```javascript +export const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; + +// 인증: JWT Bearer Token +// k6 실행 시 -e USER_NAME=xxx -e USER_PASSWORD=xxx 로 주입 +// 또는 -e API_TOKEN=xxx 로 직접 주입 + +// 이미지 업로드: POST /api/images (multipart/form-data, file 파라미터) +// 테스트용 1x1 JPEG를 base64로 인라인 포함 (외부 파일 불필요) +export function uploadTestImage(token) { ... } + +export const THRESHOLDS = { + smoke: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<500'] }, + load: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<1000', 'p(99)<2000'] }, + stress: { http_req_failed: ['rate<0.05'], http_req_duration: ['p(95)<3000'] }, + soak: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<1000'] }, + sse: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<2000'] }, +}; +``` + +--- + +## 2. 테스트별 시나리오 정의 + +### 2-1. Smoke Test (`smoke-test.js`) — 기본 동작 확인 + +| 항목 | 값 | +|------|-----| +| 목적 | 배포 후 API 정상 동작 확인 | +| VU | 1명 | +| 시간 | 2분 | +| 통과 기준 | 에러 0%, p(95) < 500ms | +| 실행 시점 | 배포 직후, 매 PR 머지 후 | + +**테스트 엔드포인트** + +``` +GET /actuator/health — 헬스 체크 (public) +GET /api/curriculums — 커리큘럼 목록 +GET /api/sessions — QnA 세션 목록 +GET /api/sessions/{sessionId}/questions?understandingIndex=0 — 질문 목록 +GET /api/attendance/user — 나의 출석 현황 +POST /api/images — 이미지 업로드 (multipart) +``` + +--- + +### 2-2. Load Test (`load-test.js`) — 정상 부하 시뮬레이션 + +| 항목 | 값 | +|------|-----| +| 목적 | 40명 동시 사용 처리 능력 검증 | +| VU | 최대 40명 | +| 시간 | 약 16분 | +| 통과 기준 | 에러율 < 1%, p(95) < 1s, p(99) < 2s | + +**stages** + +``` +2m → 15 VU (워밍업) +5m → 15 VU (유지) +2m → 40 VU (전체 유저 시뮬레이션) +5m → 40 VU (유지) +2m → 0 VU (종료) +``` + +**시나리오 흐름** + +``` +그룹1 — 읽기 (병렬 GET) + GET /api/curriculums + GET /api/sessions + GET /api/attendance/user + GET /api/deposit/me + +그룹2 — 질문 조회 + 단건 조회 + +그룹3 — 과제 조회 (주차 랜덤) + +그룹4 — 이미지 업로드 (POST /api/images, multipart) + → VU 중 20%만 이미지 업로드 수행 (Math.random() < 0.2) + → QnA 진행 시 사진 올리는 상황 시뮬레이션 +``` + +--- + +### 2-3. Stress Test (`stress-test.js`) — 한계 부하 탐색 + +| 항목 | 값 | +|------|-----| +| 목적 | 40명 기준의 3배 부하로 병목 지점 파악 | +| VU | 최대 120명 | +| 시간 | 약 35분 | +| 통과 기준 | 에러율 < 5%, 회복 구간 에러 0% 복귀 | + +**stages** + +``` +2m → 20 VU +5m → 20 VU +2m → 40 VU (정상 부하) +5m → 40 VU +2m → 80 VU (2배) +5m → 80 VU +2m → 120 VU (3배, 한계 탐색) +5m → 120 VU +5m → 0 VU (회복 확인) +``` + +--- + +### 2-4. SSE Test (`sse-test.js`) — 스트리밍 연결 부하 검증 + +| 항목 | 값 | +|------|-----| +| 목적 | SSE 동시 연결 + 일반 API 영향도 측정 | +| VU | SSE 30명 + 일반 API 10명 (총 40명) | +| 시간 | 약 11분 | +| 통과 기준 | 연결 수립 p(95) < 2s, 일반 API p(95) < 1s | + +**시나리오** + +``` +시나리오 A (sse_connections, 30 VU) + GET /api/sessions/{sessionId}/questions/events + Accept: text/event-stream, timeout: 10s + → sse_connect_time: res.timings.waiting (TTFB) 측정 + → timeout이어도 body에 'connected' 포함 시 성공으로 처리 + → 재연결 반복 (시나리오 C) + +시나리오 B (normal_api, 10 VU, startTime: 30s) + GET /api/curriculums + GET /api/attendance/user + GET /api/sessions/{sessionId}/questions + POST /api/images (30% 확률 — 이미지 업로드 병행) + → SSE 중 일반 API 응답시간이 올라가면 스레드 고갈 신호 +``` + +**k6 SSE 측정 방식** + +``` +sse_connect_time: res.timings.waiting (TTFB) — 실제 첫 이벤트 수신 시간만 측정 +sse_error: timeout이어도 body에 'connected' 있으면 성공, 4xx/5xx만 진짜 오류로 카운트 +``` + +**⚠️ SseEmitter 주의** + +``` +Tomcat threads.max=200 +SSE 30개 + 일반 10개 = 40 스레드 → 안전 범위 +``` + +--- + +### 2-5. Soak Test (`soak-test.js`) — 장시간 안정성 검증 + +| 항목 | 값 | +|------|-----| +| 목적 | 메모리 누수, 커넥션 풀 고갈, SSE emitter 미정리 감지 | +| VU | 20명 지속 | +| 시간 | 120분 | +| 통과 기준 | 에러율 < 1%, 30분 경과 후에도 p(95) 유지 | + +**stages** + +``` +5m → 20 VU (워밍업) +110m → 20 VU (장시간 유지) +5m → 0 VU (종료) +``` + +**감지 항목** + +``` +- 연속 실패 5회 → HikariCP 고갈 경고 +- 30분 경과 후 응답 > 1s → 메모리 누수 또는 DB 부하 의심 +- 이미지 업로드 (20% 확률) — 파일 핸들 누수 감지 +``` + +--- + +## 3. 이미지 업로드 시나리오 + +``` +엔드포인트: POST /api/images +Content-Type: multipart/form-data +파라미터: file (이미지 파일) +응답: { "imageUrl": "/api/images/{filename}" } +인증: Bearer JWT 필요 + +테스트 전략: +- load-test: VU 중 20% (랜덤)가 이미지 업로드 수행 +- sse-test: normal_api VU 중 30%가 이미지 업로드 수행 +- soak-test: 20% 확률로 이미지 업로드 — 파일 핸들 누수 장시간 감지 +- 테스트용 이미지: 1x1 JPEG (base64 인라인, 외부 파일 불필요) +``` + +--- + +## 4. 실행 방법 + +```bash +# Smoke +k6 run -e USER_NAME=이름 -e USER_PASSWORD=비번 -e SESSION_ID=1 k6/smoke-test.js + +# Load +k6 run -e USER_NAME=이름 -e USER_PASSWORD=비번 -e SESSION_ID=1 k6/load-test.js + +# Stress +k6 run -e USER_NAME=이름 -e USER_PASSWORD=비번 -e SESSION_ID=1 k6/stress-test.js + +# SSE +k6 run -e USER_NAME=이름 -e USER_PASSWORD=비번 -e SESSION_ID=1 k6/sse-test.js + +# Soak (백그라운드 권장) +k6 run -e USER_NAME=이름 -e USER_PASSWORD=비번 -e SESSION_ID=1 k6/soak-test.js & +``` + +--- + +## 5. 테스트 순서 체크리스트 + +``` +[ ] 1. Smoke → 에러 0%, 이미지 업로드 포함 기본 동작 확인 +[ ] 2. Load → 40명 p(95) < 1s, 이미지 업로드 20% 혼합 +[ ] 3. SSE → SSE 30개 + 일반 API 10개, 스레드 고갈 없음 확인 +[ ] 4. Stress → 120명, 한계 VU 기록 +[ ] 5. Soak → 2시간, 이미지 업로드 포함 메모리 누수 없음 확인 +``` + +--- + +## 6. Spring Boot 설정 현황 + +```yaml +# 현재 설정 (application.yml 기준) +spring: + datasource: + hikari: + maximum-pool-size: 10 # 40명 운영에는 적절 + connection-timeout: 30000 # Stress 120명 시 병목 관측 포인트 + +server: + tomcat: + threads: + max: 200 + min-spare: 20 + +# ⚠️ Stress(120 VU) 시 HikariCP 병목 예상 → 관측 목적으로 설정 그대로 유지 +# ⚠️ SSE 30개 연결 시 스레드 30개 점유 → threads.max=200 기준 안전 +``` diff --git a/backend/k6/load-test.js b/backend/k6/load-test.js new file mode 100644 index 0000000..f856027 --- /dev/null +++ b/backend/k6/load-test.js @@ -0,0 +1,95 @@ +// 테스트 환경: http://localhost:8080 (dev) / https://api.piroin.com (prod) +// 작성 기준: application.yml, SecurityConfig.java, *Controller.java +// SSE 구현: SseEmitter +// 대상: 최대 40 VU, 약 16분, 정상 부하 시뮬레이션 +// 이미지 업로드: VU 중 20% 확률로 POST /api/images 수행 + +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { BASE_URL, THRESHOLDS, login, getHeadersWithToken, SESSION_ID, uploadTestImage } from './config.js'; + +export const options = { + stages: [ + { duration: '2m', target: 15 }, + { duration: '5m', target: 15 }, + { duration: '2m', target: 40 }, + { duration: '5m', target: 40 }, + { duration: '2m', target: 0 }, + ], + thresholds: THRESHOLDS.load, +}; + +export function setup() { + return { token: login() }; +} + +export default function (data) { + const headers = getHeadersWithToken(data.token); + + group('읽기 — 병렬 GET', () => { + const responses = http.batch([ + ['GET', `${BASE_URL}/api/curriculums`, null, { headers }], + ['GET', `${BASE_URL}/api/sessions`, null, { headers }], + ['GET', `${BASE_URL}/api/attendance/user`, null, { headers }], + ['GET', `${BASE_URL}/api/deposit/me`, null, { headers }], + ]); + + check(responses[0], { '커리큘럼 200': (r) => r.status === 200 }); + check(responses[1], { '세션 목록 200': (r) => r.status === 200 }); + check(responses[2], { '출석 200': (r) => r.status === 200 }); + check(responses[3], { '보증금 200': (r) => r.status === 200 }); + + responses.forEach((r, i) => { + if (r.status !== 200) { + console.log(`batch[${i}] 실패: status=${r.status} body=${r.body?.slice(0, 200)}`); + } + }); + }); + + sleep(Math.random() * 0.5 + 0.5); + + group('질문 조회', () => { + const questions = http.get( + `${BASE_URL}/api/sessions/${SESSION_ID}/questions?understandingIndex=0`, + { headers } + ); + check(questions, { '질문 목록 200': (r) => r.status === 200 }); + + try { + const list = questions.json(); + if (Array.isArray(list) && list.length > 0) { + const qId = list[0].id || list[0].questionId; + if (qId) { + const detail = http.get(`${BASE_URL}/api/questions/${qId}`, { headers }); + check(detail, { '질문 상세 200': (r) => r.status === 200 }); + } + } + } catch { /* 파싱 실패 무시 */ } + }); + + sleep(Math.random() * 0.5 + 0.5); + + group('과제 조회', () => { + const week = Math.floor(Math.random() * 8) + 1; + const assignment = http.get(`${BASE_URL}/api/assignments/me/${week}`, { headers }); + check(assignment, { '과제 조회 200': (r) => r.status === 200 }); + }); + + sleep(Math.random() * 0.5 + 0.5); + + // QnA 사진 첨부 시뮬레이션 — 20% 확률 (40명 중 약 8명이 동시 업로드) + if (Math.random() < 0.2) { + group('이미지 업로드', () => { + const imgRes = uploadTestImage(data.token); + check(imgRes, { + '이미지 업로드 200': (r) => r.status === 200, + 'imageUrl 포함': (r) => (r.json('imageUrl') || '').length > 0, + }); + if (imgRes.status !== 200) { + console.log(`이미지 업로드 실패: status=${imgRes.status} body=${imgRes.body}`); + } + }); + } + + sleep(Math.random() * 0.5 + 0.5); +} diff --git a/backend/k6/smoke-test.js b/backend/k6/smoke-test.js new file mode 100644 index 0000000..7e3964e --- /dev/null +++ b/backend/k6/smoke-test.js @@ -0,0 +1,71 @@ +// 테스트 환경: http://localhost:8080 (dev) / https://api.piroin.com (prod) +// 작성 기준: application.yml, SecurityConfig.java, *Controller.java +// SSE 구현: SseEmitter +// 대상: 1 VU, 2분, 배포 직후 기본 동작 확인 + +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { BASE_URL, THRESHOLDS, login, getHeadersWithToken, SESSION_ID, uploadTestImage } from './config.js'; + +export const options = { + vus: 1, + duration: '2m', + thresholds: THRESHOLDS.smoke, +}; + +export function setup() { + return { token: login() }; +} + +export default function (data) { + const headers = getHeadersWithToken(data.token); + + // 1. 헬스 체크 (public) + const health = http.get(`${BASE_URL}/actuator/health`); + check(health, { + '헬스 체크 200': (r) => r.status === 200, + '헬스 UP': (r) => r.json('status') === 'UP', + }); + sleep(0.5); + + // 2. 커리큘럼 목록 + const curriculum = http.get(`${BASE_URL}/api/curriculums`, { headers }); + check(curriculum, { + '커리큘럼 200': (r) => r.status === 200, + '커리큘럼 배열': (r) => Array.isArray(r.json()), + }); + if (curriculum.status !== 200) { + console.log(`커리큘럼 실패: status=${curriculum.status} body=${curriculum.body}`); + } + sleep(0.5); + + // 3. QnA 세션 목록 + const sessions = http.get(`${BASE_URL}/api/sessions`, { headers }); + check(sessions, { '세션 목록 200': (r) => r.status === 200 }); + sleep(0.5); + + // 4. 질문 목록 + const questions = http.get( + `${BASE_URL}/api/sessions/${SESSION_ID}/questions?understandingIndex=0`, + { headers } + ); + check(questions, { '질문 목록 200': (r) => r.status === 200 }); + sleep(0.5); + + // 5. 나의 출석 현황 + const attendance = http.get(`${BASE_URL}/api/attendance/user`, { headers }); + check(attendance, { '출석 200': (r) => r.status === 200 }); + sleep(0.5); + + // 6. 이미지 업로드 (QnA 사진 첨부 시뮬레이션) + const imgRes = uploadTestImage(data.token); + check(imgRes, { + '이미지 업로드 200': (r) => r.status === 200, + 'imageUrl 포함': (r) => (r.json('imageUrl') || '').length > 0, + }); + if (imgRes.status !== 200) { + console.log(`이미지 업로드 실패: status=${imgRes.status} body=${imgRes.body}`); + } + + sleep(1); +} diff --git a/backend/k6/soak-test.js b/backend/k6/soak-test.js new file mode 100644 index 0000000..9733b73 --- /dev/null +++ b/backend/k6/soak-test.js @@ -0,0 +1,86 @@ +// 테스트 환경: http://localhost:8080 (dev) / https://api.piroin.com (prod) +// 작성 기준: application.yml, SecurityConfig.java, *Controller.java +// SSE 구현: SseEmitter +// 대상: 20 VU, 120분, 메모리 누수·HikariCP 고갈·파일 핸들 누수 감지 + +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Counter, Trend } from 'k6/metrics'; +import { BASE_URL, THRESHOLDS, login, getHeadersWithToken, SESSION_ID, uploadTestImage } from './config.js'; + +const slowResponses = new Trend('slow_response_trend'); +const dbErrorCount = new Counter('db_error_count'); + +let consecutiveFails = 0; + +export const options = { + stages: [ + { duration: '5m', target: 20 }, + { duration: '110m', target: 20 }, + { duration: '5m', target: 0 }, + ], + thresholds: { + ...THRESHOLDS.soak, + db_error_count: ['count<20'], + }, +}; + +export function setup() { + return { token: login(), startTime: Date.now() }; +} + +export default function (data) { + const headers = getHeadersWithToken(data.token); + const elapsed = (Date.now() - data.startTime) / 1000 / 60; + + const cur = http.get(`${BASE_URL}/api/curriculums`, { headers }); + const curOk = check(cur, { + '커리큘럼 200': (r) => r.status === 200, + '1s 이내': (r) => r.timings.duration < 1000, + }); + + if (!curOk) { + consecutiveFails++; + dbErrorCount.add(1); + if (consecutiveFails >= 5) { + console.warn(`⚠️ DB 커넥션 고갈 의심 — 연속 실패 ${consecutiveFails}회 (${elapsed.toFixed(1)}분 경과)`); + } + } else { + consecutiveFails = 0; + } + + if (cur.timings.duration > 1000) { + slowResponses.add(cur.timings.duration); + console.log(`응답 지연: ${cur.timings.duration}ms (${elapsed.toFixed(1)}분 경과)`); + } + + sleep(1); + + const att = http.get(`${BASE_URL}/api/attendance/user`, { headers }); + check(att, { '출석 200': (r) => r.status === 200 }); + if (att.status !== 200) dbErrorCount.add(1); + + sleep(1); + + const q = http.get( + `${BASE_URL}/api/sessions/${SESSION_ID}/questions?understandingIndex=0`, + { headers } + ); + check(q, { '질문 목록 200': (r) => r.status === 200 }); + if (elapsed >= 30 && q.timings.duration > 1000) { + console.warn(`⚠️ 30분 경과 후 응답 저하: ${q.timings.duration}ms — 메모리 누수 또는 DB 부하 의심`); + } + + sleep(1); + + // 이미지 업로드 20% 확률 — 파일 핸들 누수 장시간 감지 + if (Math.random() < 0.2) { + const imgRes = uploadTestImage(data.token); + check(imgRes, { '이미지 업로드 200': (r) => r.status === 200 }); + if (imgRes.status !== 200) { + console.log(`이미지 업로드 실패 (${elapsed.toFixed(1)}분 경과): status=${imgRes.status}`); + } + } + + sleep(2); +} diff --git a/backend/k6/sse-test.js b/backend/k6/sse-test.js new file mode 100644 index 0000000..3b9e4a9 --- /dev/null +++ b/backend/k6/sse-test.js @@ -0,0 +1,130 @@ +// 테스트 환경: http://localhost:8080 (dev) / https://api.piroin.com (prod) +// 작성 기준: application.yml, SecurityConfig.java, QuestionController.java, QuestionEventService.java +// SSE 구현: SseEmitter (Spring WebMVC) +// 대상: SSE 30 VU + 일반 API 10 VU = 총 40명 +// +// ⚠️ k6 SSE 측정 방식 +// http.get()은 연결이 닫혀야 status를 확정함 +// SSE는 서버가 3분간 연결 유지 → k6 timeout(10s) 후 status=0으로 기록됨 +// 따라서: +// - sse_connect_time: res.timings.waiting (TTFB, 첫 바이트 수신 시간) 사용 +// - sse_error: timeout이어도 body에 'connected' 포함 시 성공으로 처리 +// +// ⚠️ SseEmitter 주의사항 +// Tomcat threads.max=200, SSE 30개 + 일반 10개 = 40 스레드 → 안전 범위 +// SSE timeout=3분 (QuestionEventService.SSE_TIMEOUT_MILLIS) 자동 해제 + +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Counter, Trend } from 'k6/metrics'; +import { BASE_URL, THRESHOLDS, login, getHeadersWithToken, SESSION_ID, uploadTestImage } from './config.js'; + +const sseConnectTime = new Trend('sse_connect_time'); // TTFB (첫 바이트 수신 시간) +const sseEventCount = new Counter('sse_event_received'); +const sseErrorCount = new Counter('sse_error'); // 진짜 서버 오류만 카운트 + +export const options = { + scenarios: { + sse_connections: { + executor: 'constant-vus', + vus: 30, + duration: '10m', + exec: 'sseScenario', + }, + normal_api: { + executor: 'constant-vus', + vus: 10, + duration: '10m', + exec: 'apiScenario', + startTime: '30s', + }, + }, + thresholds: { + // TTFB 기준 — 연결 수립 + 첫 이벤트 수신 시간 + sse_connect_time: ['p(95)<2000'], + // 진짜 서버 오류만 카운트 (timeout 제외) + sse_error: ['count<10'], + 'http_req_duration{scenario:normal_api}': ['p(95)<1000'], + }, +}; + +export function setup() { + return { token: login() }; +} + +export function sseScenario(data) { + const params = { + headers: { + ...getHeadersWithToken(data.token), + 'Accept': 'text/event-stream', + 'Cache-Control': 'no-cache', + }, + timeout: '10s', // connected 이벤트 수신에 충분한 시간, 전체 연결 유지 불필요 + }; + + const res = http.get( + `${BASE_URL}/api/sessions/${SESSION_ID}/questions/events`, + params + ); + + const body = res.body || ''; + const receivedConnected = body.includes('connected'); + const isTimeout = res.status === 0; + + // TTFB — 실제 서버가 첫 바이트를 보낼 때까지 걸린 시간 + if (res.timings.waiting > 0) { + sseConnectTime.add(res.timings.waiting); + } + + if (res.status === 200 || (isTimeout && receivedConnected)) { + // 정상 연결: 200 응답 또는 timeout이어도 connected 이벤트 수신한 경우 + const events = body.split('\n').filter(l => l.startsWith('data:')); + sseEventCount.add(events.length); + + check(res, { + 'SSE connected 이벤트 수신': () => receivedConnected, + 'Content-Type text/event-stream': (r) => + r.status === 200 + ? (r.headers['Content-Type'] || '').includes('text/event-stream') + : true, // timeout 시 헤더 미수신 허용 + }); + } else { + // 진짜 서버 오류 (4xx, 5xx, 네트워크 오류) + sseErrorCount.add(1); + console.log(`SSE 연결 실패: status=${res.status} body=${body.slice(0, 200)}`); + } + + sleep(1); +} + +export function apiScenario(data) { + const headers = getHeadersWithToken(data.token); + + const responses = http.batch([ + ['GET', `${BASE_URL}/api/curriculums`, null, { headers }], + ['GET', `${BASE_URL}/api/attendance/user`, null, { headers }], + ['GET', `${BASE_URL}/api/sessions/${SESSION_ID}/questions?understandingIndex=0`, null, { headers }], + ]); + + check(responses[0], { '[SSE중] 커리큘럼 200': (r) => r.status === 200 }); + check(responses[1], { '[SSE중] 출석 200': (r) => r.status === 200 }); + check(responses[2], { '[SSE중] 질문 목록 200': (r) => r.status === 200 }); + + responses.forEach((r, i) => { + if (r.status !== 200) { + console.log(`⚠️ SSE 중 일반 API 실패[${i}]: status=${r.status} — 스레드 고갈 의심`); + } + }); + + sleep(0.5); + + if (Math.random() < 0.3) { + const imgRes = uploadTestImage(data.token); + check(imgRes, { '[SSE중] 이미지 업로드 200': (r) => r.status === 200 }); + if (imgRes.status !== 200) { + console.log(`⚠️ SSE 중 이미지 업로드 실패: status=${imgRes.status}`); + } + } + + sleep(1); +} diff --git a/backend/k6/stress-test.js b/backend/k6/stress-test.js new file mode 100644 index 0000000..e7cb615 --- /dev/null +++ b/backend/k6/stress-test.js @@ -0,0 +1,71 @@ +// 테스트 환경: http://localhost:8080 (dev) / https://api.piroin.com (prod) +// 작성 기준: application.yml, SecurityConfig.java, *Controller.java +// SSE 구현: SseEmitter +// 대상: 최대 120 VU (40명 기준 3배), 약 35분, 한계 부하 탐색 +// ⚠️ HikariCP pool-size=10 → 120 VU 시 DB 커넥션 병목 관측 예상 (의도된 관측) + +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Counter } from 'k6/metrics'; +import { BASE_URL, THRESHOLDS, login, getHeadersWithToken, SESSION_ID } from './config.js'; + +const errorCount = new Counter('custom_errors'); + +export const options = { + stages: [ + { duration: '2m', target: 20 }, + { duration: '5m', target: 20 }, + { duration: '2m', target: 40 }, // 정상 부하 + { duration: '5m', target: 40 }, + { duration: '2m', target: 80 }, // 2배 + { duration: '5m', target: 80 }, + { duration: '2m', target: 120 }, // 3배, 한계 탐색 + { duration: '5m', target: 120 }, + { duration: '5m', target: 0 }, // 회복 확인 + ], + thresholds: { + ...THRESHOLDS.stress, + custom_errors: ['count<100'], + }, +}; + +export function setup() { + return { token: login() }; +} + +export default function (data) { + const headers = getHeadersWithToken(data.token); + + const cur = http.get(`${BASE_URL}/api/curriculums`, { headers }); + const ok = check(cur, { + '커리큘럼 200': (r) => r.status === 200, + '응답 3s 이내': (r) => r.timings.duration < 3000, + }); + if (!ok || cur.status !== 200) { + errorCount.add(1); + if (cur.timings.duration >= 3000) { + console.log(`응답 지연: ${cur.timings.duration}ms status=${cur.status}`); + } + } + + sleep(0.5); + + const att = http.get(`${BASE_URL}/api/attendance/user`, { headers }); + check(att, { '출석 200': (r) => r.status === 200 }); + if (att.status !== 200) errorCount.add(1); + + sleep(0.5); + + const q = http.get( + `${BASE_URL}/api/sessions/${SESSION_ID}/questions?understandingIndex=0`, + { headers } + ); + check(q, { '질문 목록 200': (r) => r.status === 200 }); + if (q.status !== 200) errorCount.add(1); + + sleep(0.5); +} + +export function teardown() { + console.log('Stress test 완료 — 회복 구간(마지막 5분) 에러율을 결과에서 확인할 것'); +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index b0e4826..5063915 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -13,6 +13,9 @@ spring: max-lifetime: 1800000 leak-detection-threshold: 5000 + transaction: + default-timeout: 30 # 트랜잭션 전체 30s 초과 시 롤백 + flyway: # repair-on-migrate: true enabled: true @@ -28,6 +31,17 @@ spring: packagesToScan: com.example.Piroin.project.domain jdbc: time_zone: Asia/Seoul + # 슬로우 쿼리 로그 — 1초 이상 걸리는 쿼리를 WARN 레벨로 출력 + generate_statistics: true + session: + events: + log: + LOG_QUERIES_SLOWER_THAN_MS: 1000 + # 쿼리 실행 자체 타임아웃 — 커넥션 획득 후 쿼리가 느린 경우 대응 + javax: + persistence: + query: + timeout: 30000 # 30s 초과 쿼리 예외 발생 jackson: time-zone: Asia/Seoul @@ -36,6 +50,14 @@ jwt: secret: ${JWT_SECRET} expiration: ${JWT_EXPIRATION} +logging: + level: + # 슬로우 쿼리 로그 (1초 이상 쿼리 감지) + org.hibernate.SQL_SLOW: WARN + # HikariCP 커넥션 풀 상태 로그 + com.zaxxer.hikari: DEBUG + com.zaxxer.hikari.HikariConfig: DEBUG + management: endpoints: web: @@ -52,3 +74,8 @@ server: threads: max: 200 # 기본값 200 (명시적으로 설정) min-spare: 20 # 최소 대기 스레드 수 + # 요청 레벨 타임아웃 — Soak에서 최대 24분 응답 방지 + connection-timeout: 20000 # 커넥션 수립 타임아웃 20s + servlet: + session: + timeout: 30m