첫번째 테스트
테스트 세팅
VM 인프라 구성
역할 |
인스턴스 스펙 |
설명 |
FastAPI 서버 |
e2-medium (vCPU 1~2 (공유 코어 1개), RAM 4GB) |
GCS에서 이미지 70~100개 다운로드 처리 |
K6 서버 |
n2-standard-4 (vCPU 4, RAM 16GB) |
테스트 트래픽 생성기 |
Cloud Storage 구성
- 버킷 이름:
my-bucket1008
- 경로:
test-images/image_001.jpg ~ image_100.jpg
- 스펙: Standard Tier (전송 성능 최적화됨)
FastAPI 서버 코드 요약
@app.get("/images")
def get_images(file_names: List[str] = Query(...)):
for file_name in file_names:
blob = bucket.blob(f"test-images/{file_name}")
_ = blob.download_as_bytes() # GCS에서 이미지 다운로드
return JSONResponse(status_code=200, content={"message": f"이미지 {len(file_names)}장 로딩 완료"})
- 요청당 70~100장의 이미지 다운로드
- 실패 시 즉시 500 응답 반환
K6 부하 테스트 스크립트
export const options = {
vus: 30, // 동시 가상 유저 수
duration: '10m', // 테스트 시간
};
function getRandomImageList() {
const total = Math.floor(Math.random() * 31) + 70; // 70~100개
...
}
export default function () {
const query = getRandomImageList();
const res = http.get(`${BASE_URL}/images?${query}`);
check(res, { 'status is 200': (r) => r.status === 200 });
sleep(Math.random() * 2 + 3);
}
테스트 결과 요약
- 평균 응답 시간(P95): 약
4.9초
- 요청 성공률:
100%
분석
분석 요약
지표 |
수치 |
P95 응답 시간 |
약 4.9초 |
실패율 |
0% |
부하테스트 회고
- 사진 처리량이 우리 서비스의 예상 요청 규모에 비해 커서 현실성 있는 예측이 어려움
- 부하 테스트 이후, 테스크별 cpu, gpu 리소스 사용량 테스트 결과 부하 테스트에 사용한 fast api 인스턴스는 충분치 않다는 판단을 하게 되어 적정 인스턴스로 교체 후, 재테스트 요함
- 처리 속도가 너무 느리기 때문에 요청 규모와 인스턴스 유형을 변경하고 처리 속도 재확인한 후, 여전히 지연 시간이 길면 병목 개선 방안 탐색 요함
- k6 서버는 인스턴스 스펙이 과했음을 알게 되어 vCPU2, mem 4gb의 인스턴스로 교체 요함
두번째 테스트 - 부하 용량 변경 및 인스턴스 유형 변경
테스트 세팅 변경 사항
- 인스턴스 유형 변경
- 부하량 감소 (서비스 규모에 맞춰)
VM 인프라 구성
역할 |
인스턴스 스펙 |
설명 |
FastAPI 서버 |
n2d-standard-4 (vCPU 4개, 메모리 16GB) |
GCS에서 이미지 30~50개 다운로드 처리 |
K6 서버 |
e2-medium (vCPU 2개, 메모리 4GB) |
테스트 트래픽 생성기 |
FastAPI 서버 코드 요약
@app.get("/images")
def get_images(file_names: List[str] = Query(...)):
for file_name in file_names:
blob = bucket.blob(f"test-images/{file_name}")
_ = blob.download_as_bytes() # GCS에서 이미지 다운로드
return JSONResponse(status_code=200, content={"message": f"이미지 {len(file_names)}장 로딩 완료"})
- 요청당 30~50장의 이미지 다운로드
- 실패 시 즉시 500 응답 반환
K6 부하 테스트 스크립트
export const options = {
vus: 30, // 동시 가상 유저 수
duration: '10m', // 테스트 시간
};
function getRandomImageList() {
const total = Math.floor(Math.random() * 21) + 30; // 30~50개
...
}
export default function () {
const query = getRandomImageList();
const res = http.get(`${BASE_URL}/images?${query}`);
check(res, { 'status is 200': (r) => r.status === 200 });
sleep(Math.random() * 2 + 3);
}
테스트 결과 요약
- 평균 응답 시간(P95): 약
3.3초
- 요청 성공률:
100%
분석
분석 요약
지표 |
수치 |
P95 응답 시간 |
약 3.3초 |
실패율 |
0% |
부하테스트 회고
- 이미지를 한 장씩 다운로드하기 때문에 순차 진행으로 인한 지연 시간이 길다. → 병렬 다운로드로 속도 개선하자
- 버킷과 연결하는 세션 객체를 매 연결마다 생성하고 있어 지연이 발생한다(인증 및 연결 과정) → 클라이언트 세션 객체를 통해 GCS 버킷 객체를 서버에서 미래 만들어두고 재사용하는 방식으로 속도 개선하자
세번째 테스트 - 멀티 스레드를 이용한 로딩 및 버킷 인증&연결 사전 처리
테스트 세팅 변경 사항
- 사진 로딩 병렬 처리 (멀티 스레드)
- 버킷 인증 및 연결 사전 처리
VM 인프라 구성
역할 |
인스턴스 스펙 |
설명 |
FastAPI 서버 |
n2d-standard-4 (vCPU 4개, 메모리 16GB) |
GCS에서 이미지 30~50개 다운로드 처리 |
K6 서버 |
e2-medium (vCPU 2개, 메모리 4GB) |
테스트 트래픽 생성기 |
FastAPI 서버 코드 요약
from fastapi import FastAPI, Query
from typing import List
from fastapi.responses import JSONResponse
from concurrent.futures import ThreadPoolExecutor
from google.cloud import storage
app = FastAPI()
# GCS 버킷 초기화 (앱 시작 시 1회)
client = storage.Client.from_service_account_json("team5-457107-91e3baf044af.json")
bucket = client.bucket("my-bucket1008")
# ThreadPoolExecutor 전역 재사용
executor = ThreadPoolExecutor(max_workers=10)
print("서버 준비 완료")
@app.get("/")
def ping():
return {"message": "FastAPI is running"}
@app.get("/images")
def get_images(file_names: List[str] = Query(...)):
def download(file_name):
# 객체 생성은 빠른 편이며 내부적으로 재사용
blob = bucket.blob(f"test-images/{file_name}")
_ = blob.download_as_bytes() # 다운로드 자체가 주요 I/O 비용
return file_name
# 병렬 다운로드
result = list(executor.map(download, file_names))
return JSONResponse(
status_code=200,
content={"message": f"이미지 {len(result)}장 로딩 완료"}
)
- 요청당 30~50장의 이미지 다운로드
- 실패 시 즉시 500 응답 반환
K6 부하 테스트 스크립트
export const options = {
vus: 30, // 동시 가상 유저 수
duration: '10m', // 테스트 시간
};
function getRandomImageList() {
const total = Math.floor(Math.random() * 21) + 30; // 30~50개
...
}
export default function () {
const query = getRandomImageList();
const res = http.get(`${BASE_URL}/images?${query}`);
check(res, { 'status is 200': (r) => r.status === 200 });
sleep(Math.random() * 2 + 3);
}
테스트 결과 요약
- 평균 응답 시간(P95): 약
1초
- 요청 성공률:
100%
분석

