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

비밀번호

댓글 작성자
 




... 16  17  18  19  20  21  [22]  23  24  25  26  27  28  29  30  ...
NoWriterDateCnt.TitleFile(s)
13487정성태12/16/202315003개발 환경 구성: 696. C# - 리눅스용 AOT 빌드를 docker에서 수행 [1]
13486정성태12/15/202312783개발 환경 구성: 695. Nuget config 파일에 값 설정/삭제 방법
13485정성태12/15/202312387오류 유형: 883. dotnet build/restore - error : Root element is missing
13484정성태12/14/202314430개발 환경 구성: 694. Windows 디렉터리 경로를 WSL의 /mnt 포맷으로 구하는 방법
13483정성태12/14/202313363닷넷: 2184. C# - 하나의 resource 파일을 여러 프로그램에서 (AOT 시에도) 사용하는 방법파일 다운로드1
13482정성태12/13/202315991닷넷: 2183. C# - eFriend Expert OCX 예제를 .NET Core/5+ Console App에서 사용하는 방법 [2]파일 다운로드1
13481정성태12/13/202314774개발 환경 구성: 693. msbuild - .NET Core/5+ 프로젝트에서 resgen을 이용한 리소스 파일 생성 방법파일 다운로드1
13480정성태12/12/202317013개발 환경 구성: 692. Windows WSL 2 + Chrome 웹 브라우저 설치
13479정성태12/11/202312756개발 환경 구성: 691. WSL 2 (Ubuntu) + nginx 환경 설정
13477정성태12/8/202313847닷넷: 2182. C# - .NET 7부터 추가된 Int128, UInt128 [1]파일 다운로드1
13476정성태12/8/202315011닷넷: 2181. C# - .NET 8 JsonStringEnumConverter의 AOT를 위한 개선파일 다운로드1
13475정성태12/7/202315973닷넷: 2180. .NET 8 - 함수 포인터에 대한 Reflection 정보 조회파일 다운로드1
13474정성태12/6/202314233개발 환경 구성: 690. 닷넷 코어/5+ 버전의 ilasm/ildasm 실행 파일 구하는 방법 - 두 번째 이야기
13473정성태12/5/202315451닷넷: 2179. C# - 값 형식(Blittable)을 메모리 복사를 이용해 바이트 배열로 직렬화/역직렬화파일 다운로드1
13472정성태12/4/202313524C/C++: 164. Visual C++ - InterlockedCompareExchange128 사용 방법
13471정성태12/4/202313747Copilot - To enable GitHub Copilot, authorize this extension using GitHub's device flow
13470정성태12/2/202315562닷넷: 2178. C# - .NET 8부터 COM Interop에 대한 자동 소스 코드 생성 도입 [1]파일 다운로드1
13469정성태12/1/202315929닷넷: 2177. C# - (Interop DLL 없이) CoClass를 이용한 COM 개체 생성 방법파일 다운로드1
13468정성태12/1/202314643닷넷: 2176. C# - .NET Core/5+부터 달라진 RCW(Runtime Callable Wrapper) 대응 방식파일 다운로드1
13467정성태11/30/202315573오류 유형: 882. C# - Unhandled exception. System.Runtime.InteropServices.COMException (0x800080A5)파일 다운로드1
13466정성태11/29/202313934닷넷: 2175. C# - DllImport 메서드의 AOT 지원을 위한 LibraryImport 옵션
13465정성태11/28/202314241개발 환경 구성: 689. MSBuild - CopyToOutputDirectory가 "dotnet publish" 시에는 적용되지 않는 문제파일 다운로드1
13464정성태11/28/202314251닷넷: 2174. C# - .NET 7부터 UnmanagedCallersOnly 함수 export 기능을 AOT 빌드에 통합파일 다운로드1
13463정성태11/27/202312527오류 유형: 881. Visual Studio - NU1605: Warning As Error: Detected package downgrade
13462정성태11/27/202313779오류 유형: 880. Visual Studio - error CS0246: The type or namespace name '...' could not be found
13461정성태11/26/202313902닷넷: 2173. .NET Core 3/5+ 기반의 COM Server를 registry 등록 없이 사용하는 방법파일 다운로드1
... 16  17  18  19  20  21  [22]  23  24  25  26  27  28  29  30  ...