포트 사용 중 확인하고 종료하는 법: EADDRINUSE·Address already in use 해결 (윈도우·리눅스·맥)

이 글의 핵심

EADDRINUSE, Address already in use, Port 8080 was already in use는 모두 '그 포트를 이미 다른 소켓이 잡고 있다'는 같은 뜻입니다. 이 글은 운영체제별로 점유 프로세스를 찾는 명령, 안전하게 종료하는 순서, 그리고 종료해도 다시 살아나거나 아예 프로세스가 보이지 않는 경우(systemd 재시작, Docker 컨테이너, 윈도우 Hyper-V·WSL 예약 포트, 맥 AirPlay 수신 모드)를 원인별로 정리합니다.

서버가 안 뜨는 이유는 하나: 그 포트에 이미 소켓이 있다

개발 서버를 실행했는데 바로 죽으면서 아래 같은 메시지가 나오는 경우가 있습니다. 언어와 프레임워크마다 문구가 다르지만 뜻은 같습니다. 서버가 bind()로 특정 주소와 포트를 잡으려 했는데, 운영체제가 “그 자리는 이미 다른 소켓이 쓰고 있다”며 거절한 것입니다.

Node.js(Express, Next.js 등):

Error: listen EADDRINUSE: address already in use :::3000

Python(리눅스). 맥에서는 번호만 48로 바뀝니다.

OSError: [Errno 98] Address already in use
OSError: [Errno 48] Address already in use

Python(윈도우):

OSError: [WinError 10048] Only one usage of each socket address (protocol/network address/port) is normally permitted

한국어 윈도우에서는 이 메시지가 운영체제 언어로 번역되어 나옵니다. 윈도우 11에서 Python 3.13으로 같은 포트에 소켓 두 개를 bind해 보면 이렇게 출력됩니다. 검색할 때는 영어 원문과 한국어 문구 중 실제로 본 쪽으로 찾으면 됩니다.

OSError: [WinError 10048] 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다

Spring Boot:

***************************
APPLICATION FAILED TO START
***************************

Description:

Web server failed to start. Port 8080 was already in use.

Docker:

Error response from daemon: driver failed programming external connectivity on endpoint web (...): Bind for 0.0.0.0:8080 failed: port is already allocated

가장 흔한 원인은 의외로 단순합니다. 이전에 띄운 개발 서버가 아직 살아 있는 경우입니다. 터미널 창을 닫았는데 프로세스는 백그라운드에 남았거나, IDE의 실행 버튼을 두 번 눌렀거나, npm run dev를 Ctrl+Z로 멈춰 놓고(일시 정지일 뿐 종료가 아닙니다) 잊어버린 경우가 대부분입니다. 그다음으로 많은 것이 같은 포트를 기본값으로 쓰는 다른 프로그램(Docker 컨테이너, 로컬에 설치된 Tomcat·Jenkins, 맥의 AirPlay 수신 모드)입니다.

해결 순서는 운영체제와 상관없이 같습니다.

  1. 그 포트를 LISTEN 중인 프로세스의 PID를 찾습니다.
  2. PID로 프로그램 이름과 실행 경로를 확인해서, 죽여도 되는 것인지 판단합니다.
  3. 정상 종료 신호부터 보내고, 안 되면 강제 종료합니다.
  4. 종료했는데 다시 살아나면 그 프로세스를 관리하는 상위 주체(systemd, Docker, 서비스 관리자)를 멈춥니다.

2번을 건너뛰고 PID만 보고 바로 죽이는 것이 가장 흔한 실수입니다. 같은 포트 번호를 데이터베이스나 회사 VPN 클라이언트가 쓰고 있을 수도 있기 때문입니다.

기준 환경: 윈도우 11(23H2/24H2, PowerShell 5.1과 명령 프롬프트), 우분투 24.04 LTS(iproute2의 ss, lsof, psmisc의 fuser), macOS 14 Sonoma·15 Sequoia. 다른 리눅스 배포판도 명령은 같고 패키지 이름만 다를 수 있습니다.

