파이썬 AI, 웹 서버가 되다 : MSA와 FastAPI 아키텍처에 대한 고찰
지난 글에서 저는 파이썬으로 500개의 주식 데이터를 학습시키고, 학습하지 않은 주식(예: 삼성전자)도 분류해내는 예측 엔진을 완성했습니다. 여기에 포트폴리오를 유명 투자자의 페르소나와 매칭시키는 로직까지 더해졌죠.
하지만 냉정하게 말해서, 이 코드는 아직 제 노트북 터미널 안에 갇혀 있는 '연구용 스크립트'일 뿐입니다.
사용자가 스마트폰 앱에서 "내 포트폴리오 분석해줘" 버튼을 눌렀을 때, 이 파이썬 코드가 0.1초 만에 실행되어 결과를 돌려주려면 어떻게 해야 할까요?
AI 엔진을 24시간 깨어있는 웹 서버로 만들어야 합니다. 그래야 메인 서버나 클라이언트가 언제든 말을 걸고 분석을 요청할 수 있으니까요. 이번 글에서는 AI 모델을 실제 서비스 가능한 API 서버로 구축하는 과정과, 그 속에 담긴 아키텍처 설계의 고민을 이야기합니다.
MSA와 FastAPI 아키텍처에 대한 고찰
아키텍처 고민 : 이것은 MSA인가?
서버를 개발하기 전, 전체 시스템 구조를 잡아야 했습니다. Stockit의 메인 비즈니스 로직(회원가입, 게시판, DB 관리)은 Java(Spring Boot)로 작성되어 있습니다. 하지만 AI 라이브러리는 Python 생태계가 압도적입니다.
필연적으로 두 개의 서버가 필요했습니다.
- 메인 서버 (Spring Boot): 사용자 관리, DB CRUD 담당
- AI 서버 (Python): 순수 데이터 연산 및 추론 담당
처음에는 이 구조를 "MSA(Microservices Architecture)"라고 정의하려 했습니다.
서버가 분리되어 있고, HTTP 통신을 하니까요.
하지만 개발을 진행하며 냉정하게 제 프로젝트를 진단해 보았습니다.
- 서비스 독립 배포? (X) -> 메인 서버 기능과 강하게 묶여 있음
- 데이터의 독립 소유? (X) -> AI 서버는 DB가 없음
- 장애 격리? (X) -> AI 서버가 죽으면 핵심 기능 마비
엄밀히 말하면 정통 MSA는 아니었습니다.
하지만 저는 이 구조를 "MSA를 지향하는 서비스 분리 구조(Modular Architecture)"라고 정의하기로 했습니다.
비록 운영 수준의 MSA는 아니지만, 설계 철학만큼은 MSA를 따랐기 때문입니다.
첫째, 기술 스택의 분리입니다. Java는 웹/DB에, Python은 AI 연산에 강점이 있으니 각자 잘하는 일을 맡겼습니다.
둘째, 책임의 분리입니다. AI 모델은 CPU/Memory를 많이 사용하는 연산 집약적 작업입니다. 이를 메인 서버와 분리함으로써, 추후 트래픽이 몰릴 때 AI 서버만 따로 확장(Scale-out)할 수 있는 구조를 마련했습니다.
프레임워크 선택 : 왜 FastAPI인가?
파이썬 웹 프레임워크로는 Flask나 Django도 있습니다.
하지만 저는 FastAPI를 선택했습니다. 여기에는 프로젝트의 성격에 따른 명확한 이유가 있습니다.
- 구조적 적합성 (Architectural Fit) : 메인 서버가 AI 서버를 호출하면, AI 서버는 연산 후 응답만 반환하면 됩니다. AI 서버의 역할은 순수 연산 엔진 (Input -> AI Model -> Output)인데 굳이 Django가 제공하는 관리자 페이지나 ORM같은 기능은 불필요한 오버헤드였습니다
- 데이터 검증 (Pydantic) : AI 모델은 입력 데이터의 타입(float, int)에 매우 민감합니다. FastAPI는 들어오는 데이터를 엄격하게 검사해주어, 잘못된 데이터로 인한 모델 에러를 사전에 차단해줍니다.
- 비동기 처리 (Async) : 추론 요청이 동시에 들어왔을 때, 비동기 처리를 통해 높은 동시성을 효율적으로 감당할 수 있습니다.
- 자동 문서화 (Swagger) : 파이썬 코드로 입출력 타입만 정의하면, 별도의 설정 없이 서버를 켜고 /docs에 접속하면 바로 테스트 가능한 Swagger가 나옵니다. 실제로 메인 서버(Spring) 개발 시 연동 규격을 맞추는 데 큰 도움이 되었습니다.
API 설계 : 왜 조회(Read)인데 GET이 아닌 POST를 썼는가?
구현해야 할 API는 두 가지였습니다.
- 개별 주식 분석 API
- 포트폴리오 분석 API
RESTful 원칙에 따르면 데이터를 조회하는 요청은 GET 메서드를 사용하는 것이 일반적입니다. 하지만 저는 이 두 API 모두 POST 방식을 채택했습니다. 여기에는 MSA 아키텍처의 결합도(Coupling)를 낮추기 위한 치열한 고민이 담겨 있습니다.
고민 1: 누가 데이터를 가져올 것인가?
만약 GET 방식을 사용하여
GET /analyze/stock?code=005930
처럼 종목 코드만 넘긴다면 어떻게 될까요? AI 서버가 이 코드를 받아서 직접 DB에 접속해 재무제표 데이터를 조회해야 합니다.
- 문제점: AI 서버가 메인 서비스의 DB 스키마를 알고 있어야 합니다. DB 구조가 바뀌면 AI 서버 코드도 수정해야 합니다. (강한 결합)
고민 2: AI 서버의 정체성 (Lookup vs Inference)
저는 AI 서버를 'DB 조회 도구'가 아니라 '순수 연산 엔진(Pure Compute Engine)으로 정의했습니다.
마치 계산기처럼, 입력값이 들어오면 내부 로직(모델)을 통해 결과를 뱉어내기만 하면 됩니다. 데이터가 어디에 저장되어 있는지는 알 필요가 없습니다.
결론: POST를 통한 데이터 파이프라인 구축
그래서 저는 POST 방식을 택했습니다. 메인 서버(Spring)가 이미 DB에서 필요한 재무 데이터를 모두 꺼낸 뒤, 이를 JSON Body에 담아 AI 서버에 던져주는 구조입니다.
{
"stocks": [
{
"stock_code": "005930",
"market_cap": 6363611.0,
"per": 21.72,
...
}
]
}
이렇게 설계함으로써 얻은 이점은 명확합니다.
- 완벽한 Stateless: AI 서버는 Spring 서버의 DB 연결 없이 독립적으로 동작합니다. (참고: 종목명 조회를 위해 내부적으로 로컬 CSV 파일을 참조하긴 하지만, 이는 읽기 전용 정적 데이터일 뿐 외부 DB 의존성은 없습니다.)
- 확장성: 추후에 재무 데이터가 아닌 다른 데이터(예: 미국 주식)를 분석하고 싶어도 AI 서버 코드는 수정할 필요가 없습니다. 입력값만 맞춰주면 되니까요.
구현 : Stateless 서버 만들기
설계가 끝났으니 구현은 명확했습니다. 핵심은 서버가 시작될 때 모델을 로드하고, 요청이 들어오면 순수하게 연산만 수행하여 결과를 반환하는 것입니다
1. 서버 시작 시 AI 모델 로드
서버 시작 시 모델 로드 AI 모델 파일(.pkl)을 요청마다 로드하면 서비스가 매우 느려집니다. 서버가 시작될 때(Startup) 단 한 번만 메모리에 올리도록 구현했습니다.
# app/domain/portfolio_analyze/service.py
import joblib
from pathlib import Path
# ✅ 서버 시작 시 한 번만 실행 (전역 변수로 로드)
BASE_DIR = Path(__file__).resolve().parents[2]
MODEL_DIR = BASE_DIR / "ai_models"
model = joblib.load(str(MODEL_DIR / "kmeans_model.pkl")) # AI의 뇌
scaler = joblib.load(str(MODEL_DIR / "scaler.pkl")) # 번역기
2. 포트폴리오 분석 API (핵심 엔진)
이곳이 바로 AI 서버의 심장입니다. DB 조회 없이, 입력받은 데이터만으로 전처리부터 예측까지 수행하는 Stateless 로직입니다.
# app/domain/portfolio_analyze/service.py
def analyze_portfolio(request: PortfolioAnalyzeRequest):
"""
포트폴리오 분석:
사용자가 보유한 여러 주식을 종합하여 투자 성향(페르소나)을 도출
"""
# 1. 요청 데이터(DTO)를 DataFrame으로 변환
df = pd.DataFrame([{
"단축코드": stock.stock_code,
"시가총액": stock.market_cap,
"per": stock.per,
# ... (중략) ...
"투자금액": stock.investment_amount,
} for stock in request.stocks])
# 2. AI 예측 (전처리 -> 모델 추론)
feature_columns = ["시가총액", "per", "pbr", "ROE", "부채비율", "배당수익률"]
scaled_data = scaler.transform(df[feature_columns])
# 각 종목을 8개 클러스터로 분류
predicted_groups = model.predict(scaled_data)
df["group_tag"] = predicted_groups
# 3. 투자 비중 기반 스타일 벡터 계산 및 페르소나 매칭
df["비중"] = df["투자금액"] / df["투자금액"].sum()
user_style_vector = df.groupby("group_tag")["비중"].sum()
persona_results = calculate_persona_match(user_style_vector)
# 4. 결과 반환
return {
"summary": {...}, # 포트폴리오 평균 지표
"style_breakdown": [...], # 스타일별 비중
"persona_match": persona_results # 페르소나 매칭 결과
}
3. 개별 주식 분석 API (Redis 캐싱 적용)
포트폴리오 분석과 달리, 개별 주식 분석은 동일한 요청이 빈번하게 발생합니다. (예: 삼성전자는 누구나 검색함) 따라서 매번 AI 연산을 하지 않고, Redis 캐시를 적용하여 응답 속도를 최적화했습니다.
# app/domain/stock_analyze/service.py
def analyze_stock(request: StockAnalyzeRequest, use_cache: bool = True):
"""
개별 주식 분석:
단일 종목의 재무 지표를 받아 스타일 태그로 분류 (Redis 캐싱 적용)
"""
# 1. 캐시 확인 (재무 지표가 동일하면 캐시된 결과 반환)
if use_cache:
cache_key = generate_cache_key(request)
cached_result = redis_client.get(cache_key)
if cached_result:
return json.loads(cached_result)
# 2. 캐시 미스 → AI 모델 추론
# ... (데이터 프레임 변환 및 추론 로직) ...
# 3. 결과 반환 및 캐시 저장 (TTL: 1시간)
if use_cache:
redis_client.setex(cache_key, 3600, json.dumps(result))
return result
이제 배포만 남았다
이제 Stockit의 AI는 파일 입출력이나 콘솔 창을 벗어나, HTTP 요청 한 번으로 호출 가능한 살아있는 엔진이 되었습니다.
설계는 완벽해 보였습니다. 로컬 테스트도 성공적이었습니다. 하지만 이 코드를 Docker 컨테이너로 묶어 클라우드에 배포하자마자, 예상치 못한 치명적인 오류가 발생했습니다.
curl: (52) Empty reply from server
서버가 요청을 받자마자 로그도 없이 비정상 종료되는 현상을 겪었습니다.

다음 글에서는 로컬에서는 잘 되던 서버가 배포 환경에서 죽어버린 원인을 찾기 위해, "서버가 로그 없이 죽었다 : 쿠버네티스를 디버거로 쓴 사연"을 다루겠습니다.
'모의투자' 카테고리의 다른 글
| ~AI가 멍때리지 않게 감시하기 : PLG 모니터링~ 스톡잇! 개발기 #10 (1) | 2025.12.26 |
|---|---|
| ~서버가 로그 없이 죽었다 : 쿠버네티스를 디버거로 쓴 사연~ 스톡잇! 개발기 #9 (1) | 2025.12.26 |
| ~세상의 모든 주식을 분석하다 : 조회에서 예측으로~ 스톡잇! 개발기 #7 (0) | 2025.12.26 |
| ~투자 성향을 캐릭터로 만들다: 페르소나 매칭 설계~ 스톡잇! 개발기 #6 (0) | 2025.12.26 |
| ~숫자를 언어로 번역하다 : 태깅~ 스톡잇! 개발기 #5 (1) | 2025.12.26 |