분석 요약
지표 |
수치 |
P95 응답 시간 |
약 1초 |
실패율 |
0% |
부하테스트 회고
- 주된 병목 지점들은 해결한 듯하다. 추가적으로 시간 단축할 수 있는 방안이 있는지 더 알아볼 예정.
- 실수로 def 라우터로 구축했었다(요청들 직렬 처리됐었음) → async def 라우터로 변경하여 변경 처리하기
네번째 테스트 - 요청 비동기 처리
테스트 세팅 변경 사항
- def → async def : 요청 병렬 처리
VM 인프라 구성
역할 |
인스턴스 스펙 |
설명 |
FastAPI 서버 |
n2d-standard-4 (vCPU 4개, 메모리 16GB) |
GCS에서 이미지 30~50개 다운로드 처리 |
K6 서버 |
e2-medium (vCPU 2개, 메모리 4GB) |
테스트 트래픽 생성기 |
FastAPI 서버 코드 요약
from fastapi import FastAPI, Query
from typing import List
from fastapi.responses import JSONResponse
from concurrent.futures import ThreadPoolExecutor
from google.cloud import storage
from starlette.concurrency import run_in_threadpool
app = FastAPI()
# GCS 버킷 초기화 (앱 시작 시 1회)
client = storage.Client.from_service_account_json("team5-457107-91e3baf044af.json")
bucket = client.bucket("my-bucket1008")
# ThreadPoolExecutor 전역 재사용
executor = ThreadPoolExecutor(max_workers=10)
print("서버 준비 완료")
@app.get("/")
async def ping():
return {"message": "FastAPI is running"}
@app.get("/images")
async def get_images(file_names: List[str] = Query(...)):
def download(file_name):
# 객체 생성은 빠른 편이며 내부적으로 재사용
blob = bucket.blob(f"test-images/{file_name}")
_ = blob.download_as_bytes() # 다운로드 자체가 주요 I/O 비용
return file_name
# 병렬 다운로드
result = await run_in_threadpool(lambda: list(executor.map(download, file_names)))
return JSONResponse(
status_code=200,
content={"message": f"이미지 {len(result)}장 로딩 완료"}
) 6,51 Top
- 요청당 30~50장의 이미지 다운로드
- 실패 시 즉시 500 응답 반환
K6 부하 테스트 스크립트
export const options = {
vus: 30, // 동시 가상 유저 수
duration: '10m', // 테스트 시간
};
function getRandomImageList() {
const total = Math.floor(Math.random() * 21) + 30; // 30~50개
...
}
export default function () {
const query = getRandomImageList();
const res = http.get(`${BASE_URL}/images?${query}`);
check(res, { 'status is 200': (r) => r.status === 200 });
sleep(Math.random() * 2 + 3);
}
테스트 결과 요약
- 평균 응답 시간(P95): 약
1초
- 요청 성공률:
100%
분석


