본문으로 건너뛰기
개발 머꼬
개발 노트Node.js
hohyeon.dev24

서버를 껐는데도 EADDRINUSE: address already in use가 계속 난 이유

  • #Common Pitfall
  • #Engineering Note
  • #Node.js

문제 발생

로컬에서 API 서버를 띄워 놓고 작업하다가 코드를 고쳐서 다시 켜려는데, 서버가 뜨자마자 죽었습니다.

Error: listen EADDRINUSE: address already in use :::3000
    at Server.setupListenHandle [as _listen2] (node:net:1940:16)
    at listenInCluster (node:net:1997:12)

분명히 방금 껐습니다. 터미널에도 프롬프트가 돌아와 있었고요. 그런데 브라우저로 localhost:3000을 열어 보니 에러 페이지도 안 뜨고 하염없이 로딩만 돌았습니다. 꺼진 서버라면 바로 연결 거부가 떠야 하는데, 뭔가가 포트를 쥔 채 대답을 안 하고 있었던 겁니다. 범인을 찾아보니 제가 껐다고 생각한 그 서버였습니다. 터미널 위쪽에 이런 줄이 남아 있었습니다.

[1]+  Stopped                 node server.js

Ctrl+C를 누른다는 게 Ctrl+Z를 누른 거였습니다.

원인 분석

EADDRINUSE는 누군가 이미 그 포트에서 듣고 있다는 뜻입니다. Node.js의 server.listen() 문서는 이걸 listen할 때 가장 흔한 에러로 꼽으면서, 다른 서버가 이미 요청한 포트에서 듣고 있을 때 난다고 설명합니다. 에러 자체는 단순합니다. 어려운 건 "그 누군가"가 누구인지입니다.

Ctrl+Z는 프로세스를 끝내지 않고 멈춰 둡니다. Bash 매뉴얼의 작업 제어 설명에 따르면 실행 중에 suspend 문자(보통 Ctrl+Z)를 누르면 그 프로세스를 멈추고 제어를 Bash로 돌려줍니다. 이후 bg로 백그라운드에서 이어 가거나, fg로 다시 앞으로 가져오거나, kill로 끝낼 수 있다고요. 멈춘 프로세스는 살아 있으니 열어 둔 소켓도 그대로 갖고 있습니다. 직접 재현해 보니 멈춘 Node 프로세스는 상태가 T(stopped)였고, 같은 포트로 다시 띄우면 똑같이 EADDRINUSE가 났습니다. curl로 요청하면 거부당하는 게 아니라 타임아웃까지 기다렸습니다. 운영체제가 연결은 받아 주지만, 그걸 처리할 프로세스가 멈춰 있으니 응답이 오지 않는 겁니다. 브라우저가 로딩만 돌던 이유가 이거였습니다.

:::3000이라고 나와도 IPv4 쪽과 같이 부딪칩니다. 에러 끝의 :::3000은 IPv6의 "모든 주소(::)"의 3000번 포트라는 뜻입니다. Node 문서에 따르면 listen()에 host를 생략하면 IPv6를 쓸 수 있을 때 ::에서, 아니면 0.0.0.0에서 연결을 받고, 대부분의 운영체제에서 ::에서 들으면 0.0.0.0에서도 함께 듣게 될 수 있습니다. 그래서 다른 프로그램이 0.0.0.0:3000을 쥐고 있어도 Node는 :::3000이 이미 쓰인다고 말할 수 있습니다. IPv6를 끈 환경에서 재현했을 때는 같은 에러가 address already in use 0.0.0.0:3000으로 나왔습니다. 주소 표기만 다를 뿐 같은 충돌입니다.

해결 방안

  1. 먼저 누가 포트를 쥐고 있는지 확인합니다. 추측으로 이것저것 끄기 전에 PID부터 봅니다. Linux와 macOS에서는 lsof로 듣고 있는 프로세스를 찾습니다.
$ lsof -i :3000 -sTCP:LISTEN
COMMAND PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
node    144 root   21u  IPv4   1262      0t0  TCP *:3000 (LISTEN)

