본문 바로가기

C#

C# Span<T>와 stackalloc로 고성능 버퍼 생성

GC 할당을 줄이고 빠른 임시 버퍼가 필요할 때, C#의 Span<T>와 stackalloc는 강력한 선택지입니다. Span<T>는 연속된 메모리를 안전하게 다루는 ref struct이며, stackalloc는 스택에 고정 크기 버퍼를 즉시 생성합니다. 두 기능을 함께 사용하면 작은 임시 버퍼 작업에서 힙 할당 없이 매우 빠른 처리가 가능합니다.

1. Span<T>와 stackalloc를 왜 써야 하나요?

짧게 쓰고 버리는 임시 버퍼를 힙에 만들면 GC 부담이 커집니다. stackalloc는 스택에 메모리를 확보하므로 함수가 끝나면 즉시 회수되며, Span<T>는 해당 메모리를 안전하게 다룹니다. 문자열 파싱, 바이너리 변환, 포맷팅, 스트림 읽기 등에서 특히 효과적입니다.

2. 기본 사용법: stackalloc로 Span 만들기

stackalloc는 unsafe 없이 Span<T>로 직접 받을 수 있습니다.

using System;

class Demo
{
    static void Basic()
    {
        Span buffer = stackalloc byte[256];
        for (int i = 0; i < buffer.Length; i++)
            buffer[i] = (byte)i;

        // 일부만 잘라 쓰기
        Span slice = buffer.Slice(10, 20);
        slice.Clear(); // 10~29 구간을 0으로 설정

        // 주의: Span은 ref struct라서 반환/박싱/필드 저장 불가
    }
}

3. 힙 할당 없이 숫자 포맷팅하기

TryFormat과 stackalloc를 조합하면 숫자를 문자열로 만들 때 중간 할당을 피할 수 있습니다.

using System;
using System.Globalization;

static string IntToStringNoAlloc(int value)
{
    Span buf = stackalloc char[32]; // int 최대 길이에 충분
    if (value.TryFormat(buf, out int written, provider: CultureInfo.InvariantCulture))
    {
        return new string(buf.Slice(0, written));
    }
    return value.ToString(CultureInfo.InvariantCulture);
}

4. 빠른 헥스(HEX) 인코딩 예시

바이트 배열을 헥스 문자열로 변환할 때도 stackalloc를 활용하면 매우 빠릅니다.

using System;

static string ToHex(ReadOnlySpan data)
{
    Span chars = stackalloc char[data.Length * 2];
    int p = 0;
    foreach (byte b in data)
    {
        chars[p++] = NibbleToHex(b >> 4);
        chars[p++] = NibbleToHex(b & 0xF);
    }
    return new string(chars);

    static char NibbleToHex(int v) => (char)(v < 10 ? '0' + v : 'a' + (v - 10));
}

5. 문자열 파싱: ReadOnlySpan<char> 활용

문자열 조작도 AsSpan과 Slice로 부분 문자열을 할당 없이 다룰 수 있습니다. .NET에는 TryParse 오버로드가 ReadOnlySpan<char>를 지원합니다.

using System;
using System.Globalization;

static int ParseFirstNumber(ReadOnlySpan line)
{
    int comma = line.IndexOf(',');
    ReadOnlySpan first = comma >= 0 ? line.Slice(0, comma) : line;
    if (int.TryParse(first, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value))
        return value;
    return 0;
}

// 사용 예시
// var n = ParseFirstNumber("123,456,789".AsSpan());

6. Stream 읽기에서의 스택 버퍼

짧은 블록을 반복해서 읽는 경우 스택 버퍼는 매우 효율적입니다. Stream.Read(Span<byte>) 오버로드를 활용하세요.

using System;
using System.IO;

static void ReadSmallBlocks(string path)
{
    using var fs = File.OpenRead(path);
    Span buffer = stackalloc byte[4096];
    int read;
    while ((read = fs.Read(buffer)) > 0)
    {
        ReadOnlySpan chunk = buffer.Slice(0, read);
        // chunk 처리
    }
}

7. 크기 가변 시: stackalloc + ArrayPool 하이브리드

버퍼 크기가 작을 때는 stackalloc, 클 때는 ArrayPool을 쓰는 하이브리드 패턴이 좋습니다. 스택 오버플로우 위험을 줄이면서 GC 부담도 완화합니다.

using System;
using System.Buffers;

static void UseHybridBuffer(int size)
{
    const int StackThreshold = 1024; // 상황에 맞게 조정
    byte[]? rented = null;
    Span buffer = size <= StackThreshold ? stackalloc byte[size] : default;

    if (buffer == default)
    {
        rented = ArrayPool.Shared.Rent(size);
        buffer = rented.AsSpan(0, size);
    }

    try
    {
        buffer.Clear();
        // buffer 사용
    }
    finally
    {
        if (rented != null)
            ArrayPool.Shared.Return(rented);
    }
}

8. 제약과 주의사항

Span<T>는 ref struct이므로 다음 제약을 반드시 지켜야 합니다.

- 힙에 저장 불가: 클래스 필드, 박싱, 인터페이스 변환 불가입니다.

- 비동기/이터레이터 경계 불가: await 또는 yield를 넘어서 생존할 수 없습니다.

- 람다 캡처 불가: 클로저로 캡처할 수 없습니다.

- 수명 준수: stackalloc로 만든 Span은 현재 메서드/스코프를 벗어나면 무효입니다.

또한 stackalloc 크기를 과도하게 키우면 StackOverflowException 위험이 있습니다. 일반적으로 수백~수천 바이트 정도의 작은 버퍼에 한정하는 것이 좋습니다.

9. 성능 팁 요약

- 작은 임시 버퍼: stackalloc + Span 활용으로 힙 할당 제거

- 큰/가변 버퍼: ArrayPool과 혼용

- 문자열 처리: AsSpan, Slice, TryParse/TryFormat 적극 활용

- 안전성: Span 수명은 스코프 내로 제한, async 경계 넘지 않기

- 측정: BenchmarkDotNet 등으로 실제 워크로드에서 할당과 시간 모두 확인

정리하면, Span<T>와 stackalloc는 작은 임시 버퍼를 빠르게 처리하고 GC 압력을 줄이는 데 최적화된 도구입니다. 위 패턴들을 적절히 적용해 핫패스의 할당을 제거하고 전체 처리량과 지연 시간을 개선해 보시기 바랍니다.