윈도우: netstat과 Get-NetTCPConnection으로 PID 찾기

netstat -ano로 PID 확인

명령 프롬프트나 PowerShell에서 아래처럼 실행합니다. -a는 모든 연결과 대기 포트, -n은 이름 대신 숫자 주소, -o는 소유 프로세스 ID(PID)를 표시하라는 뜻입니다.

netstat -ano | findstr :3000
  TCP    0.0.0.0:3000           0.0.0.0:0              LISTENING       18244
  TCP    [::]:3000              [::]:0                 LISTENING       18244
  TCP    127.0.0.1:3000         127.0.0.1:53122        TIME_WAIT       0
  TCP    127.0.0.1:53122        127.0.0.1:3000         TIME_WAIT       0

여기서 봐야 할 것은 두 번째 열(로컬 주소)이 :3000으로 끝나고 상태가 LISTENING인 줄의 마지막 숫자, 즉 18244입니다. 나머지 줄은 이미 끝난 연결의 흔적이라 신경 쓰지 않아도 됩니다. TIME_WAIT 줄의 PID가 0으로 나오는 것은 소유 프로세스가 이미 없는 연결이라서입니다.

findstr :3000은 단순 문자열 검색이라 :30000이나 :3000을 원격 포트로 가진 줄까지 함께 걸립니다. 출력이 많아 헷갈리면 LISTENING만 남깁니다.

netstat -ano | findstr LISTENING | findstr :3000

PowerShell의 Get-NetTCPConnection

PowerShell에서는 문자열을 자를 필요 없이 포트와 상태를 조건으로 바로 걸 수 있어서 더 정확합니다.

Get-NetTCPConnection -LocalPort 3000 -State Listen |
  Select-Object LocalAddress, LocalPort, OwningProcess

PID를 프로세스 정보와 바로 연결하려면 아래처럼 씁니다. Path 속성으로 실행 파일 위치까지 볼 수 있어서 “이 node.exe가 어느 프로젝트의 것인지” 판단하는 데 도움이 됩니다.

Get-NetTCPConnection -LocalPort 3000 -State Listen |
  ForEach-Object { Get-Process -Id $_.OwningProcess } |
  Select-Object Id, ProcessName, Path

해당 포트에 LISTEN 중인 소켓이 없으면 No MSFT_NetTCPConnection objects found with property 'LocalPort' equal to '3000' 같은 오류가 납니다. 이 경우는 실제로 아무도 그 포트를 쓰지 않는 것이므로 아래 “프로세스가 안 보이는데 포트를 못 쓰는 경우”로 넘어가야 합니다.

tasklist로 확인하고 taskkill로 종료

명령 프롬프트에서는 PID로 프로세스 이름을 먼저 확인합니다.

tasklist /FI "PID eq 18244"
이미지 이름                    PID 세션 이름              세션#  메모리 사용
========================= ======== ================ =========== ============
node.exe                     18244 Console                    1     85,412 K

죽여도 되는 프로세스라는 것을 확인했으면 종료합니다.

taskkill /PID 18244
taskkill /PID 18244 /F
taskkill /PID 18244 /T /F

/F 없이 실행하면 창이 있는 프로그램에는 닫기 요청을 보내지만, node.exe나 java.exe처럼 콘솔 프로그램은 대개 이 요청에 반응하지 않아서 /F(강제)가 필요한 경우가 많습니다. /T는 자식 프로세스까지 함께 종료합니다. npm run dev처럼 npm이 node를 자식으로 띄우는 구조에서는 부모만 죽이면 실제로 포트를 잡고 있던 자식이 남을 수 있으므로, 포트를 잡은 PID 자체를 죽이거나 /T를 붙이는 편이 확실합니다.

