본문 바로가기

C#

C# unsafe 코드와 포인터 사용하기

unsafe 코드는 CLR의 메모리 안전 장치를 우회해 포인터로 직접 메모리를 다루게 합니다. 관리 코드만으로는 어려운 고성능 연산, 네이티브 상호운용, 바이너리 파싱에 유용합니다. 다만 잘못 사용하면 크래시/데이터 손상이 일어날 수 있으니 반드시 범위를 최소화하고 검증하며 사용합니다.

1. 프로젝트에서 unsafe 활성화

// .csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>
</Project>
// CLI 빌드
csc /unsafe Program.cs
// 또는
dotnet build -p:AllowUnsafeBlocks=true

2. 기본 문법 요약

using System;

class Program
{
    static void Main()
    {
        unsafe
        {
            int x = 10;
            int* p = &x;       // 주소 연산자 &
            int v = *p;        // 역참조 *
            *p = 42;           // 값 쓰기

            Console.WriteLine(v);   // 10
            Console.WriteLine(x);   // 42

            // 구조체 포인터와 -> 연산자
            Point pt = new Point { X = 3, Y = 4 };
            Point* pp = &pt;
            pp->X = 7;
            Console.WriteLine(pp->Y); // 4
        }
    }
}

struct Point
{
    public int X;
    public int Y;
}

포인터 산술은 동일한 요소 크기 단위로 이동합니다. 예: p + 1은 sizeof(T)만큼 이동합니다.

3. fixed로 관리 배열 고정(pinning) 후 접근

GC는 객체를 이동시킬 수 있으므로, 관리 힙 객체의 주소를 얻어 쓰려면 fixed로 고정해야 합니다.

using System;

class Program
{
    static unsafe long Sum(int[] data)
    {
        long sum = 0;
        fixed (int* p = data) // 배열을 고정하고 첫 요소 포인터 획득
        {
            for (int i = 0; i < data.Length; i++)
                sum += p[i]; // 인덱서 문법 허용
        }
        return sum;
    }

    static unsafe void Fill(byte[] data, byte value)
    {
        fixed (byte* p = data)
        {
            for (int i = 0; i < data.Length; i++)
                p[i] = value;
        }
    }
}

4. stackalloc으로 빠른 임시 버퍼 만들기

stackalloc은 스택에 고정 크기 버퍼를 할당합니다. 함수가 끝나면 자동 해제되며 GC 부담이 없습니다.

using System;

class Program
{
    static unsafe byte[] MakeSequence(int count)
    {
        byte* buf = stackalloc byte[count];
        for (int i = 0; i < count; i++)
            buf[i] = (byte)i;

        var managed = new byte[count];
        fixed (byte* pManaged = managed)
        {
            Buffer.MemoryCopy(buf, pManaged, managed.Length, count);
        }
        return managed;
    }
}

stackalloc 버퍼 크기는 과도하게 키우지 않습니다(스택 오버플로우 위험). 큰 버퍼는 ArrayPool 등 다른 방법을 고려합니다.

5. 고성능 메모리 복사/변환

using System;

class Program
{
    static unsafe void FastCopy(byte[] src, byte[] dst, int count)
    {
        if (count < 0 || count > src.Length || count > dst.Length)
            throw new ArgumentOutOfRangeException(nameof(count));

        fixed (byte* pSrc = src)
        fixed (byte* pDst = dst)
        {
            Buffer.MemoryCopy(pSrc, pDst, dst.Length, count);
        }
    }

    // 재해석 캐스팅: 같은 크기의 값 형식 간 바이트 레벨 접근
    static unsafe uint ReinterpretToUInt(int value)
    {
        int* p = &value;
        return *(uint*)p;
    }
}

재해석 캐스팅은 엔디언, 정렬에 민감합니다. 교차 플랫폼을 고려해야 합니다.

6. 구조체의 고정 크기 버퍼

unsafe struct에서 fixed 버퍼를 선언하면 헤더 없는 연속 메모리 필드를 가질 수 있습니다.

using System;

unsafe struct Packet
{
    public int Length;
    public fixed byte Data[256];
}

class Program
{
    static unsafe void UsePacket()
    {
        Packet p;
        p.Length = 4;
        // fixed 버퍼는 포인터를 얻어 접근
        fixed (byte* d = p.Data)
        {
            for (int i = 0; i < p.Length; i++)
                d[i] = (byte)(i + 1);

            for (int i = 0; i < p.Length; i++)
                Console.Write(d[i]);
        }
    }
}

7. 안전 가이드와 체크리스트

  • 범위를 최소화: 필요한 코드에만 unsafe 키워드/블록을 씁니다.
  • 고정 최소화: fixed로 핀 상태를 짧게 유지합니다(긴 pinning은 GC 단편화 유발).
  • 경계 검사: 길이/오프셋을 엄격히 검증합니다. 오버플로우를 주의합니다.
  • 플랫폼 고려: 포인터 크기(32/64비트)와 정렬, 엔디언을 고려합니다. 포인터/크기엔 IntPtr, UIntPtr, nint, nuint를 활용합니다.
  • 대안 우선: 가능한 경우 Span<T>/ReadOnlySpan<T>, MemoryMarshal, BinaryPrimitives 등 안전한 API를 먼저 고려합니다.

8. 언제 쓰면 좋은가

  • 핫패스의 바운드 체크 제거, 대량 메모리 조작이 필요한 경우
  • 네이티브 라이브러리와 포인터 기반 인터페이스로 상호운용할 때
  • 프로토콜/파일 포맷의 바이너리 파싱 최적화

unsafe는 마지막 수단입니다. 먼저 안전한 방법을 검토하고, 측정으로 필요성을 확인한 뒤, 작은 단위로 도입하고 철저히 테스트합니다.