Fintech 회고 (2) - 왜 4개 서비스로 쪼갰나

Published:

Fintech 회고 시리즈

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

Part 1 이 Prophet 얘기였다면 이 글은 그 Prophet 서비스가 어디에 박혀있는지. 즉 전체 시스템 경계 설계.

처음엔 FastAPI 앱 한 개였다

초기 구조:

fintech/
└── app/
    ├── main.py         # /upload, /classify, /analyze 전부
    ├── gpt.py
    ├── prophet.py
    └── csv_handler.py

한 프로세스, 한 Uvicorn, 엔드포인트 전부 한 곳에 박힘. 개발 초반엔 이게 제일 빠름 — 공유 코드 그냥 import 하면 되고 배포도 한 방. “굳이 뭘 더 쪼개?” 싶었음.

IoT 회고 에서 “처음엔 그냥 로그 파일이었다” 로 시작했던 거랑 똑같은 상황. 단순한 게 맞을 때까지만 단순하다.

4개로 쪼갠 이유

쪼개게 된 트리거는 3개.

트리거 단일 앱에서의 증상
리소스 프로필 차이 Prophet은 메모리 1~2GB 먹고 CPU 풀로 씀. GPT 호출은 메모리 수백 MB에 I/O wait 뿐. 한 프로세스 안에 있으면 Prophet 학습 중에 GPT 응답이 막힘
재배포 블라스트 반경 분류기 프롬프트 한 줄 바꿨는데 Prophet 서비스까지 같이 내려갔다 올라감. 테스트 스코프가 쓸데없이 넓어짐
책임 경계가 흐려짐 “CSV 업로드는 어느 코드 책임?” — 초반엔 gateway 코드에서 MinIO도 만지고, 검증도 하고, 타 서비스한테 메타데이터도 밀어넣음. 뭐든 다 함

그래서 4개로 쪼갰다.

서비스 포트 (내부) 책임 메모리 리미트
Gateway 8000 (외부 노출) 라우팅 · OpenAPI 병합 · health aggregation · CORS 800M
Classifier 8001 GPT-5-nano 거래 카테고리 분류 800M
Analysis 8002 Prophet 예측 + baseline + Doojo 조언 2.5G
CSV-Manager 8003 CSV 업로드 · 검증 · MinIO 연동 · 메타데이터 800M

외부에 열려있는 건 Gateway 8000 하나뿐. 나머지 3개는 docker-compose에서 ports를 주석 처리해놓고 ai-network 브릿지 위에서 http://classifier:8001, http://analysis:8002, http://csv-manager:8003 로만 접근. 이게 첫 번째 경계.

Analysis만 메모리 2.5G인 이유는 Part 1에서 말한 Prophet 학습이 무겁기 때문. Prophet + pandas + Stan 런타임을 4 worker × 500MB 로 잡으면 2G가 꽉 참.

CSV-Manager 는 왜 또 따로 있나

“업로드면 Gateway에서 그냥 받으면 되는 거 아님?” — 내가 처음 든 생각이었다. 근데 Gateway가 파일까지 만지기 시작하면:

  1. CORS 헤더 + multipart 파싱 + MinIO boto3 가 Gateway에 들어옴 → Gateway 본연의 “얇은 프록시” 역할에서 벗어남
  2. Redis 네임스페이스가 둘이 됨 — Gateway는 라우팅용 cache, CSV 업로드용 metadata 둘 다 관리
  3. 파일 검증 로직 (row ≥ 30, 날짜 범위, 컬럼 스키마) 이 Gateway에 박힘. 이건 도메인 로직

그래서 2025-11 리팩토링에서 CSV 로직을 통째로 떼어 csv-manager 서비스로 옮김. Gateway의 main.py 상단에 남아있는 흔적:

# CSV router removed - now handled by csv-manager service

이 한 줄짜리 주석이 제일 중요한 리팩토링 기록. Git blame 찍으면 나오는 “왜 옮겼는지” 맥락이 코드에 남아있음.

Data Flow — CSV 업로드부터 조언까지

[Client]
   │
   │ 1. POST /api/ai/csv/upload  (multipart)
   ▼
[Gateway 8000]
   │
   │ 2. proxy → csv-manager:8003/csv/upload
   ▼
