SSAFY 기업연계 회고 — 1,129줄짜리 CLI 스크립트를 FastAPI 서비스로 옮긴 기록

Published:

SSAFY 기업연계 프로젝트에서 내가 맡은 건 AI 이미지 분석 API 서비스였다. 팀 전체 과제는 기존 NestJS 백엔드를 FastAPI로 갈아엎는 마이그레이션이었고, 그중 AI 분석 파트가 분리돼서 나한테 떨어졌다.

처음 레포를 열었을 때 algorithm_original/ 이라는 폴더가 먼저 눈에 들어왔다. 이게 원본이고, 이걸 서비스로 만드는 게 내 일이었다. 스크립트 1,129줄을 그대로 @app.post("/") 뒤에 붙이면 되는 줄 알았는데 그게 아니었다.

왜 Python / FastAPI 였나

분석 대상이 소변 스트립(urine strip) 이미지 다. 의료 현장에서 쓰는 그 종이 조각. 환자가 직접 찍어 올리면 6가지 파라미터 (ketone, glucose, protein, pH, blood, white) 를 색상 변화로 판정해야 한다.

처리 파이프라인을 쭉 보면 이렇게 된다:

 이미지 업로드 (S3)
    ↓
 [1] QR / BlackBox 검출          ← OpenCV (Contour, Morphology)
    ↓
 [2] ROI(Region of Interest) 추출 ← OpenCV
    ↓
 [3] Strip 인식 & 기울기 정정     ← scipy.optimize.least_squares, CLAHE
    ↓
 [4] 색상 보정 (그림자·레퍼런스)   ← NumPy 3차 회귀
    ↓
 [5] KNN 레벨 분류 (6 parameter)  ← scikit-learn / cv2.ml.KNearest
    ↓
 JSON 응답

단계 하나하나가 Python 과학 계산 스택에 특화된 작업이다. OpenCV, NumPy, SciPy, scikit-learn — 이것들 전부 C/C++ 구현체를 Python이 얇게 감싼 바인딩이다. 그래서:

  1. NestJS(TypeScript) 에서 하려면 sharp/jimp 같은 Node 이미지 라이브러리를 억지로 끌어와야 함 — 레거시 알고리즘이 이미 Python인 마당에 재작성은 비효율
  2. C 레벨 연산은 GIL을 해제한다 — OpenCV 내부 루프는 파이썬 인터프리터 락에 걸리지 않음. 그래서 프로세스 워커 4개로도 병렬 처리 가능
  3. FastAPI + Pydantic 조합이 JSON 스키마를 타입 레벨에서 보장 — 프론트가 받을 응답 shape가 코드로 고정됨

JS 진영에서 AI 분석 서비스를 굳이 NestJS로 유지할 이유가 없었다. “AI 파트는 Python으로 떼어낸다” 가 애초에 팀 합의였고, 내 역할은 그 Python을 서비스 형태로 만들어 올리는 것이었다.

출발점 — algorithm_original/ 진입점을 열었을 때

원본 진입점은 이렇게 생겨있었다.

import pipeline as algo
from config import *

BASE_DIR = os.path.dirname(os.path.abspath(__file__))

bbox = algo.BlackBox(bbox_points, roi_size, qr_centers, qr_size, min_area_ratio=0.01)
strip = algo.Strip(bbox_points, patch_points, patch_hsize, ref_points, strip_length, bbox_padding)

모듈 import 시점에 전역 객체 두 개가 생성된다. 즉, 이 파일을 import 만 해도 BlackBox, Strip 인스턴스가 즉시 만들어진다. 단일 프로세스 CLI에서는 문제가 없지만, FastAPI 앱에서 이 모듈을 import 하면:

  • 앱 기동 시 자동으로 객체가 생성돼 버림 (초기화 순서 제어 불가)
  • 테스트 fixture를 못 꽂음 (이미 bbox 가 특정 설정으로 고정됨)
  • 설정을 바꾸려면 모듈 reload 해야 함

