지난달, 팀의 MacBook Pro 한 대가 "임시 백엔드"로 사용되고 있었습니다. iOS 앱의 로컬 API 디버깅용으로 Swift Vapor 서비스를 돌리고 있었는데, swift run으로 실행한 뒤에는 터미널 창을 닫을 수조차 없는 상황이었습니다. 그러다 시스템 업데이트로 자동 재부팅이 발생하자 서비스는 아무 알림도 없이 반나절이나 다운되어 있었고, QA 동료는 API에 연결이 안 되는 걸 자기 네트워크 문제로 오해하고 있었습니다. 이런 "수동 데몬" 방식은 상시 온라인 상태의 클라우드 Mac mini를 임대한 시점에서 반드시 바꿔야 합니다. 이 글에서는 Vapor 서비스를 터미널에서 launchd 상주 방식으로 옮기고, 나아가 무중단 배포까지 구현하는 전체 과정을 정리합니다.
시나리오: Vapor 서비스를 클라우드 Mac mini에 두는 이유
Swift Vapor는 흔히 "iOS 클라이언트를 위한 경량 백엔드"로 사용됩니다. 가짜 로그인, 푸시 알림 콜백, 트래킹 데이터 수신, 내부 관리자 페이지 등 규모는 크지 않지만 24시간 365일 온라인 상태가 요구되며, 반드시 실제 macOS 환경이어야 하는 경우가 많습니다(APNs 인증서 연동 테스트나 swift build의 네이티브 종속성이 필요한 경우도 있음). 클라우드 Mac mini는 물리 머신을 단독으로 점유하며 가상 머신이 아니기 때문에, "로컬 머신은 상시 켜둘 수 없고, 클라우드 Linux는 macOS 전용 종속성을 실행할 수 없다"는 공백을 정확히 채워줍니다. 다만 베어메탈을 단독으로 점유하게 되면 프로세스 관리의 책임도 전부 본인에게 넘어옵니다——플랫폼이 크래시된 프로세스를 대신 재시작해주는 관리 레이어가 없으므로, launchd를 직접 구성해야 합니다.
환경 준비: 툴체인과 포트 설계
로그인 후 먼저 툴체인 버전을 확인해, 로컬 개발 환경과 클라우드 환경의 차이로 빌드 결과물의 동작이 달라지는 것을 방지합니다:
swift --version
xcode-select -p
mkdir -p ~/apps/vapor-api/releases
mkdir -p ~/apps/vapor-api/logs
포트를 설계할 때는 이후의 무중단 전환을 위해 두 개의 포트를 미리 확보합니다:
| 용도 | 포트 | 설명 |
|---|---|---|
| 프로덕션 메인 포트 | 8080 | Nginx가 외부에 노출하는 현재 활성 버전 |
| 카나리/신규 버전 포트 | 8081 | 새 빌드가 먼저 이 포트에서 자체 점검 |
| 내부 헬스체크 | 8080/8081 /healthz |
Vapor 커스텀 라우트, 빌드 버전 번호 반환 |
/healthz 라우트는 Git 커밋의 짧은 해시를 직접 반환하도록 만드는 것을 권장합니다. 배포 시 눈으로 직접 올바른 버전으로 전환되었는지 확인할 수 있고, 프로세스가 실제로 재시작되었는지 추측할 필요가 없어집니다.
launchd로 nohup을 대체해 상주 서비스로 만들기
~/Library/LaunchAgents/com.m4rent.vaporapi.plist를 다음과 같이 작성합니다:
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.m4rent.vaporapi</string>
<key>ProgramArguments</key>
<array>
<string>/Users/deploy/apps/vapor-api/current/Run</string>
<string>serve</string>
<string>--hostname</string>
<string>127.0.0.1</string>
<string>--port</string>
<string>8080</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/deploy/apps/vapor-api/current</string>
<key>KeepAlive</key>
<true/>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/deploy/apps/vapor-api/logs/stdout.log</string>
<key>StandardErrorPath</key>
<string>/Users/deploy/apps/vapor-api/logs/stderr.log</string>
</dict>
</plist>
로드 및 검증:
launchctl load ~/Library/LaunchAgents/com.m4rent.vaporapi.plist
launchctl list | grep vaporapi
curl -s localhost:8080/healthz
plist 주요 필드 해설
KeepAlive를 true로 설정하면 프로세스가 비정상 종료될 경우 launchd가 자동으로 재시작해주기 때문에, 별도의 감시 스크립트를 작성할 필요가 없습니다. 다만 설정 파일 자체에 오류가 있어 시작 즉시 크래시가 나는 경우, launchd는 "초 단위 재시작 무한 루프"에 빠질 수 있습니다. 이 경우 로그를 보는 것보다 launchctl list에서 재시작 횟수를 확인하는 것이 문제를 더 빠르게 파악하는 방법입니다. RunAtLoad는 부팅/로그인 시 자동 실행을 보장하며, 클라우드 Mac mini의 계획된 다운타임 없는 운영 방식과 결합하면 기본적으로 시작 절차에 수동으로 개입할 필요가 없어집니다. WorkingDirectory는 반드시 명시적으로 작성해야 합니다. 그렇지 않으면 상대 경로로 지정된 설정 파일이나 로그 경로가 launchd의 기본 작업 디렉터리가 예상과 다른 위치이기 때문에 잘못 읽힐 수 있습니다.
무중단 배포: 이중 포트 + Nginx 리버스 프록시 전환
새 버전을 배포할 때 current 디렉터리를 직접 덮어쓰고 같은 프로세스를 재시작하면 안 됩니다. 그렇게 하면 반드시 몇 초간의 서비스 공백이 발생합니다. 대신 "새 포트에서 새 프로세스를 시작하고, 헬스체크를 확인한 후 트래픽을 전환"하는 방식을 사용합니다:
cp -R releases/build-2026-07-12 releases/build-2026-07-12-verify
sed -i '' 's/8080/8081/' com.m4rent.vaporapi-staging.plist
launchctl load com.m4rent.vaporapi-staging.plist
curl -s localhost:8081/healthz
/healthz가 예상하는 커밋 해시를 반환하면, Nginx upstream을 전환합니다:
upstream vapor_api {
server 127.0.0.1:8081;
}
nginx -s reload는 설정만 다시 불러올 뿐, 이미 맺어진 연결을 끊지 않습니다. 기존 8080 프로세스는 남아있는 요청을 처리 완료한 후 launchctl unload로 우아하게 종료시킵니다.
트래픽을 전환하기 전에 절대 이전 포트를 먼저 중지하지 마세요——헬스체크를 통과했다고 해서 신규 버전이 실제 부하 상황에서 문제없다는 뜻은 아닙니다. 최소 몇 분간의 관찰 시간을 확보해두면, 이상이 발견되었을 때 명령어 한 줄로 이전 upstream으로 되돌리는 것이 사후 롤백보다 훨씬 빠릅니다.
로그와 모니터링: 디스크 용량 초과 방지
launchd가 리다이렉트하는 stdout.log/stderr.log는 기본적으로 자동 로테이션되지 않습니다. 장기간 운영되는 서비스라면 몇 달 만에 몇 GB까지 쌓일 수 있습니다. 시스템 기본 제공 newsyslog에 규칙을 하나 추가합니다:
/Users/deploy/apps/vapor-api/logs/stdout.log deploy:staff 644 7 10240 * N
이 규칙은 로그가 10MB를 초과하면 한 번 로테이션하고, 최대 7개의 이전 파일을 보관한다는 의미입니다. 클라우드 Mac mini의 SSD 용량은 고정되어 있으므로, 로그가 통제되지 않으면 빌드 결과물과 스냅샷 공간에 직접적인 영향을 줍니다. 매주 du -sh ~/apps/vapor-api/logs로 증가 추세를 확인하는 것을 권장합니다.
트러블슈팅 체크리스트
- 환경 변수 누락: launchd가 실행하는 프로세스는
.zshrc에서export한 변수를 이어받지 않습니다. 데이터베이스 연결 문자열 등의 설정은 plist의EnvironmentVariables딕셔너리에 작성하거나, 독립된.env파일을 코드에서 명시적으로 불러오도록 해야 합니다. - 포트 점유가 감지되지 않음:
launchctl load가 "서비스가 이미 존재함"이라고 반환하는데도 포트에 연결되지 않는 경우, 대개 이전 비정상 종료로 남은 좀비 plist 기록 때문입니다. 먼저launchctl remove를 실행한 후 다시load하세요. - KeepAlive 무한 루프: 설정 파일 경로 오류로 프로세스가 즉시 종료되는 경우,
KeepAlive로 인해 무한히 재시작되어 CPU 사용률이 순식간에 치솟습니다. 먼저launchctl unload로 지혈한 뒤 원인을 파악하세요. - 배포 스크립트에 롤백 지점이 없음:
current디렉터리는 구체적인releases/build-*디렉터리를 가리키는 심볼릭 링크로 만드는 것을 권장합니다. 롤백은 이 링크를 이전 디렉터리로 되돌린 뒤 reload하는 것만으로 충분합니다. 덮어쓰기 방식의 배포는 피하세요.
자주 묻는 질문
nohup으로 실행하는 것과 launchd를 쓰는 것은 어떤 차이가 있나요?
nohup은 터미널에서 분리만 시켜줄 뿐 프로세스가 죽으면 다시 살아나지 않습니다. launchd는 macOS의 시스템 수준 상시 관리 기능으로 KeepAlive를 통한 자동 재시작과 부팅 시 자동 실행을 설정할 수 있어 장기 임대하는 Mac mini에 적합합니다.
무중단 배포에 반드시 포트 두 개가 필요한가요?
같은 포트에서 재시작하면 새 프로세스가 뜰 때까지 몇 초간 연결이 끊깁니다. 새 버전을 다른 포트에서 띄우고 헬스체크 통과 후 Nginx 업스트림을 전환하면, 기존 프로세스는 남은 연결을 처리하고 새 요청은 새 버전으로 흘러가 무중단을 실현합니다.
데이터베이스도 같은 Mac mini에 두어야 하나요?
소규모라면 로컬 PostgreSQL이나 SQLite에 매일 스냅샷을 걸어도 충분하지만, 데이터가 많거나 여러 인스턴스가 공유해야 한다면 별도의 관리형 데이터베이스에 연결해 Mac mini는 애플리케이션 계층만 담당하게 하는 것이 안전합니다.
전용 Mac mini에서 직접 확인해 보세요
일 단위 대여, root 권한, 몇 분 만에 사용 시작. 먼저 테스트해보고 장기 이용 여부를 결정하기에 적합합니다.