
서버 20개를 한 머신에 띄우며 정한 규칙 — 희귀 포트와 WireGuard 전용 바인딩
개인 프로젝트 서버가 늘어나면 포트가 충돌하고 0.0.0.0 바인딩이 쌓인다. 포트를 프로젝트 고정값으로 박고 wg0 에만 묶기로 한 이유를 정리했다.
개인 프로젝트가 늘면서 한 머신에 서버가 여러 개 돌게 됐다. 검색 API, 토큰 사용량 대시보드, 주식 리서치 워커, 영상 생성 대시보드, 에이전트 허브… 세어보니 스무 개가 넘는다.
이쯤 되면 두 가지 문제가 반복해서 터진다. 포트가 겹치고, 열지 말아야 할 걸 열어둔다.
문제 1: 3000번 포트를 누가 쓰고 있는가
처음에는 다들 기본값을 썼다. Next.js는 3000, FastAPI는 8000, Flask는 5000. 프로젝트가 서너 개일 때는 괜찮았다.
문제가 생기는 방식은 대략 이렇다.
A 프로젝트를 띄운다. 잊는다. 며칠 뒤 B 프로젝트를 띄운다. EADDRINUSE. 뭐가 쓰고 있는지 확인하기 귀찮으니 B를 3001로 띄운다. 다음 주에 C를 띄운다. 3002. 이제 아무도 어느 프로젝트가 몇 번인지 모른다.
더 나쁜 건 자동으로 다른 포트를 잡는 코드다. “포트가 사용 중이면 +1 해서 재시도” 같은 로직이 있으면, 서버가 매번 다른 포트에 뜬다. 클라이언트 설정이 어긋나고, 헬스체크가 엉뚱한 걸 찌르고, 종료 스크립트가 남의 프로세스를 죽인다.
규칙: 포트는 프로젝트 고정값, 충돌하면 멈춘다
그래서 두 가지를 정했다.
첫째, 프로젝트마다 포트를 하나 정하고 그 값만 쓴다. high/private 범위(49152~65535)나 그 언저리에서 잘 안 쓰이는 값을 고른다. 실제로 쓰고 있는 값들이다.
| 프로젝트 | 포트 (예시) |
|---|---|
| 토큰 사용량 대시보드 | 61204 |
| 영상 생성 대시보드 | 63118 |
| 자녀 독서 앱 | 54903 |
| 주식 위험 감시 | 49277 |
| 검증 게이트 | 57621 |
| 자체 검색 API (내부용) | 39415 |
숫자 자체에 의미는 없다. 겹치지 않는 게 목적이다. 기본값(3000, 8000)을 피하는 것만으로 충돌이 거의 사라진다. (위 값은 예시다. 실제로 쓰는 포트를 공개 글에 적을 이유는 없다 — 어차피 VPN 안에서만 열리지만, 설정이 어긋나 외부에 노출되는 날 표적 목록이 되기 때문이다.)
둘째, 포트가 이미 사용 중이면 다른 포트로 우회하지 않고 멈춘다.
[오류] 포트 4321 를 이미 다른 프로세스가 사용 중입니다.
무엇이 쓰고 있는지 확인한 뒤 처리하세요: ss -ltnp | grep :4321
임의의 다른 포트로 우회하지 않습니다. 포트를 바꾸려면 .env 의 PORT 를 수정하세요.
이 블로그의 run.sh에도 같은 처리가 들어 있다. 우회는 편하지만 문제를 미룰 뿐이다. 멈추면 그 자리에서 원인을 본다.
포트를 점유한 게 내 앱의 이전 버전인 경우는 다르다. 이때는 확인 후 정상 종료하고 같은 포트로 다시 띄운다. 판단 기준은 PID 파일, 프로세스의 작업 디렉토리, 명령행이다. 확실하지 않으면 죽이지 않고 보고한다. 남의 프로세스를 죽이는 것보다 안 뜨는 편이 낫다.
문제 2: 0.0.0.0 이 쌓인다
두 번째 문제가 더 위험하다.
노트북에서 접속하려고 --host 0.0.0.0을 준다. 잘 된다. 그리고 그대로 둔다. 이게 스무 번 반복되면 머신에 전체 공개 포트가 스무 개 열려 있다.
각각이 무해해 보인다는 게 함정이다. “그냥 대시보드인데”, “인증도 있는데”. 그런데 그중 하나에 인증이 빠져 있거나, 디버그 엔드포인트가 열려 있거나, 파일 업로드가 있다.
앞서 만든 자체 검색 API가 좋은 예다. 메타 검색엔진을 외부에 열어두면 남이 내 서버로 검색을 돌린다. 내 API 키 쿼터가 남의 검색에 쓰인다. 열어두면 결국 발견된다.
규칙: 기본은 localhost, 필요하면 WireGuard 인터페이스
정한 순서는 이렇다.
1순위 — 127.0.0.1. 같은 머신에서만 쓰는 것은 전부 여기 묶는다. 내부 SearXNG처럼 다른 서비스가 내부적으로만 호출하는 것도 마찬가지다.
2순위 — WireGuard 인터페이스 IP. 다른 기기에서 접근해야 하면 0.0.0.0이 아니라 wg0에 할당된 주소에만 묶는다.
# .env
HOST=10.20.30.2 # wg0 인터페이스 IP (ip -4 addr show wg0 로 확인)
이러면 VPN 안에 있는 기기만 접근할 수 있다. 공인 IP로는 포트 스캔을 해도 닫혀 있다. 방화벽 규칙을 따로 관리할 필요도 없다. 인터페이스 자체가 경계가 된다.
3순위 — 0.0.0.0. 정말 필요할 때만. 그리고 이건 실수로 되면 안 되니까 별도 플래그로 막아뒀다.
ALLOW_PUBLIC_BIND=0 # 1 로 명시적으로 바꾸지 않으면 전체 공개 바인딩을 거부
더 나아간 방식: 잘못된 바인딩이면 아예 안 뜨게
자녀용 앱에서는 한 단계 더 갔다. HOST가 필수이고, 실제 wg0 인터페이스에 단독 할당된 IPv4만 허용한다. 미설정, loopback, wildcard, 일반 LAN 주소로는 서버가 시작하지 않는다.
처음엔 과하다고 생각했는데 실제로는 이게 맞았다. 아이가 쓰는 앱이고 책 콘텐츠가 들어 있다. “실수로 열려 있었다”가 나오면 안 되는 종류다.
어떤 값이 위험한지 아는 시점은 코드를 짤 때고, 실수하는 시점은 6개월 뒤다. 그 간극을 메우는 건 문서가 아니라 시작을 거부하는 코드다.
토큰 대시보드에서는 바인딩을 자동으로 골랐다
여러 장비의 토큰 사용량을 모으는 대시보드에서는 설정 값을 하나 더 뒀다.
TOKENBURN_BIND_HOST=auto-wg # wg0 의 IPv4 를 찾아 거기에만 바인딩
auto-wg로 두면 시작할 때 wg0의 IP를 찾아 거기에만 묶는다. WireGuard IP는 잘 안 바뀌지만, 장비를 여러 대 굴리면 각각 주소가 다르다. 장비마다 .env에 IP를 손으로 적어 넣는 대신 이름으로 지정하는 편이 낫다.
기존 WireGuard 설정, peer, route, 방화벽 규칙은 건드리지 않는다. 읽기만 하고 바인딩에만 쓴다. 앱이 VPN 설정을 고치기 시작하면 그때부터는 디버깅이 불가능해진다.
정리
- 기본 포트(3000, 8000)를 피하고 프로젝트마다 고정 포트를 정한다. 충돌 자체가 거의 사라진다
- 포트가 점유돼 있으면 다른 포트로 우회하지 말고 멈춘다. 우회는 문제를 미룰 뿐이다
- 내 앱의 이전 버전이면 정상 종료 후 같은 포트로 재기동한다. 확실하지 않으면 죽이지 않는다
- 기본 바인딩은
127.0.0.1. 다른 기기가 필요하면0.0.0.0대신 WireGuard 인터페이스에 묶는다 0.0.0.0은 별도 플래그로 막아둔다. 실수로 되면 안 된다- 중요한 앱은 잘못된 바인딩이면 아예 시작하지 않게 만든다. 6개월 뒤의 나는 규칙을 기억하지 못한다