Wintersalmon | Blog

서버리스는 게임에 맞는 형태가 아니었습니다: Knative 함수 17개에서 Go 서버 4개로

5 min read

서버리스는 정상 상태의 게임 트래픽에 맞는 형태가 아니었기에, 클러스터의 Knative Bun 함수 17개를 Go HTTP 서버 4개로 통합했습니다. 이 결정은 미적인 선택이 아니었습니다. 홈 k8s 클러스터 부트스트래핑이 콜드 스타트를 해결하기 위해 autoscaling.knative.dev/min-scale: "1"을 설정한 순간, 게임은 이미 always-on이었습니다. Knative의 유일한 실질적 이점은 그날 사라졌고, 이번 마이그레이션은 그 사실을 10개월 늦게 인정한 것에 불과합니다.

  • 서버 4개auth-api-go (라우트 7개), game-api-go (라우트 11개), ws-relay-go (WS + Mongo change streams), doc-search-api-go (Qdrant + Neo4j + LLM Gateway RAG).
  • 상시 가동 오버헤드 제거 — 17 × (50m CPU + 64Mi RAM) ≈ 750m CPU + 928Mi RAM이 트래픽이 들어오기 전부터 영구 점유되고 있었습니다. 파드 4개로 줄었습니다.
  • 5단계 계획, PR 약 20개 — 기반, auth, game, WS relay, doc-search, 컷오버. 각 단계마다 스테이징 배포와 스모크 테스트를 통과한 후에야 다음 단계로 넘어갔습니다.
  • 지연 삭제 — PR #359에서 Bun을 0으로 스케일 다운하고 매니페스트는 그대로 두었습니다. 실제 rm을 수행하는 PR #23은 2주 뒤인 2026-04-20로 예약되었습니다.
  • 세 갈래 정리 작업 병행 — monorepo 정리(3월 9일), 함수 명명 정리(3월 19일), Docker 레이어 캐시(4월 12일)가 인접 브랜치에서 진행되어 Go 컷오버 비용을 줄였습니다.

Go 선호가 아니라 게임이 강제한 결정

min-scale: 1은 게임을 실사용 가능하게 만든 1주 차 이후의 수정사항이었습니다. 이것이 적용된 순간 17개의 Bun 함수는 0으로 스케일 다운되지 않게 되었습니다. 콜드 스타트는 더 이상 기능이 아니라 750m CPU를 비용으로 지불해 억누르고 있는 어떤 것이었습니다. WS relay는 이미 Deployment였습니다. Knative의 scale-to-zero가 영속적인 WebSocket 연결과 양립하지 않기 때문입니다. 교훈은 분명합니다. 네임스페이스 안의 모든 Knative 서비스가 min-scale: 1을 달고 있다면, 작성하지 않은 HTTP 서버에 서버리스 오버헤드를 지불하고 있다는 뜻입니다.

여기서 Go의 가치는 좁고 구체적이었습니다. ~150MB Bun 이미지 대비 ~15MB Alpine + 정적 바이너리, relay의 connection manager에 쓰일 네이티브 goroutine, 클러스터 풀(pull) 시 빌드 단계 없음. 의존성 목록은 지루할 정도로 단순하게 유지했습니다: mongo-driver/v2, golang-jwt/v5, bcrypt, github.com/coder/websocket(deprecated된 nhooyr.io/websocket의 maintained fork), slog, testify.

같은 라우트, 다른 바이너리

서버 엔드포인트 대체 대상
auth-api-go 7개 (register, login, logout, refresh, me, guest, google) Knative auth 함수 7개
game-api-go 11개 (lobby, room, share-links, player stats) Knative game 함수 10개
ws-relay-go WS upgrade + Mongo change streams game-ws-relay-service (Bun Deployment)
doc-search-api-go Qdrant + Neo4j + LLM Gateway 위의 RAG doc-search-api (Bun Deployment)

func-api.wintersalmon.com 아래 동일한 경로, 동일한 MongoDB 컬렉션, 동일한 JWT 쿠키. PR #354의 ingress 매니페스트는 라우트마다 정확히 한 필드만 바꾸었습니다. backend.service.namekourier-proxy에서 매칭되는 *-go ClusterIP로 변경된 것이 전부입니다.

5단계 계획

Phase 0은 기반을 세우는 단계였습니다. go/에 단일 Go 모듈(github.com/wintersalmon/cloudnest-go), 공유 패키지(internal/{mongo,jwt,middleware,crypto,response}), Makefile, golangci-lint v2, GHCR 파이프라인까지. Phase 1-4는 각각 서버 하나를 담당했고, 다음 단계로 넘어가기 전에 스테이징 배포와 스모크 테스트를 마쳤습니다. Phase 5는 프로덕션 컷오버와 Bun 스케일 다운이었습니다.

순서는 임의로 정한 것이 아닙니다. auth가 먼저 나가야 했습니다. 다른 모든 서버가 JWT 미들웨어를 재사용했기 때문입니다. relay는 게임 서버 중 적어도 하나가 이벤트를 발행하기 시작해야 출시할 수 있었습니다.

