[AI] 이미지 로딩 부하 테스트 - 100-hours-a-week/5-yeosa-wiki GitHub Wiki

첫번째 테스트

테스트 세팅

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%

분석

image (1)

분석 요약

지표 수치
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%

분석

image (2)

  • 이전 결과 참고용

image (1)

분석 요약

지표 수치
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);
}

테스트 결과 요약

image (3)

  • 평균 응답 시간(P95): 약 2초 후반
  • 요청 성공률: 요청 실패 발생

분석

분석 요약

지표 수치
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와 같은 지연 시간 지표를 기반으로 실제 성능을 분석하고, 그 결과를 바탕으로 기술 선택을 결정하는 것이 중요하다.