앞서 다룬 FastAPI는 LLM serving을 최우선 목표로 삼은 서비스에서 파이썬의 AI 생태계를 완전히 활용할 수 있다는 점에서 최적의 기술이었다. 코드가 준비되었으니 다음은 '이 서버를 어디에 띄울 것인가'라는 물리적인 인프라 고민으로 넘어갈 차례다.
이 챕터에서는 FastAPI 기반의 Sleuth 백엔드가 어떻게 클라우드 네이티브의 이점을 활용할 수 있었는지 정리해보고자 한다.
Cloud Run과 Scale-to-Zero
초기 스타트업에게 고정 비용은 언제나 큰 부담이다. 아직 서비스 오픈을 준비 중인 트래픽이 예측 불가능하게 발생하는 상황에서, 24시간 내내 켜져 있는 GCE(Google Compute Engine) 같은 전통적인 VM 인스턴스를 유지하는 것은 리소스 낭비에 가깝다고 판단했다.
우리가 GCP의 Cloud Run을 배포 타겟으로 설정한 이유가 바로 여기에 있다. Cloud Run은 컨테이너 기반의 서버리스 환경으로, 트래픽이 들어올 때만 컨테이너를 스핀업하여 요청을 처리하고 트래픽이 0이 되면 인스턴스 수를 '0'으로 축소하는 Scale-to-Zero 기능을 지원한다. 따라서 이는 비용과 확장의 측면에서 모두 유리한 방법이라 생각했다.
비용 효율성: CPU가 실제로 연산을 수행한 시간(밀리초 단위)에 대해서만 과금된다. 새벽 시간대처럼 접속자가 없는 상황에서는 서버 유지 비용이 '0원'에 수렴한다.
무한한 확장성: (희망 사항으로 서비스가 잘 됐을 경우에) 갑작스럽게 수만 명의 유저가 몰려 플레이하더라도, Cloud Run은 최대 인스턴스 개수만 널널하다면 몇 초 만에 인스턴스를 수백 개로 자동 확장하여 트래픽을 거뜬히 방어 가능하다.
물론 이런 경우가 반복되면 GCE와 비용 효율성이 교차되는 지점이 존재하긴 하지만, 그건 그때 가서 생각해보면 되니 현 시점에서 Cloud Run이 최적이라고 생각했다.
Part 1에서 강조했듯, 우리의 FastAPI 백엔드는 세션 상태를 저장하지 않는 'Stateless'로 설계되었다. 클라이언트가 대화 내역 전체를 "Fat Payload"로 묶어 보내기 때문에, Cloud Run이 인스턴스를 무작위로 생성하거나 파기하더라도 어떤 인스턴스에 라우팅되든 완벽하게 동일한 컨텍스트를 유지할 수 있다. 인프라 레벨의 효율성을 활용하기 위한 코드 상의 설계였다.
Firestore I/O 병목과 Startup Cache
Cloud Run의 Stateless 구조는 훌륭하지만, 여기서 치명적인 성능 병목의 가능성을 마주하게 된다. 바로 '페르소나 조립을 위한 DB 호출 비용'이다.
Sleuth의 캐릭터들은 단순한 챗봇이 아니다. 수천 자에 달하는 촘촘한 페르소나, 인물 간의 관계도, 사건 당일의 알리바이까지 방대한 데이터를 Firestore의 story_list 컬렉션에 저장해두고 있다.
만약 유저가 채팅을 칠 때마다(매 API 요청마다) 백엔드가 Firestore에 접근해 이 방대한 데이터를 쿼리해야 한다면 어떻게 될까?
Latency 이슈: LLM 응답 자체도 오래 걸리는데, 매번 발생하는 네트워크 I/O(DB Read) 시간까지 더해져 유저 경험이 끔찍해진다.
비용 이슈: Firestore는 Document Read 횟수당 과금된다. 한 유저가 20번의 채팅을 주고받는다면 불필요한 DB 조회 비용이 막대하게 발생한다. (firestore를 페르소나 조립에만 활용하는 것이 아니었기에... 역시나 이 이유가 가장 컸다)
근본적인 원인을 파악했으니, 아키텍처 레벨에서 이를 우회해야 했다. 해결책은 FastAPI의 lifespan 이벤트 스펙을 활용한 Startup Cache 였다. lifespan은 아래 yield 키워드를 기준으로 이전에는 애플리케이션 시작 시 실행되는 작업, 이후에는 종료 시 실행되는 작업을 정의한다.
# app/main.py
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Starting up: preloading story_list cache from Firestore...")
try:
# 1. 애플리케이션(컨테이너) 구동 시 최초 1회만 Firestore 전체 활성 스토리 쿼리
await load_story_data_from_firestore()
logger.info("Startup complete. %s personas cached", len(PERSONA_CACHE))
except Exception as e:
logger.error("Failed to preload cache: %s", e)
yield
# Shutdown logic ...즉. Cloud Run 컨테이너가 처음 활성화되는 Cold Start 시점에, 애플리케이션이 유저의 HTTP 요청을 받기 직전 lifespan 라이프사이클을 통해 Firestore에 단 한 번 접근한다. 활성화된 모든 캐릭터의 데이터를 긁어와 서버의 RAM 영역에 전역 딕셔너리(PERSONA_CACHE, GRADING_RULE_CACHE) 형태로 저장해둔다.
이후 발생하는 수많은 /chat/send 요청들은 DB를 거치지 않고 오직 메모리에서 데이터를 꺼내 해당 용의자에 해당하는 프롬프트를 적용하여 레이턴시와 DB 읽기 비용을 극적으로 낮췄다.
Cold Start 문제와 Client-Side Warm-up
Lifespan 캐싱으로 DB 병목은 해결했지만, 여전히 컨테이너가 깨어나는 물리적 시간 자체는 5~7초가 소요된다. 유저가 채팅을 입력하고 답변을 받는데도 5초라면... (+ 이전의 캐싱 시간도 2~3초 소요) 첫 답변을 받기까지 10초 넘는 시간이 걸리므로 시작도 전에 이탈할 가능성이 컸다.
| First message sent when there are no instances |
이 절대적인 문제 어떻게 극복할까?
물론 min-instance를 1로 설정하면 이런 문제가 없지만, 그렇게 되면 Cloud Run을 선택한 가장 큰 이유인 '비용'이 다시 원점으로 돌아가게 된다. 따라서 관점을 돌려 프론트엔드(Client)와 사용자 경험(UX)의 영역에서 해답을 찾기로 했다.
생각해보면 사용자가 앱을 켜자마자 0.1초 만에 채팅을 입력하는 일은 없다. 게임을 시작하면 시나리오를 읽고, 사건 개요를 파악하고, 채팅 UI에 진입하는 텀이 존재한다.
나는 이 틈을 노렸다. 사용자가 게임 시나리오 화면에 진입하는 순간, 클라이언트 단에서 비동기적으로 백엔드의 /health 엔드포인트로 Warm-up(예열) 요청을 날리도록 구성했다.
| Warming-up in advance |
# app/main.py
@app.get("/health")
async def health_check():
"""Health check endpoint for Cloud Run."""
return {
"status": "healthy",
}결과는 성공적이었다. 사용자가 시나리오를 읽는 5~10초의 시간 동안, 백엔드에서는 이미 Cloud Run 컨테이너가 스핀업되고 Firestore의 데이터를 메모리에 캐싱하는 작업(Lifespan)이 완료된다. 사용자가 화면을 넘겨 용의자에게 첫 메시지를 보낼 즈음에는, 서버가 이미 완벽하게 예열된 상태로 요청을 즉각적으로 처리한다.
| First message sent after the instance had warmed-up |
마치며
이번 아키텍처 전환과 인프라 최적화를 진행하며 느낀 점은, 훌륭한 엔지니어링이란 단순히 최신 기술을 덕지덕지 바르는 것이 아니라는 것이다.
스타트업이 처한 한정된 자원이라는 제약 속에서, Cloud Run과 같은 클라우드 네이티브 기술로 비용을 최적화하고, 인메모리 캐싱으로 성능을 끌어올렸다. 그리고 기술만으로는 해결할 수 없는 물리적인 지연 시간의 한계는 UX의 흐름을 이용한 Client Warm-up 설계로 매끄럽게 가려냈다.
복잡한 문제를 작게 쪼개고, 서버의 로우 레벨부터 클라이언트의 사용자 경험까지 관점을 넘나들며 해결책을 찾아가는 과정. 오늘도 이 치열한 고민으로 Sleuth를 더욱 견고하고 스케일러블한 실제 프로덕트로 만들어가고 있는 중이다.
추천글
[스타트업/기술] FastAPI와 클라우드 네이티브 | 클라이언트 LLM 호출의 한계와 백엔드 전환기 (Part 1)