분석 요약
지표 |
수치 |
P95 응답 시간 |
약 1초 |
실패율 |
0% |
부하테스트 회고
- p95는 동일하지만 내부 수치 확인했을 때, 개선된 것을 볼 수 있다.
다섯번째 테스트 - aiohttp + signedURL
테스트 세팅 변경 사항
- aiohttp + signedURL
- signedURL을 k6 서버에서 사전 생성
- k6 서버에서 signedURL을 이용하여 FastAPI 서버에 요청 전송
- FastAPI 서버에서 signedURL을 aiohttp를 이용해 비동기 read
VM 인프라 구성
역할 |
인스턴스 스펙 |
설명 |
FastAPI 서버 |
n2d-standard-4 (vCPU 4개, 메모리 16GB) |
GCS에서 이미지 30~50개 다운로드 처리 |
K6 서버 |
e2-medium (vCPU 2개, 메모리 4GB) |
테스트 트래픽 생성기 |
FastAPI 서버 코드 요약
from fastapi import FastAPI, Query
from typing import List
from fastapi.responses import JSONResponse
import aiohttp
import asyncio
app = FastAPI()
print("서버 준비 완료")
@app.get("/")
async def ping():
return {"message": "FastAPI is running"}
@app.post("/load-images")
async def load_images(signed_urls: List[str]):
"""
클라이언트가 전달한 signed URL들을 통해 이미지 다운로드 요청.
FastAPI는 다운로드만 수행하고 signed URL은 클라이언트가 미리 발급받음.
"""
async def fetch(session, url: str):
async with session.get(url) as response:
_ = await response.read()
return url
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in signed_urls]
result = await asyncio.gather(*tasks)
return JSONResponse(
status_code=200,
content={"message": f"{len(result)}장 이미지 로딩 완료"}
)
- 요청당 30~50장의 이미지 다운로드
- 실패 시 즉시 500 응답 반환
K6 부하 테스트 스크립트
import http from 'k6/http';
import { check, sleep } from 'k6';
import { SharedArray } from 'k6/data';
export const options = {
vus: 30,
duration: '10m',
};
// JSON 객체 리스트에서 url만 추출
const signedUrls = new SharedArray('signedUrls', function () {
const raw = JSON.parse(open('./signed_urls_100.json'));
return raw.map(item => item.url); // "url"만 추출해서 사용
});
function getRandomUrlList() {
const total = Math.floor(Math.random() * 21) + 30;
const copy = Array.from(signedUrls); // 복제
const shuffled = copy.sort(() => 0.5 - Math.random()); // 복제본 정렬
return shuffled.slice(0, total);
}
export default function () {
const urls = getRandomUrlList();
const payload = JSON.stringify(urls); // 서버가 요구하는 구조
const headers = { 'Content-Type': 'application/json' };
const res = http.post('http://10.178.0.2:8000/load-images', payload, { headers });
check(res, {
'status is 200': (r) => r.status === 200,
});
sleep(1);
}
테스트 결과 요약

- 평균 응답 시간(P95): 약
2초 후반
- 요청 성공률:
요청 실패 발생
분석
분석 요약
테스트 시도 이유
- 기존 방식:
download_byte
(S3 SDK)
- 기존 방식의 단점
- download_byte는 동기 함수: i/o 차단 발생
- 이로 인해 네트워크 요청/응답 처리 시간이 길어질 수 있음
- 개선 시도:
signed URL + aiohttp
(비동기 방식)
- 기대 효과
- S3 객체 접근용 signed URL을 서버에서 미리 발급
- 클라이언트는
aiohttp
를 이용해 비동기적으로 read 처리
read()
는 asyncio 기반 → I/O 블로킹 최소화
- 요청 시 인증 헤더 없이 URL에 서명 포함 → 전송 요청이 더 가벼움
병목 원인 분석
- 각 signed URL 요청마다 개별 HTTP 커넥션이 생성됨
- 따라서 매 요청마다 TCP 3-way handshake + TLS handshake
- → 커넥션 생성 비용이 누적되어 응답 시간 증가
결론
항목 |
평가 |
signed URL + aiohttp |
비동기 처리와 요청 경량화는 장점이지만, 커넥션 재사용 불가로 오히려 지연 발생 |
download_byte |
동기 방식이지만, persistent connection 유지 덕분에 안정적인 성능 확보 |
부하테스트 회고
- 단순히 비동기 처리가 항상 빠르지 않다
- 커넥션 수립 비용, TLS 핸드셰이크, 연결 재사용 여부가 중요한 변수
- 단순히 "비동기"라는 개념 자체만을 근거로 기술을 도입하기보다는, P95, P99와 같은 지연 시간 지표를 기반으로 실제 성능을 분석하고, 그 결과를 바탕으로 기술 선택을 결정하는 것이 중요하다.