PowerShell이라면 Stop-Process -Id 18244 -Force가 같은 역할을 합니다. 다른 사용자 계정이나 관리자 권한으로 실행된 프로세스는 일반 권한 터미널에서 액세스가 거부되었습니다 오류가 나므로, 터미널을 관리자 권한으로 다시 열어야 합니다.

리소스 모니터로 눈으로 확인하기

명령어가 익숙하지 않다면 Win+R에서 resmon을 실행해 리소스 모니터를 엽니다. 네트워크 탭의 수신 대기 포트 항목을 펼치면 포트 번호, 프로세스 이름, PID가 표로 나옵니다. 포트 열을 클릭해 정렬하면 원하는 번호를 빨리 찾을 수 있고, 해당 프로세스를 우클릭해 바로 종료할 수도 있습니다. 작업 관리자의 “세부 정보” 탭에서 PID 열을 기준으로 찾아 종료하는 것도 같은 결과입니다.

PID가 4(System)로 나오는 경우

80, 443, 8080 같은 포트의 PID가 4로 나오면 실제 소유자는 커널 모드 HTTP 드라이버인 http.sys입니다. IIS, 일부 SQL Server 구성 요소, Windows 원격 관리(WinRM) 등이 http.sys를 통해 포트를 예약하기 때문에 프로세스 목록에서는 System으로만 보입니다. 이때는 System 프로세스를 죽이는 것이 아니라(죽일 수도 없습니다) 어떤 서비스가 그 URL을 등록했는지 확인해야 합니다.

netsh http show servicestate

출력에서 해당 포트가 들어간 URL 그룹과 그 요청 큐를 소유한 프로세스 ID를 찾을 수 있습니다. IIS라면 services.msc에서 World Wide Web Publishing Service를 중지하거나, 쓰지 않는다면 “Windows 기능 켜기/끄기”에서 IIS를 끄면 됩니다.

윈도우: 프로세스가 안 보이는데 포트를 못 쓰는 경우

netstat에도, Get-NetTCPConnection에도 아무것도 안 나오는데 서버가 그 포트를 못 잡는 경우가 있습니다. 이때 오류 메시지를 자세히 보면 “이미 사용 중”이 아니라 “권한이 없다”로 바뀌어 있습니다.

OSError: [WinError 10013] An attempt was made to access a socket in a way forbidden by its access permissions
Error: listen EACCES: permission denied 0.0.0.0:3000

Docker Desktop에서는 이런 형태로 나옵니다.

Error response from daemon: Ports are not available: exposing port TCP 0.0.0.0:8080 -> 0.0.0.0:0: listen tcp 0.0.0.0:8080: bind: An attempt was made to access a socket in a way forbidden by its access permissions.

가장 흔한 원인은 Hyper-V, WSL2, Docker Desktop이 사용하는 Windows NAT 드라이버(winnat)가 포트 범위를 통째로 예약해 둔 것입니다. 이 예약은 프로세스가 아니라 운영체제 수준에서 걸리기 때문에 netstat에는 나오지 않습니다. 예약된 범위는 아래 명령으로 확인합니다.

netsh interface ipv4 show excludedportrange protocol=tcp

아래는 WSL2와 Docker Desktop이 설치된 윈도우 11(24H2 계열, 빌드 26200) PC에서 실제로 실행한 결과입니다. 표시 언어가 영어인 PC라 머리글이 영어로 나왔고, 한국어 표시 언어에서는 “프로토콜 tcp 포트 제외 범위”처럼 번역되어 나옵니다.

Protocol tcp Port Exclusion Ranges

Start Port    End Port
----------    --------
      5426        5426
      8772        8871
      8872        8971
      9014        9113
      9187        9286
     27339       27339
     50000       50059     *
     54235       54235
     54288       54288

* - Administered port exclusions.

