
들어가며
사이드 프로젝트 Shift(shift-dev.kr)를 운영하면서, 배포할 때마다 서비스가 수십 초간 중단되는 문제가 있었다. 실사용자 500명이 쓰는 서비스에서 이건 용납할 수 없었다.
Windows self-hosted runner + NSSM + Caddy 조합으로 Blue-Green 무중단 배포를 구축했는데, 50개 이상의 커밋에 걸쳐 엣지 케이스를 하나씩 해결해야 했다. 이 글은 그 삽질의 기록이다.
환경
- Windows 10 + GitHub Actions self-hosted runner
- Java 25 + Spring Boot 4 (백엔드)
- SvelteKit + Svelte 5 (프론트엔드)
- Caddy (리버스 프록시)
- NSSM (Windows 서비스 관리)
Blue-Green 배포 기본 구조
┌─ :8080 (Blue) ← 현재 활성
사용자 → Caddy ────┤
└─ :8081 (Green) ← 대기- Green(8081)에 새 버전 배포
- 헬스체크 통과하면 Caddy가 Green으로 트래픽 전환
- Blue(8080)가 대기 상태로 전환
- 다음 배포 시 역할 교대
프론트엔드도 동일하게 3000↔3001 포트 로테이션.
만난 문제들과 해결
1. NSSM "marked for deletion" 상태
증상: 서비스 제거 후 바로 재등록하면 "서비스가 삭제 대기 중"이라며 실패.
원인: Windows SCM(서비스 컨트롤 매니저)이 서비스 핸들을 완전히 해제하기 전에 재등록을 시도.
해결: 서비스 제거 후 상태 폴링을 추가.
nssm stop $serviceName
nssm remove $serviceName confirm
# 서비스가 완전히 제거될 때까지 대기
while (Get-Service $serviceName -ErrorAction SilentlyContinue) {
Start-Sleep -Seconds 1
}
nssm install $serviceName ...
2. SERVICE_PAUSED 과도 상태
증상: 서비스가 시작도 중지도 아닌 "일시정지" 상태에 빠짐.
해결: 배포 스크립트에 상태 체크 로직 추가. PAUSED 상태면 강제로 continue → stop → start 순서로 복구.
3. GitHub Actions stderr terminating error
증상: PowerShell에서 stderr 출력이 있으면 GitHub Actions가 step을 실패로 처리.
해결: $ErrorActionPreference = "Continue"로 설정하여 stderr가 step 실패를 트리거하지 않도록 변경.
4. Workspace 파일 락
증상: 빌드 후 git checkout 시 jar 파일이 락 걸려서 실패.
원인: 이전 배포의 Java 프로세스가 jar를 물고 있음.
해결: 배포 시작 전 이전 프로세스를 확실히 종료하고, shallow clone으로 incremental fetch 전환.
git fetch --depth=1 origin $branch
git reset --hard FETCH_HEAD
5. Gradle 데몬 OOM
증상: 빌드가 반복되면서 Gradle 데몬이 메모리를 점점 먹다가 OOM.
해결: JVM 힙을 2G로 증가하고, 빌드 전 데몬을 정리.
# gradle.properties
org.gradle.jvmargs=-Xmx2g
# 빌드 전 데몬 정리
./gradlew --stop
6. Node.js max-old-space-size
증상: SvelteKit 빌드 시 메모리 부족으로 크래시.
해결: NODE_OPTIONS 환경 변수 설정.
$env:NODE_OPTIONS = "--max-old-space-size=4096"
npm run build
실서비스 이슈 대응
배포 인프라 외에도 런칭 후 실사용자 대면 이슈가 연이어 발생했다.
인앱 브라우저 Google OAuth 차단
카카오톡/인스타그램에서 링크를 열면 인앱 브라우저로 열리는데, Google이 인앱 브라우저에서의 OAuth를 차단한다. 안내 UI → iframe → location.href 등 여러 시도 끝에, 인앱 브라우저 감지 후 외부 브라우저로 자동 유도하는 것이 가장 깔끔한 해결책이었다.
인메모리 랭킹 데이터 유실
Hot Ranking을 인메모리로 관리했더니, 배포/재시작 시 랭킹이 사라지는 치명적 버그가 발생. DB에 주기적으로 스냅샷을 저장하는 영속화 로직(HotRankingSnapshotHelper)으로 근본 해결했다.
최종 배포 흐름
# GitHub Actions workflow (간략화)
jobs:
deploy:
runs-on: self-hosted
steps:
- name: Detect active port
run: # Caddy 설정에서 현재 활성 포트 확인
- name: Build
run: ./gradlew build
- name: Deploy to inactive port
run: # NSSM으로 비활성 포트에 배포
- name: Health check
run: # 30초간 헬스체크 폴링
- name: Switch traffic
run: # Caddy 설정 변경으로 트래픽 전환
결과
- 다운타임: 수십 초 → 제로
- 50+ 커밋에 걸친 점진적 안정화
- 롤백: 트래픽을 이전 포트로 되돌리면 즉시 완료
배운 점
- 무중단 배포는 "구현"이 아니라 "안정화"가 진짜 작업이다 — 기본 구조는 하루면 만들지만, 엣지 케이스 해결에 50커밋이 들었다
- Windows 환경의 서비스 관리는 Linux보다 훨씬 까다롭다 — NSSM의 상태 머신을 이해하지 않으면 디버깅이 불가능하다
- "만드는 것"과 "운영하는 것"은 완전히 다른 영역이다 — 실사용자가 있는 서비스를 운영하면서 비로소 체감했다
'백엔드 트러블슈팅' 카테고리의 다른 글
| Kafka 동기화 삽질기 4편: 멀티테넌시 + @Async에서 ThreadLocal이 사라지는 문제 (0) | 2026.03.29 |
|---|---|
| Kafka 동기화 삽질기 3편: Reconciliation — "없는 것"을 처리하는 기술 (0) | 2026.03.29 |
| BPMN 자동화 엔진 리팩토링: 이벤트 디스패치 · 상태 업데이트 역할 분리 5단계 정리 (0) | 2026.03.28 |
| Kafka 동기화 삽질기 2편: batchInsert Lock Wait Timeout과 청크 전략 (0) | 2026.03.28 |
| Kafka 동기화 삽질기 1편: 트랜잭션 중첩이 만든 유령 롤백 분석 (0) | 2026.03.28 |
