사이드 프로젝트로 만든 롤 내전 모임 서비스(모노레포: packages/frontend - Vite/React, packages/api - Express/Prisma)를 실제로 운영하면서 크고 작은 사고를 겪었다. 코드 몇 줄 잘못 짜서 나는 버그와는 결이 다른, 배포 프로세스 자체에서 나는 사고들이라 따로 기록해두려 한다. 앞으로도 비슷한 일이 생기면 이 시리즈에 계속 추가할 예정이다.


사고 1. git과 운영이 서로 다른 코드를 가리키게 된 사건

origin/main을 실서비스의 기준(Source of Truth)으로 삼고 있었는데, 운영 중에 급한 마음에 로컬의 커밋되지 않은 코드(gitDirty 상태)를 vercel/gcloud CLI로 바로 배포해버린 일이 반복됐다.

문제는 그 다음이었다. 이후 누군가 “정상적으로” git push → 배포라는 표준 절차를 따랐을 뿐인데, 그 배포가 이전에 CLI로 몰래 얹어놨던 운영 코드를 통째로 덮어써버린 것이다. 결과적으로 League, Recap, 네비게이션 등 이미 배포되어 있던 기능들이 사라지는 사고로 이어졌다.

원인

  • git 저장소와 실제 운영 서버의 코드가 어느 순간부터 서로 다른 상태가 되어버림 (git ≠ 운영)
  • “지금 급하니까 일단 배포부터” 라는 생각으로 커밋을 생략한 게 반복되면서, 아무도 눈치채지 못한 채 간극이 벌어짐

해결 / 교훈

  • 운영 소스를 다시 git에 정합화(reconcile)하는 작업을 해서 git = 운영 상태로 맞춰놓음
  • 이후로는 예외 없이 커밋 → 푸시 → 배포 순서만 지키기로 함. 로컬 미커밋 코드를 CLI로 직접 얹는 행위 자체를 금지
  • “빨리 배포하고 싶다”는 마음이 들 때가 제일 위험한 순간이라는 걸 배웠다


사고 2. gcloud 계정/프로젝트를 안 적었다가 엉뚱한 곳에 서비스가 생긴 사건

백엔드(Cloud Run) 배포 커맨드를 짧게 줄여서 --project, --account 없이 실행한 적이 있다.

gcloud run deploy lol-api --source packages/api --region asia-northeast3 --quiet

당연히 운영 프로젝트에 배포될 줄 알았는데, 로그를 보니 전혀 엉뚱한 (예전에 다른 실험용으로 써뒀던) GCP 프로젝트에 빈 lol-api 서비스가 새로 생성되어 있었다.

원인

  • 로컬 gcloud의 기본 계정과 기본 프로젝트가 평소 다른 작업에 쓰던 것으로 맞춰져 있었는데, 그 계정은 애초에 운영 프로젝트에 대한 권한이 없었음
  • --project/--account를 생략하면 이 기본값들이 그대로 쓰인다는 걸 간과함

해결 / 교훈

  • 잘못 생성된 stray 서비스는 삭제해서 정리
  • 운영 배포 권한이 있는 계정이 딱 하나뿐이라는 걸 다시 확인하고, 배포 커맨드에는 항상 --project, --account를 명시하기로 룰을 정함
  • CLI의 “기본값”은 로컬 환경마다 다를 수 있다는 걸, 사고가 나고 나서야 체감했다


사고 3. 고정 IP가 바뀌면서 도메인이 조용히 끊긴 사건

실시간 경매(옥션) 기능은 Cloud Run이 아니라 별도 GCE VM에서 pm2로 띄운 WebSocket 서버가 따로 담당하고 있다. 이 VM의 외부 IP가 인스턴스 재시작 등의 이유로 바뀌었는데, 여기에 연결해두었던 동적 DNS 도메인이 예전 IP를 그대로 가리키고 있었다.

원인

  • WS 서버가 Cloud Run처럼 안정적인 URL을 자동으로 유지해주는 게 아니라, VM의 고정 IP에 직접 동적 DNS로 연결해둔 구조였음
  • IP가 바뀔 수 있다는 전제를 깜빡하고 DNS 레코드 갱신을 누락함

해결 / 교훈

  • DNS 레코드를 새 IP로 갱신
  • 이후로는 VM IP를 다시 확인/변경할 일이 있으면 DNS 레코드도 같이 확인하는 걸 체크리스트에 넣어둠


사고 4. API와 WS가 같은 상태를 따로 들고 있다가 꼬인 사건

경매 라운드의 진행 상태(auctionPhase)를 API 서버(finalizeRound)와 WS 서버 양쪽에서 각자 들고 있는 구조였는데, Redis에 BIDDING 상태가 stale하게 남아버리는 경우가 생겼다. 그 결과 실제로는 라운드가 끝났는데도 “라운드를 시작할 수 없다”는 버그가 발생했다.

원인

  • 상태를 두 프로세스(API, WS)가 공동으로 소유하다 보니, 한쪽에서 상태를 정리하지 못하고 죽거나 타이밍이 어긋나면 다른 쪽은 그 사실을 알 방법이 없었음
  • Redis에 남은 상태를 그대로 신뢰하다 보니 실제 상태와 어긋난 채로 판단해버림

해결 / 교훈

  • start_round가 Redis만 믿지 않고, DB를 권위(authoritative) 소스로 삼아 재검증하도록 수정
  • 여러 프로세스가 같은 상태를 나눠 가질 때는 “누가 진짜(source of truth)인지”를 명확히 정해둬야 한다는 걸 다시 느낌


정리

네 가지 사고 모두 로직 버그가 아니라 배포/운영 프로세스에서 난 사고였다는 공통점이 있다. 그래서 이번에 정리하면서 얻은 원칙들을 프로젝트의 AGENTS.md에도 그대로 박아뒀다.

  • git = 운영. 항상 커밋 → 푸시 → 배포 순서
  • 배포 커맨드에 프로젝트/계정을 생략하지 않기
  • 고정 IP처럼 바뀔 수 있는 값에 의존하는 곳은 변경 시 체크리스트를 같이 둘 것
  • 여러 프로세스가 상태를 나눠 가지면, 권위 있는 소스를 명확히 정해둘 것

다음에 또 사고가 나면 (안 나면 제일 좋겠지만) 이 시리즈에 이어서 기록할 예정이다.