[CSV-Manager]
   │
   │ 3. stream to MinIO (file_id = UUID)
   │ 4. Redis: csv:status:{file_id} = "uploading" → "ingesting" → "none"
   │ 5. Redis: csv:metadata:id:{file_id} = {rows, range, categories}
   │ 6. 200 OK with file_id
   ▼
[Client]
   │
   │ 7. POST /api/ai/data?file_id=...
   ▼
[Gateway] → [Analysis 8002]
   │
   │ 8. Redis: acquire lock:analysis:{file_id}  (SET NX EX 120)
   │ 9. MinIO에서 CSV pull (S3 client)
   │ 10. Prophet 현재월 예측 (category_executor 병렬)
   │ 11. MySQL: predictions bulk upsert
   │ 12. BackgroundTask → Prophet baseline (11개월)
   │ 13. Redis: release lock
   │ 14. 202 Accepted
   ▼
[Client]
   │
   │ 15. GET /api/ai/data/doojo?file_id=...&year=...&month=...
   ▼
[Gateway] → [Analysis]
   │
   │ 16. MinIO CSV + MySQL baseline
   │ 17. GPT-5-nano (개인화 조언)
   │ 18. MySQL: doojo_analysis upsert
   │ 19. 200 OK with 조언 JSON
   ▼
[Client]

이 한 다이어그램이 전체 제품의 라이프사이클. 여기서 재미있는 지점들:

  • Gateway는 언제나 얇은 프록시 — 4 → 5 에서 Redis 건드리는 건 csv-manager, Gateway는 스킴 변환만. Gateway 안에 도메인 로직 넣고 싶은 유혹이 여러 번 있었는데 참음.
  • Classifier 서비스가 이 플로우에 안 나옴 — 초기 설계는 업로드 직후 자동 분류였는데, 지금은 거래가 이미 분류된 상태로 CSV에 들어오는 가정. /api/ai/classify 는 독립 엔드포인트로만 남아있음. Part 3에서 자세히.
  • 8 → 12 사이 사용자는 응답을 기다림 — 이 길이가 3~5초. 대부분 Prophet 학습 시간. Part 4에서 이걸 어떻게 깎았는지.

인프라 선택 — 뭐를 왜 썼나

MinIO vs S3

로컬 개발 + 테스트 환경에서는 MinIO (S3 호환 오픈소스). 프로덕션도 MinIO로 유지. 선택 이유:

  • boto3 그대로 쓸 수 있음 → 코드에 로컬/클라우드 분기 없음
  • Docker 컨테이너 하나면 끝 → CI에서 통합 테스트 쉬움
  • SSAFY 환경에서 외부 AWS 계정 관리 부담 없음

Trade-off: 실제 AWS S3만이 제공하는 다중 region, Glacier 같은 건 없음. 우리 유즈케이스엔 CSV 한 개당 수백 KB 수준이라 영향 없음.

Azure MySQL — 왜 외부로 뽑았나

docker-compose.yml에 이 주석이 있음:

# depends_on removed - using external Azure MySQL
# MySQL Database container removed - using Azure MySQL instead

초기엔 로컬 MySQL 컨테이너 썼음. 옮긴 이유:

  1. 컨테이너 재시작 시 데이터 증발docker compose down -v 한 번이면 분석 기록 다 날아감
  2. 배포마다 schema migration 반복 — 팀원이 테이블 구조 바꾸면 내 로컬 DB가 어긋남
  3. SSAFY에서 Azure MySQL 제공 — 안 쓸 이유 없음

Trade-off: 네트워크 연결이 추가 의존성.

Redis의 두 역할

Redis가 두 가지 완전히 다른 목적으로 쓰임:

A. State machine storage

csv:status:{file_id} = "uploading" | "ingesting" | "analyzing" | "none"
csv:metadata:id:{file_id} = {rows: 1497, range: [..], categories: [..]}
analysis:metadata:{file_id} = {prediction_id, created_at, ...}

파일 상태를 조회하는 모든 서비스가 이 키로 공유. Namespace를 prefix로 분리 (csv:, analysis:) 해서 충돌 회피.

B. Distributed lock

lock:analysis:{file_id} = <UUID token>  (SET NX EX 120)

같은 CSV에 대해 동시에 분석 요청 2개 오면 두 번째는 락 획득 실패 → 202 대신 409 반환. acquire_analysis_lock 이 Lua 스크립트로 atomic release까지 보장. 이 구조의 디테일은 Part 4.

