26년 반기를 돌아보면서
추천 엔진 제작기 05: Cloud Run 리소스 정리와 production readiness
앞선 글에서 recommendation-service를 GCP Cloud Run에 올리는 과정을 정리했다.
이번 글은 그 다음 질문이다.
Cloud Run에 이것저것 떠 있는데, recommendation-service 관점에서 무엇이 진짜 runtime이고 무엇이 operational job인가?
이 질문은 비용 문제처럼 보이지만, 실제로는 production readiness 문제다. 어떤 리소스를 삭제해도 되는지 판단하려면 먼저 그 리소스가 “서비스의 online path”에 있는지, “rebuild/maintenance path”에 있는지 구분해야 한다.
recommendation-service 기준 Cloud Run 리소스
현재 recommendation-service 관점에서 중요한 리소스는 크게 두 종류다.
online service
operational job
서비스는 계속 요청을 받아야 한다. Job은 필요할 때만 실행하면 된다.
| 이름 | 종류 | recommendation runtime에 필요? | 설명 |
|---|---|---|---|
recommendation-service | Cloud Run service | yes | Flutter/gateway가 호출할 gRPC API |
recommendation-qdrant-staging | Cloud Run service | yes, 현재 구조에서는 | vector retrieval index |
recommendation-qdrant-rebuild-staging | Cloud Run job | no, 상시 실행 불필요 | PostgreSQL vector에서 Qdrant 재구축 |
recommendation-migrate-staging | Cloud Run job | no, 상시 실행 불필요 | Alembic migration |
recommendation-seed-staging | Cloud Run job | no, 상시 실행 불필요 | beverage seed promotion |
recommendation-catalog-audit-staging | Cloud Run job | no, 상시 실행 불필요 | catalog audit |
recommendation-beverage-smoke-staging | Cloud Run job | no, 상시 실행 불필요 | staging recommendation smoke |
그래서 Cloud Run 화면에서 recommendation 관련 리소스를 정리할 때 기준은 단순하다.
service = 온라인 트래픽 경로라면 유지
job = 재생성 가능하고 지금 실행 중이 아니라면 삭제 가능
왜 Qdrant service는 아직 지우면 안 되는가
Qdrant는 canonical store가 아니다. 이 원칙은 계속 유지해야 한다.
PostgreSQL = canonical recommendation-owned state
Qdrant = rebuildable derived vector index
그렇다고 Qdrant를 아무 때나 삭제해도 된다는 뜻은 아니다. 현재 deployed recommendation-service는 Qdrant URL/API key secret을 설정하고 있고, vector retrieval path에서 Qdrant를 사용할 수 있다.
따라서 Qdrant를 지우려면 먼저 둘 중 하나를 해야 한다.
- recommendation-service가 PostgreSQL-only fallback으로 동작하도록 확인한다.
- Qdrant 의존 설정을 제거/비활성화한 새 revision을 배포한다.
그 전에는 삭제보다 cost control이 맞다.
gcloud run services update recommendation-qdrant-staging \
--project on-the-block-2026 \
--region asia-northeast3 \
--min-instances=0
이렇게 하면 idle 비용을 줄이면서도 필요할 때 다시 cold start로 올라올 수 있다.
Rebuild job은 왜 삭제해도 되는가
recommendation-qdrant-rebuild-staging 같은 job은 계속 떠 있는 service가 아니다. 필요할 때 실행해서 Qdrant collection을 다시 만드는 도구다.
Qdrant rebuild의 source는 Qdrant가 아니라 PostgreSQL이다.
Q =
rebuild(PG_{vectors}, vector\_schema\_version)
여기서:
Q: Qdrant collection statePG_vectors: PostgreSQL에 저장된 canonical recommendation vectorsvector_schema_version: vector schema version
즉 Qdrant rebuild job 자체는 다시 만들 수 있어야 한다. 그래서 job은 disposable이어도 된다.
삭제해도 되는 경우:
- 현재 실행 중이 아니다.
- 같은 job을
scripts/deploy/gcp-run-staging-job.sh로 다시 만들 수 있다. - PostgreSQL에 canonical vectors가 남아 있다.
- Qdrant를 다시 만들 runbook이 있다.
삭제하면 안 되는 경우:
- 지금 실행 중이다.
- job execution log를 아직 분석 중이다.
- script와 image source가 사라져 재생성이 어렵다.
- Qdrant가 canonical처럼 잘못 쓰이고 있다.
정상적인 구조라면 마지막 경우는 없어야 한다.
recommendation-service의 online path
Flutter가 추천을 요청할 때의 online path는 다음이다.
Flutter
-> gateway or direct staging gRPC
-> recommendation-service
-> auth-service ValidateToken
-> PostgreSQL profile/catalog/log tables
-> Qdrant vector index
-> PostgreSQL hydration/logging
여기서 online request에 들어가는 핵심 RPC는 네 개다.
rpc GetProfileStatus(GetProfileStatusRequest)
returns (GetProfileStatusResponse);
rpc GetBeverageRecommendations(GetBeverageRecommendationsRequest)
returns (GetBeverageRecommendationsResponse);
rpc GetVenueRecommendations(GetVenueRecommendationsRequest)
returns (GetVenueRecommendationsResponse);
rpc RecordRecommendationEvent(RecordRecommendationEventRequest)
returns (RecordRecommendationEventResponse);
Flutter 입장에서는 먼저 GetProfileStatus를 호출해야 한다.
왜냐하면 설문이 끝났어도 recommendation profile이 아직 생성되지 않았을 수 있기 때문이다.
가능한 상태:
missing
pending_generation
active
stale
failed_generation
추천 UI는 active가 아니면 바로 추천 리스트를 보여주면 안 된다. 대신 설문 완료 대기, profile 생성 중, 재시도 같은 상태를 보여줘야 한다.
production readiness를 수식으로 보면
추천 서비스의 readiness는 “서버가 떠 있다”가 아니다.
다음 조건이 동시에 맞아야 한다.
Ready =
H_{grpc}
\land A_{auth}
\land P_{profile}
\land C_{catalog}
\land V_{vector}
\land L_{logging}
각 항의 의미:
H_grpc: gRPC health servingA_auth: auth-service token validation 통과P_profile: safe user의 active profile 존재C_catalog: beverage catalog seed/audit 통과V_vector: PostgreSQL vector와 Qdrant index rebuild 통과L_logging: recommendation request/result/event logging 정상
현재 Plan 012에서 이미 통과한 것:
H_grpc = pass
C_catalog = pass
V_vector = pass
auth public key smoke = pass
invalid bearer rejection = pass
아직 남은 것:
A_auth with safe JWT
P_profile from deployed survey result
L_logging through RecordRecommendationEvent
즉 현재 상태는 health-ready이지 product-ready는 아니다.
acceptance runner가 필요한 이유
단일 smoke command를 여러 개 수동으로 치면 실수하기 쉽다. 특히 safe survey user와 safe auth token이 같은 user인지 확인해야 한다.
그래서 Plan 012에는 guarded runner를 만들었다.
PLAN012_SAFE_SURVEY_EXTERNAL_USER_ID=<safe-user-id> \
SMOKE_AUTH_BEARER_TOKEN=<safe-staging-token-for-same-user> \
SMOKE_GRPC_TIMEOUT_SECONDS=30 \
PLAN012_ALLOW_PROFILE_WRITE=1 \
PLAN012_ALLOW_EVENT_WRITE=1 \
GCP_PROJECT=on-the-block-2026 \
bash scripts/deploy/gcp-run-plan-012-acceptance.sh
이 runner가 강제로 확인하는 것:
- auth-service가 token을 expected user로 resolve하는가
- survey-service의
SurveyResult도 같은 user인가 - adapter가 derived profile을 생성하는가
- recommendation-service가 active profile을 보고 beverage recommendation을 반환하는가
- 첫 번째 결과에 impression event를 기록할 수 있는가
여기서 PLAN012_ALLOW_PROFILE_WRITE=1과 PLAN012_ALLOW_EVENT_WRITE=1을 요구하는 이유는 명확하다.
둘 다 recommendation-owned DB에 쓰기 때문이다.
쓰기 작업은 실수로 실행되면 안 된다. safe test user와 explicit opt-in이 필요하다.
트래픽 500-5,000명에서 보는 운영 지표
500-5,000명 규모는 대기업 추천 시스템처럼 거대하지 않다. 하지만 recommendation-service에서는 충분히 병목이 보일 수 있다.
가장 먼저 볼 지표:
| Metric | 의미 | 위험 신호 |
|---|---|---|
| p95 latency | 사용자 체감 응답속도 | 500ms/800ms 목표 초과 |
| error rate | gRPC 실패율 | 1% 이상 |
| profile missing rate | 추천 불가 user 비율 | 설문 후에도 계속 증가 |
| empty recommendation rate | 빈 추천 비율 | catalog/filter 문제 |
| Qdrant failed/pending | vector index 문제 | rebuild 필요 |
| DB pool checked out | DB connection 압박 | pool saturation |
| survey sync lag | profile 생성 지연 | 설문 후 추천 불가 |
| map snapshot age | 장소 추천 freshness | 오래된 가격/재고 |
간단한 SLO 형태로 쓰면 다음과 같다.
SLO_{bev} =
\mathbb{1}
\left[
p95_{bev} \leq 500ms
\land errorRate \leq 1\%
\land emptyRate \leq \epsilon
\right]
장소 추천은 더 많은 feature를 보므로 목표가 조금 느슨하다.
SLO_{venue} =
\mathbb{1}
\left[
p95_{venue} \leq 800ms
\land errorRate \leq 1\%
\land snapshotFreshness \geq \theta
\right]
이 수식은 정교한 모델이 아니라 운영 기준이다. 중요한 것은 목표값을 정하고 release gate와 load test에서 확인하는 것이다.
왜 아직 Redis/Kafka/Airflow를 넣지 않았는가
추천 시스템을 만들다 보면 빨리 복잡한 infra를 붙이고 싶어진다.
- Redis cache
- Kafka event stream
- Airflow batch pipeline
- separate ML serving
- feature store
하지만 지금 단계에서 이것들을 넣으면 문제가 더 어려워질 수 있다.
현재 병목은 대규모 streaming infra가 없어서가 아니다.
현재 병목:
- safe auth JWT가 있어야 end-to-end smoke 가능
- deployed survey sync RPC가 아직 없음
- safe survey user/profile gate가 남아 있음
- map snapshot deployed smoke가 아직 external dependency
- real label volume이 아직 부족함
그래서 지금 필요한 것은 더 많은 infra가 아니라 더 정확한 gate다.
논문 관점에서 보는 현재 결정
blog-kr03.mdx에서 리뷰한 “Hidden Technical Debt in ML Systems” 관점으로 보면, 이번 Cloud Run 정리는 technical debt를 줄이는 작업이다.
우리가 피한 debt:
- shared DB coupling
- Qdrant-as-canonical anti-pattern
- unversioned mapper change
- hidden auth ownership
- LLM/RAG ranker shortcut
- job과 service의 역할 혼동
추천 논문에서 자주 나오는 two-tower, DeepFM, DLRM 같은 모델은 나중에 유용할 수 있다. 하지만 지금 운영 단계에서 중요한 것은 모델 구조보다 이 질문이다.
이 추천 결과를 나중에 재현할 수 있는가?
이 결과가 어떤 profile/vector/scoring config에서 나왔는가?
문제가 생기면 deterministic ranker로 rollback 가능한가?
이 질문에 답하지 못하면 ML 모델은 성능 개선 도구가 아니라 장애 원인을 숨기는 층이 된다.
Cloud Run 정리 원칙
recommendation-service 기준으로 정리 원칙을 만들면 다음과 같다.
1. Online service는 삭제하지 않는다
recommendation-service는 Flutter/gateway online path다. 삭제 대상이 아니다.
recommendation-qdrant-staging도 현재는 online dependency다. 삭제 대신 min instance를 0으로 둔다.
2. Rebuild job은 disposable해야 한다
recommendation-qdrant-rebuild-staging 같은 job은 필요할 때 다시 만들 수 있어야 한다.
삭제 command:
gcloud run jobs delete recommendation-qdrant-rebuild-staging \
--project on-the-block-2026 \
--region asia-northeast3
이름이 실제로 recommendation-drant-rebuild-staging처럼 오타가 있다면 실제 이름을 써야 한다.
3. Secret과 DB는 job보다 훨씬 조심해야 한다
Cloud Run job은 삭제해도 다시 만들 수 있다. 하지만 Secret Manager secret이나 Cloud SQL instance는 다르다.
지우면 안 되는 것:
recommendation-db-dsn-staging
recommendation-qdrant-url-staging
recommendation-qdrant-api-key-staging
recommendation-postgres-staging
recommendation_service database
recommendation_user
이것들은 recommendation-owned state 또는 runtime configuration이다.
4. 다른 서비스 리소스는 건드리지 않는다
recommendation-service 작업 중에는 다음을 삭제하거나 수정하면 안 된다.
authorization-service
survey-service
chat-service-staging
auth-postgres
ontheblock-chat-staging
다른 서비스 owner가 따로 있다. 추천 서비스 관점에서 “필요 없어 보인다”는 이유로 지우면 안 된다.
현재 readiness를 퍼센트로 말하면
정확한 퍼센트는 조심해야 한다. 그래도 engineering readiness 관점에서 말하면, 현재 recommendation-service는 staging backend로는 꽤 많이 왔다.
대략적인 상태:
foundation/database/vector/catalog = high
deterministic beverage recommendation = high
GCP staging deployment = high
auth/survey deployed health = medium-high
end-to-end safe user profile smoke = pending
map deployed snapshot smoke = pending
real ML model serving = low by design
즉 “추천 엔진 자체”는 70% production readiness 방향에 가까워졌지만, “실제 사용자에게 안전하게 켜기”는 마지막 safe-user gate가 끝나야 한다.
개인적으로는 이렇게 말하는 게 가장 정확하다.
backend foundation: 70%+
public production recommendation: not yet
이 차이를 구분해야 한다.
다음 단계
이제 필요한 것은 큰 refactor가 아니다.
필요한 순서:
- safe survey user 또는 survey response ID 확보
- 같은 user로 resolve되는 safe auth JWT 확보
- Plan 012 acceptance runner 실행
- Flutter에서
GetProfileStatus먼저 호출 - active profile이면
GetBeverageRecommendations - 첫 노출에
RecordRecommendationEvent - 실제 interaction label을 쌓기 시작
- Plan 011의 label quality audit과 shadow model로 이동
이 순서를 지키면 recommendation-service는 다음 단계로 자연스럽게 간다.
deployed\ deterministic\ backend
\rightarrow real\ interaction\ logs
\rightarrow label\ quality\ audit
\rightarrow offline\ candidate\ model
\rightarrow shadow\ model
\rightarrow canary
정리
Cloud Run 리소스 정리는 단순 청소가 아니다.
recommendation-service에서는 다음 질문에 답하는 일이다.
- online path인가?
- rebuild path인가?
- canonical state인가?
- 다른 서비스 소유인가?
- 삭제 후 재생성 가능한가?
- 삭제가 recommendation reproducibility를 깨는가?
이 기준으로 보면 지금 정리할 수 있는 것은 rebuild job이다. 반대로 recommendation-service, recommendation-qdrant-staging, recommendation DB/secret은 아직 유지해야 한다.
그리고 Plan 012의 마지막 결론은 여전히 같다.
health check가 아니라 safe user 기반 end-to-end acceptance가 끝나야 Flutter에 추천 기능을 자신 있게 연결할 수 있다.
댓글