진입 함수도 예외 처리가 공격적이다.

try:
    result_bbox = bbox(filename)
except:
    result['error'] = "UNKNOWN"
    return result

if not result_bbox['success']:
    result['error'] = "QR_BBOX"
    return result

try:
    result_strip = strip(result_bbox['bbox'], filename, result_bbox['roi'])
except:
    result['error'] = "UNKNOWN"
    return result
# ...

베어 except: 가 세 군데. 무슨 에러가 났는지 스택트레이스를 읽을 방법이 없다. 디버깅하다가 OpenCV가 뱉는 cv2.error 인지, NumPy의 IndexError 인지, S3가 죽은 건지 구분이 안 됐다.

그 외에 눈에 띄는 것들:

  • 타입 힌팅 0건 — 함수 시그니처만 보고는 뭐가 들어가고 뭐가 나오는지 모름
  • print() 만 남김 — 로깅 시스템 없음
  • getopt.getopt(argv[1:], 'i:o:', ...) + CSV 출력 — 완전한 CLI 도구
  • 결과물을 파일로 저장cv2.imwrite(filename_split + '_mask.jpg', bbox) 같은 부작용 곳곳에

1,129 줄을 한 번 쭉 읽고 나서 결정했다. 계층을 나누자.

6개 파라미터, 5개 에러 코드

리팩토링 들어가기 전에 도메인 정리부터.

파라미터 의학적 의미 색상 변화
Ketone 케톤 (당뇨·기아) 노랑 → 보라
Glucose 포도당 (당뇨) 파랑 → 갈색
Protein 단백질 (신장) 노랑 → 파랑
pH 산성도 오렌지 → 청록
Blood 혈액 (요로 출혈) 노랑 → 녹색
White 백혈구 (감염) 베이지 → 보라

그리고 실패 케이스가 5개. 이게 도메인 지식이다.

에러 코드 의미 발생 원인
QR_BBOX QR 또는 블랙박스 미감지 조명·각도·흔들림으로 검출 실패
NOSTRIP 스트립 영역 없음 사용자가 엉뚱한 사진 올림
STRIP 스트립 인식 실패 감지는 됐는데 패치 색을 못 뽑음
REF 레퍼런스 색상 보정 실패 3차 polyfit 회귀 오차 > 1000
SHADOW 그림자로 색 왜곡 코너 4점 기준 상대 어둡기 초과
UNKNOWN 그 외 전부 ← 이걸 최소화하는 게 목표

원본 코드에서는 UNKNOWN 이 전체 실패의 꽤 큰 비중이었다. except: 에 걸려서 뭐든 “UNKNOWN” 으로 갈 수 있었으니까. 리팩토링 목표 하나는 UNKNOWN 비율 줄이기 였다.

리팩토링 — 스크립트에서 서비스로

계층 분리

4개 층으로 나눴다.

 ┌───────────────────────────────────────────────┐
 │  app/api/analyze.py        ← FastAPI 엔드포인트 │
 └───────────────────┬───────────────────────────┘
                     ▼
 ┌───────────────────────────────────────────────┐
 │  app/schemas/              ← Pydantic 요청/응답 │
 └───────────────────┬───────────────────────────┘
                     ▼
 ┌───────────────────────────────────────────────┐
 │  app/services/             ← 비즈니스 로직       │
 │    - analysis_service.py   (분석 서비스)        │
 │    - s3_service.py         (이미지 다운로드)    │
 │    - knn_cache.py          (KNN 싱글톤)        │
 └───────────────────┬───────────────────────────┘
                     ▼
 ┌───────────────────────────────────────────────┐
 │  app/algorithm/            ← 순수 알고리즘      │
 │    (BlackBox · Strip · Estimate 모듈)          │
 └───────────────────────────────────────────────┘

원칙은 단순하다. 아래 층은 위 층을 모른다. 알고리즘 층은 numpy.ndarray 를 받아서 dict 를 뱉는 순수 함수. 서비스 층은 알고리즘을 조립해서 로깅·타이밍·에러 매핑을 얹는다. API 층은 FastAPI 의존성 주입과 Pydantic 검증만 담당.

