Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (seongtaejeong at gmail.com)
홈페이지
첨부 파일

C# - stun.l.google.com을 사용해 공용 IP 주소와 포트를 알아내는 방법

예전에 Hole Punching에 대해서 설명한 적이 있는데요,

홀 펀칭(Hole Punching)을 이용한 Private IP 간 통신 - C#
; https://www.sysnet.pe.kr/2/0/1226

당시에는 NAT 환경으로부터 인터넷으로 나가는 구간의 공용 IP/포트를 알아내기 위해 제가 별도로 서버를 만들어 사용했었습니다. 그런데, 이와 관련해 STUN(Session Traversal Utilities for NAT) 표준 프로토콜(RFC 8489)이 이미 존재하고, 심지어 구글은 이에 대한 물리 서버를 무료로 공개해 두고 있습니다.

// https://stackoverflow.com/questions/20068944/how-to-self-host-to-not-rely-on-webrtc-stun-server-stun-l-google-com19302/20134888#20134888

stun:stun.l.google.com:19302
stun:stun1.l.google.com:19302
stun:stun2.l.google.com:19302
stun:stun3.l.google.com:19302
stun:stun4.l.google.com:19302

어떻게 사용하는지 좀 볼까요? ^^




가장 기본적인 STUN 서버의 기능은, 그 응답으로 공용 IP와 Port를 알려주는 것입니다. 이에 대한 주소를 일컬어 "Server Reflexive Address"라고 부르는데요, 프로토콜에서는 이에 대한 요청 패킷을 20바이트 크기로 정의하고 있습니다.

// 요청에 사용하는 STUN 헤더 (20 bytes)

0                   1                   2                   3                   4
+-------------------+-------------------+-------------------+-------------------+
|      Message Type (0x0001)            |     Message Length (0x0000)           |
+-------------------+-------------------+-------------------+-------------------+
|                               Magic Cookie (0x2112A442)                       |
+-------------------+-------------------+-------------------+-------------------+
|                                                                               |
|                     Transaction ID (12 bytes, random)                         |
|                                                                               |
+-------------------+-------------------+-------------------+-------------------+

간단하죠? ^^ 저 정도는 일단 C#으로 대충 이렇게 표현할 수 있습니다.

using System.Net.Sockets;

namespace ConsoleApp1;

internal class Program
{
    static async Task Main(string[] args)
    {
        StunHeader request = new StunHeader(StunMessageType.BindingRequest);

        byte[] message = request.ToByteArray();
    }
}

public enum StunMessageType : ushort
{
    BindingRequest = 0x0001,

    BindingResponse = 0x0101,
    BindingErrorResponse = 0x0111,
}

public enum StunAttrType : ushort
{
    ATTR_MAPPED_ADDRESS = 0x0001,       // RFC 3489 (legacy)
    ATTR_XOR_MAPPED_ADDRESS = 0x0020,   // RFC 5389
}

public struct StunHeader
{
    public StunMessageType Type { get; init; }
    ushort messageLength; // body(attributes) length (if no attrs == 0)
    public const uint MagicCookie = 0x2112A442; // 고정값
    byte[] transactionId = new byte[12];

    public StunHeader(StunMessageType type, ushort messageLength = 0)
    {
        this.Type = type;
        this.messageLength = messageLength;
        Random.Shared.NextBytes(transactionId);
    }

    public byte[] ToByteArray()
    {
        byte[] result = new byte[20]; // 20-byte header, no attributes

        ByteOrder.WriteUInt16BE(result, 0, (ushort)this.Type);
        ByteOrder.WriteUInt16BE(result, 2, this.messageLength);
        ByteOrder.WriteUInt32BE(result, 4, MagicCookie);

        transactionId.CopyTo(result, 8);

        return result;
    }
}

자, 그럼 저 내용 그대로 UDP에 실어 보낸 후 응답을 받으면 됩니다.

using (var udpClient = new UdpClient())
{
    udpClient.Connect("stun.l.google.com", 19302);

    await udpClient.SendAsync(message, message.Length);

    var result = await udpClient.ReceiveAsync(); // 서버가 보내준 응답
}

이제 UdpClient.ReceiveAsync가 반환한 UdpReceiveResult.Buffer 속성의 바이트 배열을 분석하면 되는데요, 응답 역시 요청에 사용한 StunHeader 구조와 동일한 20바이트로 시작하고 요청했던 메시지를 위한 추가 Attribute 값들이 붙게 됩니다.

// 반환 배열의 앞부분 20 bytes == STUN 헤더와 동일
// 20바이트 후부터는 요청에 대한 응답과 관련된 속성(Attribute) 데이터