한 Redis 인스턴스에서 이 두 역할 같이 해결하는 건 운영상 문제 없음 — 오히려 따로 Redis 띄우는 게 YAGNI.

Redis 4-state 머신

파일 상태를 Redis에 두고 관리:

      ┌──────┐
      │ none │ ← 초기/완료
      └──┬──┬┘
         │  │
  upload │  │ analyze
         ▼  ▼
┌───────────┐    ┌───────────┐
│ uploading │    │ analyzing │
└─────┬─────┘    └─────┬─────┘
      │                │
      │  (S3 업로드 완료) │  (Prophet 완료)
      ▼                │
┌───────────┐          │
│ ingesting │          │
└─────┬─────┘          │
      │  (검증 완료)     │
      └──────┬─────────┘
             ▼
         ┌──────┐
         │ none │
         └──────┘

왜 DB에 안 두고 Redis에 뒀나:

이유 설명
낮은 latency “지금 업로드 중인가?” 는 ms 단위 응답 필요. Redis GET 은 0.5ms, MySQL round trip은 수 ms
TTL 공짜 상태가 꼬여서 영원히 uploading 으로 남으면 안 됨. Redis는 EXPIRE 한 줄이면 자동 cleanup
서비스 간 공유 Gateway, csv-manager, analysis 모두 같은 Redis 봄. DB를 공유 채널로 쓰면 DB 테이블 하나가 message bus가 됨 — 안티패턴
무상태 서비스 서비스 컨테이너 쪽엔 상태 전혀 없음. 스케일 아웃 쉬움

이 선택의 대가: Redis가 죽으면 상태가 “몰라” 가 됨. 우리 규모에서는 복구 시 새 업로드로 재생성하면 되니까 괜찮지만, 진지한 프로덕션이라면 Redis persistence (RDB + AOF) 를 켜야 한다.

Gateway의 OpenAPI 스키마 런타임 병합

Gateway에 재미있는 트릭이 하나 있다. 각 서비스가 자체 /openapi.json 을 제공하는데, Gateway가 런타임에 이 스펙들을 fetch + merge 해서 단일 통합 문서 하나로 재공개.

# gateway/app/main.py:69-89
async def fetch_service_openapi(service_name: str, service_config: dict):
    max_retries = 3
    for retry in range(max_retries):
        try:
            async with httpx.AsyncClient(timeout=GATEWAY_SHORT_TIMEOUT) as client:
                response = await client.get(f"{service_config['url']}/openapi.json")
                if response.status_code == 200:
                    return response.json()
        except Exception as e:
            logger.error(...)
        if retry < max_retries - 1:
            await asyncio.sleep(2)

startup 시 Gateway가 Classifier/Analysis/CSV-Manager 세 서비스의 OpenAPI를 가져오고, 스키마 이름 충돌은 서비스명 prefix로 네임스페이스 분리. $ref 경로도 동적으로 재작성.

왜 이렇게까지? 단순한 이유:

  • 각 서비스팀이 자기 OpenAPI만 관리. Gateway는 선언적으로 묶어줌
  • 프론트가 보는 건 Swagger UI 한 곳. 서비스 몇 개 붙어있는지 모름
  • 서비스 추가/삭제할 때 Gateway 코드 거의 안 바뀜 (SERVICES dict에 한 줄)

복잡도는 있다. 기동할 때 서비스 세 개 다 올라와 있어야 Gateway도 완전히 준비됨. 세 번 재시도하고 죽게 해서 compose restart 로 자연스럽게 회복되게 해뒀다.

컨테이너 리소스 — 왜 Analysis만 2.5G

# docker-compose.yml 요약
gateway      memory limit: 800M   (2 workers × ~300MB)
classifier   memory limit: 800M   (2 workers × ~300MB)
analysis     memory limit: 2.5G   (4 workers × ~500MB)   ← 다른 애들의 3배
csv-manager  memory limit: 800M   (2 workers × ~300MB)

Analysis만 3배 큰 이유는 Part 1에서 말했듯 Prophet. 각 요청당:

  • pandas DataFrame (거래 내역 전체)
  • Prophet 모델 1개 (fitted Stan 객체, ~50MB)
  • 병렬 학습할 때는 min(cpu, 8) 개가 동시

이게 Uvicorn worker 4개 곱해지면 쉽게 1.5GB 넘고 피크 때 2G 근처 감. 2.5G로 잡은 건 측정 없이 감으로. 이건 Part 4 “TODO” 중 하나. 실제 peak 측정해서 조정해야 함.