전역 객체 제거

원본의 모듈 레벨 전역 객체 두 개를 서비스 클래스 안으로 밀어 넣었다.

class ImageAnalysisService:
    """Image analysis service"""

    def __init__(self):
        self.bbox = algo.BlackBox(bbox_points, roi_size, qr_centers, qr_size, min_area_ratio=0.01)
        self.strip = algo.Strip(bbox_points, patch_points, patch_hsize, ref_points, strip_length, bbox_padding)

그리고 파일 맨 아래에:

# 파일 하단의 싱글톤 인스턴스
analysis_service = ImageAnalysisService()

결과적으로 싱글톤이긴 한데, 만드는 책임과 쓰는 책임이 분리됐다. 테스트에서는 ImageAnalysisService() 를 새로 만들어 fixture로 꽂을 수 있다.

베어 except → 명시적 에러 매핑

원본의 try: ... except: result['error'] = "UNKNOWN" 패턴을 다 들어냈다. 대신 실패 경로마다 어떤 에러 코드로 번역될지를 명시했다.

bbox_start = time.time()
result_bbox = self.bbox(image)
bbox_elapsed = (time.time() - bbox_start) * 1000
logger.info(f"[TIMING] BlackBox: {bbox_elapsed:.0f}ms")

if not result_bbox['success']:
    result['error'] = "QR_BBOX"
    logger.error("BlackBox analysis failed: QR or BBOX not found")
    return result

# ... Strip 단계
result_strip = self.strip(result_bbox['bbox'], image, result_bbox['roi'])

if not result_strip['success']:
    if 'error' in result_strip:
        result['error'] = result_strip['error']   # NOSTRIP / REF / SHADOW
    else:
        result['error'] = 'STRIP'
    logger.error(f"Strip analysis failed: {result.get('error')}")
    return result

맨 바깥에 최종 그물 하나만 둔다.

except Exception as e:
    logger.error(f"Unexpected error during analysis: {e}", exc_info=True)
    result['error'] = "UNKNOWN"
    return result

UNKNOWN 은 “예상 밖의 진짜 예외” 전용. 도메인 실패는 모두 구조화된 에러 코드로 빠진다. 덕분에 UNKNOWN 이 찍히면 진짜 버그 라는 신호가 됐다.

파일 I/O 제거

원본은 스트립 인식 결과를 _mask.jpg 파일로 디스크에 저장했다. API 서버에서 이건 의미가 없다. 매 요청마다 디스크를 쓰면 오히려 병목이다. 그래서:

  • 알고리즘 층은 경로 대신 numpy 배열을 받는다
  • 결과물은 dict로 반환하고 파일 저장 안 함
  • S3에서 받은 이미지도 디스크 거치지 않고 메모리에서 처리
# 메모리 버퍼로 다운로드
buffer = io.BytesIO()
self.s3_client.download_fileobj(bucket, key, buffer)
buffer.seek(0)

# Decode image from buffer
image_array = np.frombuffer(buffer.read(), dtype=np.uint8)
image = cv2.imdecode(image_array, cv2.IMREAD_COLOR)

BytesIOnp.frombuffercv2.imdecode 조합으로 S3 → OpenCV 까지 디스크를 한 번도 안 거친다. 컨테이너의 디스크 IOPS에도 의존하지 않음.

측정 가능한 로깅

모든 단계에 타이밍 로그를 달았다.

[TIMING] BlackBox: 87ms
[TIMING] Strip: 142ms
[TIMING] Estimate: 4ms
Analysis completed successfully. Strip type: 6-parameter

production에서 이걸 수집하면 어느 단계가 느린지 바로 보인다. 나중에 튜닝할 때 이 숫자가 기준선이 됐다.

성능 튜닝 — 측정 가능한 개선

KNN 모델 싱글톤 캐싱 (가장 큰 효과)