0                   1                   2                   3                   4
+-------------------+-------------------+-------------------+-------------------+        ---
|      Message Type (0x0001)            |  Message Length == sizeof(attrs)      |         ^
+-------------------+-------------------+-------------------+-------------------+         |
|                               Magic Cookie (0x2112A442)                       |         |
+-------------------+-------------------+-------------------+-------------------+   (Stun Header)
|                                                                               |         |
|           Transaction ID (== same as request)                                 |         v
|                                                                               |        ---
+-------------------+-------------------+-------------------+-------------------+------(variable attribute length)---+
|        Attribute Type (1)             |  Attribute Length                     |  Attribute Value (4byte padding)   |
+-------------------+-------------------+-------------------+-------------------+------------------------------------+----+
|        Attribute Type (2)             |  Attribute Length                     |  Attribute Value (4byte padding)        |
+-------------------+-------------------+-------------------+-------------------+---------------------------------------+-+
|        Attribute Type ...(N)          |  Attribute Length                     |  Attribute Value (4byte padding)      |
+-------------------+-------------------+-------------------+-------------------+---------------------------------------+

따라서 Header 부분을 이전에 요청했던 내용과 비교하며 정합성을 체크하는 것부터 시작한 후,

var result = await udpClient.ReceiveAsync();

var response = request.GetResponse(result.Buffer);

public struct StunHeader
{
    // ...[생략]...

    public StunResponse GetResponse(byte[] buffer)
    {
        Trace.Assert(buffer.Length > 20, "Buffer too small for STUN header");

        StunMessageType type = (StunMessageType)ByteOrder.ReadUInt16BE(buffer, 0);
        Trace.Assert(type == StunMessageType.BindingResponse, $"Unexpected STUN message type: {type:x}");

        ushort messageLength = ByteOrder.ReadUInt16BE(buffer, 2);

        uint magicCookie = ByteOrder.ReadUInt32BE(buffer, 4);
        Trace.Assert(magicCookie == MagicCookie, "Invalid magic cookie");

        Trace.Assert(Enumerable.SequenceEqual(buffer.Skip(8).Take(12), this.transactionId), "transaction ID mismatch");

        // ...[생략]...
    }
}

남은 영역을 순회하며 Attribute에 대해 Type, Length, Value를 추출하면 됩니다.

public struct StunResponse
{
    public StunHeader Header { get; init; }
    public IPEndPoint MappedAddress { get; init; }

    public override string ToString()
    {
        return $"Type={Header.Type}, MappedAddress={MappedAddress}";
    }

    public static IPEndPoint ParseAttributes(byte[] buffer, int offset, int length, byte[] transactionId)
    {
        int idx = offset;
        int end = offset + length;
        IPEndPoint? mappedAddress = null;

        while (idx + 4 <= end) // attr type과 length 읽기: 최소 4바이트 필요
        {
            StunAttrType attrType = (StunAttrType)ByteOrder.ReadUInt16BE(buffer, idx);
            ushort attrLength = ByteOrder.ReadUInt16BE(buffer, idx + 2);

            idx += 4;

            if (idx + attrLength > end)
            {
                throw new Exception("Attribute length exceeds message length");
            }

            switch (attrType)
            {
                case StunAttrType.ATTR_MAPPED_ADDRESS:
                    mappedAddress = ...[생략: ATTR_MAPPED_ADDRESS 처리 코드]...
                    break;

                case StunAttrType.ATTR_XOR_MAPPED_ADDRESS:
                    mappedAddress = ...[생략: ATTR_XOR_MAPPED_ADDRESS 처리 코드]...
                    break;

                default:
                    // 관심 없는 속성은 무시 (FINGERPRINT, SOFTWARE, ERROR-CODE, ALTERNATE-SERVER, ...)
                    break;
            }

            // Attributes are padded to a multiple of 4 bytes
            idx += attrLength;
            if (attrLength % 4 != 0)
            {
                idx += 4 - (attrLength % 4);
            }
        }
        
        if (mappedAddress == null)
        {
            throw new Exception("No MAPPED-ADDRESS or XOR-MAPPED-ADDRESS attribute found");
        }

        return mappedAddress;
    }
}

여기서 우리가 가장 관심 있는 속성은 Server Reflexive Address를 알려주는 ATTR_MAPPED_ADDRESSATTR_XOR_MAPPED_ADDRESS입니다.