8772부터 8971까지 200개 포트가 통째로 잡혀 있는 것이 보입니다. 이 범위 안의 8800번에 Python으로 bind해 보면, netstat에는 아무것도 없는데도 아래처럼 10013이 납니다. 같은 PC에서 범위 밖의 포트(38123)는 문제없이 열렸습니다.

PermissionError: [WinError 10013] 액세스 권한에 의해 숨겨진 소켓에 액세스를 시도했습니다

이 PC라면 Jupyter 기본 포트인 8888도 8872~8971 범위에 들어가서, 다른 프로그램이 없는데도 Jupyter가 기본 포트로 뜨지 못합니다. 내가 쓰려는 포트가 어느 범위 안에 들어 있다면 원인이 확인된 것입니다. 예약 범위는 부팅할 때마다 바뀔 수 있어서, 어제는 되던 포트가 재부팅 후 갑자기 안 되는 현상이 이 때문에 생깁니다.

해결은 관리자 권한 터미널에서 winnat 서비스를 잠시 멈췄다가 다시 시작하는 것입니다. 서비스가 다시 올라오면서 예약 범위를 새로 잡기 때문에 대개 원하는 포트가 풀립니다.

net stop winnat
net start winnat

winnat을 멈추는 동안 WSL2와 Docker Desktop의 네트워크가 잠시 끊기므로, 실행 중인 컨테이너나 WSL 배포판이 있다면 그 점을 감안해야 합니다. 같은 일이 반복된다면 자주 쓰는 개발 포트를 미리 예약해서 winnat이 가져가지 못하게 할 수 있습니다. 이 명령은 해당 포트가 이미 예약 범위에 들어 있으면 실패하므로, winnat을 멈춘 상태에서 실행합니다.

net stop winnat
netsh int ipv4 add excludedportrange protocol=tcp startport=3000 numberofports=1
net start winnat

이렇게 추가한 항목은 위 목록에서 *가 붙은 “관리되는 포트 제외”로 표시되며, 내 프로그램은 그 포트를 정상적으로 쓸 수 있고 Hyper-V만 그 범위를 건드리지 않게 됩니다.

예약 범위가 1024 근처부터 넓게 잡힌다면 TCP 동적 포트 범위가 기본값(49152부터 16384개)에서 바뀌어 있을 수 있습니다. netsh int ipv4 show dynamicport tcp로 확인하고, 시작 포트가 1024 같은 낮은 값이면 netsh int ipv4 set dynamic tcp start=49152 num=16384로 되돌린 뒤 재부팅하는 방법이 알려져 있습니다. 다만 일부 VPN이나 보안 소프트웨어가 의도적으로 바꿔 둔 경우도 있으니 회사 PC라면 관리자와 확인하는 편이 좋습니다.

WSL2에서 띄운 서버가 윈도우 쪽 포트를 잡는 경우

WSL2 안에서 localhost:3000으로 서버를 띄우면 WSL의 localhost 포워딩 기능이 윈도우 쪽에도 같은 포트를 열어 줍니다. 이때 윈도우에서 netstat을 보면 포트 소유자가 wslrelay.exe로 나옵니다. wslrelay.exe를 죽이는 것은 의미가 없고, WSL 안에서 원래 서버 프로세스를 찾아 종료해야 합니다. WSL 안에서는 아래 리눅스 명령이 그대로 통하고, 원인을 찾기 어렵다면 PowerShell에서 wsl --shutdown으로 WSL 전체를 내리는 방법도 있습니다.

리눅스: ss·lsof·fuser로 점유 프로세스 찾기

ss -ltnp

우분투를 비롯한 최근 배포판은 netstat이 들어 있는 net-tools를 기본으로 설치하지 않고, 대신 iproute2의 ss를 씁니다. 옵션은 -l(LISTEN 상태만), -t(TCP), -n(숫자로 표시), -p(프로세스 정보)입니다.

