1. 문서 개요
- 작성자: aaron.lee(이유성)/클라우드
- 작성일: 2025-06-30
- 목적
- 이벤트 상황(예상 동시 접속자 수 100~200명) 발생 시, GCP HTTPS Load Balancer와 Managed Instance Group(MIG)의 오토스케일링 기능을 통해 서버 인프라가 안정적으로 요청을 처리할 수 있는지를 검증하기 위해 수행되었습니다.
2. 테스트 스크립트 개요
export const options = {
stages: [
{ duration: '15m', target: 200 }, // 15분간 VU 0→200 램프업
{ duration: '5m', target: 0 }, // 5분간 VU 200→0 램프다운
],
};
- 시나리오 분기
- 5%: 사진 30장 업로드 → 앨범 생성
- 95%: 월별 앨범 조회 → 앨범 요약 조회 → 앨범 상세 조회
- 사이클: 각 VU가 1회 수행 후 sleep(1)
3. 테스트 환경
- 인스턴스: M2-medium (2 vCPU / 4 GB RAM) × MIG 2대 (최대 3대)
- 로드밸런서: GCP HTTPS Load Balancer
- 데이터베이스: Cloud SQL (MySQL 8.4) Master-Replica
- 부하 도구: k6 v0.45.0
- 모니터링: HTTP 응답 시간, 처리량, 오류율, CPU 사용률(Cloud Monitoring)
4. 실행 프로필 및 주요 지표
4.1 테스트 개요
- 테스트 기간 (Duration): 20분 (15분 램프업 → 5분 램프다운)
- 최대 VUs (vus_max): 200
4.2 Counters & Rates
지표 |
Count |
Rate |
수신 데이터량 (data_received) |
14.1 GB |
11.8 MB/s |
전송 데이터량 (data_sent) |
11.8 GB |
9.8 MB/s |
HTTP 요청 수 (http_reqs) |
61,200 |
50.91 req/s |
Iterations |
13,500 |
11.23 iter/s |
HTTP 실패율 (http_req_failed) |
0 건 |
0.01 /s |
4.3 Trends (응답·반복 소요 시간)
지표 |
평균 (avg) |
중앙값 (med) |
P90 |
P95 |
최대 (max) |
전체 요청 소요 시간 (http_req_duration) |
1 s |
65 ms |
4 s |
13 s |
30 s |
서버 응답 대기 시간 (TTFB) (http_req_waiting) |
1 s |
61 ms |
4 s |
13 s |
30 s |
사이클당 전체 소요 시간 (iteration_duration) |
9 s |
3 s |
28 s |
31 s |
1 m |
5. Auto Scaling(MIG) 동작 결과
- 초기 인스턴스 수: 2
- 테스트 구간 전체: 평균 CPU 사용률 > 60%
- 최종 인스턴스 수: 4
6. 분석
- 처리량 & 응답성
- VU 200 상태에서 HTTP 요청 약 50 req/s, Iteration 약 11 iter/s로 안정적 처리
- p50 65 ms / p95 13 s 수준, 읽기 중심 시나리오(95%)에 대해 허용 범위 내 응답
- p95 기준 15s 예상 - 비동기 처리 방식으로 앨범을 생성해주기 때문에 허용범위
- 구성 한계
- 급격한 스파이크(순간 대량 업로드) 시에는 스케일링 지연 가능
- 장시간 부하 → 스케일 인/아웃 사이클 검증 필요
7. 추후 고려 사항
- 부하 스크립트 강화
- 생성 시나리오 비율 확대 및 사진 개수 증가로 쓰기 부하 강화
- 즉각적인 VU 스파이크 테스트 추가
- 다중 지표 스케일링
- CPU 외 응답 시간(p95 > 500 ms), 메모리, 네트워크 지표 복합 정책 도입
- 예비 용량 확보
- Spike 대응을 위해 최소 인스턴스 3대 검토
- 프로비저닝 지연 최소화를 위한 버퍼 확보
- 캐시 계층 도입
- Cloud Memorystore(Redis)로 조회 결과 캐싱 → 애플리케이션 서버 부하 저감
- 장시간 스트레스 테스트
- 1시간 이상 지속 부하 → 스케일 인/아웃 주기 및 안정성 검증
8. 보고서

