현상
2026년 5월 10일, Lynceus 백엔드를 native 모드(uvicorn 직접 실행)로 개발하던 중 이상한 일이 생겼다.
$ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
터미널에는 기대한 기동 로그가 보이지 않았다. “Started server process” 메시지도 없었다. 그런데 이전 터미널 출력과 섞여 있어서 실패를 바로 알아채지 못했다.
그런데 헬스체크는 통과했다.
$ curl http://localhost:8000/health
{"status": "healthy"}
“아, 잘 떴구나.” 개발을 계속했다. 로그 변경이 반영이 안 되는 것 같았지만 캐시 문제려니 했다. /docs 엔드포인트를 열었는데 라우트 목록이 이상했다. 내가 추가한 엔드포인트가 없었다.
그제서야 이상하다는 걸 알아챘다.
원인: OrbStack이 8000번 포트를 잡고 있었다
Mac에서 OrbStack을 사용하면 docker-compose로 떠 있는 서비스들이 로컬 포트에 바인딩된다. 이 프로젝트는 docker-compose.yml에서 백엔드를 8000:8000으로 매핑하고 있었다.
OrbStack의 특성상 docker-compose로 실행된 컨테이너는 localhost:8000에 바인딩되어 계속 살아 있었다. 내가 uvicorn을 직접 실행하려 했을 때, 8000번 포트는 이미 점유된 상태였다. uvicorn은 정상 기동하지 않았고, 내가 본 헬스체크 응답은 기존 컨테이너에서 온 것이었다.
curl http://localhost:8000/health가 200을 반환한 건 맞다. 하지만 그것은 OrbStack 위의 도커 컨테이너가 응답한 것이었다. 내가 방금 고친 코드를 실행하는 uvicorn은 존재하지 않았다.
$ lsof -i :8000
COMMAND PID USER FD TYPE ... NAME
com.dock 1234 ... ... ... ... *:8000 (LISTEN)
OrbStack의 가상화 레이어가 포트를 가져가고 있었다.
왜 진단이 어려웠나
일반적인 포트 충돌이라면 uvicorn이 에러 메시지를 출력한다.
ERROR: [Errno 48] Address already in use
이 사례에서 진단을 어렵게 만든 것은 에러 메시지가 없었다는 사실보다, 헬스체크 성공이 너무 그럴듯했다는 점이다. 포트 충돌 에러를 놓쳤거나, 실행 스크립트/터미널 출력이 실패를 충분히 드러내지 못했을 수 있다. 어느 쪽이든 curl localhost:8000/health가 200을 반환하면서 판단이 흐려졌다.
OrbStack은 macOS에서 컨테이너 포트를 매우 자연스럽게 localhost에 노출한다. 그래서 Docker 컨테이너가 응답하는 localhost:8000과 네이티브 uvicorn이 응답해야 하는 localhost:8000을 구분하지 않으면, “포트가 열려 있다"를 “내 서버가 떴다"로 오해하기 쉽다.
거짓 성공을 만든 요소를 나열하면:
| 요소 | 역할 |
|---|---|
| OrbStack docker-compose 컨테이너 | localhost:8000에서 헬스체크 응답 |
| uvicorn 기동 실패 | 시작 로그를 확인하지 않아 실패를 놓침 |
| 헬스체크 엔드포인트 | /health는 컨테이너가 올바르게 응답함 |
| 개발자 기대 | “200이면 내 서버가 뜬 것” |
이 네 가지가 겹쳐서 “서버가 잘 떴다"는 잘못된 확신을 만들어 냈다.
재현 조건
- OrbStack 실행 중
docker-compose up(또는 이전 세션에서 컨테이너가 살아 있음)- 같은 포트로 uvicorn 네이티브 실행 시도
- → uvicorn 기동 실패
- →
curl localhost:<포트>/health→ 200 (도커 컨테이너 응답)
Docker Desktop에서는 컨테이너를 명시적으로 stop/rm 하지 않으면 포트가 유지되는 것이 눈에 보이지만, OrbStack은 VM 없이 동작하고 포트 매핑이 더 “투명"하게 이루어지기 때문에 이 상황을 더 인식하기 어렵다.
해결: docker 모드 / native 모드 포트 분리
포트가 겹치는 것이 근본 문제이므로, docker 모드와 native 모드가 다른 포트를 쓰도록 분리했다.
# scripts/dev-restart.sh 발췌
MODE="docker"
if [[ "$1" == "--native" ]]; then
MODE="native"
fi
if [[ "$MODE" == "docker" ]]; then
# docker-compose: 8000/3000
docker-compose up -d
echo "백엔드: http://localhost:8000"
echo "프론트: http://localhost:3000"
else
# native: 8001/5173 (OrbStack 포트와 충돌 없음)
uvicorn app.main:app --host 127.0.0.1 --port 8001 --reload &
vite --port 5173 &
echo "백엔드: http://localhost:8001"
echo "프론트: http://localhost:5173"
fi
docker-compose.yml은 8000:8000, native 실행은 8001과 5173을 사용한다. 두 모드를 동시에 켜도 포트 충돌이 없다.
CLAUDE.md에도 접속 주소 테이블을 추가했다.
| 모드 | 백엔드 | 프론트 |
|------|--------|--------|
| docker | localhost:8000 | localhost:3000 |
| native | localhost:8001 | localhost:5173 |
진단 명령
OrbStack 환경에서 포트 점유 여부를 확인하는 빠른 방법:
# 특정 포트를 누가 쓰고 있는지
lsof -i :8000
# OrbStack이 점유한 포트 목록 (docker ps와 함께 확인)
docker ps --format "table {{.Names}}\t{{.Ports}}"
# 포트에 실제로 누가 응답하는지
curl -v http://localhost:8000/health 2>&1 | grep "Server:"
Server: 헤더가 없거나 uvicorn 특유의 응답 형식이 아니라면 다른 무언가가 응답하는 것이다.
일반화: 헬스체크 응답이 “내 서버” 응답임을 보장하는 방법
헬스체크 엔드포인트가 200을 반환해도, 그것이 정말 내가 방금 실행한 서버인지 확인할 방법이 필요하다.
1. 버전/커밋 해시를 응답에 포함한다
@app.get("/health")
async def health():
return {
"status": "healthy",
"version": settings.APP_VERSION, # 예: "1.7.10"
"commit": settings.GIT_COMMIT_SHA[:7], # 예: "a07cdaa"
}
헬스체크 응답에 버전이나 커밋 해시가 있으면, 내가 배포한 코드가 응답하는지 즉시 확인할 수 있다.
$ curl http://localhost:8001/health
{"status":"healthy","version":"1.7.10","commit":"a07cdaa"}
2. 시작 로그를 반드시 확인한다
uvicorn이 정상 기동됐다면 반드시 이 메시지가 나온다.
INFO: Started server process [12345]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8001 (Press CTRL+C to quit)
이 메시지 없이 uvicorn이 종료됐다면 기동 실패다. 헬스체크 결과와 무관하게.
3. PID를 추적한다
uvicorn app.main:app --port 8001 &
UVICORN_PID=$!
sleep 2
if kill -0 $UVICORN_PID 2>/dev/null; then
echo "uvicorn 기동 성공 (PID: $UVICORN_PID)"
else
echo "uvicorn 기동 실패"
exit 1
fi
PID가 살아 있는지 확인하면 “프로세스가 떠 있는가"를 직접 알 수 있다.
정리
| 항목 | 내용 |
|---|---|
| 현상 | uvicorn이 뜬 줄 알았지만 헬스체크는 기존 Docker 컨테이너가 응답 |
| 원인 | OrbStack docker 컨테이너가 같은 포트를 점유, native 실행 포트와 충돌 |
| 왜 헬스체크가 속였나 | 도커 컨테이너의 /health가 동일 포트에서 응답 |
| 해결 | docker 모드(8000)와 native 모드(8001) 포트 분리 |
| 재발 방지 | 시작 로그 확인 + 버전/커밋 포함 헬스체크 + PID 추적 |
macOS에서 OrbStack을 쓴다면, “포트가 열려 있다"는 사실과 “내 서버가 그 포트를 쓰고 있다"는 사실은 다른 이야기다. 헬스체크가 성공했다는 건 누군가가 응답했다는 것이지, 내 코드가 실행 중이라는 뜻이 아니다.