Fintech 회고 (4) - 돌아가는 시스템을 운영 가능한 시스템으로

Published:

Fintech 회고 시리즈

  1. Prophet 기획
  2. 전체 아키텍처 기획
  3. GPT Classifier 기획
  4. 운영 회고 ← 현재

앞선 세 편이 “뭘 만들었나” 라면 이 글은 “만들어놓고 뭘 고쳤나”. 다른 회고에서도 늘 반복되는 결어 — “돌아가는 시스템” 과 “운영 가능한 시스템” 사이의 거리. 이걸 좁히는 과정의 기록.

git log를 보면 P0 → P3 우선순위가 commit 메시지에 붙어있는데, 이게 실제 리팩토링 순서를 그대로 보여줌:

P0  성능 붕괴 해결 (워커/스레드)
P1  정확성 (락, DB 풀)
P2  코드 품질 (N+1, 상수, 테스트, 예외)
P3  마감 정리 (싱글톤, 세부 상수)

이 순서대로 꺼내봄.

P0-1. Uvicorn 싱글 워커 → 멀티 워커 (4배)

증상: 동시 요청 2~3개만 들어와도 응답이 밀림. Prophet 학습 중엔 다른 요청이 전부 대기.

원인: Uvicorn을 기본값(워커 1개)으로 돌리고 있었음. FastAPI async 코드라도 CPU 바운드 작업은 워커 레벨에서 분산 되어야 한다. Prophet fit이 한 워커 프로세스를 점유하면 그 워커의 async 루프 전체가 멈춤.

해결:

# docker-compose.yml:68
environment:
  - UVICORN_WORKERS=${UVICORN_WORKERS:-4}

Analysis 서비스에 워커 4개. 이 한 줄 차이로 처리량 약 4배. 리소스 계산도 바뀜 — 메모리 리미트를 2.5G 로 올린 이유 (Part 2) 가 여기 있음. 워커당 Prophet + pandas 런타임이 ~500MB 올라가서 4 × 500MB + 버퍼 ≈ 2.5GB.

커밋: c3b9eef [P0] Configure Uvicorn multi-worker setup for 4x performance improvement

P0-2. Prophet을 await으로 감싸면 다 막힌다 — ThreadPool 3개

증상: 워커 4개로 올렸는데도 특정 패턴에서 여전히 block. 한 요청 안에 “현재월 예측 + baseline 11개월” 을 다 돌리면 한 워커가 수십 초 묶여있음.

원인:

  1. Prophet .fit()sync 블로킹 CPU 작업. await 로 감싸도 실제로는 async 루프 위에서 동기 실행됨 → 같은 워커의 다른 async 요청 전부 대기
  2. 한 요청 안에서 baseline 11개월을 또 돌리면 수십 초 × 워커 = 치명

해결: ThreadPoolExecutor를 목적별로 3개 분리.

# analysis/app/services/prophet_service.py:32-46
self.main_executor     = ThreadPoolExecutor(max_workers=4, thread_name_prefix="prophet-main")
self.baseline_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="prophet-baseline")
self.category_executor = ThreadPoolExecutor(max_workers=min(cpu_count, 8), thread_name_prefix="category-worker")
용도왜 분리
main_executor사용자 대기 요청 (현재월 예측)응답 시간 직결 — 여기 워커 부족하면 사용자가 느낌
baseline_executor11개월 backtesting (background)사용자가 기다리지 않는 것 — 일부러 적게 (2개) 줘서 전체 CPU를 잡아먹지 않게
category_executor13개 카테고리 병렬 fan-out인스턴스 공유 — 매 요청마다 새 풀 만들면 32개+ 스레드 폭발

중요한 건 baseline을 FastAPI BackgroundTasks 로 응답 뒤로 밀었다는 점.

# analysis/app/api/endpoints/leak.py 근처 (개념)
response = {...}  # 현재월 예측 결과만
background_tasks.add_task(run_baseline_async, ...)
return response  # 202 Accepted, baseline은 백그라운드에서

사용자는 3~5초만 기다림. baseline 11개월은 뒤에서 조용히 돎.

커밋:

  • c74fb17 [P0] Separate ThreadPool for background tasks to eliminate blocking
  • 1fb112c [P0] Implement parallel category processing for 60% response time reduction

P1-1. Redis 락의 race condition

