🔀

Portless 동작 원리

localhost:3000 대신 myapp.localhost — 포트 번호를 이름으로 바꾸는 프록시

로컬 개발을 하다 보면 localhost:3000, localhost:3001, localhost:5173... 포트 번호 지옥에 빠진다. 뭐가 몇번이었는지 기억이 안 나고, AI 에이전트한테 "3000번으로 접속해" 라고 말해봐야 다음에 바뀌면 또 알려줘야 한다.

Portless는 이 문제를 근본적으로 해결한다. portless myapp next dev 로 실행하면 http://myapp.localhost 로 접근 가능.

핵심 구조: 이름 → 포트 매핑 프록시

브라우저                    Portless Proxy (443)           개발 서버
myapp.localhost ──────→  routes.json 조회 ──────→  localhost:4231
api.myapp.localhost ──→  서브도메인 매칭 ──────→  localhost:4582

실행 흐름: portless myapp next dev

  1. 프록시 데몬 확인~/.portless/proxy.pid 로 기존 데몬 존재 여부 체크. 없으면 자동 시작 (detached child process). 항상 떠있는 시스템 서비스가 아니라 처음 사용할 때 자동 기동

  2. 빈 포트 할당 — 4000~4999 범위에서 랜덤 포트 탐색. net.createServer().listen() 으로 실제 바인딩 테스트

  3. 프레임워크 감지 + 포트 주입

  • Next.js, Express, Remix → PORT 환경변수를 자동 인식하므로 env 에 넣기만 하면 됨

  • Vite, Astro, Angular → PORT 를 안 보는 프레임워크. --port, --host 플래그를 CLI 인자에 자동 삽입

// Vite, Astro 등은 PORT env를 안 봐서 직접 플래그 주입
const FRAMEWORKS_NEEDING_PORT = {
  vite: { strictPort: true },
  astro: { strictPort: false },
  ng: { strictPort: false },    // Angular CLI
};

  1. 라우트 등록routes.json{ hostname: 'myapp', port: 4231, pid: 12345 } 기록

  2. 자식 프로세스 실행PORT=4231 HOST=127.0.0.1 next dev 실행. 종료 시 라우트 자동 제거

프록시 서버 내부 (proxy.ts)

포트 443 에서 TCP 소켓의 첫 바이트를 peek 해서 TLS/평문 HTTP를 분기:

socket.once('readable', () => {
  const buf = socket.read(1);
  socket.unshift(buf);          // 바이트를 다시 돌려놓음
  if (buf[0] === 0x16) {        // 0x16 = TLS ClientHello
    h2Server.emit('connection', socket);
  } else {
    plainServer.emit('connection', socket);
  }
});

하나의 포트에서 HTTPS/H2 와 평문 HTTP 를 동시에 처리.

라우팅 로직

function findRoute(routes, host, strict) {
  return (
    routes.find(r => r.hostname === host) ||       // 정확 매칭
    (!strict &&
      routes.find(r => host.endsWith('.' + r.hostname)))  // 서브도메인
  );
}

  • myapp.localhost → 정확 매칭 → port 4231

  • api.myapp.localhost → 서브도메인 매칭 → port 4231 (같은 서버)

  • 루프 감지: x-portless-hops 헤더를 매 요청마다 +1, 5회 이상이면 508 에러. Vite 의 dev server proxy 가 Host 헤더를 안 바꾸고 다시 portless 로 보내는 실수를 방지

.localhost 가 작동하는 이유

.localhost 는 RFC 2606 예약 TLD. 대부분의 브라우저(Chrome, Firefox, Edge)가 *.localhost/etc/hosts 수정 없이 자동으로 127.0.0.1 로 해석한다.

Safari 만 안 됨 → portless 가 /etc/hosts# portless-start / # portless-end 마커 사이에 엔트리를 자동 동기화.

HTTPS 인증서 자동 생성

  1. 자체 CA 생성 — EC 키(prime256v1), SHA-256, 10년 유효. ~/.portless/ 에 저장
  2. SNI 콜백 — TLS 핸드셰이크 시 요청된 hostname 에 맞는 인증서를 동적 생성 + 캐싱
  3. 시스템 신뢰 저장소 등록portless trust 명령으로 CA를 macOS Keychain / Linux ca-certificates 에 설치
  4. 자식 프로세스에 NODE_EXTRA_CA_CERTS 주입 → Node.js 개발 서버가 자동으로 CA 를 신뢰

