worktree 여러 개를 동시에 굴리는 dev 서버 전략 — pm2 를 버린 이유

pm2 는 프로세스 이름이 전역이라 같은 프로젝트의 worktree 를 동시에 띄울 수 없고, 시작 시점 PATH 를 박아둬서 fnm 과 충돌한다. 경로 해시 기반 결정적 포트 스크립트로 교체했다.

한 저장소에서 워크트리 서너 개를 동시에 열고, 각각에 AI 에이전트 세션을 하나씩 붙여 일을 시키기 시작했다. 그러자 dev 서버 관리 방식이 먼저 무너졌다.

원래 쓰던 건 pm2 였다. 세 가지가 맞지 않았다.

  • 프로세스 이름이 전역이다. 같은 프로젝트의 워크트리 두 개를 동시에 띄우려면 이름이 겹친다.
  • 시작 시점의 PATH 를 박아둔다. fnm 의 multishell 임시 경로가 사라지면 Cannot find module 이 난다.
  • 에이전트가 자율적으로 시작/중지하기 어렵다. 포트를 스스로 골라야 하는데 그 규칙이 없다.

대체 — 경로에서 포트를 유도하는 스크립트

~/.local/bin/dev-server 하나를 전역 PATH 에 두고, 워크트리 안에서 그냥 부른다.

dev-server start   # 시작. 이미 실행 중이면 URL 만 출력
dev-server stop    # 종료
dev-server url     # 포트 URL 출력
dev-server status  # 실행 상태 + URL
dev-server log     # tail -f .dev-server.log

start 가 이미 떠 있을 때 실패하지 않고 URL 만 뱉는 게 중요하다. 에이전트가 상태를 모른 채 start 를 불러도 안전하고, 결과에서 URL 을 바로 읽어 쓸 수 있다.

stop프로세스 그룹을 kill 한다. Gradle 로 띄운 JVM 처럼 래퍼 스크립트가 자식을 남기는 경우, 부모만 죽이면 좀비가 포트를 붙잡고 있다.

포트를 결정적으로 뽑는다

PORT=$(( 14000 + (cksum($PWD) % 1000) ))   # 14000–14999

경로 해시라서 성질이 좋다.

  • 같은 워크트리는 항상 같은 포트를 쓴다. 북마크가 유효하고, 에이전트가 알려준 URL 이 다음 세션에서도 맞는다.
  • 다른 워크트리는 다른 포트를 쓴다. 조율 없이 동시 실행된다.
  • 14000번대를 고른 건 기존 3xxx / 4xxx 대역과 안 겹치게 하려는 것뿐이다.

실제로 한 프로젝트의 메인 체크아웃이 14318, 워크트리 하나가 14523 을 잡았다. 둘이 나란히 떠 있어도 서로를 모른다.

충돌 확률이 0 은 아니다. 1000개 슬롯에 워크트리 수십 개면 생일 문제상 언젠가 겹친다. 겹치면 그때 워크트리 디렉터리 이름을 한 글자 바꾸면 되므로 회피 로직을 넣지 않았다.

프로젝트 타입 자동 감지

워크트리마다 프로젝트가 다르므로 시작 명령을 파일 존재로 판별한다.

감지 조건 타입 포트 주입
vite.config.ts / .js vite -- --port $PORT
gradlew + build.gradle spring SERVER_PORT=$PORT ./gradlew bootRun
실행 가능한 ./pm2-serve.sh static-proxy PORT=$PORT ./pm2-serve.sh
package.json"start": node PORT=$PORT $PKG run start

이 표에 안 맞는 프로젝트는 루트에 .dev-server-start 라는 실행 가능한 셸 스크립트를 두면 그걸 대신 실행한다. 감지 로직을 계속 늘리는 것보다 탈출구 하나를 두는 쪽이 오래 갔다.

.gitignore 에 두 줄을 추가한다.

.dev-server.pid
.dev-server.log

pm2 를 어디까지 버릴 것인가

전부 버리지는 않았다.

케이스 처리
프론트엔드 Vite dev 서버 완전 교체
Node.js 백엔드 교체 가능 — process.env.PORT 를 읽는 앱 한정
Spring Boot 교체 가능. 30초 startup 을 감수해야 하므로 재시작이 잦지 않을 때
프로덕션 유사 데몬 (재시작 정책, 메모리 제한, 클러스터링) pm2 유지

마지막 줄이 pm2 가 실제로 잘하는 영역이다. 개발용 dev 서버는 그 기능들이 필요 없었을 뿐이다. 개인 작업과 에이전트 워크플로에서는 pm2 가 거의 필요 없다는 결론이 났다.

참고 — fnm 과 pm2 가 싸우는 이유

버리기 전에 겪은 에러라 원인만 남겨둔다.

pm2 는 pm2 start 시점의 exec_path 를 그대로 저장한다. fnm 을 쓰면 그 경로가 이런 모양이다.

/run/user/1000/fnm_multishells/<sessionid>/bin/npm

셸 세션이 끝나면 이 임시 디렉터리가 사라진다. pm2 는 저장된 경로를 계속 쓰려 하고, Cannot find module 이 난다. 프로세스는 살아 있는데 재시작만 실패하는 상태라 원인이 잘 안 보인다.

고치려면 저장된 경로를 지우고 안정 경로로 다시 등록해야 한다.

pm2 delete <app>
pm2 start ecosystem.config.js --only <app>
pm2 save

이때 ecosystem 설정에서 fnm 의 alias 경로를 쓰면 세션과 무관하게 유지된다.

~/.local/share/fnm/aliases/default/bin

정리

멀티 워크트리로 넘어가면서 필요해진 성질은 두 개였다. 포트가 경로에서 결정적으로 나올 것, 그리고 에이전트가 상태를 몰라도 안전하게 호출할 수 있을 것. pm2 는 둘 다 전제가 다른 도구였다.

워크트리 간 .env 공유는 별개 문제다. PORT 를 공유 .env 에 넣으면 지금까지 만든 격리가 한 번에 무너진다. 그쪽은 post-checkout 훅으로 .env 심볼릭 링크에 정리했다.