[MAPPED-ADDRESS (RFC 3489)]
0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|0 0 0 0 0 0 0 0|    Family     |           Port                |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
|                 Address (32 bits or 128 bits)                 |
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

[ATTR_XOR_MAPPED_ADDRESS (RFC 5389)]
0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|x x x x x x x x|    Family     |         XOR-Port              |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
|                XOR-Address (32 bits or 128 bits)              |
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

이 둘은 사실 동일한 정보를 담고 있는데요, 단지 후자는 Magic Cookie와 Transaction ID를 이용해 IP와 Port를 XOR 연산한 값을 담고 있습니다.

또한, 차이점이 하나 더 있는데요, 현재 (구글의 stun.l.google.com을 포함해) 대부분의 STUN 서버는 RFC 5389를 지원하므로 ATTR_MAPPED_ADDRESS가 아닌 ATTR_XOR_MAPPED_ADDRESS를 반환한다는 점입니다. 따라서, 위의 코드를 stun.l.google.com에 대해서만 사용한다면 ATTR_MAPPED_ADDRESS 처리는 지워도 무방합니다.

설명한 바에 따라, ATTR_XOR_MAPPED_ADDRESS 속성의 Value 부분을 처리하는 코드는 다음과 같습니다.

IPEndPoint? mappedAddress = null;

if (attrType == StunAttrType.ATTR_XOR_MAPPED_ADDRESS)
{
    mappedAddress = ParseXorMappedAddress(buffer, idx, attrLength, transactionId);
} else ... {
    // ...
}

static IPEndPoint? ParseXorMappedAddress(byte[] buf, int pos, int len, byte[] transactionId)
{
    // Format (RFC 5389):
    //   0x00, family, xport(2), xaddr(4 or 16)
    if (len < 4)
    {
        return null;
    }

    byte family = buf[pos + 1];
    ushort xport = ByteOrder.ReadUInt16BE(buf, pos + 2);
    ushort port = (ushort)(xport ^ (StunHeader.MagicCookie >> 16)); // XOR with 0x2112

    if (family == 0x01) // IPv4
    {
        if (len < 8)
        {
            return null;
        }

        uint xaddr = ByteOrder.ReadUInt32BE(buf, pos + 4);
        uint addr = xaddr ^ StunHeader.MagicCookie;

        var addrBytes = new byte[]
        {
            (byte)((addr >> 24) & 0xFF),
            (byte)((addr >> 16) & 0xFF),
            (byte)((addr >> 8) & 0xFF),
            (byte)(addr & 0xFF)
        };

        return new IPEndPoint(new IPAddress(addrBytes), port);
    }
    else if (family == 0x02) // IPv6
    {
        if (len < 20)
        {
            return null;
        }

        var addrBytes = new byte[16];
        Buffer.BlockCopy(buf, pos + 4, addrBytes, 0, 16);

        // For IPv6, XOR with (magic cookie || transaction ID) 16 bytes
        var mask = new byte[16];
        ByteOrder.WriteUInt32BE(mask, 0, StunHeader.MagicCookie);
        Buffer.BlockCopy(transactionId, 0, mask, 4, 12);
        for (int i = 0; i < 16; i++)
        {
            addrBytes[i] ^= mask[i];
        }

        return new IPEndPoint(new IPAddress(addrBytes), port);
    }

    return null;
}

복잡한 듯 보이지만, IPv4, IPv6를 모두 지원한다는 점과 XOR 연산을 하는 정도가 추가된 것을 감안하면 거의 직관적으로 이해할 수 있을 것입니다.

끝입니다. ^^ 물론, STUN 프로토콜에 준비된 몇 가지 Attribute가 더 있지만, 우리가 관심을 두는 부분은 저 정도로 충분합니다.

(이 글의 소스 코드를 포함하는 첨부 파일의) 예제를 실행하면 이제 다음과 같은 결과를 보게 될 것입니다.

Received STUN response: Type=BindingResponse, MappedAddress=20.249.98.255:57101




그나저나, 현실적인 기준으로 보면 저렇게 공용 STUN 서버가 있다고 해도 여전히 (자신이 아닌) "상대방"의 공용 IP/Port를 알기 위해서는 별도 서버를 거쳐야만 합니다. 왜냐하면, 서로 간의 공용 IP/Port를 어딘가에는 기록해 두었다가 알려줘야 하기 때문인데 아쉽게도 공용 STUN 서버에는 이런 보관 기능이 없습니다.

물론, 수작업으로 서로 직접 입력하도록 해도 되지만... 글쎄요, 불편함만 감수할 수 있다면 그렇게 해도 되겠죠? ^^