증상: 같은 file_id 로 분석 요청이 거의 동시에 두 번 들어오면 두 건 다 받아들임. 같은 Prophet 학습이 중복으로 돌고, 결과가 MySQL에 중복 insert → Unique constraint 에러. 심지어 두 번째 요청이 첫 번째의 결과를 덮어쓰는 경우도.

초기 구현 (잘못된 버전):

# 잘못된 패턴
if redis.get(f"lock:analysis:{file_id}"):
    return 409
redis.set(f"lock:analysis:{file_id}", "1", ex=120)
# ... 분석 시작 ...
redis.delete(f"lock:analysis:{file_id}")

이 코드에 race가 두 개:

  1. check-then-set 이 원자가 아님 — 두 요청이 get() 을 거의 동시에 실행하면 둘 다 “잠김 없음” 을 보고 set()
  2. 소유권 검증 없음 — A 요청이 lock을 걸고 hang 중인데 TTL 만료됐음. B 요청이 새 lock을 걸고 진행. A가 뒤늦게 깨어나서 delete 하면 B의 lock을 지움

해결: SET NX EX + UUID 토큰 + Lua 스크립트.

# analysis/app/services/redis_client.py:108-145

def acquire_analysis_lock(self, file_id: str, timeout: int = 120) -> Optional[str]:
    lock_key = f"lock:analysis:{file_id}"
    lock_token = str(uuid.uuid4())
    acquired = self.client.set(
        lock_key,
        lock_token,
        nx=True,  # Not eXists
        ex=timeout  # EXpire in seconds
    )
    return lock_token if acquired else None

SET key value NX EX 120단일 Redis 명령. 체크와 쓰기가 원자적.

# release 쪽 (라인 161-176)
lua_script = """
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end
"""
self.client.eval(lua_script, 1, lock_key, lock_token)

release 전에 토큰 검증 → 내 토큰이 맞을 때만 삭제. 만료된 lock을 남의 락이 대체해도 안전.

커밋:

  • b681872 [P1] Implement atomic Redis lock to prevent race conditions
  • 8eeb084 Fix critical lock ownership and release issues — 토큰 검증 미구현 버그
  • cba4538 [P1] Implement atomic Redis lock to prevent race conditions (followup)

이 3개 커밋이 한 주제를 세 번에 걸쳐 수정한 기록. 초기 구현 → 버그 발견 → fix → 추가 엣지 케이스. 락 구현은 한 번에 맞기 어려운 영역이고, commit 로그에 그 과정이 남아있음이 솔직해서 좋음.

P1-2. N+1 쿼리 지옥 → Repository + bulk_upsert

증상: 13개 카테고리 × 11개 월 baseline 저장할 때 MySQL 쿼리 143개 연속 발사. 응답 시간의 절반 가까이를 DB round trip이 먹음.

해결: SQLAlchemy insert().on_duplicate_key_update() 를 감싼 BaseRepository.bulk_upsert 하나로 통합.

# analysis/app/repos/base_repo.py
def bulk_upsert(self, data: List[Dict], unique_keys: List[str]):
    if not data:
        return
    stmt = insert(self.model).values(data)
    update_dict = {
        c.name: stmt.inserted[c.name]
        for c in self.model.__table__.columns
        if c.name not in unique_keys and c.name != 'id' and c.name != 'created_at'
    }
    stmt = stmt.on_duplicate_key_update(update_dict)
    self.db.execute(stmt)

4개 repo (prediction_repo, baseline_repo, leak_repo, doojo_repo) 가 전부 이걸 상속. 한 요청에 쿼리 143개 → 약 5~10개 (테이블별 bulk).

Upsert로 바꾼 이유: INSERT 만 쓰면 같은 파일에 재분석이 들어올 때 Unique 제약 터짐. ON DUPLICATE KEY UPDATE 로 있으면 덮어쓰기. MySQL이라 이 문법 씀 (PostgreSQL이면 ON CONFLICT DO UPDATE).