원본에서 estimate() 함수는 예측할 때마다 CSV를 읽고 KNN을 재학습했다. 6 파라미터 중 5개가 KNN을 쓰니까 요청 한 건당 5번씩 반복됐다.

서비스 계층에 KNN 싱글톤 캐시를 만들었다.

class KNNModelCache:
    """KNN 모델 메모리 캐시 (싱글톤)"""

    _instance = None
    _models: Dict[str, cv2.ml.KNearest] = {}
    _initialized = False

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

앱 기동 시(startup 이벤트) 한 번만 initialize() 를 부른다.

for category in categories:
    rows = train_data['category'] == category
    data = train_data.loc[rows]
    data_rgb = data.loc[:, ['R', 'G', 'B']]
    data_levels = data.loc[:, 'expected']

    array_data_rgb = data_rgb.to_numpy().astype(np.float32)
    array_data_levels = data_levels.to_numpy().astype(np.int32)

    knn = cv2.ml.KNearest_create()
    knn.train(array_data_rgb, cv2.ml.ROW_SAMPLE, array_data_levels)

    self._models[category] = knn

예측 시에는 학습 단계가 통째로 사라진다.

knn = self._models[category]
_, results, _, dist = knn.findNearest(rgb, k)
predicted_level = int(results[0, 0])

효과: 예측당 50~100ms → 1~5ms. 단일 요청에서 5번 반복되던 걸 생각하면 전체 응답 시간이 눈에 띄게 내려갔다.

싱글톤으로 만든 이유는 하나 더 있다. Uvicorn workers=4 로 띄우면 프로세스가 4개로 나뉘는데, 각 프로세스가 기동 시에 KNN을 1회씩만 학습하면 된다. 요청이 쇄도해도 학습은 총 4번이 끝. 프로세스 격리의 이점을 살린 셈.

CLAHE 객체 재사용

알고리즘 모듈에서 CLAHE (Contrast Limited Adaptive Histogram Equalization) 객체를 매 __call__ 마다 생성하던 걸 __init__ 으로 올렸다. 동시에 타일 그리드를 8×84×4 로 줄였다. 도메인 실험으로 확인한 결과 분석 대상 사이즈에 4×4가 더 적합했고, 연산량도 준다. 단순한 변경인데 요청당 0.2~0.5초 절약.

Docker 멀티스테이지 빌드

pyzbarlibzbar0 시스템 패키지를 요구한다. OpenCV도 libglib2.0, libgl1-mesa-glx 등이 필요. 이걸 runtime 이미지에 다 넣으면 1GB를 훌쩍 넘는다.

멀티스테이지로 나눴다:

  • Builder stage: build-essential, libzbar-dev, Poetry 설치 후 의존성 빌드
  • Runtime stage: 런타임 라이브러리만, 빌드된 휠을 복사

이미지 크기가 체감으로 반 이하가 됐다. CI 빌드 속도, 배포 속도 둘 다 이득.

단계별 지연 (실측 기반 추정)

단계 지연 비고
S3 다운로드 100~200ms 네트워크 의존
BlackBox (QR + ROI) 50~100ms Morphology, Contour
Strip (인식 + 색상) 100~200ms CLAHE 4×4 튜닝 후
Estimate (보정 + KNN) 10~50ms KNN 캐시 덕분
총계 300~500ms 이미지 크기 따라 변동

Locust 200 users / 6분 부하 테스트에서 대체로 이 범위 안에서 수렴했다.

동시성 — 왜 worker 4개, 왜 thread가 아닌가

팀 리뷰에서 질문을 두 번쯤 받았다. “async 안 쓰는 이유?”, “thread pool 쓰면 더 빠르지 않나?”.