하지만, 그걸 해결했다고 해도 (다시 한번 현실적인 기준으로) 결국엔 Hole Punching이 100% 성공하는 것은 아니므로 그에 대한 대비책으로 메시지 중계 서버를 준비해야 하기 때문에 결국엔 별도 서버가 필요하긴 마찬가지입니다. ^^;

그나마 부하 분산용으로 google의 STUN 서버를 활용할 수는 있겠지만 다른 의미로는 의존성을 늘리는 결과를 낳게 되므로 장단점을 따져봐야 합니다.




참고로, STUN 서버를 이용해 NAT 유형을 판별하는 방법도 있습니다. 이것은 단 한 번의 요청으로 해결되는 것은 아니고, 일정한 절차의 요청과 응답을 통해 판별해야 합니다.

이에 대한 이력을 잠시 살펴보면, 초기 RFC 3489에서는 NAT 유형을 "full-cone / restricted-cone / port-restricted / symmetric"이라는 용어로 구분했다고 합니다. 하지만, RFC 5780으로 오면서 NAT 유형이 단순하지 않다는 점을 감안해 2가지 Behavior(Mapping, Filtering)로 나누어 구분하고 있습니다.

Mapping Behavior:
    - Endpoint Independent Mapping (EIM)
    - Address Dependent Mapping (ADM)
    - Address and Port Dependent Mapping (APDM)

Filtering Behavior:
    - Endpoint Independent Filtering (EIF)
    - Address Dependent Filtering (ADF)
    - Address and Port Dependent Filtering (APDF)

각각을 나누는 기준에 대해서는 아래의 글에서 잘 설명하고 있으므로 생략합니다. ^^

NAT 장비는 이렇게 만들어야 하는데... (RFC 4787) - 1편: Mapping Behavior
; https://www.netmanias.com/ko/?m=view&id=blog&no=5833

NAT 장비는 이렇게 만들어야 하는데... (RFC 4787) - 2편: Filtering Behavior
; https://www.netmanias.com/ko/?m=view&id=blog&no=5839

RFC 5780 (STUN 확장) NAT Behavior Discovery를 통한 UDP Hole punching 가능 여부 판별
; https://swjman.tistory.com/169

새로운 Behaivor 기준을 기존 용어에 적용하면 다음과 같이 매핑됩니다.

| Mapping  | Filtering | Legacy label                  |
| -------- | --------- | ----------------------------- |
| EIM      | EIF       | Full-cone NAT                 |
| EIM      | ADF       | (Address-)Restricted-cone NAT |
| EIM      | APDF      | Port-restricted-cone NAT      |
| ADM/APDM | any       | Symmetric NAT                 |

간단하게 요약하면, Hole Punching이 성공하려면 Mapping Behavior의 판정이 EIM(Endpoint Independent Mapping)이어야 합니다. 만약 ADM 또는 APDM이라면 Filtering Behavior가 어떻든 간에 Hole Punching은 실패합니다. (ADM의 경우에는 100%는 아니고 대부분 실패한다고 합니다.)

P2P 응용 프로그램의 개발자 입장에서 현실적인 관점으로 저 내용을 다시 살펴보면, Mapping/Filtering Behavior 판별 결과를 굳이 알 필요가 없습니다. 그에 상관없이 상대방 공용 IP/Port를 알았다면 일단 Hole Punching을 시도해 보고, 실패하면 중계 서버로 연결을 시도하면 그만이기 때문입니다.

이 과정에서 하나 더 고려한다면, 우선 같은 NAT 환경에 있을 수도 있으므로 우선적으로 1) Private IP로 연결을 시도하고, 2) STUN으로 알아낸 공용 IP/Port로 연결을 시도하고, 3) 마지막으로 중계 서버로 연결을 시도하는 순서로 진행할 수 있습니다. 그리고 이런 3가지 절차를 프로토콜로 정리한 것이 ICE(Interactive Connectivity Establishment)이고 각각 "Local Address", "Server Reflexive Address", "Relayed Address" 3개의 후보군 중에서 연결을 선택하게 됩니다.




Python에서는 talkiq/pystun3라는 패키지를 이용해,

// talkiq/pystun3
// https://github.com/talkiq/pystun3

python -m pip install pystun3

STUN 서버를 쉽게 사용할 수 있습니다.

import stun

nat_type, external_ip, external_port = stun.get_ip_info(
    stun_host="stun.l.google.com",
    stun_port=19302
)
print(f"NAT Type: {nat_type}\nExternal IP: {external_ip}\nExternal Port: {external_port}")