커밋: 51761d7 [P2] Implement Repository pattern to fix N+1 query issues (#8)

P1-3. DB Connection Pool 튜닝

증상: 부하 올라가면 sqlalchemy.exc.OperationalError: (pymysql.err.OperationalError) (2006, 'MySQL server has gone away'). connection 재사용 실패.

해결: .env 로 DB 풀 설정 외부화.

# docker-compose.yml:69-71
- DB_POOL_RECYCLE=${DB_POOL_RECYCLE:-3600}
- DB_POOL_TIMEOUT=${DB_POOL_TIMEOUT:-30}
- DB_CONNECT_TIMEOUT=${DB_CONNECT_TIMEOUT:-10}
파라미터이유
pool_recycle3600sMySQL wait_timeout 이 보통 8시간이지만 Azure MySQL은 더 짧음. 1시간마다 connection 재활용해서 stale 방어
pool_timeout30sconnection 고갈 시 30초 대기 후 실패. 그보다 길면 client side timeout으로 먼저 죽는 게 나음
connect_timeout10s네트워크 지연 탐지용 짧은 초기 연결 타임아웃

커밋: e2dfb8a [P1] Optimize DB Connection Pool for scalability, b679e99 [P1] Optimize DB Connection Pool for scalability (followup)

P2-1. OpenAI v1.x 마이그레이션 — 예외 이름이 다 바뀌었다

증상: OpenAI SDK를 1.x로 업그레이드했더니 from openai import TimeoutImportError 로 터짐. 배포 자체가 망가짐.

원인: OpenAI Python SDK 1.x 에서 예외 이름 체계가 완전 재편. 주요 변경:

0.x1.x
openai.error.Timeoutopenai.APITimeoutError
openai.error.RateLimitErroropenai.RateLimitError
openai.error.APIErroropenai.APIError
openai.error.OpenAIErroropenai.OpenAIError
openai.ChatCompletion.createopenai.OpenAI().chat.completions.create

해결:

# classifier/app/services/gpt_classifier.py:4-5 (새로운 import)
import openai
from openai import OpenAIError, RateLimitError, APIError, APITimeoutError

그리고 예외 catch 부분을 전부 새 이름으로 교체.

커밋: 86cd23c Fix OpenAI v1.x API import compatibility

교훈: 외부 SDK의 major version bump는 import 한 줄보다 훨씬 큰 변경. CI에서 pip install 한 번 더 돌려보면 잡혔을 일이 production 에서 터짐. dependency pinning을 정확히 (==1.55.3 처럼 minor-patch까지) 해두는 게 기본.

P2-2. 예외 구체화 — except Exception 을 쪼갰다

증상: except Exception as e: logger.error(...) 패턴이 곳곳에 있었음. Rate limit인지, network timeout인지, JSON parse 실패인지 로그만 봐서는 구분 안 됨. 재시도 정책도 못 짬.

해결: 도메인별 예외 타입을 catch하고 그에 맞는 대응 으로 분기.

# classifier/app/services/gpt_classifier.py:72-90 (단일 분류)
except (RateLimitError, APITimeoutError) as e:
    logger.warning(f"GPT API rate limit or timeout: {str(e)}")
    return self._fallback_classification(merchant_name, amount)

except APIError as e:
    logger.error(f"OpenAI API error: {str(e)}")
    return self._fallback_classification(merchant_name, amount)

except ValueError as e:
    logger.error(f"GPT response parsing error: {str(e)}")
    return self._fallback_classification(merchant_name, amount)

except Exception as e:
    logger.exception(f"Unexpected GPT classification error: {type(e).__name__}")
    return self._fallback_classification(merchant_name, amount)

핵심: except Exception맨 마지막 에만. 그 위 계층에서 가능한 것들을 명시적으로 잡음.

Analysis 쪽 Prophet 서비스에도 똑같이 적용:

# analysis/app/services/prophet_service.py:300 근처
except KeyError as e:
    return {'category': ..., 'error': 'missing_column', ...}
except RuntimeError as e:  # Prophet이 수렴 안 할 때
    return {'category': ..., 'error': 'prediction_failed', ...}
except Exception as e:
    logger.exception(...)
    return {'category': ..., 'error': 'unknown_error', ...}

S3 클라이언트에는 boto3.exceptions.ClientError, NoSuchKey 같은 구체 예외 도입.

왜 이게 중요한가 — 로깅만 바꾸는 게 아니다. 예외 타입에 따라 재시도/ fallback/ 종료 가 달라져야 함.

  • RateLimit → 재시도 / fallback classification
  • Timeout → 재시도
  • Parsing error → 재시도 말고 fallback (같은 오류 반복될 테니)
  • APIError → fallback
  • Unknown → 502로 외부에 알림

커밋: 746e62f Fix #10: Refactor error handling with specific exception types, e0fadb3 [P2] Refactor error handling with specific exception types (#10)

P2-3. 환경변수 검증 — 죽을 거면 일찍 죽자

증상: 서비스가 일단 뜨고 나서 첫 요청에서 KeyError: 'GMS_API_KEY' 로 500. 기동 로그만 보면 정상이라 운영자가 “왜 죽었지?” 를 나중에 발견.

해결: startup 시점에 필수 env var 존재 여부를 전부 검증.

# 개념 (settings / config에 구현)
REQUIRED_ENV_VARS = [
    "DATABASE_URL",
    "REDIS_HOST", "REDIS_PORT",
    "GMS_API_KEY", "GMS_BASE_URL",
    "S3_ENDPOINT", "S3_ACCESS_KEY", "S3_SECRET_KEY",
]

for var in REQUIRED_ENV_VARS:
    if not os.getenv(var):
        raise RuntimeError(f"Missing required env var: {var}. See .env.example")

“죽을 거면 일찍, 명확한 에러 메시지와 함께” — fail-fast 원칙. 운영자가 compose 로그 첫 10줄만 봐도 뭐가 빠졌는지 안다.

커밋: 2ae7e02 [P2] Add environment variable validation with clear error messages, ebd1cbd (후속)

P2-4. Magic number → 상수화

증상: 코드 여기저기 if len(data) < 30: if amount > 500000: BASELINE = 11 같은 숫자가 흩뿌려져 있었음.

해결: app/core/constants.py 에 전부 모으고 이름 붙임.

# analysis/app/core/constants.py
PROPHET_MIN_DATA_DAYS = 30
PROPHET_INTERVAL_WIDTH = 0.95
PROPHET_MAIN_WORKERS = 4
PROPHET_BASELINE_WORKERS = 2
BASELINE_MONTHS_COUNT = 11
ANALYSIS_LOCK_TIMEOUT = 120
DB_QUERY_TIMEOUT = 30
REDIS_CACHE_TTL = 86400
# ... 이하 계속

# classifier/app/core/constants.py
HIGH_AMOUNT_THRESHOLD = 500_000
CONFIDENCE_VERY_HIGH = 0.95
CONFIDENCE_HIGH = 0.93
# ...

이 리팩토링이 의외로 중요한 이유 — 튜닝하려는 사람이 어디를 봐야 할지 한눈에 안다. 문서가 따로 필요 없음. constants.py 주석이 그대로 운영 매뉴얼.

커밋: 2d5cb16, 8a523af, 98ba486 — P2/P3로 나눠서 여러 번 들어옴.

P3-1. OpenAI 클라이언트 싱글톤 (doojo)

증상: Doojo 조언 엔드포인트가 요청마다 OpenAI(api_key=..., base_url=...) 를 새로 만듦. HTTP connection pool이 매번 새로 붙음.

해결: 모듈 레벨 싱글톤.

# analysis/app/api/endpoints/doojo.py:51-65 (개념)
_openai_client: Optional[OpenAI] = None

def get_openai_client() -> OpenAI:
    global _openai_client
    if _openai_client is None:
        _openai_client = OpenAI(
            api_key=settings.GMS_API_KEY,
            base_url=settings.GMS_BASE_URL,
        )
    return _openai_client

작지만 한 요청당 수백 ms 아낌. connection 재사용의 가치.

커밋: ef60a15 [P3] Optimize doojo endpoint with OpenAI client singleton

P3-2. data.py 분할

증상: analysis/app/api/endpoints/data.py 한 파일에 leak / baseline / doojo / analysis trigger 엔드포인트 전부 박혀있었음. 변경 하나하나가 이 파일 전체 리뷰를 요구.

해결: 도메인별로 분리.

analysis/app/api/endpoints/
├── leak.py         # 누수 분석
├── baseline.py     # 11개월 baseline
├── doojo.py        # 두꺼비 조언
└── analysis.py     # 분석 트리거

FastAPI는 APIRouter 로 각 모듈을 깔끔히 합쳐줌. 코드 양은 그대로인데 변경 단위가 작아짐.

커밋: 34c56fb [P2] Split data.py into focused endpoint modules

아직 남은 TODO

운영 가능해졌다고 선언할 수 있으려면 남은 건:

1. Azure MySQL SSL verify 꺼져있음 (Critical)

.env.example 기준 MYSQL_SSL_VERIFY=false. 인증서 체인 설정 안 해서 편의상 꺼둔 상태. 중간자 공격 노출. 프로덕션 넘기기 전 반드시 고쳐야 함.

2. 분산 trace 없음

요청 ID가 Gateway → Classifier/Analysis 경계를 안 넘어감. OpenTelemetry traceparent 전파 필요. 디버깅 효율이 크게 달라지는 지점.

3. Classifier 배치 병렬화 안 됨

CSV 한 장을 분류할 때 for row in df.iterrows(): 로 순차 호출. asyncio.gather + 동시성 상한으로 바꿔야 함. GMS rate limit 확인이 선결.

4. Analyst-in-the-loop UI 없음

Prophet 논문 에서 얘기한, confidence 낮은 분류나 오차 큰 예측을 사람이 확인/교정하는 UI. 지금은 전부 자동. 감사 로그만 쌓임.

5. 관측성

Prometheus exporter, 구조화 로그, 대시보드 — 전부 없음. /api/ai/health 가 유일. “지금 lock 몇 개 걸려 있나?”, “최근 1시간 평균 분석 시간?” 질문을 할 수 없음.

6. k8s 또는 수평 확장 준비

Docker Compose 기반 단일 노드. 리플리카 2개 이상 띄우면 ClassifierService.jobs 같은 in-memory dict 때문에 상태가 갈라짐. Redis/DB로 옮겨야 함.

7. 모델 정확도 벤치마크

Classifier는 자체 테스트셋 30~50개 기준. Prophet은 cross_validation() 자체가 없음. 두 모델 다 “얼마나 좋은지” 를 숫자로 못 말함. Part 1 에서도 강조한 1순위.

뭘 놓쳤나 / 배운 것

1. P0 이슈는 “쓰는 사람” 만 안다 워커 1개로 돌리던 초기에 “내가 혼자 개발” 할 땐 아무 문제 없었음. 동시 사용자 2~3명 테스트 붙자마자 터짐. 로컬 단일 요청이 돌아간다 는 건 성능에 대해 아무것도 말해주지 않음. 부하 테스트를 첫 주 에 한 번은 돌려봤어야 했다.

2. Redis 락은 3번에 걸쳐 제대로 됐다 이건 reference 학습이 필요한 영역. “SET NX EX” 한 줄로 안 끝난다. 소유권 검증, TTL 만료 처리, Lua 스크립트 원자성까지 고려할 게 많음. 한 번에 맞추려 하지 말고 “구현 → 부하 테스트 → fix → 재테스트” 루프를 돌릴 준비 를 미리 했어야 했음.

3. 에러 처리가 도메인 설계다 except Exception 을 쪼개는 일이 “코드 품질” 문제로 보였는데, 실제로는 시스템의 resilience 모델 자체 를 정의하는 일이었음. 어떤 예외에 재시도, 어떤 예외에 fallback, 어떤 예외에 502. 이게 분산 시스템의 핵심 설계 결정 중 하나.

4. git commit 메시지가 곧 회고다 이 글 쓸 때 내가 한 일 대부분을 git log 에서 복원함. [P0], [P1] 같은 태그 + 명확한 제목을 붙여둔 게 이 시점에서 빛을 봄. “나중에 회고 쓸 나” 를 위해 커밋 메시지를 잘 쓰는 게 진짜 쓸모 있다는 증거.

시리즈 마무리

IoT 파이프라인 회고 도 끝에 똑같이 썼는데 — “돌아가는 것” 과 “운영 가능한 것” 은 다른 수준의 완성도. Fintech 프로젝트는 P0 단계에서는 전자(돌아감)였고, P1 ~ P2 거쳐서 후자(운영 가능)에 반 발짝 다가왔다. 아직 7개 TODO가 남아있어서 완주는 아닌데, 그래도 “쓰는 사람이 있어도 안 쓰러지는” 지점까지는 왔음.

다음 같은 프로젝트를 만들면 “운영 TODO 체크리스트” 를 첫 주에 미리 뽑아놓고 작업할 거다. 어차피 이 7개 (부하 테스트, trace, 관측성, rate limit, …) 는 대부분 프로덕트에서 공통이니까. 기능 만드는 것보다 이 리스트 소화하는 데 더 많은 시간을 쏟게 되는 게 정상이라는 걸 이번에 알았다.

다음 읽을거리

  • Part 1 — Prophet 기획. 이 글의 P0 ThreadPool 이야기의 배경
  • Part 2 — 전체 아키텍처. 이 글의 TODO(분산 trace, k8s) 맥락
  • Part 3 — Classifier 기획. 이 글의 OpenAI 1.x / 예외 분기 이야기 대상
  • Prophet 논문 리뷰 — analyst-in-the-loop 개념은 여전히 미완
  • IoT 파이프라인 회고 — 같은 “운영 가능한 시스템” 결어를 가진 앞선 글