다른 세 서비스는 HTTP I/O 기반이라 메모리보다 소켓이 병목.

MySQL 스키마 — 뭘 저장하나

init.sql 기준 테이블 5개:

테이블 핵심 컬럼 유니크 제약 용도
predictions file_id, category, prediction_date, predicted_amount, lower/upper_bound (file_id, category, prediction_date) Prophet 현재월 일별 예측
baseline_predictions file_id, category, year, month, predicted_amount, training_cutoff_date (file_id, category, year, month) 11개월 과거 baseline
leak_analysis file_id, year, month, actual/predicted/leak_amount, analysis_data JSON (file_id, year, month) 월별 누수 계산 결과 + 확장 JSON
analysis_jobs job_id, file_id, status, error_message, job_metadata JSON job_id 비동기 분석 잡 추적
doojo_analysis file_id, category, year, month, min/max/current_threshold, real_amount, result (file_id, category, year, month) 두꺼비 조언 + 임계치

두 가지 관찰:

  1. 전부 file_id 중심user_id 대신. 서비스가 stateless. 로그인/세션 개념 없이 “이 파일에 대한 분석” 으로만 얘기함. 장점은 단순함, 단점은 같은 사용자가 파일 여러 개 올리면 연결 불가.
  2. JSON 컬럼을 열어뒀음analysis_data, job_metadata 둘 다 JSON. 스키마 확장 위한 탈출구. 지금은 거의 비어있음. Part 1에서 언급한 evaluation metrics 같은 걸 나중에 여기 쌓을 수 있게.

외래키는 안 걸었다. file_id 로 느슨하게 묶인 관계라 DELETE cascade 대신 application-level cleanup. 마이크로서비스 경계를 DB까지 확장 안 시킨 의도.

놓친 것 / 다음엔

분산 trace 없음

요청 하나가 Gateway → CSV-Manager → Redis → Analysis → MinIO → MySQL 을 거치는데 요청 ID가 서비스 경계를 안 탐. 각 서비스가 로컬로만 로깅. Redis lock 이슈 디버깅할 때마다 timestamp로 수동 상관관계 맞춰야 했음.

OpenTelemetry 한 번만 깔면 traceparent 헤더가 전파되면서 해결됨. 다음 이터레이션 0순위.

Service mesh 없음

Istio / Linkerd 같은 건 우리 스케일엔 오버킬. 하지만 mTLS + rate limiting + circuit breaker 3개는 필요함. 지금은 Gateway에서 단순 httpx timeout으로만 방어. 분류기나 분석기가 퍼져서 hang 되면 Gateway worker가 block 되고 연쇄 실패.

Azure MySQL SSL verify 꺼둠

.env.exampleMYSQL_SSL_VERIFY=false 가 기본. 인증서 체인 설정이 귀찮아서 그랬던 건데 프로덕션에서 이 상태로 나가면 중간자 공격 노출. Part 4의 “아직 남은 TODO” 에서 다룸.

로컬/프로덕션 동일 구성

Docker Compose 파일이 로컬 개발용인데 프로덕션에서도 사실상 같은 구성으로 돈다. k8s 매니페스트 없음. 단일 노드 + compose는 “한 번에 하나 이상 떠있는 리플리카” 를 전제로 한 락/카운터 로직을 못 함. 지금 코드는 단일 인스턴스 전제가 섞여있어서 수평 스케일 바로 안 됨 (예: RedisClient singleton의 in-memory jobs dict).

관측성

Prometheus exporter 없음, 로그도 stdout 이후 stream 처리 안 함. /api/ai/health 가 유일한 dashboard. “지금 분석 큐 몇 개 쌓였나?” 를 물어볼 수 있는 엔드포인트도 없다.

다음 읽을거리

  • Part 1 — Prophet 기획: 이 글의 Analysis 서비스 안쪽
  • Part 3 — GPT Classifier 기획: Classifier 서비스 안쪽
  • Part 4 — 운영 회고: 이 아키텍처를 돌리면서 터진 문제들
  • Prophet 논문 리뷰: 왜 Prophet 자체가 “제품 설계” 에 가까운 논문인가
  • IoT 파이프라인 회고: “처음엔 단순했다 → 왜 복잡해졌나” 같은 리듬의 앞선 회고