Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions backend/k6/config.js
Original file line number Diff line number Diff line change
@@ -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'] },
};
144 changes: 144 additions & 0 deletions backend/k6/k6-result.md
Original file line number Diff line number Diff line change
@@ -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분 발생) |
Loading
Loading