전체 스크립트
import http from 'k6/http';
import { sleep } from 'k6';
import { uuidv4, randomIntBetween } from 'https://jslib.k6.io/k6-utils/1.1.0/index.js';
import { postJson, putBinary, getJson } from '../utils/request.js';
import { BASE_URL, HEADERS } from '../config.js';
let ACCESS_TOKEN = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyIiwiZXhwIjoxNzUyNDYwNjU5fQ.5XU1Vqi-ZVACOzOaVE1UIAIrRYiOvEpR0rF9_-RHiTU';
const photoCount = 30;
const IMAGE_FILES = [
'ongi.png',
'ongi2.png',
'ongi3.png',
'닝닝1.jpeg',
'닝닝2.jpeg',
'에스파단체1.jpeg',
'에스파단체2.jpeg',
'에스파단체3.jpeg',
'Mount.jpeg',
'cat.jpeg',
];
const IMAGES = IMAGE_FILES.map(f => open(`../assets/${f}`, 'b'));
const CONTENT_TYPES = IMAGE_FILES.map(f =>
f.endsWith('.png') ? 'image/png' : 'image/jpeg'
);
export const options = {
stages: [
{ duration: '15m', target: 200 }, // 15분간 VUs 0→200
{ duration: '5m', target: 0 }, // 5분간 VUs 200→0
],
};
function createAlbumScenario() {
const headers = HEADERS(ACCESS_TOKEN);
// 1) presigned-url 요청
const pictures = Array.from({ length: photoCount }, () => {
const idx = randomIntBetween(0, IMAGES.length - 1);
return {
idx,
pictureName: `ongi_${uuidv4().slice(0, 8)}${IMAGE_FILES[idx].match(/\.[^.]+$/)[0]}`,
pictureType: CONTENT_TYPES[idx],
};
});
// 2) Presigned URL 발급
const presignedRes = postJson(
`${BASE_URL}/api/presigned-url`,
{ pictures },
headers
);
const presignedFiles = presignedRes.json().data.presignedFiles;
// 3) 이미지 업로드
const uploadedUrls = [];
presignedFiles.forEach((file, i) => {
const { idx, pictureType } = pictures[i];
putBinary(
file.presignedUrl,
IMAGES[idx],
{ 'Content-Type': pictureType }
);
uploadedUrls.push(file.pictureURL);
});
// 4) 앨범 생성
const pictureUrlObjects = uploadedUrls.map(url => ({
pictureUrl: url,
latitude: null,
longitude: null,
}));
postJson(
`${BASE_URL}/api/album`,
{ albumName: '부하테스트', pictureUrls: pictureUrlObjects },
headers
);
}
function retrieveAlbumScenario() {
const headers = HEADERS(ACCESS_TOKEN);
// 1) 월별 앨범 조회
const monthlyRes = getJson(
`${BASE_URL}/api/album/monthly`,
headers
);
const albumInfo = monthlyRes.json().data.albumInfo;
if (!albumInfo || albumInfo.length === 0) {
console.error('경고: 앨범이 하나도 없습니다.');
return;
}
// 2) 첫 앨범 요약 조회
const albumId = albumInfo[0].albumId;
getJson(`${BASE_URL}/api/album/${albumId}/summary`, headers);
// 3) 첫 앨범 사진 리스트 조회
getJson(`${BASE_URL}/api/album/${albumId}`, headers);
}
export default function () {
// 5% 생성, 95% 조회
if (Math.random() < 0.05) {
createAlbumScenario();
} else {
retrieveAlbumScenario();
}
sleep(1);
}