현상

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이면 내 서버가 뜬 것”

이 네 가지가 겹쳐서 “서버가 잘 떴다"는 잘못된 확신을 만들어 냈다.


재현 조건

  1. OrbStack 실행 중
  2. docker-compose up (또는 이전 세션에서 컨테이너가 살아 있음)
  3. 같은 포트로 uvicorn 네이티브 실행 시도
  4. → uvicorn 기동 실패
  5. → 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을 쓴다면, “포트가 열려 있다"는 사실과 “내 서버가 그 포트를 쓰고 있다"는 사실은 다른 이야기다. 헬스체크가 성공했다는 건 누군가가 응답했다는 것이지, 내 코드가 실행 중이라는 뜻이 아니다.