sudo ss -ltnp 'sport = :3000'
State  Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0      511          0.0.0.0:3000      0.0.0.0:*     users:(("node",pid=48211,fd=22))

users:(("node",pid=48211,fd=22))에서 프로세스 이름과 PID를 읽으면 됩니다. 필터 문법이 낯설다면 sudo ss -ltnp | grep :3000도 충분합니다.

여기서 sudo가 중요합니다. 일반 사용자로 실행하면 다른 사용자(root, www-data, postgres 등)가 소유한 소켓은 줄은 나오지만 Process 열이 비어 있습니다. 그래서 “포트는 LISTEN인데 프로세스가 없다”고 착각하기 쉽습니다. 내 계정으로 띄운 개발 서버라면 sudo 없이도 보이지만, nginx나 데이터베이스처럼 서비스로 돌아가는 프로세스를 찾을 때는 반드시 sudo를 붙입니다.

lsof -i

lsof는 “열린 파일 목록”을 보여 주는 도구인데, 리눅스에서는 소켓도 파일로 취급되므로 포트 점유를 확인할 수 있습니다.

sudo lsof -i :3000
sudo lsof -nP -iTCP:3000 -sTCP:LISTEN
COMMAND   PID  USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
node    48211 dev    22u  IPv4 512340      0t0  TCP *:3000 (LISTEN)

첫 번째 형태는 그 포트와 관련된 모든 연결(클라이언트로서 접속한 것 포함)을 보여 주고, 두 번째는 LISTEN 중인 서버만 걸러 냅니다. 브라우저가 그 포트에 접속해 있으면 첫 번째 명령에 브라우저 프로세스도 나오므로, 죽일 대상을 고를 때는 (LISTEN)이 붙은 줄을 봐야 합니다. -t 옵션을 붙이면 PID만 출력하므로 kill $(lsof -t -iTCP:3000 -sTCP:LISTEN)처럼 이어 쓸 수 있습니다.

우분투 서버 최소 설치에는 lsof가 없을 수 있으니 sudo apt install lsof로 설치합니다.

fuser

fuser는 포트 번호만으로 PID를 바로 보여 주고, 옵션 하나로 종료까지 할 수 있습니다.

sudo fuser 3000/tcp
sudo fuser -k -TERM 3000/tcp

주의할 점은 fuser -k의 기본 신호가 SIGTERM이 아니라 SIGKILL이라는 것입니다. -TERM을 명시하지 않으면 정리할 기회도 주지 않고 바로 강제 종료합니다. 리눅스 명령어 전반은 개발자를 위한 리눅스·맥 명령어 치트시트에 따로 정리해 두었습니다.

kill -15와 kill -9의 차이

PID를 찾았으면 종료합니다. 처음부터 -9를 쓰지 말고 기본 신호부터 보내는 것이 원칙입니다.

kill 48211        # 기본값 SIGTERM(15)
kill -9 48211     # SIGKILL, 위가 안 통할 때만

SIGTERM은 “이제 종료해 달라”는 요청이라 프로세스가 받아서 처리할 수 있습니다. 웹 서버는 이 신호를 받으면 새 연결을 받지 않고 처리 중인 요청을 마무리한 뒤, 데이터베이스 연결을 닫고 임시 파일을 지우고 종료합니다. SIGKILL은 커널이 프로세스를 즉시 없애는 것이라 이런 정리가 전혀 일어나지 않습니다. 개발 서버라면 큰 문제가 없지만, 데이터베이스나 파일을 쓰는 중인 프로세스에 -9를 보내면 잠금 파일이 남아서 다음 실행이 실패하거나 데이터가 손상될 수 있습니다. SIGTERM을 보내고 몇 초 기다려도 포트가 계속 잡혀 있을 때만 -9를 씁니다.