작업을 흔들 뻔한 세 번의 오후

PR #343은 한 줄짜리 Dockerfile 수정이었지만 한 시간을 잡아먹었습니다. golang:1.24-alpineGOTOOLCHAIN=local을 설정합니다. 그래서 go.modgo 1.26.1이 선언되어 있어도 자동으로 다운로드되지 않고, 빌드는 조용히 1.24를 사용하다가 1.26 전용 stdlib 호출에서 크래시했습니다.

PR #346은 mongo-driver v2에서 제거된 mongo.NewClient() API를 수정했습니다. client.Database("")는 URI 기본값으로 폴백하지 않습니다. 말 그대로 빈 문자열 이름의 데이터베이스를 반환합니다. 모든 서버는 이제 URI에서 DB 이름을 추출합니다.

PR #349는 쿠키 이름 불일치를 수정했습니다. Bun은 access-token을 설정했고 Go는 accessToken을 설정했습니다. 옛 이름에 고정된 클라이언트는 스테이징 컷오버 도중 깨졌습니다. 교훈은 이렇습니다. 스테이징 컷오버는 curl localhost:18001이 아니라 HTTPS로 검증해야 합니다. 쿠키는 실제 ingress 뒤에서 다르게 동작합니다.

지연 삭제가 안전망이었습니다

프로덕션은 2026-04-06에 컷오버되었습니다. PR #354가 ingress를 전환했고, PR #359가 17개 Knative 서비스 전부에 min-scale: 0max-scale: 0을 설정하고 Bun Deployment 두 개에 replicas: 0을 걸었습니다. 매니페스트는 2주간 리포지토리에 그대로 남았습니다. 롤백은 정해진 순서로 수행하는 두 번의 git revert였습니다. 먼저 Bun 파드 복원(revert #359), 다음으로 ingress 복원(revert #354). 둘 다 단일 커밋 작업이었고 FluxCD가 1분 안에 동기화했습니다. 실제 삭제 PR인 #23은 2026-04-20로 예약되었습니다.

즉시 삭제 컷오버는 새로운 정보를 알려주지 않습니다. 2주 동안 깨끗한 Go 트래픽이 흐르면 업무 외 시간 요청, 주간 cron, 재접속 폭주까지 모두 잡힙니다.

같은 흐름 위에 정리 세 건이 함께 도착했습니다

이번 마이그레이션은 진공 상태에서 진행된 것이 아닙니다. monorepo 정리(3월 9일)는 deprecated된 앱 12개를 삭제했습니다 — auth-playground, 독립형 멀티플레이어 클라이언트 셋, v1 chess 함수들 — 그 결과 앱 9개와 패키지 9개가 남았습니다. 포팅할 Bun 함수 수가 줄었습니다. 함수 명명 정리(3월 19일)는 CI의 path_to_name() 헬퍼가 끼워넣던 -service-를 기계적인 규칙 func-{domain}-{category}-{function}으로 교체했습니다. PR #23이 이들을 삭제할 무렵에는 이름이 마침내 일관되어 있었습니다. Docker 캐시 수정(4월 12일)은 18개 워크플로우에서 no-cache: true를 제거하고 워크플로우 단위 스코프의 cache-from/cache-to: type=gha를 추가했습니다. 모든 Dockerfile에서 두 번째 빌드가 30-50% 빨라졌고, 새로 만든 Go Dockerfile 4개도 그 혜택을 받았습니다.

아키텍처가 설계상 가정한 트래픽이 아니라, 실제로 흐르는 트래픽에 맞는 형태를 골라야 합니다.

#golang #migration

AI 워크플로우 메모

Claude는 Go 코드가 한 줄이라도 나가기 전에 5단계 분할을 계획 문서(.claude/plans/foamy-mixing-zephyr.md)로 작성했습니다. 클러스터 부트스트랩에서 효과를 본 것과 같은 패턴입니다. code-reviewer 에이전트(Go 서브에이전트)가 모든 단계의 PR에서 돌았고, 작은 수정의 대부분 — errcheck의 cursor close, reviveauth.AuthResponseauth.Response stutter rename, gocritichttp.NoBody 교체 — 은 제가 직접 golangci-lint를 돌려서가 아니라 그 리뷰에서 드러났습니다. 태스크 로그에 명문화하고 타협 불가로 못 박은 규율은 이것입니다. 프로덕션이 Go로 최소 한 주 동안 정상 가동될 때까지 Bun 매니페스트 삭제는 금지. 태스크 로그의 handoff 섹션이 각 컷오버 직전의 검증 게이트였습니다. 모든 스모크 테스트 명령과 시크릿 키 관련 함정이 거기에 적혀 있었고, PR #354를 열기 전에 다시 읽으면서 그렇지 않았다면 그대로 나갔을 stale reference 두 개를 잡아냈습니다.


Hungjoon

I'm Hungjoon, a software engineer based in South Korea. This is my long-form notebook — homelab, Kubernetes, AI infra, and whatever else keeps me up at night.