매번 같은 답을 하려니 지쳐서 python-concurrency-patterns.md 라는 문서를 따로 970줄 정도 썼다. 요약하면:

  1. 요청 간 독립성: 각 요청이 서로 다른 이미지를 들고 옴. 공유 상태 없음. → 프로세스 격리가 안전
  2. 요청 내부는 순차 의존: BBox 결과가 Strip의 입력, Strip 결과가 Estimate의 입력. 병렬화 여지가 적음
  3. OpenCV/NumPy는 GIL을 해제한다: thread로도 되긴 하는데, 디버깅 난이도와 메모리 공유 이슈 고려하면 process worker 가 나음
  4. Uvicorn workers=4: CPU 4 core 컨테이너에 정확히 맞춤. 워커당 KNN 캐시 1셋 = 메모리 4배. 수용 가능한 트레이드오프

make load-test (200 users, 6분), make load-test-stress (0→50→100→200 단계적), make load-test-spike (200→500) 세 시나리오로 검증했다. docker stats 로 CPU/메모리 추이 찍어보면 worker 4개가 균등하게 로드를 나눠 가진다.

뭘 놓쳤나 — 회고 3포인트

1. 알고리즘 단계 병렬화를 포기했다

“단계 간 의존성 때문” 이라고 합리화했는데, 실제로는 S3 다운로드와 KNN warm-up은 독립적이다. FastAPI 쪽에서 asyncio.gather([download_s3(), warm_knn()]) 로 묶었으면 첫 요청의 cold-start 지연을 줄일 수 있었다. 깊게 들어가면 BlackBox 내부의 QR 검출과 ROI 추출도 일부 병렬화 여지가 있다. 이건 내가 “단계”라는 단어에 갇혀서 못 본 부분.

2. S3 다운로드가 동기(sync) 호출이다

boto3download_fileobj블로킹 I/O 다. FastAPI 는 async 프레임워크인데 여기서 이벤트 루프가 네트워크 대기 동안 멈춘다. 지금은 Uvicorn worker process 4개로 우회 중이라 체감 문제가 없지만, 단일 워커 내부에서의 concurrent 요청은 직렬화되는 셈. 제대로 하려면 두 가지 옵션 중 하나였다:

  • aiobotocore 로 바꿔서 진짜 async 다운로드
  • 최소한 asyncio.to_thread(self.s3_client.download_fileobj, ...) 로 스레드풀에 위임

프로세스 워커 4개로 커버되니까 “async 이점의 절반만 살리는” 형태로 타협했는데, 커넥션 풀이나 동시 요청 수가 늘면 S3 대기가 곧 병목이 된다. “async 프레임워크 쓴다” 와 “async 하게 동작한다” 는 별개라는 걸 놓쳤다.

3. Observability 가 로그뿐

[TIMING] BlackBox: 87ms 같은 걸 logger.info 로 찍는 수준에 그쳤다. 이걸 히스토그램으로 쌓아야 튜닝 근거가 된다 — “p50/p95/p99 가 어디냐, CLAHE 4×4 바꿨을 때 분포가 어떻게 이동했냐”. Prometheus exporter 하나 붙이고 Grafana로 받았으면 성능 최적화의 대화가 숫자 위에서 가능했을 것. 지금은 “체감상 빨라졌다” 수준의 주장만 남아있다.

마치며

1,129줄짜리 레거시 스크립트를 @app.post("/") 뒤에 그대로 꽂는 건 10분이면 끝났을 것이다. 실제로는 그 스크립트를 서비스로 만드는 과정이 프로젝트 전체의 절반 이상 걸렸다. 계층 분리, 전역 객체 제거, 에러 코드 구조화, 파일 I/O 제거, KNN 캐싱 — 이게 다 “CLI → 서비스” 로 오는 길 위에서 어느 하나 건너뛸 수 없었다.

그리고 “서비스” 라는 단어의 무게도 배웠다. 동시성, 부하 분산, 관측 가능성 — 이게 다 서비스가 된 이후에만 의미가 있는 개념들이다. Python 스크립트를 FastAPI로 감쌌다고 자동으로 이게 따라오지 않는다. 하나하나 의식적으로 얹어야 했다.

다음 읽을거리