포스트

Node 버전 변경 뒤 webpack Proxy ECONNREFUSED — localhost·IPv4·IPv6 경계 진단

webpack-dev-server Proxy만 ECONNREFUSED일 때 Backend 상태, localhost의 주소 해석 순서, ::1/127.0.0.1 Bind 불일치를 분리해 진단하고 명시적 Target이나 DNS 정책으로 해결하는 흐름을 정리한다.

Node 버전 변경 뒤 webpack Proxy ECONNREFUSED — localhost·IPv4·IPv6 경계 진단

Node 버전을 바꾼 뒤 Browser에서 Backend를 직접 열면 정상인데 webpack-dev-server Proxy만 ECONNREFUSED가 나는 경우가 있다.

이 증상을 바로 “Node의 IPv6 버그”로 고정하기보다 연결 경로를 분리해서 본다.

1
2
3
4
5
6
7
8
9
10
Browser → Backend
        정상

Node Dev Server
   ↓ Proxy Target: localhost:8080
DNS / localhost 해석
   ↓
127.0.0.1 또는 ::1
   ↓
Backend가 실제 Listen 중인 주소와 일치?

핵심은 localhost가 하나의 고정 IP가 아니라 IPv4/IPv6 Loopback 주소로 해석될 수 있다는 점이다.

증상

예:

1
2
[webpack-dev-server] [HPM] Error occurred while proxying request
localhost:9000/api to http://localhost:8080/ [ECONNREFUSED]

상태:

1
2
3
4
5
6
7
8
Frontend localhost:9000
→ 정상

Backend localhost:8080
→ Browser 직접 접근 정상

Frontend Proxy → Backend
→ ECONNREFUSED

여기서 중요한 단서는 Backend Application 자체가 완전히 죽은 것은 아니라는 점이다.

1. 먼저 CORS와 TCP 연결 실패를 구분한다

ECONNREFUSED는 HTTP Response를 받은 뒤 Browser가 CORS 정책으로 막은 상황이 아니다.

1
2
3
4
5
TCP Connection 자체 실패
→ ECONNREFUSED

HTTP Response 도착
→ 그 다음에야 HTTP/CORS 문제를 논의

따라서 Backend Log에 요청 자체가 없다면 Proxy가 어느 주소로 연결을 시도했는지부터 본다.

2. Backend가 어느 주소에 Listen 중인지 확인한다

macOS/Linux:

1
lsof -nP -iTCP:8080 -sTCP:LISTEN

또는:

1
ss -lntp | grep ':8080'

예를 들어:

1
127.0.0.1:8080

만 보인다면 IPv4 Loopback만 Listen 중이다.

반대로 ::1:8080, :::8080, 0.0.0.0:8080 등은 각각 다른 Bind 의미를 가진다.

3. 현재 환경에서 localhost가 어떻게 해석되는지 본다

OS 설정과 Runtime에 따라 localhost에서 IPv4와 IPv6 주소가 모두 후보가 될 수 있다.

1
node -e "require('dns').lookup('localhost', { all: true }, console.log)"

예:

1
2
3
4
[
  { address: '::1', family: 6 },
  { address: '127.0.0.1', family: 4 }
]

Node.js는 과거 버전과 달리 dns.lookup() 결과를 임의로 IPv4-first 정렬하지 않고 OS Resolver의 순서를 보존하는 방향으로 동작이 변경된 시기가 있다. 따라서 Version 변경 뒤 OS가 ::1을 먼저 돌려주는 환경의 차이가 드러날 수 있다.

중요한 표현은:

1
Node 17+는 항상 IPv6 우선

이 아니라:

1
2
localhost가 IPv4/IPv6 둘 다 가질 수 있고
Node/OS의 결과 순서와 Backend Bind가 어긋날 수 있다

이다.

4. IPv4/IPv6를 직접 호출해 가설을 검증한다

1
2
curl -v http://127.0.0.1:8080/
curl -g -v 'http://[::1]:8080/'

결과가:

1
2
127.0.0.1 → 성공
::1       → Connection refused

라면 Proxy가 ::1로 연결을 시도했을 때 실패하는 이유가 명확해진다.

5. 해결 방법 1 — Target 주소를 명시한다

개발 환경에서 Backend가 IPv4 Loopback에만 Bind하는 것이 의도라면 Proxy Target도 같은 주소를 명시할 수 있다.

1
2
3
4
5
6
proxy: {
  '/api': {
    target: 'http://127.0.0.1:8080',
    changeOrigin: true,
  },
}
1
2
3
4
5
Proxy Target
→ 127.0.0.1

Backend Bind
→ 127.0.0.1

로 양쪽의 의미가 일치한다.

단, 팀 전체에서 IPv4만 사용해야 한다는 보편 규칙은 아니다. 개발 서버를 Dual-stack으로 Bind하도록 구성하는 것이 더 적절한 환경도 있다.

6. 해결 방법 2 — Node의 DNS 결과 정책을 명시한다

특정 Application 전체에서 IPv4를 먼저 시도하려는 의도라면 Node의 DNS Result Order를 설정할 수 있다.

명령행 예:

1
node --dns-result-order=ipv4first app.js

환경 변수로 Node Option을 전달하는 방법도 있다.

1
NODE_OPTIONS=--dns-result-order=ipv4first npm start

이 방법은 Process 전반의 Name Resolution 정책에 영향을 주므로 특정 Proxy 하나만 IPv4여야 하는 문제라면 Target을 명시하는 방식이 영향 범위가 더 작다.

7. 해결 방법 3 — Backend를 의도한 주소에 Bind한다

IPv4/IPv6 양쪽에서 접근해야 하는 개발 환경이라면 Backend Listen 정책 자체를 확인한다.

1
2
3
Frontend를 IPv6에서도 사용
+ Backend도 IPv6 접근 필요
→ Backend Bind 정책 수정 검토

이 경우 127.0.0.1로 Proxy를 고정하는 것은 증상만 우회할 수 있다.

진단 순서

1
2
3
4
5
6
1. ECONNREFUSED인가 HTTP/CORS Error인가?
2. Backend Process가 실제로 Listen 중인가?
3. 어느 Address(127.0.0.1 / ::1 / wildcard)에 Bind했나?
4. Node가 localhost를 어떤 순서로 해석하나?
5. IPv4와 IPv6를 직접 호출하면 각각 어떤가?
6. Target 고정 / DNS 정책 / Backend Bind 중 어느 층을 고칠까?

이 순서면 nvm이나 Node Version 자체를 원인으로 단정하지 않고 실제 Socket 연결 실패를 확인할 수 있다.

정리

1
2
3
4
5
6
7
8
localhost
→ 이름

127.0.0.1
→ IPv4 Loopback

::1
→ IPv6 Loopback

이 셋을 같은 것으로 취급하면 Node Version이나 OS가 바뀔 때 숨어 있던 Bind 불일치가 드러날 수 있다.

Browser는 되는데 Node Proxy만 ECONNREFUSED라면 “localhost가 실제로 어느 주소가 되었고 Backend는 어디에 Listen하는가”를 먼저 확인한다.

같은 Dual-stack 문제를 Dev Server의 Listen 쪽에서 만난 사례는 Node localhost dual-stack — browser-sync listen 함정에서 이어진다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.