-9로 부모 프로세스만 죽였을 때 자식 프로세스가 고아로 남아 포트를 계속 잡는 경우도 있습니다. 서버가 포크 방식으로 워커를 띄우면 리스닝 소켓이 자식에게 상속되기 때문입니다. ss -ltnp 출력의 users 괄호 안에 PID가 여러 개 나오면 그 PID들이 모두 같은 소켓을 공유하고 있다는 뜻이고, 전부 종료되어야 포트가 풀립니다.

리눅스: 죽였는데 다시 살아나는 프로세스

PID를 종료했는데 몇 초 뒤 다시 확인하면 다른 PID로 같은 프로그램이 포트를 잡고 있는 경우가 있습니다. 프로세스를 감시하다가 죽으면 다시 띄우는 관리자가 있다는 뜻입니다. 우분투에서는 대부분 systemd입니다.

systemd는 PID만 주면 그 프로세스가 어느 서비스(unit)에 속하는지 알려 줍니다.

systemctl status 48211
● nginx.service - A high performance web server and a reverse proxy server
     Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled; preset: enabled)
     Active: active (running) since ...

서비스 이름을 알았으면 프로세스를 직접 죽이지 말고 서비스를 멈춥니다. Restart=always나 Restart=on-failure가 설정된 서비스는 kill로 죽이면 실패로 간주해 바로 다시 시작하기 때문입니다.

sudo systemctl stop nginx        # 지금 멈춤
sudo systemctl disable nginx     # 부팅 시 자동 시작 끔

로컬 개발 PC에 Apache·nginx·Jenkins·Tomcat 같은 패키지를 apt로 설치하면 설치와 동시에 서비스가 켜져서 80이나 8080 포트를 차지하는 경우가 많습니다.

systemd 외에도 pm2, supervisord, Docker의 재시작 정책이 같은 증상을 만듭니다. pm2라면 pm2 list로 확인하고 pm2 stop <이름>으로 멈춥니다.

방금 끈 서버를 다시 켰는데 잠깐 실패하는 이유: TIME_WAIT

서버를 Ctrl+C로 정상 종료하고 바로 다시 실행했는데 Address already in use가 나고, 1분쯤 기다리면 정상적으로 뜨는 경우가 있습니다. 이때 ss -ltnp로 보면 LISTEN 중인 프로세스는 없습니다. 원인은 TCP의 TIME_WAIT 상태입니다.

ss -tan state time-wait '( sport = :3000 )'

TCP 연결을 먼저 끊은 쪽은 연결이 끝난 뒤에도 일정 시간 TIME_WAIT 상태로 그 주소·포트 조합을 붙잡아 둡니다. 네트워크에 늦게 도착한 이전 연결의 패킷이 같은 포트로 새로 맺어진 연결에 섞이는 것을 막기 위해서입니다. 리눅스는 이 시간이 60초로 고정되어 있습니다. 서버를 종료하면 서버 쪽에서 클라이언트 연결들을 먼저 끊게 되므로, 직전까지 접속이 있었다면 서버 포트에 TIME_WAIT 연결이 여러 개 남습니다. TCP 연결 종료 과정은 TCP 프로토콜 가이드에서 더 자세히 다룹니다.

이 상태에서 새 서버가 bind를 시도하면, 소켓에 SO_REUSEADDR 옵션이 설정되어 있지 않은 경우 커널이 거절합니다. SO_REUSEADDR는 “LISTEN 중인 소켓만 없다면 TIME_WAIT가 남아 있어도 bind를 허용하라”는 옵션입니다. Node.js(libuv), nginx, Python의 http.server, asyncio 서버 등 대부분의 서버 프레임워크는 리눅스·맥에서 이 옵션을 기본으로 켜기 때문에 이 문제를 잘 겪지 않습니다. 직접 socket으로 서버를 짜거나 socketserver.TCPServer를 그대로 쓰면 기본값이 꺼져 있어 증상이 나타납니다.

import socket

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)  # bind 전에 설정
s.bind(("0.0.0.0", 3000))
s.listen()
import socketserver

