Part 1에서는 클라이언트가 직접 LLM API를 호출하던 구조의 한계를 진단하고, Python/FastAPI 기반의 Stateless 서버로 전환한 배경을 다루었다. 이제 다음으로 했던 고민은 "이 서버를 어디에 띄우고 어떻게 운영할 것인가"이다.
극초기 팀에게 매달 나가는 고정 인프라 비용은 무시하기 어렵다. 아직 정식 런칭 전이라 접속자가 불규칙한 상황에서, 24시간 내내 켜져 있는 GCE(Google Compute Engine) 가상 머신을 유지하는 것은 낭비라고 생각했다. 추리 그리고 콘텐츠의 특성 상 한번 플레이하면 재플레이를 하는 경우가 많이 없는데, 우린 절대적인 콘텐츠의 수도 적은 상황이라 리텐션을 기대하기 어려웠다.
그래서 이 글에는 FastAPI 백엔드를 GCP Cloud Run에 배포하게 된 과정과 발생한 문제들을 정리해보았다.
1. Cloud Run과 Scale-to-Zero 선택
배포 인프라로 GCP의 Cloud Run을 선택한 가장 큰 이유는 Scale-to-Zero 기능 때문이었다.
Cloud Run은 컨테이너 기반 서버리스 환경으로, 요청이 들어올 때만 컨테이너를 실행해 처리하고 트래픽이 없으면 인스턴스를 0개로 줄인다. CPU가 실제로 연산을 수행한 시간(밀리초 단위)에 대해서만 과금되기 때문에, 사용자가 없는 새벽 시간대에는 서버 유지 비용이 0원에 수렴한다.
Part 1에서 백엔드를 세션 상태를 저장하지 않는 'Stateless'로 설계했던 이유도 여기에 있었다. 클라이언트가 매 요청 대화 이력 전체를 묶어서 보내는 Fat Payload 구조를 취했기 때문에, Cloud Run이 인스턴스를 임의로 생성하거나 파기하더라도 어떤 컨테이너로 라우팅되든 완벽히 동일한 응답을 낼 수 있었다. 인프라의 서버리스 특성을 살리는 준비였다.
2. 문제 1: Firestore I/O 병목과 Startup Cache
하지만 무상태 서버리스 구조를 프로덕션에 올리자마자 성능 병목이 나타났다. 바로 캐릭터 페르소나와 게임 규칙 데이터를 읽어오는 DB 호출 비용이었다.
Sleuth의 용의자 캐릭터들은 단순한 챗봇이 아니다. 수천 자에 달하는 상세한 성격, 사건 당일의 알리바이, 각 용의자 인물들과의 관계에 대한 기록까지 방대한 데이터가 DB 컬렉션에 저장되어 있다.
만약 유저가 채팅을 칠 때마다(매 API 요청마다) 백엔드가 Firestore에 접근해 이 데이터를 조회한다면 두 가지 문제가 발생한다.
- 지연 시간 가중: LLM 응답 자체도 2~5초가 걸리는데, 매번 외부 DB 네트워크 I/O(200~400ms)가 더해져 응답 속도 손실이 생긴다.
- Firestore 읽기 비용 누적: Firestore는 Document Read 횟수당 과금된다. 한 유저가 한 사건에서 20턴 동안 대화를 나누면, 대화 한 번마다 최소 1회 이상의 스토리 문서 읽기가 발생한다. 유저 수가 늘어날수록 불필요한 DB 비용이 선형으로 증가하게 된다.
| 초기 팀에게 중요한 무료 할당량 |
스토리 데이터는 게임 진행 중에 실시간으로 내용이 바뀌는 데이터가 아니라, 한 번 발행되면 수정 빈도가 낮은 읽기 전용 데이터였다. 매번 DB를 조회할 이유가 없었다.
우리는 FastAPI의 lifespan 컨텍스트 매니저를 활용해 Startup Cache를 구축했다.
# app/main.py
@asynccontextmanager
async def lifespan(app: FastAPI):
# 1. 컨테이너 시작 시 Firestore에서 활성 스토리 목록 사전 로드
logger.info("Starting up: preloading story cache from Firestore...")
try:
await preload_story_cache()
logger.info("Startup complete. Personas cached successfully.")
except Exception as e:
logger.error("Failed to preload cache: %s", e)
yield
# 2. 컨테이너 종료 시 정리 작업
logger.info("Shutting down container...")
Cloud Run 컨테이너가 처음 부팅되는 시점에, 애플리케이션이 유저의 HTTP 요청을 받기 전에 Firestore에서 전체 활성 스토리 데이터를 한 번만 읽어온다. 그리고 이를 서버 프로세스의 메모리 딕셔너리(PERSONA_CACHE, GRADING_RULE_CACHE)에 저장해 둔다.
이 작업 덕분에 이후 들어오는 모든 /v1/chat/send 요청은 Firestore를 거치지 않고 메모리에서 즉시 페르소나를 꺼내 프롬프트를 조립할 수 있었다. 매 턴마다 발생하던 DB I/O 지연과 읽기 비용을 완전히 0으로 만들었다.
3. 첫 번째 캐시의 한계: 배포 커플링과 Self-Healing 캐시
하지만 단순히 부팅 시점에 1회 로드하는 Startup Cache는 운영 환경에서 곧바로 한계에 부딪혔다.
신규 스토리 배포가 백엔드 재배포에 커플링되는 문제였다.
자체제작 도구(26년 6월 이후 추가)에서 기획자가 새로운 추리 스토리를 완성해 Firestore에 발행하더라도, 이미 떠 있는 Cloud Run 인스턴스들은 부팅 시점의 스냅샷 데이터만 들고 있었다. 결과적으로 유저가 신규 에피소드에 접속해 대화를 시도하면, 컨테이너 메모리에 해당 캐릭터가 없어 404 Not Found 에러가 발생했다. 미국에 있을 때 처음으로 신규 스토리를 추가할 일이 있었는데, 이 때문에 새벽부터 강제로 장애 대응을 했어야 했다.
다행히도 인스턴스 종료 후 재배포로 해결했었지만, 새 스토리가 발행될 때마다 Cloud Run 서비스를 수동으로 재배포하거나 인스턴스를 강제로 죽여 캐시를 갱신하는 것은 정상적인 운영 방식이 아니었다.
이 문제를 해결하기 위해 캐시 시스템을 세 단계로 보완했다.
[자가 치유 캐시 구조]
Client Request ──> Cache Check (Memory)
│
┌───────────────┴───────────────┐
│ (Hit) │ (Miss)
v v
Return Persona asyncio.Lock 획득
│
Firestore 1회 지연 적재 (Lazy Load)
│
단일 Writer (apply_story_document)
│
Memory Cache 갱신 후 반환
1) 단일 Writer (apply_story_document)
메모리 캐시 딕셔너리를 수정하는 경로가 여러 곳에 흩어져 있으면 상태가 꼬이기 쉽다. 페르소나, 채점 규칙, 스토리 활성 상태 등 모든 인메모리 딕셔너리의 갱신을 apply_story_document라는 단일 함수로 일원화했다.
2) 지연 적재 (Lazy Load)와 asyncio.Lock
부팅 시 캐시에 없던 신규 스토리 ID로 요청이 들어오면, 무조건 404를 내는 대신 Firestore에 단 1회 조회를 시도하도록 했다. 이때 동시에 여러 요청이 들어와 동일한 문서를 중복 조회하는 Cache Stampede 현상을 막기 위해, asyncio.Lock을 걸어 하나의 코루틴만 DB를 조회하고 대기 중이던 다른 요청들은 갱신된 메모리 캐시를 즉시 읽어가도록 설계했다.
3) Story Studio 연동 리로드 웹훅 (POST /internal/cache/reload)
제작 툴에서 스토리 발행이 완료되면 내부 시크릿 헤더를 포함해 백엔드의 내부 리로드 엔드포인트를 호출하도록 연결했다. 이 요청을 받은 인스턴스는 백그라운드에서 변경된 문서를 즉시 메모리에 반영한다.
이 세 가지 장치를 통해, 백엔드를 재배포하지 않고도 새로운 콘텐츠가 즉시 서빙되는 자가 치유(Self-Healing) 구조를 완성했다.
4. 문제 2: 초기 10초의 Cloud Run의 한계
캐싱으로 DB 병목을 잡았지만, 여전히 서버리스의 근본적인 물리적 한계가 남아 있었다. 바로 콜드스타트(Cold Start)였다.
인스턴스가 0개인 상태에서 첫 번째 유저의 요청이 들어오면 다음과 같은 지연이 순차적으로 발생했다.
- Cloud Run 컨테이너 인스턴스 할당 및 Docker 이미지 기동: 5~7초
- FastAPI 애플리케이션 초기화 및
lifespan캐시 적재: 2~3초 - LLM API 호출 및 첫 번째 토큰 생성: 1~2초
결과적으로 첫 번째 유저가 메시지를 보내고 답변을 받기까지 10초가 넘는 지연이 발생했다. 모바일 게임에서 첫 화면에 진입해 질문을 던졌는데 10초 이상 아무 반응이 없다면 대다수의 유저는 앱이 멈춘 것으로 생각하고 이탈할 것이 분명했다.
| First message sent when there are no instances |
가장 단순한 해결책은 Cloud Run의 최소 인스턴스(min-instances)를 1로 올려 상시 켜두는 것이다. 하지만 그렇게 하면 월 최소 15,000~20,000원 이상의 고정비가 발생하며, 트래픽이 없을 때 비용을 아끼기 위해 Cloud Run을 선택했던 원래의 취지가 무색해졌다.
5. 해결: Client-Side Warm-up과 사용자 경험(UX) 설계
기술만으로 인스턴스 부팅에 걸리는 물리적인 시간 자체를 0초로 만드는 것은 불가능했다. 그래서 관점을 돌려 모바일 클라이언트(Flutter)의 사용자 행동 흐름(UX)에서 해법을 찾았다.
유저가 앱을 켜자마자 0.1초 만에 용의자에게 채팅을 전송하는 일은 없다. 실제 유저의 행동 패턴을 관찰해 보면 다음과 같은 단계가 존재했다.
[유저의 실제 진입 플로우]
사건 목록 선택 ──> 사건 개요 및 인트로 텍스트 읽기 (5~10초 소요) ──> 용의자 선택 ──> 첫 메시지 전송
│
└── [이 틈에 백그라운드 /health 웜업 호출!]
유저가 사건 개요 화면에 진입해 텍스트를 읽기 시작하는 그 5~10초의 시간을 활용하기로 했다.
클라이언트는 사건 개요 화면에 진입하는 즉시, 비동기(Background)로 백엔드의 /health 엔드포인트에 가벼운 HTTP 요청을 날리도록 구성했다.
# app/main.py
@app.get("/health")
async def health_check():
"""Cloud Run 인스턴스 예열 및 헬스 체크 엔드포인트"""
return {"status": "healthy"}
유저가 화면의 사건 배경 설명을 천천히 읽는 5~10초 동안, 백엔드에서는 이미 Cloud Run 컨테이너가 깨어나고 lifespan을 통해 스토리 데이터를 메모리에 캐싱해 둔다. 유저가 인트로를 다 읽고 용의자 심문 화면으로 넘어가 첫 메시지를 입력할 즈음에는, 컨테이너가 이미 완전히 예열된 상태가 된다.
실제 측정 결과, 유저의 첫 메시지 요청은 콜드스타트 없이 2~5초 안팎의 LLM 생성 시간만 거친 뒤 즉시 응답되었다. 비용을 한 푼도 추가하지 않고 클라이언트 UX와 서버 라이프사이클을 맞물려 체감 지연을 해결하였다.
| First message sent after the instance had warmed-up |
6. 웜업이 실패하는 경우: 엣지 케이스와 방어
하지만 모든 유저가 개발자의 의도대로 행동하지는 않는다. 실무 환경에서는 웜업 설계가 어긋나는 두 가지 예외 상황이 발생할 수 있었다.
1) 사용자가 인트로를 1초 만에 스킵하는 경우
사건을 이미 플레이해 본 유저이거나 성격이 급한 유저는 인트로 화면이 뜨자마자 '건너뛰기' 버튼을 연타해 1초 만에 채팅창으로 진입할 수 있다. 이 경우 웜업 요청이 아직 처리 중이므로 첫 메시지에 지연이 노출될 수밖에 없다.
이를 보완하기 위해 클라이언트 UI에 방어선을 두었다.
- 메시지를 전송했을 때 서버 응답이 10초 이상 지연되면, "응답이 늦어지고 있어요. 잠시후 다시 시도해주세요."와 같은 맥락에 맞는 로딩 인디케이터와 스켈레톤 애니메이션을 띄웠다.
- 기술적 지연을 실제 사람과의 채팅처럼 자연스러운 연출로 녹여내어, 사용자가 시스템 멈춤이 아닌 캐릭터의 반응 대기로 인식하도록 유도했다.
2) 웜업 요청 자체가 네트워크 문제로 유실되는 경우
모바일 환경에서는 지하철이나 엘리베이터 등 음영 지역에서 네트워크 요청이 실패할 수 있다.
이때 웜업 요청이 실패했다고 해서 클라이언트 앱의 화면 진입을 막아서는 안 된다. 따라서 클라이언트의 웜업 호출은 철저히 메인 스레드와 격리된 unawaited 비동기 요청으로 날렸고, 실패하더라도 에러 팝업 없이 조용히 무시되도록 했다. 웜업이 실패하면 유저의 첫 메시지가 직접 컨테이너를 깨우는 일반 요청으로 자연스럽게 폴백되도록 설계했다.
7. 인메모리 상태의 부작용: 장기 가동 시 메모리 누수와 백그라운드 GC
lifespan을 활용한 인메모리 구조는 속도를 비약적으로 높여주었지만, 또 다른 관리 포인트를 만들어냈다. 바로 장기 실행 인스턴스에서의 메모리 누수 위험이었다.
백엔드에는 스토리 캐시뿐만 아니라, 클라이언트의 악의적인 요청을 막기 위한 인메모리 가드레일(IP/유저별 Rate Limit, 30초 내 중복 요청을 막는 Idempotency 키 딕셔너리, 반복 쿼리 감지 deque)이 함께 메모리에 상주하고 있었다.
트래픽이 꾸준히 유지되어 인스턴스가 며칠 동안 죽지 않고 살아남을 경우, 메모리에 만료된 멱등성 키와 IP 기록이 계속 쌓여 컨테이너 메모리가 점진적으로 증가하는 문제가 생길 수 있었다.
그렇다고 매 요청이 들어올 때마다 딕셔너리를 순회하며 만료된 키를 지우는 것은, 핫 패스(Hot path)의 요청 처리 시간을 늦추는 원인이 된다.
우리는 이 문제를 해결하기 위해 lifespan 내에서 동작하는 비동기 백그라운드 GC 루프(run_gc_loop)를 구축했다.
# app/domain/guardrails/service.py
async def run_gc_loop(interval_seconds: int = 60):
"""요청 처리 경로 밖에서 주기적으로 만료된 캐시 키를 정리하는 루프"""
while True:
try:
await asyncio.sleep(interval_seconds)
now = time.time()
# await 없이 단일 이벤트 루프 내에서 동기적으로 원자적 정리 수행
clean_expired_idempotency_keys(now)
clean_expired_rate_limit_buckets(now)
except asyncio.CancelledError:
break
except Exception as e:
logger.error("Error in guardrails GC loop: %s", e)
이 백그라운드 태스크는 유저의 요청 처리 경로 밖에서 60초마다 한 번씩 조용히 깨어나, 만료 시간이 지난 키들을 딕셔너리에서 제거한다.
파이썬의 asyncio 이벤트 루프 특성상 await가 없는 딕셔너리 키 삭제 작업은 원자적(Atomic)으로 빠르게 끝나므로, 별도의 스레드 락(Lock) 없이도 메인 요청 핸들러와 충돌하지 않고 안전하게 메모리를 일정 수준 이하로 유지할 수 있었다.
8. 실측과 검증 결과
인메모리 캐싱과 Client-Side Warm-up을 적용한 후 측정한 결과는 다음과 같았다.
| 측정 항목 | 적용 전 (기본 Cloud Run + Firestore) | 적용 후 (Startup Cache + Warm-up) | 비고 |
|---|---|---|---|
| 컨테이너 기동 후 첫 요청 지연 | 약 9~11초 (컨테이너 6s + DB 2s + LLM 3s) | 약 2~5초 (LLM 응답 시간만 소요) | 웜업 성공 시 체감 지연 80% 단축 |
| 대화 1턴당 Firestore 읽기 횟수 | 매 턴 1회 이상 (20턴 대화 시 20회+) | 0회 (부팅 시 1회 로드 후 메모리 참조) | 대화 중 발생하는 DB 비용 0원 |
| 신규 스토리 발행 반영 시간 | 백엔드 수동 재배포 필요 (수 분 소요) | 웹훅 및 지연 적재로 즉시 반영 (1초 이내) | 자가 치유 캐시 구조로 해결 |
| 상시 인프라 유지 비용 | min-instances=1 시 월 1.5~2만 원 이상 | 0원 (Scale-to-Zero 온전히 유지) | 새벽 유휴 시간 비용 0원 |
9. 달라진 점
정량적 변화
- 첫 메시지 체감 지연 시간을 10초에서 1초대로 압축하여, 게임 시작 단계에서의 사용자 이탈 요인을 차단했다.
- 대화 세션 중 발생하는 Firestore Document Read 호출을 완전히 제거하여, 유저가 아무리 긴 대화를 나눠도 DB 비용이 증가하지 않는 구조를 만들었다.
- 고정 인프라 비용을 추가하지 않고 서버리스의 비용 절감 효과를 유지했다.
정성적 변화
- 기술적인 지연 문제를 무조건 서버 인프라 스펙을 높여 돈으로 해결하는 대신, 클라이언트의 유저 동선과 결합하여 비용 효율적으로 해결하였다.
- 단순 인메모리 캐싱을 넘어, 프로덕션 배포 파이프라인과 맞물린 캐시 무효화 및 지연 적재(Lazy load) 설계를 통해 운영 안정성을 확보했다.
10. 설계 원칙
Cloud Run이나 FastAPI를 쓰지 않는 환경이더라도, 서버리스 환경에서 인메모리 최적화를 고민할 때 유효한 세 가지 원칙이다.
- 인프라 비용과 코드 아키텍처의 정합성: 서버리스 환경의 Scale-to-Zero를 온전히 누리려면, 서버 코드가 상태를 쥐지 않는 무상태(Stateless)로 작성되어야 한다. 인프라의 강점을 살리기 위한 코드 레벨의 설계가 선행되어야 한다.
- 캐시 라이프사이클의 자가 치유성: 인메모리 캐시를 도입할 때는 "캐시를 어떻게 채울 것인가"보다 "운영 중에 데이터가 변경되었을 때 어떻게 재배포 없이 갱신할 것인가"를 먼저 설계해야 한다. 지연 적재와 단일 writer 패턴은 분산 환경에서 캐시 불일치를 막는 좋은 도구다.
- UX적 관점에서의 해결책: 인프라 레벨에서 단축할 수 없는 물리적 지연(컨테이너 부팅, 모델 추론 등)은 클라이언트의 유저 행동 흐름을 이용한 사전 예열(Warm-up)이나 맥락에 맞는 로딩 연출로 가리는 것이 가장 비용 효율적인 엔지니어링이다.
11. 마치며
엔지니어링이란 단순히 기술적으로 완벽한 시스템을 만드는 것이 아니라, 주어진 자원과 비용의 제약 안에서 비즈니스 요구사항을 만족시키는 균형점을 찾아가는 과정이라고 생각한다.
고정비를 아끼기 위해 선택한 Cloud Run이었기에 10초의 콜드스타트라는 대가를 치러야 했지만, 그 대가를 무작정 서버 스펙을 올려서 때우기보다는 캐싱 라이프사이클과 사용자 경험의 틈새를 공략하여 해결했다. 이 과정에서 떠올렸던 생각들을 바탕으로 앞으로 엔지니어링을 하는데 있어 우선 상황을 파악하고 그 상황에서의 최적의 솔루션을 찾는데 집중해야겠다.
추천글
[스타트업/기술] FastAPI와 클라우드 네이티브 | 클라이언트 LLM 호출의 한계와 백엔드 전환기 (Part 1)