Windows에서는 netstat -ano | findstr :3000으로 찾습니다. Microsoft 문서대로 -a는 듣고 있는 포트를 포함한 연결을, -n은 주소를 숫자로, -o는 연결마다 PID를 보여 줍니다. 찾은 PID는 taskkill /PID 1234 /F로 끝냅니다. /f는 강제 종료입니다. 이렇게 확인하면 의외의 범인도 보입니다. 예를 들어 macOS에서 Flask 기본 포트 5000이 이미 쓰이고 있다면, Apple의 공식 포트 목록에 5000번과 7000번 TCP가 AirPlay 용도로 올라 있다는 걸 떠올려 볼 만합니다. 이럴 때는 앱 포트를 바꾸는 편이 빠릅니다.

  1. Ctrl+Z로 멈춘 서버는 같은 터미널에서 정리합니다. 프롬프트 위에 Stopped가 보였다면 그 터미널에서 jobs로 멈춘 작업을 확인하고, fg로 다시 가져와 Ctrl+C로 끄거나 kill %1로 끝냅니다. 여기서 재미있는 차이가 하나 있었습니다. 다른 터미널에서 kill <PID>로 SIGTERM을 보내면 멈춘 프로세스는 바로 죽지 않습니다. 직접 해 보니 SIGTERM을 받고도 1초 뒤까지 상태가 T 그대로였고 포트도 계속 잡혀 있었습니다. SIGCONT로 깨운 순간에야 종료됐습니다. 반면 원래 터미널에서 kill %1을 쓰니 바로 Terminated가 찍히고 포트가 풀렸습니다. 다른 터미널에서 정리해야 한다면 kill -CONT <PID>로 깨운 뒤 끄거나, 정리 코드를 포기할 각오로 kill -9(SIGKILL)를 씁니다. SIGKILL은 멈춘 프로세스도 바로 끝냈습니다.

  2. 직접 만든 소켓 서버라면 SO_REUSEADDR를 확인합니다. 프로세스가 진짜로 죽었는데도 잠깐 동안 같은 에러가 나는 경우가 있습니다. Python socket 문서의 예제 설명대로, 예제를 짧은 간격으로 여러 번 실행하면 OSError: [Errno 98] Address already in use가 날 수 있고, 이건 이전 실행이 소켓을 TIME_WAIT 상태로 남겨서 바로 재사용할 수 없기 때문입니다. 문서가 제시하는 해법이 SO_REUSEADDR입니다.

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((HOST, PORT))

Linux에서 재현해 보니 한 가지 조건이 더 있었습니다. 재시작하는 쪽만 옵션을 켜서는 여전히 Errno 98이 났고, 처음 서버와 재시작한 서버가 둘 다 SO_REUSEADDR를 켰을 때만 바로 bind가 됐습니다. 같은 프로그램을 다시 띄우는 상황이라면 처음부터 코드에 넣어 두면 됩니다. Python 3.8의 socket.create_server()는 문서대로 POSIX에서 이 옵션을 알아서 켜 줍니다. socketserver.TCPServer는 allow_reuse_address가 기본 False라서 직접 켜야 하고, 확인해 보니 http.server.HTTPServer는 이미 켜져 있었습니다. Node는 같은 상황에서 바로 다시 떴습니다. Linux의 socket(7) 매뉴얼대로 이 옵션은 "이미 듣고 있는 소켓"과의 충돌은 막지 않으니, 1번과 2번의 상황은 이걸로 해결되지 않습니다.

  1. 포트를 알아서 바꾸는 개발 서버를 조심합니다. 모든 도구가 EADDRINUSE로 죽어 주지는 않습니다. Vite 문서에 따르면 server.port의 포트가 이미 쓰이고 있으면 다음 빈 포트를 자동으로 시도하기 때문에, 실제로 듣는 포트가 설정과 다를 수 있습니다. 멈춘 개발 서버가 5173을 잡고 있으면 새 서버는 조용히 5174로 뜹니다. 그러면 CORS 허용 출처나 소셜 로그인 리다이렉트 주소가 5173으로 적혀 있는 곳에서 엉뚱한 에러가 납니다. 같은 문서의 server.strictPort를 true로 주면 포트가 이미 쓰이고 있을 때 다음 포트를 찾지 않고 종료합니다. 포트가 다른 설정과 묶여 있다면 이쪽이 낫습니다.

  2. 코드에서는 재시도보다 분명한 메시지를 남깁니다. Node 문서에는 EADDRINUSE가 나면 잠시 뒤 다시 listen하는 예제가 있습니다. 배포 중에 이전 프로세스가 내려가길 기다리는 상황이라면 쓸 만하지만, 로컬 개발에서 무한정 재시도하면 멈춘 서버를 더 오래 못 알아챕니다. 저는 포트 번호와 확인 방법을 출력하고 바로 끝내도록 바꿨습니다.

server.on('error', (e) => {
  if (e.code === 'EADDRINUSE') {
    console.error(`포트 ${PORT}가 이미 사용 중입니다. lsof -i :${PORT} -sTCP:LISTEN 으로 확인하세요.`);
    process.exit(1);
  }
  throw e;
});

포트는 PORT 환경변수로 받게 해 두면, 급할 때 PORT=3001 npm run dev로 비켜 갈 수도 있습니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.