상태 관리 (파일 기반)

~/.portless/
├── routes.json     # 라이브 라우트 테이블
├── proxy.pid       # 데몬 PID
├── proxy.port      # 데몬 포트
├── tls             # TLS 활성화 마커
└── tld             # 현재 TLD (기본: localhost)

프록시는 매 요청마다 routes.json 을 다시 읽는다. 인메모리 캐시 없음. 자식 프로세스가 파일에 쓰면 즉시 반영. 파일 잠금은 디렉토리 생성(mkdir)으로 원자적 뮤텍스 구현.

좀비 라우트는 loadRoutes() 시 PID 생존 여부(kill(pid, 0))를 체크해서 자동 제거.

Worktree 지원 (v0.5.2+)

git worktree list 를 실행해서 현재 워크트리의 브랜치명을 추출, hostname 앞에 붙인다:

main 브랜치:    myapp.localhost
feat-auth 브랜치: feat-auth.myapp.localhost

같은 프로젝트를 여러 워크트리로 동시 개발할 때 포트 충돌 없이 각각 접근 가능.

이 프로젝트의 Rails Start Template 에서의 의미

우리 프로젝트는 이미 SimpleHostConstraint + host_groups.rb 로 비슷한 구조를 쓰고 있다:

app5.localhost:30207  → Langstagram
app10.localhost:30207 → API Tester
app67.localhost:30207 → LanLan

Portless 와의 차이: 우리는 Rails 라우터 레벨에서 Host 기반 분기를 하지만, Portless 는 OS 레벨 리버스 프록시로 완전히 다른 프로세스(Node, Python, Go 등)까지 커버한다. 둘은 보완적 관계.

동작 흐름

1

CLI가 프록시 데몬 존재 확인 → 없으면 detached process 로 자동 기동 (포트 443)

2

4000~4999 범위에서 빈 포트 할당 + 프레임워크별 포트 주입 (PORT env 또는 --port 플래그)

3

routes.json 에 { hostname, port, pid } 등록 → 프록시가 매 요청마다 이 파일을 읽어 라우팅

4

TCP 첫 바이트 peek (0x16 = TLS) 으로 HTTPS/H2 vs 평문 HTTP 분기 — 포트 하나로 둘 다 처리

5

SNI 콜백으로 hostname 별 인증서 동적 생성 + 캐싱. 자체 CA 를 시스템 신뢰 저장소에 등록

6

자식 프로세스 종료 시 routes.json 에서 자동 제거 + PID 생존 체크로 좀비 라우트 GC

장점

  • 포트 번호를 기억할 필요 없음 — 이름 기반 URL
  • HTTPS + HTTP/2 자동 — 인증서 생성/설치까지 원스텝
  • 프레임워크 무관 — Next.js, Vite, Express, Nuxt 모두 지원
  • 항시 기동 아님 — 첫 사용 시 자동 시작, 시스템 서비스 등록 불필요
  • Worktree + 서브도메인 지원으로 브랜치별 독립 접근

단점

  • 포트 443 사용 시 macOS/Linux 에서 sudo 필요
  • Safari 는 *.localhost 자동 해석 미지원 → /etc/hosts 동기화 필요
  • routes.json 을 매 요청마다 읽음 — 초고빈도 요청 시 I/O 부하 가능
  • Docker 컨테이너 내부에서는 추가 설정 필요 (PID 강제 지정)
  • Node.js 20+ 필수, Windows 미지원

사용 사례

모노레포에서 여러 서비스를 동시 실행 (frontend.localhost, api.localhost, admin.localhost) AI 에이전트에게 URL 기반으로 개발 서버 접근 지시 (포트 번호 대신 이름) HTTPS 필수 API (OAuth 콜백 등) 로컬 테스트 git worktree 별 독립 URL 자동 부여 (feat-auth.myapp.localhost)