빌드노트
localhost는 같은데 curl -4만 접속된다
localhost 요청의 실제 목적지와 Listen 주소를 비교한다. 소유한 IPv4·IPv6 loopback 리스너에서 curl -4/-6와 숫자 주소를 실행해 접속 결과를 확인하고, 기본 요청 성공과 두 계열 수신을 구분한다.
같은 포트의 localhost에 접속하는데 curl -4는 응답을 받고 curl -6는 실패한다면, 서버의 수신 주소부터 확인한다. 서버가 IPv4 주소인 127.0.0.1에서만 듣고 있으면 IPv6 주소인 ::1로 들어오는 연결은 받지 못한다. 두 주소 모두 자기 컴퓨터를 가리키지만, 어느 주소의 포트에서 연결을 받는지는 별도다.
확인할 것은 localhost라는 문자열보다 curl이 실제로 시도한 주소와 서버의 Listen 주소가 맞는가다. 이번에 사용한 curl 8.21.0은 localhost의 후보로 127.0.0.1과 ::1을 표시했다. curl의 공식 URL 문서 (새 탭에서 열림)도 이 이름에 loopback 주소를 사용하고 일반 이름 해석 함수를 거치지 않는다고 설명한다. 이 관찰을 Windows 전체의 이름 해석 우선순위로 옮겨 해석하지 않는다.
같은 포트에 -4와 -6를 번갈아 보낸다
아래 명령은 Windows PowerShell 7과 curl.exe가 있는 환경에서, 같은 Windows 호스트에서 실행 중인 HTTP 서버를 확인한다. $port를 실제 서버 포트로 바꾸고, 필요하면 /도 응답을 확인할 경로로 바꾼다. 18099는 이번 실험에서 배정된 번호다.
이 두 요청으로 IPv4와 IPv6 중 어느 쪽에서 연결과 HTTP 응답이 성립하는지 비교한다.
$port = 18099
curl.exe -q --noproxy '*' --connect-timeout 1 --max-time 2 -sS -v -4 "http://localhost:$port/"
Write-Output "curl_exit=$LASTEXITCODE"
curl.exe -q --noproxy '*' --connect-timeout 1 --max-time 2 -sS -v -6 "http://localhost:$port/"
Write-Output "curl_exit=$LASTEXITCODE"
-4와 -6는 사용할 주소 계열을 각각 IPv4와 IPv6로 제한한다. 첫 인자의 -q는 기본 curl 설정 파일을 읽지 않게 하고, --noproxy '*'는 이 요청이 프록시를 거치지 않게 한다. 연결에는 1초, 요청 전체에는 2초 제한을 두었다. -sS는 진행 표시를 줄이고 오류를 남기며, -v는 연결 과정과 HTTP 헤더를 보여 준다. curl 옵션 설명 (새 탭에서 열림)
별도 실험에서는 Python으로 새 loopback HTTP 리스너를 열었다. Windows 11, PowerShell 7.6.6, Python 3.12.10, curl 8.21.0 환경이다. 각 단계에는 한 주소 계열의 리스너만 있었고, IPv6 소켓은 IPV6_V6ONLY=1로 설정했다. 기존 서비스·방화벽·hosts 파일은 바꾸지 않았다.
127.0.0.1:18099에서 듣는 단계:-4는 HTTP 200과listener-ipv4본문을 받았고 종료 코드는 0이었다.-6는::1:18099연결 시간 초과로 끝났으며 종료 코드는 28이었다.[::1]:18107에서 듣는 단계: 같은 명령의$port를 18107로 바꾸었다. 이번에는-6가 HTTP 200과listener-ipv6본문을 받았고,-4는127.0.0.1:18107연결 시간 초과로 끝났다. 종료 코드는 각각 0과 28이었다.
실험의 응답 문자열은 어느 리스너에 도착했는지 구분하기 위해 넣은 값이다. 자신의 서버에서는 해당 경로의 실제 응답을 확인한다. HTTP 404라도 응답 헤더까지 받았다면 TCP 연결은 성립한 것이다. 연결 실패와 애플리케이션의 응답 상태를 구분해야 한다.
후보 목록보다 Trying과 연결 성공 행을 읽는다
IPv4 단계의 -4 실행에서도 verbose 앞부분에는 두 주소가 모두 나왔다. 다음은 그 출력에서 주소와 연결 성공 행만 발췌한 것이다.
* Host localhost:18099 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying 127.0.0.1:18099...
* Established connection to localhost (127.0.0.1 port 18099) from 127.0.0.1 port 18102
IPv6: ::1이 보인다는 이유만으로 이 요청이 IPv6로 연결됐다고 판단하면 틀린다. 여기서는 Trying과 Established connection이 모두 127.0.0.1을 가리킨다. 반대로 -6 실행에서는 Trying [::1]:18099... 뒤에 curl: (28) Connection timed out after 1013 milliseconds가 나왔고 HTTP 응답은 없었다. 이 문구는 이번 제한 시간과 환경에서 관찰한 결과다. 다른 환경에서 반드시 같은 코드나 대기시간이 나오는 것은 아니다.
주소 계열을 지정하지 않은 기본 요청도 함께 실행했다. IPv4 리스너 단계에서는 ::1을 시도한 다음 127.0.0.1에 연결해 200을 받았다. IPv6 단계에서는 ::1에 연결해 200을 받았다. curl은 두 주소 계열의 연결을 시도하는 Happy Eyeballs 동작 (새 탭에서 열림)을 제공한다. 따라서 기본 localhost 요청 한 번이 성공했다고 두 계열의 리스너가 모두 정상인 것은 아니다. 이번 실험에서도 기본 요청은 양쪽 단계에서 모두 성공했다.
서버가 듣는 주소를 같은 포트로 조회한다
Windows의 NetTCPIP 모듈을 사용할 수 있다면, 요청한 포트의 리스너와 소유 프로세스를 다음처럼 읽는다. 앞에서 설정한 $port를 그대로 사용한다.
Get-NetTCPConnection -LocalPort $port -State Listen |
Select-Object LocalAddress, LocalPort, State, OwningProcess
실험에서 읽은 값은 다음과 같았다. 두 줄은 서버를 순서대로 열어 확인한 각각의 결과다.
LocalAddress LocalPort State OwningProcess
127.0.0.1 18099 Listen 48932
::1 18107 Listen 48932
LocalAddress와 LocalPort를 curl의 실제 목적지와 비교한다. OwningProcess도 자신의 서버 PID와 맞춰 다른 프로세스의 리스너를 보고 있지 않은지 확인한다. Get-NetTCPConnection 문서 (새 탭에서 열림)
아무 행도 없다면 그 시점에 해당 포트의 Listen 소켓을 찾지 못한 것이다. 서버의 실제 포트와 시작 상태부터 확인한다. 명령 자체가 없거나 조회 권한 오류가 난 것은 리스너 부재의 증거가 아니다. 이 경우 서버의 시작 설정·리스닝 로그나 환경에서 제공하는 소켓 조회 도구로 주소를 확인한다.
이름을 제외하고 비교하려면 같은 포트에 숫자 주소로 요청할 수 있다. IPv6 리터럴은 URL에서 대괄호로 감싼다. curl의 호스트 주소 표기 (새 탭에서 열림)
curl.exe -q --noproxy '*' --connect-timeout 1 --max-time 2 -sS -v "http://127.0.0.1:$port/"
curl.exe -q --noproxy '*' --connect-timeout 1 --max-time 2 -sS -v "http://[::1]:$port/"
실제 비교에서도 18099는 IPv4 리터럴만, 18107은 IPv6 리터럴만 200이었다. 반대 계열은 모두 종료 코드 28과 연결 시간 초과를 남겼다. 이름을 바꾸어도 어느 주소에서 듣는가라는 조건은 그대로 남았다.
접속 주소와 수신 설정 중 필요한 쪽을 맞춘다
서버가 의도적으로 127.0.0.1에서만 듣는 로컬 HTTP 서버라면, 접속 주소를 http://127.0.0.1:포트/로 명시하거나 curl 진단에 -4를 사용한다. 서버가 ::1에서만 듣는다면 대응하는 주소는 http://[::1]:포트/이고 curl 진단 옵션은 -6다. -4나 -6가 서버의 수신 설정을 바꾸어 주지는 않는다.
호출하는 프로그램들이 두 계열을 모두 사용해야 한다면 서버의 listen 또는 bind 설정에서 두 loopback 주소를 실제로 수신하도록 구성하고, 다시 뜬 프로세스에 위의 두 요청을 반복한다. 127.0.0.1을 ::1 하나로 교체하면 이번 두 번째 단계처럼 성공하는 계열이 뒤집힐 수 있다. 같은 숫자 포트의 두 계열을 모두 제공해야 한다면 그 포트에서 두 요청이 모두 응답하는지 확인한다.
리스너가 ::로 표시된 경우에도 IPv4까지 받는지는 소켓과 서버의 dual-stack 설정을 확인해야 한다. Python도 서버 소켓 생성 API (새 탭에서 열림)에서 IPv6 주소 계열과 dualstack_ipv6를 따로 지정한다. 이번 실험은 ::1에 IPv6 전용으로 바인딩했으며 wildcard 주소인 ::의 동작은 검증하지 않았다. 로컬 접속 문제를 고치기 위해 곧바로 모든 인터페이스로 수신 범위를 넓힐 필요는 없다.
시간 초과나 연결 거절만으로 실제 서버의 bind 문제를 확정할 수는 없다. 한 계열에서 HTTP 응답을 받고, 다른 계열은 연결되지 않으며, 리스너 조회도 성공한 계열만 가리키는지를 함께 확인한다. 양쪽 주소에 이미 올바른 리스너가 있다면 프로세스·포트·프로토콜과 접근 경로를 더 조사해야 한다.
이번 확인은 같은 Windows 호스트의 평문 HTTP와 IPv4·IPv6 loopback까지다. socket.has_ipv6=True뿐 아니라 실제 ::1 바인딩과 HTTP 응답을 확인했다. Python의 has_ipv6 문서 (새 탭에서 열림)는 플랫폼의 IPv6 지원 여부를 나타내는 값이라고 설명하며, 그 값만으로 서버 접속 성공까지 증명되지는 않는다. IPv6를 사용할 수 없는 환경에서는 IPv6 비교를 미확인으로 남겨야 한다. HTTPS의 인증서 이름·Host에 따른 응답 차이, 다른 OS·브라우저의 선택 순서, 외부 인터페이스와 Docker의 네트워크 공간은 이번 실험 범위에 포함하지 않았다.