Fintech 회고 (3) - GPT Classifier, 프롬프트가 인프라가 될 때
Published:
Fintech 회고 시리즈
- Prophet 기획
- 전체 아키텍처 기획
- GPT Classifier 기획 ← 현재
- 운영 회고
Part 2 에서 본 4개 서비스 중 Classifier(8001번 포트)에 대한 이야기. “거래 내역 한 건을 받아서 13개 한국어 카테고리 중 하나로 매핑” 하는 기능인데, 이게 생각보다 단순한 문제가 아니었다.
왜 LLM?
키워드 매칭 먼저 시도해봤음. "스타벅스" 가 들어가면 카페 로. "GS25" 가 들어가면 마트/편의점 으로. 단순하고 빠르고 결정론적. 근데 dummy.csv 들여다보니 이게 안 된다는 걸 금방 알았다:
| merchant_name 예시 | 올바른 카테고리 | 키워드 매칭 결과 |
|---|---|---|
"일렉트로마트 용산점" | 생활용품 | “마트” 힛 → 마트/편의점 (오답) |
"하이마트 강남" | 생활용품 | “마트” 힛 → 마트/편의점 (오답) |
"올리브영 성수" | 패션/미용 | “마트” 없음, “영” 키워드 애매 |
"카페드롭탑 (배달)" | 카페 | 배달 앱 경유라 패턴이 불규칙 |
"미가식당 포차" | 식비 | 한번도 본 적 없는 브랜드 |
브랜드 이름 끝에 ‘마트’ 가 붙었다고 다 마트/편의점이 아님. 일렉트로마트는 가전 매장이고 하이마트도 가전. 키워드 규칙을 섬세하게 짜면 짤수록 예외 테이블이 코드보다 커지는 고전적인 dead end. 여기서 LLM을 붙이기로 했음.
13개 한국 카테고리
먼저 카테고리부터. 신용카드 명세서에서 흔히 보는 분류랑 비슷하게 13개로 잡았다.
{
"식비": ["한식", "중식", "일식", "양식", "분식", "패스트푸드", "배달음식"],
"카페": ["커피전문점", "디저트카페", "베이커리"],
"마트/편의점": ["대형마트", "편의점", "온라인마트"],
"문화생활": ["영화", "공연", "전시", "도서", "음악", "게임"],
"교통/차량": ["대중교통", "택시", "주유", "주차", "통행료", "차량유지"],
"패션/미용": ["의류", "신발", "가방", "화장품", "미용실", "네일"],
"생활용품": ["가전제품", "가구", "생필품", "주방용품"],
"주거/통신": ["월세", "관리비", "전기", "가스", "수도", "인터넷", "휴대폰"],
"건강/병원": ["병원", "약국", "건강검진", "의료용품"],
"교육": ["학원", "교재", "온라인강의", "학용품"],
"경조사/회비": ["경조사", "모임회비", "기부"],
"보험/세금": ["보험료", "세금", "연금"],
"기타": ["기타"]
}
(classifier/app/services/classifier_service.py:62-76)
카테고리 13개 × 서브카테고리 3~7개. 이 구조를 고정한 게 가장 먼저 내린 설계 결정인데, 지금 돌아보면 경계가 모호한 덩어리가 몇 개 있다.
- 카페 vs 베이커리 vs 식비 — 파리바게뜨는 카페? 빵만 샀으면 식비? 음료까지 시키면?
- 드럭스토어 (올리브영) — 화장품 팔지만 약도 팜. 패션/미용이냐 건강/병원이냐
- 생활용품 vs 마트/편의점 — 이마트에서 TV 사면 마트/편의점? 생활용품?
이 모호함이 나중에 브랜드 룰 7개의 존재 이유가 된다.
4층 구조
Classifier의 분류 로직은 4개 레이어를 차례로 통과한다.
┌─────────────────────────────────────┐
│ 1. Structured Outputs (JSON 스키마 강제)│
└────────────┬────────────────────────┘
▼
┌─────────────────────────────────────┐
│ 2. Chain of Thought (reasoning 필드) │
└────────────┬────────────────────────┘
▼
┌─────────────────────────────────────┐
│ 3. Few-shot (3개 예시) │
└────────────┬────────────────────────┘
▼
┌─────────────────────────────────────┐
│ 4. Brand Rule 후처리 (7개 결정론 규칙) │
└─────────────────────────────────────┘
프롬프트만 잘 짜면 되는 게 아니다. 이 4개가 다 버전 관리 대상 (= 코드)이다. 하나씩.
레이어 1 — Structured Outputs
OpenAI Python SDK의 client.beta.chat.completions.parse() 를 쓰면 Pydantic 모델을 response_format 으로 넘길 수 있다. LLM이 반드시 그 스키마에 맞는 JSON만 뱉게 강제.
# classifier/app/services/classifier_service.py:27-40
class TransactionClassificationWithCoT(BaseModel):
"""Internal schema - CoT 포함"""
reasoning: str # 단계별 추론 (외부 노출 X)
category: str
subcategory: str
confidence: float
class TransactionClassification(BaseModel):
"""External schema - API 응답용"""
category: str
subcategory: str
confidence: float
두 개 스키마가 있다. 내부용 (CoT 포함) 과 외부용 (reasoning 제거). 왜 나눴나:
- reasoning은 API 호출자에게 불필요 — 프론트가 받을 건 category 하나
- reasoning이 drift의 증거가 될 수 있음 — 내부 로그로만 남겨서 “왜 이렇게 분류했는지” 디버깅할 때 쓰고, 외부엔 노출 안 함
- 외부 API 스키마가 내부 LLM 출력에 종속되면 안 됨 — 나중에 모델 바꾸면 reasoning 포맷이 달라질 수 있음. 외부 스키마를 얇게 유지
이게 “프롬프트가 인프라다” 의 첫 번째 실천. LLM 출력의 형상을 코드로 고정. result.category 에 뭐가 들어올지 타입 레벨로 보장됨.
레이어 2 — Chain of Thought
reasoning 필드는 값 자체가 의미 있는 게 아니라 모델에게 “생각하고 나서 답해” 라고 시키는 장치. system prompt에 이렇게 써 있음:
## 응답 형식
- reasoning: 단계별 추론 과정 (내부용, 외부 노출 안 됨)
- category: 선택한 주 카테고리
- subcategory: 선택한 세부 카테고리
- confidence: 분류 신뢰도 (0.0 ~ 1.0)
reasoning 필드에 글을 쓰게 만들면 모델이 내부적으로 분석 과정을 거친다. 그 뒤 category 결정이 더 정확해짐 — 이게 CoT의 표준 트릭. 우리는 이 reasoning을 버리고 category/subcategory/confidence 만 쓴다.
Trade-off: 더 많은 completion 토큰을 씀. max_completion_tokens로 상한 잡혀있긴 하지만 reasoning이 길어질수록 비용 증가. 우리 케이스에선 OPENAI_MAX_TOKENS 를 settings 에서 중앙 관리하고 있어서 여기서 제어.
레이어 3 — Few-shot 3개
system prompt 뒤에 “질문/답변” 3쌍을 미리 끼워넣는다.
# classifier_service.py:109-136
[
# Q1: 스타벅스 강남점 / 4,500원
{"role": "assistant", "content":
'{"reasoning": "스타벅스는 커피 전문점 체인이며, 4,500원은 일반적인 음료 가격대입니다.
카페 카테고리가 가장 적합합니다.",
"category": "카페", "subcategory": "커피전문점", "confidence": 0.95}'},
# Q2: 올리브영 / 28,000원
{"role": "assistant", "content":
'{"reasoning": "올리브영은 화장품과 생활용품을 판매하는 드럭스토어입니다.
주로 화장품 구매로 추정되므로 패션/미용 카테고리가 적합합니다.",
"category": "패션/미용", "subcategory": "화장품", "confidence": 0.85}'},
# Q3: 이마트 / 35,000원
{"role": "assistant", "content":
'{"reasoning": "이마트는 대형마트 체인이며, 35,000원은 일반적인 장보기 금액입니다.
마트/편의점 카테고리가 적합합니다.",
"category": "마트/편의점", "subcategory": "대형마트", "confidence": 0.93}'}
]
3개로 정한 이유:
- 3개 이상은 눈에 띄는 정확도 향상이 없었다 — 5개, 7개로 늘려봤는데 context 비용 대비 정확도 개선 미미
- 경계 케이스를 대표 — 스타벅스 (명확), 올리브영 (모호, 드럭스토어), 이마트 (대형마트 vs 생활용품 혼동)
- reasoning 예시로서의 톤 통일 — 모델이 이 3개 답변 스타일을 따라서 말하게 됨
confidence 값이 0.85, 0.93, 0.95 로 다양하게 섞여있는 것도 의도. 모호한 케이스에선 낮은 confidence를 주라는 신호를 심어둠.
레이어 4 — 브랜드 룰 7개 (결정론 후처리)
LLM 출력을 그대로 믿지 않고, 결정론적 룰로 덮어쓴다. _apply_rule_based_postprocessing 함수 (classifier_service.py:138-202).
| Rule | 키워드 | 강제 카테고리 | Confidence |
|---|---|---|---|
| 1 | 스타벅스, 투썸, 이디야, 커피빈, 할리스, 파스쿠찌, 메가커피, 컴포즈 | 카페 / 커피전문점 | 0.95 |
| 2 | GS25, CU, 세븐일레븐, 이마트24, 미니스톱 | 마트/편의점 / 편의점 | 0.95 |
| 3 | 올리브영, 왓슨스, 롭스 | 패션/미용 / 화장품 | 0.90 |
| 4 | 맥도날드, 버거킹, 롯데리아, KFC, 맘스터치, 서브웨이 | 식비 / 패스트푸드 | 0.93 |
| 5 | 이마트, 홈플러스, 롯데마트, 코스트코, 트레이더스 | 마트/편의점 / 대형마트 | 0.93 |
| 6 | 하이마트, 전자랜드, 일렉트로마트 | 생활용품 / 가전제품 | 0.90 |
| 7 | amount ≥ 500,000원 AND category ∈ {마트/편의점, 기타} | 생활용품 / 가전제품 | 0.80 |
왜 LLM 뒤에 결정론 룰을 두나? LLM drift 방어.
- 모델 버전이 바뀌면 “올리브영을 건강/병원으로 분류” 같은 변덕이 나올 수 있음
- 브랜드가 명확한 경우엔 모델이 틀리면 무조건 틀린 것. 모델 판단보다 룰이 우위
- 정답이 확정된 80%를 룰로 처리하고, LLM은 모호한 20%에 집중
Rule 6이 특히 재미있음 — “하이마트” 는 이름에 ‘마트’ 가 들어가서 LLM이 마트/편의점으로 분류하기 쉬운데, 이건 가전 매장. 이걸 알아서 고치라고 LLM한테 맡기는 것보다, “이 7개 브랜드는 생활용품/가전” 을 못박아두는 게 안전.
Rule 7은 수치 기반 룰. 5만원 넘는 마트/편의점 거래는 생필품보다 가전 가능성이 높다는 휴리스틱. 실제 데이터 보면 틀릴 때도 있음 (명절 제사 장보기 등). 그래도 default가 “마트/편의점” 인 것보다 “생활용품” 쪽이 더 맞는 경우가 많아서 넣었다. 이것도 “데이터 보고 정한 감” 수준.
Confidence 값의 정체
Confidence 0~1 점수를 내보내긴 하는데, 이게 뭘 의미하는가?
| 값 | 근거 | 의미 |
|---|---|---|
| 0.95 | 브랜드 룰 확정 힛 (Rule 1, 2) | “이름이 스타벅스면 카페. 99% 확신” |
| 0.93 | 브랜드 룰 힛 (Rule 4, 5) | “맥도날드/이마트처럼 명확하지만 변종 가능성 있음” |
| 0.90 | 브랜드 룰 힛 (Rule 3, 6) | “올리브영/하이마트. 그래도 주 용도는 고정” |
| 0.85 | LLM 강한 추론 (few-shot 올리브영 톤) | “브랜드 안 명확하지만 문맥 강함” |
| 0.70 이하 | LLM 약한 추론 | “애매. 사람이 검수 필요” |
룰 기반 힛과 LLM 추론이 같은 점수 스케일을 공유한다. 이게 약간 애매한 부분. 0.95와 0.95가 있어도 하나는 “브랜드 매칭” 이고 하나는 “LLM이 자신감 있게 추론” 이다. 이 둘을 구분하려면 score source 같은 메타 필드를 하나 더 내야 함. 지금 API 스펙엔 안 들어있음.
실패 모드
# classifier_service.py:300-307
except Exception as e:
logger.error(f"Classification failed for {merchant_name}: {str(e)}")
return {
"category": "기타",
"subcategory": "기타",
"confidence": 0.0
}
전체 LLM 호출이 실패하면 “기타” 에 confidence 0.0. 정책적으로:
- 서비스 전체 장애 금지 — LLM이 죽어도 파이프라인은 돈다. “기타” 로 들어가도 거래 자체는 기록됨
- confidence 0.0 은 수동 검수 신호 — 이걸 수집해서 drift 모니터링에 쓸 수 있음 (아직 안 함)
Legacy gpt_classifier.py 파일(classifier_service.py의 이전 버전) 에는 더 세밀한 예외 분기 (RateLimitError → rule-based fallback, APITimeoutError → HTTPException 504) 가 남아있음. OpenAI 1.x 예외 이름 변경(Timeout → APITimeoutError) 은 Part 4에서.
GMS 프록시 — SSAFY 전용 GPT 엔드포인트
OpenAI API 직접 쓰는 게 아니라 SSAFY의 GMS 프록시 를 통해 GPT-5-nano 씀.
# classifier_service.py:50-53
self.client = OpenAI(
api_key=settings.GMS_API_KEY,
base_url=settings.GMS_BASE_URL # https://gms.ssafy.io/gmsapi/api.openai.com/v1
)
OpenAI Python SDK는 base_url 만 바꾸면 자동으로 그 엔드포인트로 감. SSAFY 측 장점:
- 학생 비용 관리 (중앙에서 rate limit 걸 수 있음)
- 감사 로그 (누가 얼마나 호출했는지)
- 그대로 OpenAI SDK 쓸 수 있음 (코드 변경 0)
단점은 장애 전파. GMS 프록시 자체가 내려가면 전체 서비스가 LLM 호출 불가. 실제 몇 번 장애 겪음. 실패 모드가 “기타 / 0.0” 으로 graceful degrade 하는 건 그래서임.
배치 처리
단일 엔드포인트 외에 CSV 한 장을 통째로 분류하는 배치 엔드포인트도 있음 — process_batch (classifier_service.py:309). 단순 for idx, row in df.iterrows(): 로 한 건씩 classify_single 호출. N개 건이면 N번 API 호출.
비동기 병렬화 안 했다. 이건 단순 미구현. asyncio.gather 로 5~10건씩 묶어 쏘면 10배는 빠를 것. 아직 안 한 이유는 SSAFY GMS가 concurrent rate limit을 구체적으로 공개 안 해서, 429 맞으면서 튜닝할 시간이 없었음. 다음 이터레이션 TODO.
“정확도 100%” 의 맥락
README에 “테스트 정확도 100%” 라고 적혀있다. 맥락을 솔직히 쓰면:
- “우리가 만든 테스트셋” 기준 — 약 30~50건 수준의 대표 브랜드 + 경계 케이스
- 이 테스트셋이 브랜드 룰로 커버되는 범위에 맞춰져 있음 — 룰 7개가 걸리는 케이스를 일부러 넣음
- 실제 dummy.csv 1,497 건에 대한 정확도는 측정 안 함 (ground truth 없음)
그래서 “100%” 는 수치 자체보다는 “우리가 설계한 분류 의도대로 동작한다” 는 signal에 가까움. 외부에 “production-grade 100% 정확도” 처럼 말하면 안 되는 이유.
뭘 놓쳤나 — 회고 포인트 5개
1. Drift 모니터링 없음 모델 버전이 바뀌면 (GPT-5-nano → GPT-5.1-nano 등) 같은 입력에 다른 출력이 나올 수 있다. 내가 지금 감지할 수 있는 건 “테스트셋 30개가 여전히 통과하나?” 수준. 드리프트 탐지 자동화가 있어야 함.
2. Few-shot 자동 갱신 없음 3개 예시가 하드코딩. 새로운 브랜드/케이스가 발견될 때마다 코드 수정해서 배포. 실수요가 많아지면 예시를 DB/Redis 에 두고 동적 로딩으로 가야 함.
3. 사람 검수 루프 없음 confidence < 0.7 인 분류를 사람이 확인하고 교정할 UI가 없다. Prophet 논문 의 “analyst-in-the-loop” 철학이 여기도 똑같이 필요한데, 분류기 쪽도 아직 “자동화 반쪽” 상태.
4. Confidence 값의 의미 혼재 룰 힛 0.95 와 LLM 추론 0.95 가 구분 안 됨. score_source: "rule" | "llm" 같은 필드를 API에 추가하는 게 정답.
5. 캐싱 + DL/DW 적재 파이프라인 없음 같은 가맹점/금액 조합은 분류 결과가 거의 고정인데 매번 GPT를 부른다. classify:{merchant_hash} 키로 Redis TTL 캐싱만 걸어도 중복 호출이 날아감 → 비용 절감 + latency 단축. 여기서 한 걸음 더 — TTL 만료 전에 캐시 내용을 배치로 꺼내 Data Lake (S3 parquet) / DW (BigQuery 등) 에 적재해두면, 이 분류 로그 자체가 다음 단계 학습 데이터셋이 된다. 자체 분류 모델을 fine-tune 하든 GPT를 대체하든 이 데이터가 있어야 시작됨. 지금은 GPT 호출 결과가 그때그때 MySQL에 박히고 휘발되는 구조라 “우리가 축적한 게 뭐가 있나” 를 물으면 답이 없음.
다음 읽을거리
- Part 1 — Prophet: 여기선 “예측” 을, 여기선 “분류” 를 LLM 대신 시계열 모델로
- Part 2 — Classifier 서비스가 4-서비스 구도에서 어디 있나
- Part 4 — OpenAI 1.x 예외 마이그레이션, Classifier 관련 운영 이슈
- Prophet 논문 리뷰 — “analyst-in-the-loop” 개념. 여기 분류기도 같은 구조가 필요