class Server(socketserver.TCPServer):
    allow_reuse_address = True

이 옵션은 반드시 bind() 전에 설정해야 효과가 있습니다. 또 이 옵션이 있어도 다른 프로세스가 LISTEN 중이라면 bind는 여전히 실패합니다. 즉 “이전 서버 프로세스가 정말 살아 있는 경우”를 해결해 주지는 않습니다.

윈도우에서는 SO_REUSEADDR의 의미가 다릅니다. 윈도우에서는 이 옵션이 다른 프로세스가 사용 중인 포트에 끼어드는 것까지 허용해 버리므로, 윈도우 서버 코드에서는 오히려 SO_EXCLUSIVEADDRUSE를 쓰는 것이 권장됩니다. 크로스 플랫폼 코드를 짤 때 리눅스 습관대로 윈도우에도 SO_REUSEADDR를 켜면, 같은 포트로 서버가 두 개 떠도 오류가 나지 않는 이상한 상황이 생길 수 있습니다.

맥: lsof로 확인하고 AirPlay 수신 모드 의심하기

lsof -nP -iTCP:포트 -sTCP:LISTEN

macOS에는 ss가 없고, lsof가 기본으로 들어 있습니다.

lsof -nP -iTCP:3000 -sTCP:LISTEN
COMMAND   PID USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
node    71234 me     23u  IPv6 0x1a2b3c4d5e6f7a8b      0t0  TCP *:3000 (LISTEN)

-n은 IP 주소를 호스트 이름으로 바꾸지 않게, -P는 포트 번호를 서비스 이름으로 바꾸지 않게 합니다. -P를 빼면 8080이 http-alt처럼 /etc/services에 등록된 이름으로 표시되어 grep 8080으로 찾을 때 걸리지 않습니다. 둘 다 이름 조회를 건너뛰므로 출력도 빨라집니다.

리눅스와 마찬가지로 root나 다른 사용자가 띄운 프로세스는 sudo를 붙여야 보입니다. Homebrew로 설치한 PostgreSQL이나 Redis를 brew services로 띄웠다면 내 계정 소유라서 sudo 없이 보이고, 종료는 kill보다 brew services stop postgresql@16처럼 서비스 쪽에서 하는 것이 맞습니다. launchd가 다시 띄우기 때문입니다.

종료 방법은 리눅스와 같습니다.

kill 71234
kill $(lsof -t -iTCP:3000 -sTCP:LISTEN)

5000·7000 포트는 AirPlay 수신 모드가 잡고 있다

macOS 12 Monterey부터 맥이 AirPlay 수신기 역할을 할 수 있게 되면서, 기본적으로 켜진 AirPlay 수신 모드가 5000번과 7000번 포트를 LISTEN합니다. Flask의 기본 포트가 5000이라서, 맥에서 flask run을 하면 바로 아래 오류를 보게 됩니다. 브라우저로 localhost:5000에 접속하면 Flask 대신 403 응답이 돌아오는 것도 같은 원인입니다.

OSError: [Errno 48] Address already in use

lsof로 확인하면 소유자가 ControlCe(ControlCenter)로 나옵니다.

sudo lsof -nP -iTCP:5000 -sTCP:LISTEN

ControlCenter는 메뉴 막대의 제어 센터 프로세스라 죽여도 바로 다시 뜹니다. 해결 방법은 두 가지입니다. 하나는 시스템 설정 → 일반 → AirDrop 및 Handoff에서 AirPlay 수신 모드를 끄는 것이고, 다른 하나는 내 앱의 포트를 바꾸는 것(flask run --port 5001)입니다. 맥에서 아이폰 화면 미러링을 받을 일이 없다면 끄는 쪽이 간단하고, 팀원 모두 맥을 쓰는 프로젝트라면 아예 기본 포트를 5000이 아닌 번호로 정해 두는 편이 반복되는 문의를 줄여 줍니다.