# 출력 결과:
# NAT Type: Full Cone
# External IP: 20.249.98.255
# External Port: 54620

get_ip_info 함수 내부를 살펴보면, NAT 유형 판별을 위한 절차도 포함하고 있으니 필요하다면 참고하시기 바랍니다.




끝으로, 공용 IP만이라면 curl 등의 방법을 이용해서도 구할 수 있습니다.

c:\temp> curl ifconfig.me
20.249.98.255

혹은 이러한 동작을 구현하는 초간단 서버가 필요하다면 (C# + ASP.NET의 경우) Request.ServerVariables 속성으로 조회한 값을 반환하도록 구현하면 됩니다.

string addr = Request.ServerVariables["REMOTE_ADDR"];
string port = Request.ServerVariables["REMOTE_PORT"];
Response.Write($"{addr}:{port}"); // 바로 이것이 Server Reflexive Address 값입니다. (Reverse Proxy가 끼어들지 않는다면...)




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]







[최초 등록일: ]
[최종 수정일: 9/17/2025]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 




... 196  197  198  [199]  200  201 
NoWriterDateCnt.TitleFile(s)
60정성태10/3/200424688.NET Framework: 12. IDispatch::GetIDsOfNames 역변환 메서드 작성 힌트 ( DISPID 로 메서드 이름 알아내는 것 )
58정성태10/3/200427708.NET Framework: 11. HttpContext의 간략이해
56정성태10/3/200424091.NET Framework: 10. [.NET 리모팅] 원격개체를 호출한 클라이언트의 연결이 유효한지 판단하는 방법.
55정성태10/3/200424752COM 개체 관련: 11. 내가 생각해 보는 COM의 현재 위치
54정성태8/30/200430426VC++: 10. 내가 생각해 보는 MFC OCX와 ATL DLL에 선택 기준
53정성태11/20/200529636VC++: 9. CFtpFileFind/FtpFileFind가 일부 Unix FTP 서버에서 목록을 제대로 못 가져오는 문제
184정성태11/23/200523319    답변글 VC++: 9.1. FTP 관련 토픽파일 다운로드1
51정성태6/24/200428563VC++: 8. BSTR 메모리 할당 및 해제(MSDN Library 발췌) [1]
50정성태6/16/200421722기타: 6. 1차 데스크톱 컴퓨터 사양
49정성태6/16/200422117기타: 5. 53만 원대 Second PC 마련. ^^
48정성태3/2/200424032.NET Framework: 9. 윈도우즈 발전사를 모아 둔 사이트. [1]
47정성태11/14/200522328VS.NET IDE: 7. 한글 OS에서 Internet Explorer 6.0 with SP1의 UI 언어 바꾸는 방법
45정성태1/26/200421788기타: 4. MCAD 시험
44정성태1/26/200422550VS.NET IDE: 6. 터미널 서비스 포트 변경 ( 서버 및 클라이언트 )
46정성태1/26/200427650    답변글 VS.NET IDE: 6.1. Windows 2003 터미널 서비스 라이센스 서버 없이 접속
114정성태11/14/200518787    답변글 VS.NET IDE: 6.2. [터미널 서버 라이센스] : 활성화 시 오류
43정성태12/23/200322416기타: 3. XP/2003 개인 방화벽 설정파일 다운로드1
40정성태7/23/200326054COM 개체 관련: 10. IE BHO 개체를 개발할 때, 인터넷 익스플로러가 아닌 탐색기에서 활성화 되는 문제 해결 [1]
41김성현7/24/200324706    답변글 COM 개체 관련: 10.1. [답변]: IE BHO 개체를 개발할 때, 인터넷 익스플로러가 아닌 탐색기에서 활성화 되는 문제 해결
42정성태7/29/200322403        답변글 COM 개체 관련: 10.2. feedback 을 받기 위해서 답변 기능을 가능하게 해두었습니다.
39정성태7/17/200328354VS.NET IDE: 5. 원격 제어 3가지 방법
38정성태7/17/200324603.NET Framework: 8. IIS 서버 재설치와 ASP.NET 서비스의 문제점
36정성태7/17/200325462.NET Framework: 7. 시행착오 - WebService 참조 추가 오류
35정성태7/17/200326003.NET Framework: 6. Win2000에서의 .NET COM+ 자동 등록 오류 발생 해결
34정성태7/17/200324572VS.NET IDE: 4. VC++ 원격 디버깅파일 다운로드1
33정성태7/17/200324939VS.NET IDE: 3. Win2000 NAT 서비스
... 196  197  198  [199]  200  201