반응형

[PART2.변수와 기본 데이터 타입(4/13)] 네이티브 정수 — nint · nuint (C# 9 / 11에서 키워드 승격)

CPU 워드에 맞는 정수 / IntPtr의 키워드 별칭 / interop과 unsafe의 기본 도구


왜 "네이티브 정수"가 따로 필요한가

Unity에서 C로 작성된 네이티브 플러그인을 호출해 본 적이 있다면, 함수 프로토타입에서 intptr_t·size_t를 본 기억이 있을 겁니다. 이 두 타입은 모두 CPU의 기본 워드 크기, 즉 주소 하나를 담기에 딱 맞는 크기를 가지는 정수입니다. 32비트 iOS 빌드에서는 4바이트, 64비트 ARM64·x64 빌드에서는 8바이트가 됩니다.

그런데 C# 세상에서는 정수 타입의 크기가 항상 고정됩니다. int는 언제나 4바이트, long은 언제나 8바이트입니다. 그래서 C# 쪽에서 네이티브 함수의 intptr_t 매개변수를 받으려면 고민이 생깁니다.

  • int로 받는다? → 64비트 프로세스에서 포인터를 받으면 상위 4바이트가 잘려 크래시.
  • long으로 받는다? → 32비트 iOS·옛 안드로이드 ABI에서는 4바이트짜리 포인터를 8바이트 공간에 담으려다 호출 규약(calling convention)이 어긋남. 모바일에서 메모리 낭비도 큽니다.

이 간극을 메우려고 .NET은 처음부터 System.IntPtrSystem.UIntPtr이라는 구조체를 제공해 왔습니다. 하지만 IntPtr 은 오랫동안 일반 정수처럼 쓸 수 없었습니다+, -, * 같은 사칙연산자가 아예 없었고, 리터럴을 바로 대입할 수도 없었습니다. 네이티브 API 한 줄을 호출하려고 new IntPtr(10), p.ToInt64() + 16, new IntPtr(result) 같은 장황한 코드를 써야 했죠.

C# 9에서 nint · nuint 가 도입되고, C# 11에서 이 둘이 IntPtr · UIntPtr정식 키워드 별칭(alias) 으로 승격되면서 이 오래된 통증이 해결됐습니다. 이제 네이티브 interop 코드도 int처럼 짧고 자연스럽게 쓸 수 있습니다.

용어 정리 워드 크기(word size): CPU가 한 번의 연산으로 다루는 가장 자연스러운 데이터 단위. 32비트 CPU는 4바이트, 64비트 CPU는 8바이트. 포인터와 메모리 주소도 이 크기를 따른다. interop (interoperability): 관리(managed) 코드와 네이티브(unmanaged) 코드가 서로 호출하는 것. Unity에서는 C++ 네이티브 플러그인·iOS Objective-C·안드로이드 NDK 호출이 모두 interop. P/Invoke (Platform Invoke): C#에서 네이티브 DLL 함수를 호출하는 표준 메커니즘. [DllImport("kernel32.dll")] 속성으로 선언한다. IL (Intermediate Language): C# 컴파일러가 만드는 중간 언어. CLR(.NET 런타임)이 실행 직전에 기계어로 JIT 변환한다.

개념 정의

nint / nuint 란 무엇인가

한 줄 정의: 실행 중인 프로세스의 포인터 크기와 같은 부호 있는/없는 정수.

32비트 프로세스 (예: 구형 iOS, x86)
  • nint — 부호 있는 네이티브 정수. System.IntPtr 의 키워드 별칭.
  • nuint — 부호 없는 네이티브 정수. System.UIntPtr 의 키워드 별칭.

비유하자면 택배 상자의 크기와 비슷합니다. 내용물이 "주소"(포인터)라면, 상자(정수 변수)는 그 주소를 꼭 맞게 감쌀 크기여야 합니다. 32비트 주소를 8바이트 상자(long)에 넣으면 절반이 비어 공간 낭비, 64비트 주소를 4바이트 상자(int)에 넣으면 내용물이 잘려 파손. nint는 자기가 쓰일 환경을 보고 "상자 크기를 알아서 맞춰 주는" 상자입니다.

기본 사용 예시

아래 코드는 C# 9 이상이면 컴파일됩니다. C# 11 이후로는 nintIntPtr이 100% 동일한 타입이라 별도 캐스팅 없이 서로 대입됩니다.

C#
using System;

public class Program
{
    public static nint AddNint(nint a, nint b)
    {
        return a + b;
    }

    public static long AddLong(long a, long b)
    {
        return a + b;
    }

    public static nint LiteralAssign()
    {
        nint x = 10;              // C# 9 이전엔 (nint)10 캐스트가 필요했던 리터럴 대입
        nint y = x * 2 + 3;       // 사칙연산도 일반 정수처럼
        return y;
    }

    public static void Main()
    {
        nint r = AddNint(100, 200);
        long l = AddLong(100L, 200L);
        Console.WriteLine($"nint={r}, long={l}, size={IntPtr.Size}");
    }
}
IL
.method public hidebysig static
    native int AddNint (                         // 반환·매개변수 타입이 IL에서 'native int'로 찍힌다
        native int a,
        native int b
    ) cil managed
{
    IL_0001: ldarg.0
    IL_0002: ldarg.1
    IL_0003: add                                 // int·long과 동일한 add 명령 — 전용 opcode 없음
    IL_0004: stloc.0
    IL_0008: ret
}

.method public hidebysig static
    int64 AddLong (                              // long은 IL에서 'int64'로 고정 크기
        int64 a,
        int64 b
    ) cil managed
{
    IL_0001: ldarg.0
    IL_0002: ldarg.1
    IL_0003: add
    IL_0008: ret
}

.method public hidebysig static
    native int LiteralAssign () cil managed
{
    IL_0001: ldc.i4.s 10                         // int 리터럴 10을 스택에 로드
    IL_0003: conv.i                              // 네이티브 정수로 변환 — 32/64비트 환경에 맞춰 확장
    IL_0004: stloc.0
    IL_0005: ldloc.0
    IL_0006: ldc.i4.2
    IL_0007: conv.i                              // 2도 네이티브 정수로 변환
    IL_0008: mul
    IL_0009: ldc.i4.3
    IL_000a: conv.i
    IL_000b: add
    IL_000c: stloc.1
    IL_0012: ret
}

IL에서 가장 먼저 눈에 들어오는 건 타입 표기가 native int · native unsigned int 라는 점입니다. int32 · int64 처럼 비트 수가 고정된 타입이 아니라, JIT가 실행 시점에 플랫폼 워드 크기로 확정하는 특수 타입입니다. add 명령은 int32·int64·native int에 모두 공용으로 쓰이기 때문에, nint 연산에 별도 opcode가 필요하지 않습니다. 즉 nint의 성능은 기본 정수 산술과 동일합니다.

ldc.i4.s 10 뒤에 붙는 conv.i 명령이 핵심입니다. 컴파일러는 리터럴을 일단 int32로 스택에 올린 뒤, conv.i로 "네이티브 정수 폭으로 부호 확장"을 지시합니다. 32비트 프로세스에서는 그대로, 64비트 프로세스에서는 상위 4바이트를 부호 확장해 채웁니다.


내부 동작

CLR은 native int 를 어떻게 실행하는가

C# 소스: nint x = a + b;

IL 바이트코드는 어떤 플랫폼에서 실행될지 모르는 상태로 배포됩니다. native int라는 타입 표식만 있고, 실제 크기는 JIT(Just-In-Time) 컴파일러가 기계어를 만드는 순간에 결정됩니다.

  • 32비트 ARM/ x86: native int 는 32비트 레지스터(ARM의 r0, x86의 eax)에 매핑.
  • 64비트 ARM64/ x64: native int 는 64비트 레지스터(ARM64의 x0, x86-64의 rax)에 매핑.

이것이 nint가 "플랫폼 의존 크기"를 가질 수 있는 이유입니다. C#의 다른 정수 타입은 IL에서 이미 int32 혹은 int64로 확정되어 있어서 JIT가 크기를 선택할 여지가 없습니다.

IntPtr.Size 로 런타임 크기 확인

C#
using System;

public class Program
{
    public static void Main()
    {
        Console.WriteLine($"IntPtr.Size = {IntPtr.Size}");
        Console.WriteLine($"nint.MaxValue = {nint.MaxValue}");
        Console.WriteLine($"sizeof(nint) via marshal = {System.Runtime.InteropServices.Marshal.SizeOf<nint>()}");
    }
}
IL
IL_0006: call int32 [System.Runtime]System.IntPtr::get_Size()     // IntPtr.Size는 정적 프로퍼티
IL_0062: ldsfld native int [System.Runtime]System.IntPtr::MaxValue  // nint.MaxValue == IntPtr.MaxValue

nint.MaxValue가 IL에서 IntPtr::MaxValue 로 직접 번역되는 것이 보입니다. 이 하나의 사실이 "C# 11 이후 nintIntPtr은 완전히 같은 타입"이라는 걸 증명합니다.

C# 11에서의 "완전 통합" — IL 수준 증명

C# 9가 도입한 초기 nint는 엄밀히 말하면 시뮬레이트된 숫자 타입(simulated numeric type) 이었습니다. 겉으로는 nint처럼 보이지만 컴파일러가 타입 시스템 상에서 IntPtr과 미묘하게 다르게 취급해, 오버로딩 해결·리플렉션에서 잔주름이 많았습니다.

C# 11은 이 구분을 제거했습니다. 아래 코드를 컴파일해 보면 실측으로 확인할 수 있습니다.

C#
using System;

public class Program
{
    public static void Overload(nint x) { }
    public static void Overload(IntPtr x) { }   // ← 동일 시그니처로 간주되어 빌드 실패

    public static void Main() { }
}

.NET 10(C# 13 컴파일러) 기준 빌드 결과:

error CS0111: 'Program' 형식은 동일한 매개 변수 형식을 가진 'Overload' 멤버를 미리 정의합니다.

두 메서드가 "완전히 같은 시그니처"로 간주되어 오버로딩이 금지됩니다. intInt32의 관계와 정확히 동일합니다.


실전 적용

케이스 1: P/Invoke — 네이티브 함수 호출 (Before / After)

Unity iOS 빌드에서 네이티브 플러그인(.mm 파일)이 제공하는 getCurrentPlayerId 함수를 받아오는 상황을 가정해 봅시다. 함수 시그니처는 uintptr_t getCurrentPlayerId(void); 입니다.

C#
// ❌ Before — long으로 받으면 32비트 iOS에서 호출 규약 어긋남
using System.Runtime.InteropServices;

public static class BadBridge
{
    [DllImport("__Internal")]
    public static extern long getCurrentPlayerId();   // 32비트 환경에서 stack 정렬이 틀어짐
}
C#
// ✅ After — nuint로 받으면 32/64비트 모두 안전
using System.Runtime.InteropServices;

public static class GoodBridge
{
    [DllImport("__Internal")]
    public static extern nuint getCurrentPlayerId();  // uintptr_t에 1:1 대응
}

iOS 64비트 전용이 된 지금도 nint/nuint를 쓰는 편이 권장됩니다. 네이티브 API 원형과 C# 시그니처가 문자 그대로 같은 의미(같은 크기·같은 의도)가 되어 유지보수가 쉬워지기 때문입니다.

케이스 2: 포인터 산술 (unsafe 영역)

C#
using System;

public class Program
{
    // Span<int>의 길이를 네이티브 정수로 받아 unsafe 인덱싱
    public static unsafe int SumSpan(int* head, nint length)
    {
        int sum = 0;
        for (nint i = 0; i < length; i++)
        {
            sum += head[i];
        }
        return sum;
    }
}
IL
.method public hidebysig static
    int32 SumSpan (
        int32* head,
        native int length
    ) cil managed
{
    .locals init (
        [0] int32,
        [1] native int,          // 루프 카운터 i
        [2] bool,
        [3] int32
    )

    IL_0001: ldc.i4.0
    IL_0002: stloc.0
    IL_0003: ldc.i4.0
    IL_0004: conv.i              // i를 네이티브 0으로 초기화
    IL_0005: stloc.1
    IL_0006: br.s IL_001a
    // loop body
        IL_0009: ldloc.0
        IL_000a: ldarg.0          // head 로드
        IL_000b: ldloc.1          // i 로드
        IL_000c: conv.i8          // i를 int64로 확장
        IL_000d: ldc.i4.4
        IL_000e: conv.i8
        IL_000f: mul              // i * sizeof(int)
        IL_0010: conv.i           // 결과를 native int로
        IL_0011: add              // head + offset
        IL_0012: ldind.i4         // 해당 주소에서 int 로드
        IL_0013: add
        IL_0014: stloc.0
        ...
    // end loop
}

포인터 산술이 IL 레벨에서 어떻게 번역되는지 뚜렷이 보입니다. i * sizeof(int) 후 베이스 주소에 더해 ldind.i4로 읽어 옵니다. nint를 카운터로 써야 conv.i8/conv.i 변환이 플랫폼 워드와 자연스럽게 맞아떨어집니다. int로 카운터를 잡으면 64비트 환경에서 매 반복마다 불필요한 확장 변환이 추가됩니다.

케이스 3: Unity IL2CPP와 Burst — 핫패스에서의 nint

Unity 2022.3 LTS 이후의 IL2CPP 백엔드는 IL을 C++로 한 번 더 트랜스파일합니다. 이때 C# nint/nuint는 C++ 측 intptr_t/uintptr_t로 직접 매핑되어, 포인터 산술이 네이티브 C++ 코드와 같은 수준의 기계어로 컴파일됩니다.

Burst Compiler·Job System에서의 효과는 더 극적입니다. Burst는 LLVM 기반이라 워드 크기를 잘못 지정한 정수를 그대로 두고 SIMD 최적화를 시도하지 않는 경우가 있습니다. 포인터 오프셋을 int가 아닌 nint로 잡으면 Burst가 더 과감하게 벡터화를 수행합니다.

C#
// UnsafeUtility와 함께 쓰는 전형적인 Unity 패턴
using Unity.Collections.LowLevel.Unsafe;

public static unsafe class FastBufferCopy
{
    // ❌ Bad: int로 버퍼 길이를 받으면 2GB 이상 버퍼에서 오버플로
    public static void CopyBad(void* src, void* dst, int byteLen)
    {
        UnsafeUtility.MemCpy(dst, src, byteLen);
    }

    // ✅ Good: nuint로 받으면 네이티브 길이와 1:1, 향후 대용량 메모리 대비
    public static void CopyGood(void* src, void* dst, nuint byteLen)
    {
        UnsafeUtility.MemCpy(dst, src, (long)byteLen);
    }
}
UnsafeUtility.MemCpy의 공식 시그니처는 (void* dst, void* src, long size) 입니다. 여기서는 "호출자 측 길이 타입"을 어떻게 잡느냐가 포인트입니다. 텍스처·오디오 버퍼 같은 대용량 데이터에서는 2GB를 넘어 int 표현 범위를 벗어나는 일이 실제로 발생합니다.

함정과 주의사항

함정 1: 개발 머신(64비트)에서만 돌리고 32비트 배포에서 터진다

Apple Silicon Mac·윈도우 x64에서는 nint가 64비트지만, 구형 iOS·일부 안드로이드 32비트 ABI·게임 콘솔 일부에서는 32비트입니다. 개발 머신에서는 문제가 없는 리터럴이 배포 환경에서 OverflowException을 내는 것이 대표적인 함정입니다.

C#
// ❌ Bad: 64비트 머신에서는 문제없지만 32비트에서 OverflowException
public static nint ComputeOffset()
{
    nint giga = 3_000_000_000;      // 30억 — 32비트 nint 범위(약 21억)를 초과
    return giga;
}
IL
IL_0000: ldc.i4   0xB2D05E00       // 정확히 3,000,000,000이 i4(32비트)로는 음수로 취급됨
IL_0005: conv.u                    // nuint로 변환 시도 — 32비트에서는 잘릴 수 있음

ldc.i4 0xB2D05E00이 문제입니다. 3,000,000,000은 부호 있는 32비트의 양수 범위(2,147,483,647)를 넘기 때문에 비트 패턴 자체는 음수로 취급됩니다. 64비트 런타임에서는 conv.u가 비트를 확장해 양수 30억이 복원되지만, 32비트 런타임에서는 그대로 부호 있는 음수가 됩니다.

C#
// ✅ Good: long으로 명시적으로 확장한 뒤 대입
public static nint ComputeOffset()
{
    long giga = 3_000_000_000L;
    return (nint)giga;              // 32비트에서는 여기서 OverflowException
                                    // → 적어도 증상이 "데이터 손상"이 아니라 "예외"로 드러남
}

원칙: 일반 비즈니스 로직에서는 long을 쓰고, nint는 interop·포인터 산술에만 씁니다. nint를 무분별하게 쓰면 "개발 환경에서만 잘 돌아가는 코드"가 됩니다.

함정 2: 직렬화·해시·네트워크 전송에 nint 를 그대로 쓰기

C#
// ❌ Bad: 멀티플레이어에서 서버는 64비트 Linux, 클라는 32비트 모바일이면 해시 불일치
[System.Serializable]
public struct PlayerState
{
    public nint playerId;           // 크기가 플랫폼 따라 달라져 직렬화 바이트 길이가 일관되지 않음
}
C#
// ✅ Good: 직렬화·네트워크 바운더리에서는 고정 크기 정수로 강제
[System.Serializable]
public struct PlayerState
{
    public long playerId;           // 어느 플랫폼에서든 정확히 8바이트
}

직렬화·네트워크 패킷·파일 포맷처럼 "바이트 수가 곧 계약" 인 장면에서는 절대 nint를 쓰면 안 됩니다. GetHashCode()도 마찬가지로 같은 입력에 대해 32비트·64비트에서 다른 값을 낼 수 있어서, 해시 기반 분산 시스템에서는 조심해야 합니다.

함정 3: checked 컨텍스트를 잊고 오버플로 무시하기

C#
// ❌ Bad: 오버플로가 조용히 랩어라운드
public static nint UncheckedAdd(nint a, nint b)
{
    return unchecked(a + b);        // nint.MaxValue + 1 → nint.MinValue
}

// ✅ Good: checked로 감싸 OverflowException을 던지게 함
public static nint CheckedAdd(nint a, nint b)
{
    return checked(a + b);          // 오버플로 즉시 예외
}
IL
// UncheckedAdd
IL_0003: add                       // 단순 덧셈 — 오버플로 무시

// CheckedAdd
IL_0003: add.ovf                   // 오버플로 검사 덧셈 — OverflowException 던짐

IL 명령 하나가 다릅니다: add vs add.ovf. add.ovf는 하드웨어 플래그를 읽어 오버플로 시 예외를 던지는 한 줄 기계어로 JIT됩니다. 런타임 비용은 수 사이클 수준이니, Update 루프 핫패스가 아니면 기본 checked를 켜두는 편이 안전합니다(.NET CLI -checked+, Unity는 Player Settings에 해당 옵션 없음 — 수동 checked { } 권장).

함정 4: 네이티브 포인터를 int로 받아 절사

C#
using System;

public class Program
{
    // ❌ Bad: 64비트 환경에서 ToInt32()는 OverflowException 가능
    public static int BadHandleAsInt()
    {
        IntPtr handle = GetNativeHandle();
        return handle.ToInt32();
    }

    // ✅ Good: nint로 받고 그대로 유지
    public static nint GoodHandleAsNint()
    {
        nint handle = GetNativeHandle();
        nint next = handle + 16;     // 포인터 산술이 자연스럽게 성립
        return next;
    }

    private static IntPtr GetNativeHandle() => new IntPtr(0x12345678);
}
IL
.method public hidebysig static
    int32 BadHandleAsInt () cil managed
{
    IL_0001: call native int Program::GetNativeHandle()
    IL_0009: call instance int32 [System.Runtime]System.IntPtr::ToInt32()   // 명시적 축소 변환 호출
    IL_000e: stloc.1
    ...
}

.method public hidebysig static
    native int GoodHandleAsNint () cil managed
{
    IL_0001: call native int Program::GetNativeHandle()
    IL_0008: ldc.i4.s 16
    IL_000a: conv.i                                                          // 16을 native int로
    IL_000b: add                                                             // 포인터 + 오프셋, 한 줄로
    ...
}

Bad 쪽은 ToInt32() 라는 별도 메서드 호출이 필요합니다. ToInt32()는 값이 int 범위를 넘으면 OverflowException을 던지도록 런타임이 보장합니다 — 즉 이 코드는 64비트 환경에서 "조용히 망가지지" 않고 예외로 드러나긴 하지만, 그래도 앱이 죽는다는 사실은 변하지 않습니다. Good 쪽은 그냥 add 한 줄이라 예외 발생 경로 자체가 없습니다.

GetNativeHandle()의 반환 타입이 C#에서는 IntPtr인데 IL에서는 native int로 찍힌 것에 주목하세요. C# 11 이후의 "완전 별칭"이 어떻게 동작하는지 IL 레벨에서 다시 한번 확인됩니다.

C# 버전별 변화

C# 8 이하 — IntPtr 은 "덩치 큰 구조체"

C#
// C# 8 시대의 전형적인 IntPtr 코드 — 장황하다
using System;

public class Program
{
    public static IntPtr AdvancePointer(IntPtr basePtr, int offsetBytes)
    {
        // 사칙연산자가 없어서 수동으로 풀어야 함
        long raw = basePtr.ToInt64();
        long advanced = raw + offsetBytes;
        return new IntPtr(advanced);     // new IntPtr 호출이 한 번 더
    }
}

이 시절의 IntPtrAdd·Subtract 정적 메서드만 있고, 인스턴스 연산자는 없었습니다. C에 익숙한 눈에는 이 코드가 매우 어색해 보입니다.

C# 9 — nint / nuint 등장 (시뮬레이트 타입)

C#
// C# 9 이후 — 사칙연산·비교·리터럴이 한 번에 해결됨
using System;

public class Program
{
    public static nint AdvancePointer(nint basePtr, int offsetBytes)
    {
        return basePtr + offsetBytes;    // 자연스러운 포인터 산술
    }

    public static nint MaxCompare(nint a, nint b)
    {
        return a > b ? a : b;            // 비교 연산자도 지원
    }
}

이때의 nint는 "언어 측 키워드"였습니다. 런타임 타입은 여전히 IntPtr이지만, 컴파일러가 +·-·<·> 같은 연산자와 리터럴 대입을 별도 규칙으로 처리했습니다. 그래서 typeof(nint) == typeof(IntPtr)true 지만, 오버로딩이나 제네릭 제약에서 미묘한 차이가 있었습니다.

C# 11 / .NET 7+ — IntPtr 의 정식 키워드 별칭으로 완전 통합

C#
using System;

public class Program
{
    public static void Overload(nint x) { }
    public static void Overload(IntPtr x) { }   // ← CS0111: 중복 정의
}

C# 11부터 nint == IntPtr, nuint == UIntPtr언어 스펙 수준에서 확정됐습니다. intInt32의 관계처럼, 이제 둘은 이름만 다른 같은 타입입니다. 이로써 C# 9 시절 시뮬레이트 타입이 남긴 잔주름(오버로딩·리플렉션·제네릭)이 모두 사라졌습니다.

IntPtr 본체에도 C# 9~11 사이에 연산자·리터럴·checked·MaxValue/MinValue 같은 멤버가 추가됐습니다. 그 결과 이제는 IntPtr에 직접 IntPtr p = 10; 을 써도 컴파일됩니다. 역사적 이유로 두 이름이 공존할 뿐, 새 코드에서는 가독성을 위해 nint/nuint 쪽을 권장합니다.


정리

  • nint/nuint는 CPU 워드 크기와 동일한 정수 — 32비트에서 4바이트, 64비트에서 8바이트.
  • nint == System.IntPtr, nuint == System.UIntPtr — C# 11 이후 완전한 키워드 별칭. int == Int32와 같은 관계.
  • IL에서는 native int/native unsigned int 로 표현 — JIT가 실행 시점에 플랫폼 워드로 확정.
  • 반드시 써야 하는 곳: P/Invoke 시그니처, 네이티브 포인터 산술, Marshal API, UnsafeUtility 버퍼 오프셋, Burst Job 루프 카운터.
  • 절대 쓰면 안 되는 곳: 직렬화·네트워크 패킷·해시 키·파일 포맷 등 "바이트 수가 계약인 경계" — 고정 크기 long/int 사용.
  • 일반 비즈니스 로직의 기본 선택지는 여전히 long — 이식성·예측 가능성이 중요하다면 nint를 피한다.
  • checked 컨텍스트 잊지 말기 — IL에서 addadd.ovf 한 글자 차이가 디버깅 난이도를 가른다.
  • 개발 환경과 배포 환경의 비트 수가 다를 수 있다 — 리터럴 범위와 오버플로 동작을 32비트 기준으로도 한 번 검증한다.
반응형

+ Recent posts