cli 툴 만들어보았다 이름하여 porst-cli

commandline interface toolports-clicli
2026-01-14

왜 만들었나

개발하다 보면 "이 포트 뭐가 쓰고 있지?" 하는 순간이 자주 온다. 특히나 회사에서는 한 번에 여러 레포의 서버를 띄워두고 작업을 많이 하고 있기도 하다. lsof -i :3000 치면 되긴 하는데, 출력이 너무 장황하고 매번 명령어 기억하기도 귀찮았다.

그래서 만듦. ports-cli 리스닝 중인 포트를 예쁘게 보여주고, 프로세스 경로까지 알려주는 CLI 도구.

기능

  ports              # 모든 리스닝 포트 보기

  ports -u           # 내가 실행한 프로세스만 보기

  ports bye 3000     # 3000번 포트 프로세스 죽이기

ports -u 커맨드의 출력은 이렇게 나온다:

  PORT     PROCESS                  PATH
  ----     -------                  ----
  3000     my-app                   ~/Documents/GitHub/my-app
  5432     postgres                 -
  8080     test-server              ~/Documents/GitHub/test-server

내가 딱 보고 싶었던 건 현재 열린 포트, 열려 있는 앱 이름, 해당 앱의 경로.

Node 프로세스는 package.json에서 프로젝트 이름을 읽어서 보여준다. 단순히 node라고 나오는 게 아니라 실제 프로젝트 이름이 나오니까 훨씬 직관적이라고 생각해서 그렇게 구현했당.


개발 과정을 버전 순으로 나열하면 다음과 같다.

v1.0.0 - 기본 기능

처음에는 단순했다. lsof로 리스닝 포트 가져오고, Node 프로세스면 package.json에서 이름 파싱해서 보여주는 것.

  # Node 프로세스면 프로젝트 이름 가져오기
  if [ "$proc" = "node" ]; then
    cwd=$(lsof -p "$pid" 2>/dev/null | grep cwd | awk '{print $9}')
    if [ -f "$cwd/package.json" ]; then
      name=$(grep '"name"' "$cwd/package.json" | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/')
      if [ -n "$name" ]; then
        proc="$name"
      fi
    fi
  fi

v1.0.1 - -u 옵션 추가

근데 쓰다 보니까 문제가 생겼다. postgres, redis 를 비롯한 AirPlay 등의 시스템 프로세스까지 다 나오니까 내가 띄운 개발 서버를 찾기가 어려웠다.

그래서 -u (--user) 옵션을 추가했다. 사용자가 직접 띄운 프로세스가 있는 것만 필터링하는 기능이다. 쉽게 말하면, 경로가 있는 프로세스들? 시스템 프로세스는 PATH가 -로 나오니까, 이걸 기준으로 필터링하면 내가 직접 실행한 Node 프로세스만 볼 수 있다.

  if [ "$USER_ONLY" = true ]; then
    [ "$display_path" = "-" ] && continue
    [ -z "$display_path" ] && continue
  fi

여기서 삽질을 좀 했다. while read 루프 안에서 declare -A seen_ports 로 중복 포트를 추적하고 있었는데, 이게 동작을 안 했음...

원인은 파이프라인의 subshell 문제였다. lsof | while read 하면 while 루프가 subshell에서 실행되면서 변수가 부모 쉘에 전달이 안 된다.

해결책은 process substitution:


  # 이렇게 하면 안 됨 (subshell 문제)
  lsof -iTCP -sTCP:LISTEN | while read line; do
    ...
  done

  # 이렇게 해야 됨
  while read line; do
    ...
  done < <(lsof -iTCP -sTCP:LISTEN)

v1.2.0 - ports bye 명령 추가

이것도 그 즉시 포트를 킬하고 싶어서 만든 건데, 매번 PID 복사해서 kill -9 치기 귀찮아서 ports bye <port> 명령을 추가했다. bye~

  kill_port() {
    local port=$1
    local pid=$(lsof -ti TCP:$port -sTCP:LISTEN 2>/dev/null)

    if [ -z "$pid" ]; then
      echo "${RED}No process found on port $port${RESET}"
      exit 1
    fi

    kill -9 $pid 2>/dev/null

    if [ $? -eq 0 ]; then
      echo "${GREEN}✓ Killed $proc_name (PID: $pid) on port $port${RESET}"
    fi
  }

그리고 또 버그 ... macOS 기본 bash가 3.x 버전이라 declare -A (associative array)를 지원 안 한다. Homebrew로 배포하려면 기본 bash에서도 돌아가야 해서 문자열 기반으로 변경했다.

  # bash 4.x 이상에서만 동작
  declare -A seen_ports
  seen_ports[$port]=1

  # bash 3.x 호환
  seen_ports=""
  if echo "$seen_ports" | grep -q ":${port}:"; then
    continue
  fi
  seen_ports="${seen_ports}:${port}:"

이왕 만든 거 Homebrew로 배포하고 싶었다. 왜냐면 한 번도 안 해 보기도 했고, 회사에서 만든 프로젝트라 집에서는 편하게 설치하고 싶었기 때문이다. 진짜 별거 없는 이유... 지만...

  1. homebrew-tap 레포 생성

GitHub에 homebrew-tap 레포를 만들고, Formula/ports-cli.rb 파일 작성:

  class PortsCli < Formula
    desc "Show listening ports with process names and paths"
    homepage "https://github.com/givvemee/ports-cli"
    url "https://github.com/givvemee/ports-cli/archive/refs/tags/v1.2.0.tar.gz"
    sha256 "3fd0635de73b07fb87d6bdd533312e9b3fa2ab3ed286dacf2be58cf3c27d2e86"
    license "MIT"

    def install
      bin.install "bin/ports"
    end

    test do
      system "#{bin}/ports", "--version"
    end
  end
  1. 릴리즈 태그 만들기
  git tag v1.2.0
  git push origin v1.2.0
  1. sha256 해시 구하기
  curl -sL https://github.com/givvemee/ports-cli/archive/refs/tags/v1.2.0.tar.gz | shasum -a 256

이게 좀 귀찮은데, 태그 만들 때마다 새로 해시 구해서 Formula 업데이트해야 한다. 근데 크게 변경 사항은 없을 것 같으니까 조금의 귀찮음은 감안할 수 있다.

  1. 설치
  brew tap givvemee/tap
  brew install ports-cli

끝! 별 건 아니지만 요긴하게 잘 쓰고 있다.