Fintech 회고 (2) - 왜 4개 서비스로 쪼갰나
Published:
Fintech 회고 시리즈
- Prophet 기획
- 전체 아키텍처 기획 ← 현재
- GPT Classifier 기획
- 운영 회고
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가 파일까지 만지기 시작하면:
- CORS 헤더 + multipart 파싱 + MinIO boto3 가 Gateway에 들어옴 → Gateway 본연의 “얇은 프록시” 역할에서 벗어남
- Redis 네임스페이스가 둘이 됨 — Gateway는 라우팅용 cache, CSV 업로드용 metadata 둘 다 관리
- 파일 검증 로직 (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 컨테이너 썼음. 옮긴 이유:
-
컨테이너 재시작 시 데이터 증발 —
docker compose down -v한 번이면 분석 기록 다 날아감 - 배포마다 schema migration 반복 — 팀원이 테이블 구조 바꾸면 내 로컬 DB가 어긋남
- 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) | 두꺼비 조언 + 임계치 |
두 가지 관찰:
-
전부
file_id중심 —user_id대신. 서비스가 stateless. 로그인/세션 개념 없이 “이 파일에 대한 분석” 으로만 얘기함. 장점은 단순함, 단점은 같은 사용자가 파일 여러 개 올리면 연결 불가. -
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.example 에 MYSQL_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 파이프라인 회고: “처음엔 단순했다 → 왜 복잡해졌나” 같은 리듬의 앞선 회고