Docker 컨테이너가 포트를 잡고 있을 때

docker run -p 8080:80이나 Compose의 ports:로 포트를 공개한 컨테이너가 실행 중이면, 호스트의 그 포트는 컨테이너 몫이 됩니다. 이 경우 호스트에서 다른 서버를 띄우면 EADDRINUSE가 나고, 반대로 호스트 프로세스가 포트를 먼저 잡고 있으면 컨테이너 쪽에서 port is already allocated나 address already in use가 납니다.

리눅스에서 sudo ss -ltnp로 보면 포트 소유자가 docker-proxy로 나옵니다. 이것은 Docker가 포트 포워딩을 위해 띄운 보조 프로세스라 kill해도 의미가 없고, Docker 데몬의 상태를 꼬이게 만들 수 있습니다. Docker의 iptables 설정에 따라서는 docker-proxy 없이 포워딩되어 ss에 아무것도 나오지 않는 경우도 있습니다. 어느 쪽이든 Docker에게 물어보는 것이 정확합니다.

docker ps --filter "publish=8080"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"

컨테이너를 찾았으면 멈춥니다. Compose로 띄운 것이라면 해당 프로젝트 디렉터리에서 docker compose down이 깔끔합니다.

docker stop <컨테이너 이름>
docker compose down

멈췄는데 Docker가 재시작되거나 PC를 재부팅하면 다시 포트를 잡는다면 --restart always나 restart: unless-stopped 정책이 걸려 있는 것입니다. docker update --restart no <컨테이너 이름>으로 정책을 풀거나 컨테이너를 삭제합니다. 윈도우와 맥의 Docker Desktop에서는 호스트 쪽 소유 프로세스가 com.docker.backend 같은 Docker Desktop 프로세스로 나오니, 역시 그 프로세스를 죽이지 말고 docker ps로 컨테이너를 찾아야 합니다. 포트 공개와 브리지 네트워크 동작은 Docker 네트워킹 가이드에 정리해 두었습니다.

프로세스를 죽이는 대신 포트를 바꾸는 방법

점유 프로세스가 지워서는 안 되는 것(회사 보안 에이전트, 다른 프로젝트의 데이터베이스)이라면 내 서버의 포트를 옮기는 것이 맞습니다. 도구마다 설정 위치가 다릅니다.

도구포트 변경 방법
Express 등 Node 앱코드에서 process.env.PORT를 읽게 하고 PORT=3001 npm start (PowerShell은 $env:PORT=3001; npm start)
Next.jsnext dev -p 3001
Vitevite --port 5174 또는 vite.config의 server.port. server.strictPort: true면 다른 포트로 넘어가지 않고 실패
Spring Bootapplication.properties에 server.port=8081 또는 --server.port=8081 실행 인자
Flaskflask run --port 5001
Djangopython manage.py runserver 8001
Docker-p 8081:80처럼 콜론 왼쪽(호스트 포트)만 변경

Docker에서 바꾸는 것은 왼쪽 숫자뿐이라는 점을 기억해 두면 좋습니다. 오른쪽은 컨테이너 안에서 앱이 듣는 포트라서, 이것까지 바꾸면 컨테이너 안의 앱이 그 포트를 듣지 않아 접속이 안 됩니다.

같은 포트 문제가 자꾸 반복된다면 원인은 대개 종료 습관입니다. 터미널에서 서버를 끌 때는 Ctrl+Z가 아니라 Ctrl+C를 쓰고, IDE의 정지 버튼이 자식 프로세스까지 정리하는지 한 번 확인해 두면 이 글을 다시 찾을 일이 줄어듭니다. 맥·리눅스에서 Ctrl+Z로 멈춘 작업이 있는지는 같은 터미널에서 jobs로 볼 수 있고, fg로 다시 앞으로 가져와 Ctrl+C로 끝내면 됩니다.