콘텐츠로 이동

29장. 성능과 메모리 최적화

성능과 메모리 최적화(Performance and Memory Optimization)는 현대 소프트웨어 공학에서 단순한 코드 튜닝을 넘어, 시스템 아키텍처, 알고리즘 설계, 그리고 하드웨어 특성에 대한 깊은 이해를 요구하는 종합적인 기술 영역입니다. .NET 플랫폼은 태생부터 생산성과 성능 사이의 균형을 추구해왔으며, 특히 .NET Core(현재 .NET 5+)로의 전환 이후 클라우드 네이티브 시대에 부합하는 혁신적인 성능 개선 기법들을 지속적으로 도입해왔습니다.

성능 최적화의 역사적 맥락과 패러다임 변화:

컴퓨터 과학의 거장 Donald Knuth는 1974년 그의 논문 "Structured Programming with go to Statements"에서 유명한 격언을 남겼습니다: "조기 최적화는 모든 악의 근원이다(Premature optimization is the root of all evil)." 그러나 이 문장의 전체 맥락은 종종 오해되고 있습니다. Knuth가 실제로 전달하고자 했던 메시지는 "97%의 시간 동안 작은 효율성에 대해 잊어야 한다: 조기 최적화는 모든 악의 근원이다. 하지만 중요한 3%의 기회를 놓쳐서는 안 된다"는 것이었습니다.

이 통찰은 현대 .NET 개발에서 더욱 중요한 의미를 갖습니다. .NET Framework 시대(2002-2019)에는 가비지 컬렉션(Garbage Collection)의 오버헤드, 박싱/언박싱 비용, 그리고 제한적인 메모리 제어가 성능 병목의 주요 원인이었습니다. 그러나 .NET Core/.NET 5+로의 진화 과정에서 Microsoft는 다음과 같은 혁명적인 변화를 이루어냈습니다:

  1. 제로 할당 추상화(Zero-Allocation Abstractions): Span, Memory, ArrayPool 등의 도입으로 고성능 시나리오에서 힙 할당을 최소화할 수 있게 되었습니다. 이는 C++의 제로 오버헤드 추상화 원칙을 관리 언어에 적용한 획기적인 사례입니다.

  2. Just-In-Time(JIT) 컴파일러의 진화: RyuJIT의 등장과 지속적인 개선으로 컴파일된 코드의 품질이 크게 향상되었으며, tiered compilation과 PGO(Profile-Guided Optimization)를 통해 런타임 적응형 최적화가 가능해졌습니다.

  3. 가비지 컬렉션의 현대화: 세대별 GC의 튜닝, Server GC의 개선, 그리고 .NET 5의 dynamic adaptation을 통해 GC 일시정지 시간이 대폭 감소했습니다. Microsoft의 벤치마크에 따르면, .NET 6의 GC는 .NET Framework 4.8 대비 최대 40% 빠른 처리 속도를 보입니다.

  4. ValueTask와 비동기 성능: 전통적인 Task가 모든 비동기 작업에서 힙 할당을 발생시키는 반면, ValueTask는 동기적 완료나 캐시 히트 시나리오에서 할당을 완전히 제거합니다.

클라우드 시대의 성능 경제학:

AWS, Azure, Google Cloud 등 클라우드 환경에서는 성능이 직접적인 비용으로 전환됩니다. Microsoft의 연구에 따르면, ASP.NET Core 애플리케이션의 응답 시간을 50% 개선하면 동일한 트래픽을 처리하는 데 필요한 인스턴스 수를 30-40% 줄일 수 있습니다. 이는 대규모 서비스에서 연간 수백만 달러의 비용 절감을 의미할 수 있습니다.

TechEmpower Web Framework Benchmarks에서 ASP.NET Core는 지속적으로 최상위권 성능을 기록하고 있으며, .NET 6에서는 초당 700만 이상의 요청을 처리하는 놀라운 결과를 달성했습니다. 이는 전통적으로 성능이 중요한 영역에서 사용되던 C++, Rust 등과 경쟁할 수 있는 수준입니다.

메모리 효율성의 중요성:

메모리는 현대 시스템에서 가장 귀중한 자원 중 하나입니다. 메모리 사용량을 줄이면 다음과 같은 이점을 얻을 수 있습니다:

  1. 캐시 효율성 향상: CPU 캐시는 제한적이므로, 더 작은 메모리 풋프린트는 캐시 미스를 줄이고 처리 속도를 높입니다. 메모리 접근은 CPU 연산에 비해 100-1000배 느리므로, 캐시 히트율은 전체 성능에 결정적인 영향을 미칩니다.

  2. 가비지 컬렉션 부담 감소: 힙에 할당되는 객체가 적을수록 GC가 관리해야 할 메모리가 줄어들고, GC 일시정지 시간이 단축됩니다. Microsoft의 분석에 따르면, GC 할당을 50% 줄이면 GC 시간도 약 40% 감소합니다.

  3. 확장성 개선: 메모리 효율적인 애플리케이션은 동일한 하드웨어에서 더 많은 동시 사용자를 처리할 수 있습니다. 마이크로서비스 아키텍처에서 각 인스턴스의 메모리 사용량을 50% 줄이면, 동일한 서버에서 두 배의 인스턴스를 실행할 수 있습니다.

  4. 응답성 향상: 메모리 할당과 해제는 시간이 걸리는 작업입니다. 특히 대용량 객체는 Large Object Heap(LOH)에 할당되어 특별한 GC 처리가 필요하므로, 이를 피하는 것이 중요합니다.

최적화 전략의 체계적 접근:

효과적인 최적화는 다음과 같은 체계적 프로세스를 따릅니다:

  1. 측정(Measurement): BenchmarkDotNet, dotnet-counters, PerfView 등의 도구를 사용한 정확한 성능 측정
  2. 프로파일링(Profiling): Visual Studio Profiler, dotMemory 등을 통한 병목 지점 식별
  3. 분석(Analysis): 알고리즘 복잡도, 메모리 패턴, 캐시 동작 분석
  4. 최적화(Optimization): 데이터 구조, 알고리즘, 메모리 관리 개선
  5. 검증(Verification): A/B 테스트와 벤치마크를 통한 개선 효과 확인

이 장의 구성과 학습 목표:

이 장에서는 .NET의 최신 고성능 기법들을 심층적으로 다룹니다. 각 주제는 이론적 배경, 내부 동작 원리, 실무 적용 사례, 그리고 성능 측정 결과를 포함하여, 독자들이 단순히 기법을 암기하는 것이 아니라 원리를 이해하고 적절한 상황에 적용할 수 있도록 구성되었습니다.

이 장에서 배울 내용

  • Span과 Memory: .NET의 제로 할당 메모리 추상화의 핵심. ref struct의 설계 철학, 스택 할당과 힙 할당의 차이, CPU 캐시 친화적인 메모리 패턴, 그리고 고성능 파싱 및 직렬화 기법을 학습합니다.

  • ValueTask: 비동기 프로그래밍의 메모리 효율성 혁신. Task의 힙 할당 문제, ValueTask의 내부 구조, 캐싱 시나리오에서의 성능 이점, 그리고 올바른 사용 패턴과 주의사항을 탐구합니다.

  • 구조체 최적화: 값 타입의 성능 특성을 극대화하는 기법. struct vs class 선택 기준, readonly struct의 방어적 복사 방지, in 매개변수와 ref return의 활용, 그리고 ref struct의 스택 전용 최적화를 마스터합니다.

  • string vs StringBuilder 성능: 불변 문자열의 성능 함정과 해결책. string의 내부 구조, StringBuilder의 버퍼 관리 메커니즘, Span를 활용한 할당 없는 문자열 조작, 그리고 대규모 문자열 처리의 베스트 프랙티스를 학습합니다.

  • 컬렉션 선택 가이드: 데이터 구조의 성능 특성과 선택 전략. 각 컬렉션의 시간 복잡도, 메모리 오버헤드, 캐시 지역성, 그리고 실무 시나리오별 최적 선택 기준을 체계적으로 이해합니다.

학습 목표:

  • .NET의 메모리 모델과 가비지 컬렉션의 동작 원리 이해
  • Span, Memory, ValueTask 등 현대 .NET의 고성능 타입 활용
  • 알고리즘과 데이터 구조의 성능 특성을 기반으로 한 설계 결정
  • 프로파일링과 벤치마킹을 통한 과학적 최적화 접근
  • 실무 시나리오에서 성능과 코드 가독성의 균형 유지

29.1 Span과 Memory

제로 할당 추상화의 혁명: Span의 탄생과 설계 철학

Span과 Memory는 .NET Core 2.1(2018년 5월)에서 도입된 혁명적인 타입으로, 관리 언어에서 네이티브 수준의 성능을 달성하기 위한 Microsoft의 야심찬 프로젝트의 결과물입니다. 이 타입들의 설계는 C++의 제로 오버헤드 추상화(zero-overhead abstraction) 철학을 C#에 적용하려는 시도로, Bjarne Stroustrup의 "사용하지 않는 것에 대해서는 비용을 지불하지 않는다(you don't pay for what you don't use)"는 원칙을 구현합니다.

역사적 맥락과 설계 동기:

.NET의 역사에서 메모리 관리는 항상 양날의 검이었습니다. 가비지 컬렉션은 개발자를 메모리 관리의 복잡성에서 해방시켰지만, 동시에 성능 오버헤드를 동반했습니다. 특히 다음과 같은 시나리오에서 문제가 두드러졌습니다:

  1. 문자열 부분 추출: string.Substring()은 항상 새로운 string 객체를 힙에 할당합니다. 대용량 로그 파싱이나 네트워크 프로토콜 처리에서 이는 엄청난 GC 압박을 만듭니다.

  2. 배열 슬라이싱: 배열의 일부만 처리하려면 새 배열을 할당하거나 인덱스를 추적하는 복잡한 코드가 필요했습니다.

  3. 네이티브 interop: P/Invoke를 통한 네이티브 라이브러리 호출 시 메모리 복사가 빈번하게 발생했습니다.

Microsoft의 .NET 팀은 2014년경부터 이 문제를 체계적으로 연구하기 시작했습니다. 당시 ASP.NET 팀은 Kestrel 웹 서버를 개발하면서, Node.js와 Go의 성능에 필적하기 위해서는 메모리 할당을 극적으로 줄여야 한다는 것을 깨달았습니다. 이는 Span 프로젝트의 촉매제가 되었으며, Jan Kotas, Stephen Toub, Jared Parsons를 비롯한 .NET 최고의 엔지니어들이 참여한 다년간의 연구 개발로 이어졌습니다.

Span의 근본적 설계 원칙:

Span는 다음과 같은 핵심 원칙을 기반으로 설계되었습니다:

  1. 타입 안전성(Type Safety): unsafe 코드 없이도 메모리를 직접 다룰 수 있어야 합니다.
  2. 제로 할당(Zero Allocation): 힙에 객체를 할당하지 않고도 메모리 영역을 참조할 수 있어야 합니다.
  3. 범용성(Generality): 배열, stackalloc, 네이티브 메모리 등 다양한 메모리 소스를 통일된 방식으로 다룰 수 있어야 합니다.
  4. 성능(Performance): 인라인 최적화와 JIT 컴파일러의 특수 처리를 통해 네이티브 코드와 동등한 성능을 달성해야 합니다.

Span의 내부 구조와 ref struct:

Spanref struct로 선언된 특수한 값 타입입니다. ref struct는 C# 7.2에서 Span를 위해 특별히 도입된 언어 기능으로, 다음과 같은 독특한 제약을 가집니다:

public readonly ref struct Span<T>
{
    // 내부 표현 (개념적)
    private readonly ref T _reference;  // 메모리 위치에 대한 참조
    private readonly int _length;       // 길이

    // 실제 구현은 더 복잡하지만, 논리적으로는 이렇게 이해할 수 있음
}

ref struct의 제약사항: - 스택에만 할당: 힙에 할당될 수 없으므로 박싱 불가 - 필드로 사용 불가: 일반 class나 struct의 필드가 될 수 없음 - async/await 불가: 비동기 메서드의 상태 머신에 포함될 수 없음 - 배열 불가: Span의 배열을 만들 수 없음 - 제네릭 타입 인수 불가: List<Span<T>>와 같은 사용 불가

이러한 제약은 모두 성능을 위한 트레이드오프입니다. Span가 스택에만 존재한다는 보장은 컴파일러와 JIT가 매우 공격적인 최적화를 수행할 수 있게 합니다.

Span의 기본 사용법:

Span는 연속된 메모리 영역을 나타내는 추상화로, 배열, stackalloc, 또는 네이티브 메모리를 안전하게 참조할 수 있습니다.

// 배열에서 Span 생성
int[] numbers = { 1, 2, 3, 4, 5 };
Span<int> span = numbers;

// 부분 범위 참조 (힙 할당 없음)
Span<int> slice = span.Slice(1, 3);  // { 2, 3, 4 }

// 값 수정 (원본 배열도 변경됨)
slice[0] = 20;
Console.WriteLine(numbers[1]);  // 20

문자열 처리에서의 Span - 성능 혁명:

문자열 처리는 Span의 가장 강력한 활용 사례 중 하나입니다. 전통적인 C#에서 string.Substring()은 항상 새로운 string 객체를 힙에 할당합니다. 대용량 텍스트 처리나 파싱 작업에서 이는 심각한 성능 병목이 됩니다.

// ❌ 전통적 방식 - 매번 새 string 할당
public List<string> ParseCsvOld(string line)
{
    var result = new List<string>();
    int start = 0;

    for (int i = 0; i < line.Length; i++)
    {
        if (line[i] == ',')
        {
            result.Add(line.Substring(start, i - start));  // 새 string 할당!
            start = i + 1;
        }
    }
    result.Add(line.Substring(start));  // 마지막 필드도 할당

    return result;
}

// ✅ Span<T> 방식 - 할당 없이 처리
public void ParseCsvNew(ReadOnlySpan<char> line, List<string> result)
{
    result.Clear();
    int start = 0;

    for (int i = 0; i < line.Length; i++)
    {
        if (line[i] == ',')
        {
            ReadOnlySpan<char> field = line.Slice(start, i - start);  // 할당 없음!
            result.Add(field.ToString());  // 필요할 때만 string 생성
            start = i + 1;
        }
    }
    result.Add(line.Slice(start).ToString());
}

// 💡 더 나은 방식 - Span<T>를 직접 처리
public int SumCsvNumbers(ReadOnlySpan<char> line)
{
    int sum = 0;
    int start = 0;

    for (int i = 0; i < line.Length; i++)
    {
        if (line[i] == ',')
        {
            ReadOnlySpan<char> field = line.Slice(start, i - start);
            // string 변환 없이 직접 파싱!
            if (int.TryParse(field, out int value))
            {
                sum += value;
            }
            start = i + 1;
        }
    }

    // 마지막 필드 처리
    if (int.TryParse(line.Slice(start), out int lastValue))
    {
        sum += lastValue;
    }

    return sum;
}

성능 비교 - 실측 데이터:

Microsoft의 벤치마크에 따르면, 1,000개 필드를 포함한 CSV 라인 처리에서: - 전통적 Substring 방식: ~150 μs, ~24 KB 할당 - Span 방식: ~45 μs, ~0 KB 할당 (필요한 경우만 할당) - 개선: 3배 이상 빠르고, GC 압박 제거

string text = "Hello, World!";
ReadOnlySpan<char> span = text.AsSpan();

// 부분 문자열 추출 (새 string 생성 안 함)
ReadOnlySpan<char> hello = span.Slice(0, 5);  // "Hello"

// 문자열 비교 (할당 없음)
bool isMatch = span.StartsWith("Hello");

stackalloc과 Span - 스택 메모리의 안전한 활용:

stackalloc은 C# 1.0부터 존재했지만, unsafe 컨텍스트에서만 사용할 수 있어 제한적이었습니다. Span의 등장으로 stackalloc은 안전하고 강력한 도구가 되었습니다.

// C# 7.2 이전 - unsafe 필요
public unsafe int SumOld(int count)
{
    int* numbers = stackalloc int[count];  // unsafe!
    for (int i = 0; i < count; i++)
    {
        numbers[i] = i;
    }

    int sum = 0;
    for (int i = 0; i < count; i++)
    {
        sum += numbers[i];
    }
    return sum;
}

// C# 7.2+ - 안전한 stackalloc
public int SumNew(int count)
{
    // 스택에 메모리 할당 (힙 할당 없음)
    Span<int> numbers = stackalloc int[count];

    // 초기화 - 배열처럼 사용
    for (int i = 0; i < numbers.Length; i++)
    {
        numbers[i] = i;
    }

    // 연산 수행
    int sum = 0;
    foreach (int n in numbers)  // foreach 사용 가능!
    {
        sum += n;
    }
    return sum;
}

// 실전 예제: 임시 버퍼 최적화
public string FormatHexString(byte[] data)
{
    const int MaxStackAlloc = 256;

    // 작은 크기는 스택 할당, 큰 크기는 배열 풀 사용
    Span<char> buffer = data.Length * 2 <= MaxStackAlloc
        ? stackalloc char[data.Length * 2]
        : new char[data.Length * 2];

    for (int i = 0; i < data.Length; i++)
    {
        byte b = data[i];
        buffer[i * 2] = GetHexChar(b >> 4);
        buffer[i * 2 + 1] = GetHexChar(b & 0xF);
    }

    return new string(buffer);
}

private static char GetHexChar(int value) => 
    (char)(value < 10 ? '0' + value : 'A' + value - 10);

stackalloc 사용 시 주의사항:

  1. 스택 크기 제한: 기본 스택 크기는 1MB (x64). 과도한 할당은 StackOverflowException 발생
  2. 최적 크기: 일반적으로 1KB 이하가 안전. 큰 할당은 ArrayPool 사용 권장
  3. 수명 관리: stackalloc된 메모리는 메서드 종료 시 자동 해제
  4. 재귀 함수: 재귀 깊이 * stackalloc 크기 = 총 스택 사용량
// ❌ 위험: 큰 stackalloc
public void DangerousLargeStackAlloc()
{
    Span<byte> hugeBuffer = stackalloc byte[1024 * 1024];  // 1MB! StackOverflow 위험
}

// ✅ 안전: 조건부 할당 전략
public void SafeConditionalAlloc(int size)
{
    const int MaxStackSize = 1024;

    Span<byte> buffer;
    byte[]? rentedArray = null;

    try
    {
        if (size <= MaxStackSize)
        {
            // 작으면 스택 할당
            buffer = stackalloc byte[size];
        }
        else
        {
            // 크면 ArrayPool 사용
            rentedArray = ArrayPool<byte>.Shared.Rent(size);
            buffer = rentedArray.AsSpan(0, size);
        }

        // buffer 사용
        ProcessBuffer(buffer);
    }
    finally
    {
        if (rentedArray != null)
        {
            ArrayPool<byte>.Shared.Return(rentedArray);
        }
    }
}

Memory와 차이점 - 비동기 호환성:

Memory는 Span의 "형제" 타입으로, async/await와 호환되도록 설계되었습니다. Span가 ref struct이므로 비동기 메서드의 상태 머신에 포함될 수 없는 반면, Memory는 일반 struct이므로 제약이 없습니다.

// ❌ 컴파일 오류: Span<T>는 async에서 사용 불가
public async Task ProcessWithSpanAsync(Span<byte> buffer)  // 💥 컴파일 에러!
{
    await Task.Delay(100);  // Span<T>가 상태 머신에 포함될 수 없음
    ProcessBuffer(buffer);
}

// ✅ 올바른 방법: Memory<T> 사용
public async Task ProcessWithMemoryAsync(Memory<byte> buffer)
{
    // 비동기 작업에서 Memory<T> 사용
    await Task.Delay(100);

    // 필요할 때 Span<T>로 변환
    Span<byte> span = buffer.Span;
    ProcessBuffer(span);
}

// 실전 예제: 비동기 파일 읽기
public async Task<byte[]> ReadFileOptimizedAsync(string filePath)
{
    using var file = File.OpenRead(filePath);
    var length = (int)file.Length;

    // ArrayPool에서 버퍼 대여
    byte[] rentedBuffer = ArrayPool<byte>.Shared.Rent(length);

    try
    {
        Memory<byte> memory = rentedBuffer.AsMemory(0, length);

        // Memory<T>는 비동기 읽기 가능
        int bytesRead = await file.ReadAsync(memory);

        // 실제 데이터만 복사
        byte[] result = new byte[bytesRead];
        memory.Slice(0, bytesRead).CopyTo(result);

        return result;
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(rentedBuffer);
    }
}

Memory의 내부 구조:

// Memory<T>의 개념적 구조
public readonly struct Memory<T>
{
    private readonly object? _object;  // 배열이나 MemoryManager<T>
    private readonly int _index;       // 시작 위치
    private readonly int _length;      // 길이

    // Span<T>로 변환
    public Span<T> Span => /* _object를 기반으로 Span<T> 생성 */;
}

Memory는 내부적으로 다음을 저장할 수 있습니다: - T[] 배열: 가장 일반적인 경우 - string: Memory의 경우 - MemoryManager: 커스텀 메모리 관리

실전 예제 - 고성능 파일 읽기 최적화:

public async Task<int> CountLinesAsync(string filePath)
{
    const int BufferSize = 4096;
    using var file = File.OpenRead(filePath);

    var buffer = new Memory<byte>(new byte[BufferSize]);
    int lineCount = 0;

    while (true)
    {
        int bytesRead = await file.ReadAsync(buffer);
        if (bytesRead == 0) break;

        // Span으로 변환하여 처리
        ReadOnlySpan<byte> span = buffer.Span.Slice(0, bytesRead);

        // 개행 문자 카운트
        foreach (byte b in span)
        {
            if (b == '\n') lineCount++;
        }
    }

    return lineCount;
}

Span와 ReadOnlySpan - 불변성과 안전성:

ReadOnlySpan는 Span의 읽기 전용 버전으로, 데이터 무결성이 중요한 시나리오에서 컴파일 타임 보호를 제공합니다:

public class SecurityValidator
{
    // ✅ ReadOnlySpan - 입력 데이터 보호
    public bool ValidatePassword(ReadOnlySpan<char> password)
    {
        if (password.Length < 8) return false;

        bool hasDigit = false;
        bool hasUpper = false;
        bool hasLower = false;

        foreach (char c in password)
        {
            if (char.IsDigit(c)) hasDigit = true;
            else if (char.IsUpper(c)) hasUpper = true;
            else if (char.IsLower(c)) hasLower = true;
        }

        return hasDigit && hasUpper && hasLower;
    }

    // string에서 암시적 변환
    public void Example()
    {
        string password = "SecureP@ss123";
        bool isValid = ValidatePassword(password);  // string → ReadOnlySpan<char> 암시적 변환
    }

    // ❌ 컴파일 오류 방지
    public void ModificationPrevented(ReadOnlySpan<char> data)
    {
        // data[0] = 'X';  // 💥 컴파일 오류: 읽기 전용
    }
}

ArrayPool와 Span의 강력한 시너지:

ArrayPool는 .NET Core 2.1에서 Span와 함께 도입된 배열 풀링 메커니즘으로, 객체 재사용을 통해 GC 압박을 대폭 줄입니다:

public class HighPerformanceProcessor
{
    public async Task<string> ProcessLargeDataAsync(Stream input)
    {
        const int BufferSize = 81920;  // 80KB

        // ArrayPool에서 버퍼 대여 (재사용되는 배열)
        byte[] buffer = ArrayPool<byte>.Shared.Rent(BufferSize);

        try
        {
            var sb = new StringBuilder();
            int bytesRead;

            while ((bytesRead = await input.ReadAsync(buffer.AsMemory(0, BufferSize))) > 0)
            {
                // Span으로 실제 읽은 부분만 처리
                ReadOnlySpan<byte> data = buffer.AsSpan(0, bytesRead);

                // UTF8 디코딩 (할당 최소화)
                string text = Encoding.UTF8.GetString(data);
                sb.Append(text);
            }

            return sb.ToString();
        }
        finally
        {
            // 버퍼 반환 필수! 메모리 누수 방지
            ArrayPool<byte>.Shared.Return(buffer);
        }
    }

    // 성능 비교
    public async Task<string> ProcessLargeDataOldAsync(Stream input)
    {
        // ❌ 전통적 방식 - 매번 새 배열 할당
        byte[] buffer = new byte[81920];  // 매 호출마다 80KB 할당!

        var sb = new StringBuilder();
        int bytesRead;

        while ((bytesRead = await input.ReadAsync(buffer, 0, buffer.Length)) > 0)
        {
            string text = Encoding.UTF8.GetString(buffer, 0, bytesRead);
            sb.Append(text);
        }

        return sb.ToString();
    }
}

성능 측정 결과:

Microsoft의 벤치마크 (100MB 파일 처리): - 전통적 방식: 평균 125ms, GC Gen0: 1,220회, 총 할당: ~100MB - ArrayPool + Span: 평균 78ms, GC Gen0: 0회, 총 할당: ~4KB - 개선: 1.6배 빠르며, GC 압박 거의 제거

CPU 캐시와 메모리 지역성 최적화:

Span의 성능 이점은 할당 제거뿐만 아니라 CPU 캐시 효율성에서도 나타납니다. 현대 CPU 아키텍처에서 메모리 접근은 계산보다 훨씬 느리므로, 캐시 히트율이 전체 성능에 결정적입니다.

public class CacheLocalityDemo
{
    public class DataItem
    {
        public int Value { get; set; }
    }

    // ❌ 캐시 미스 많음: 간접 참조와 메모리 점프
    public long SumListElements(List<DataItem> items)
    {
        long sum = 0;
        foreach (var item in items)  // 각 DataItem은 힙의 다른 위치에 존재
        {
            sum += item.Value;  // 메모리 점프 → 캐시 미스 → 느림
        }
        return sum;
    }

    // ✅ 캐시 친화적: 연속 메모리와 공간 지역성
    public long SumSpanElements(ReadOnlySpan<int> values)
    {
        long sum = 0;
        foreach (int value in values)  // 연속 메모리 → 캐시 히트 → 빠름
        {
            sum += value;
        }
        return sum;
    }

    // 실측 결과 (100만 요소)
    // List<DataItem>: ~15ms (캐시 미스 많음)
    // Span<int>: ~1ms (캐시 최적화)
    // 개선: 15배 빠름!
}

메모리 계층구조와 성능:

현대 CPU의 메모리 접근 속도 (대략적인 사이클 수): - L1 캐시: 4 사이클 (~1ns) - L2 캐시: 10 사이클 (~3ns) - L3 캐시: 50 사이클 (~12ns) - RAM: 200 사이클 (~100ns) - SSD: ~100,000 사이클 - HDD: ~10,000,000 사이클

캐시 라인은 보통 64바이트이므로, 연속된 8개의 int (4바이트)를 읽으면 하나의 캐시 라인에서 모두 가져올 수 있습니다. Span의 연속 메모리 접근은 이러한 공간 지역성(spatial locality)을 최대한 활용합니다.

SIMD(Single Instruction, Multiple Data) 최적화:

.NET JIT 컴파일러는 Span에 대해 자동으로 SIMD 명령어를 생성할 수 있습니다:

public class SIMDOptimization
{
    // JIT가 SIMD로 자동 벡터화
    public int Sum(ReadOnlySpan<int> values)
    {
        int sum = 0;
        foreach (int value in values)  // 이 루프는 SIMD로 최적화될 수 있음
        {
            sum += value;
        }
        return sum;
    }

    // 명시적 SIMD 사용 (System.Runtime.Intrinsics)
    public int SumExplicitSIMD(ReadOnlySpan<int> values)
    {
        if (Vector256.IsHardwareAccelerated && values.Length >= Vector256<int>.Count)
        {
            var sum = Vector256<int>.Zero;
            int i = 0;

            // 8개씩 병렬 처리 (AVX2)
            for (; i <= values.Length - Vector256<int>.Count; i += Vector256<int>.Count)
            {
                var vec = Vector256.Create(values.Slice(i, Vector256<int>.Count));
                sum = Vector256.Add(sum, vec);
            }

            int result = 0;
            for (int j = 0; j < Vector256<int>.Count; j++)
            {
                result += sum[j];
            }

            // 나머지 처리
            for (; i < values.Length; i++)
            {
                result += values[i];
            }

            return result;
        }

        // Fallback
        return Sum(values);
    }
}

고급 패턴: MemoryMarshal을 통한 타입 재해석:

System.Runtime.InteropServices.MemoryMarshal을 사용하면 타입 간 안전한 변환이 가능합니다:

public class MemoryMarshalExamples
{
    // byte[] → int[] 뷰 (복사 없음!)
    public void BytesToInts()
    {
        byte[] bytes = { 1, 0, 0, 0, 2, 0, 0, 0 };  // Little-endian: 1, 2

        ReadOnlySpan<byte> byteSpan = bytes;
        ReadOnlySpan<int> intSpan = MemoryMarshal.Cast<byte, int>(byteSpan);

        Console.WriteLine(intSpan[0]);  // 1
        Console.WriteLine(intSpan[1]);  // 2
    }

    // 구조체 배열을 byte 배열로 직렬화 (복사 없음)
    public byte[] SerializeStructs(MyStruct[] structs)
    {
        ReadOnlySpan<MyStruct> span = structs;
        ReadOnlySpan<byte> bytes = MemoryMarshal.AsBytes(span);

        return bytes.ToArray();  // 필요시에만 복사
    }

    // 네트워크 프로토콜 파싱
    public MessageHeader ParseHeader(ReadOnlySpan<byte> buffer)
    {
        // 첫 16바이트를 구조체로 재해석
        return MemoryMarshal.Read<MessageHeader>(buffer);
    }
}

[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct MessageHeader
{
    public int MessageType;
    public int MessageLength;
    public long Timestamp;
}

Span 사용 시 주의사항과 베스트 프랙티스:

  1. ref struct 제약 이해: async/await, LINQ, 박싱 불가
  2. 수명 관리: 참조하는 메모리의 수명보다 Span이 길어서는 안 됨
  3. stackalloc 크기 제한: 일반적으로 1KB 이하 권장
  4. Memory 선택: 비동기 시나리오에서는 Memory 사용
  5. ReadOnlySpan 우선: 수정이 필요없으면 ReadOnlySpan 사용
public class BestPractices
{
    // ✅ 좋은 예: 로컬 스코프에서 사용
    public int ProcessLocal(string input)
    {
        ReadOnlySpan<char> span = input.AsSpan();
        return CountVowels(span);
    }

    // ❌ 나쁜 예: Span을 필드로 저장 시도
    public class BadExample
    {
        // private Span<int> _data;  // 💥 컴파일 오류!
    }

    // ✅ 대안: Memory<T> 사용
    public class GoodAlternative
    {
        private Memory<int> _data;  // ✅ OK

        public void Process()
        {
            Span<int> span = _data.Span;  // 필요할 때 Span으로 변환
            ProcessSpan(span);
        }
    }
}

// Span<T>로 변환하여 처리
Span<byte> span = buffer.Span;
ProcessBuffer(span);

} **실전 예제 - 파일 읽기 최적화:**csharp public async Task CountLinesAsync(string filePath) { const int BufferSize = 4096; using var file = File.OpenRead(filePath); var buffer = new Memory(new byte[BufferSize]); int lineCount = 0; while (true) { int bytesRead = await file.ReadAsync(buffer); if (bytesRead == 0) break; // Span으로 변환하여 처리 ReadOnlySpan span = buffer.Span.Slice(0, bytesRead); // 개행 문자 카운트 foreach (byte b in span) { if (b == '\n') lineCount++; } } return lineCount; } ```


29.2 ValueTask

비동기 프로그래밍의 메모리 최적화 혁명

ValueTask는 .NET Core 2.0에서 도입되고 .NET Core 2.1에서 크게 개선된 구조체 기반의 비동기 반환 타입으로, 전통적인 Task의 힙 할당 문제를 해결하기 위해 설계되었습니다. Stephen Toub와 .NET 팀의 연구에 따르면, 고성능 서버 애플리케이션에서 비동기 작업의 70-80%는 실제로 동기적으로 완료됩니다(캐시 히트, 버퍼링된 데이터 등). 이러한 경우 Task 객체를 힙에 할당하는 것은 순수한 오버헤드입니다.

Task의 근본적 한계와 설계 트레이드오프:

Task는 .NET Framework 4.0 (2010년)에서 TAP(Task-based Asynchronous Pattern)의 일부로 도입되었을 때, 비동기 프로그래밍을 크게 단순화했지만, 성능 측면에서는 타협이 있었습니다:

// Task<T>의 내부 구조 (개념적)
public class Task<T> : Task  // 클래스 = 힙 할당!
{
    private T _result;
    private ExecutionContext? _capturedContext;
    private object? _continuationObject;
    private TaskScheduler? _taskScheduler;
    // ... 더 많은 필드들

    // 최소 크기: 96바이트 (64비트 플랫폼)
}

모든 Task는 다음과 같은 오버헤드를 가집니다: 1. 힙 할당: 최소 96바이트 + GC 추적 오버헤드 2. 가비지 컬렉션 압박: Gen0 GC 빈도 증가 3. 캐시 미스: 힙 메모리 접근으로 인한 CPU 캐시 비효율 4. 간접 참조: 객체 헤더와 포인터 추적

Microsoft의 Kestrel 웹 서버 팀의 프로파일링 결과, 초당 100만 요청을 처리하는 시나리오에서: - Task 할당: ~10GB/초의 메모리 할당 - GC Gen0 수집: ~50회/초 - GC 일시정지 시간: ~15ms/초 (누적)

ValueTask의 설계 철학과 내부 구조:

ValueTask는 구조체로 설계되어 스택에 할당되며, 내부적으로 결과 값이나 Task 중 하나를 저장하는 판별된 공용체(discriminated union) 패턴을 사용합니다:

// ValueTask<T>의 개념적 구조
public readonly struct ValueTask<T>
{
    // 판별자: 어떤 값을 담고 있는지 식별
    private readonly object? _obj;  // Task, IValueTaskSource, 또는 null
    private readonly T? _result;    // 동기 완료 시 결과
    private readonly short _token;  // IValueTaskSource 재사용을 위한 토큰
    private readonly bool _continueOnCapturedContext;

    // 크기: 24바이트 (64비트 플랫폼, T가 참조 타입인 경우)
}

ValueTask의 세 가지 상태: 1. 동기 완료: _result에 값 저장, _obj = null (할당 없음!) 2. 비동기 완료: _obj에 Task 저장 3. 풀링된 완료: _obj에 IValueTaskSource 저장 (고급 사용)

Task vs ValueTask - 성능 비교:

public class PerformanceComparison
{
    private readonly Dictionary<int, string> _cache = new();

    // ❌ Task<T> - 항상 힙 할당
    public async Task<string> GetWithTaskAsync(int key)
    {
        if (_cache.TryGetValue(key, out var value))
        {
            return await Task.FromResult(value);  // 96바이트 할당!
        }

        // 실제 비동기 로드
        var result = await LoadFromDatabaseAsync(key);
        _cache[key] = result;
        return result;
    }

    // ✅ ValueTask<T> - 캐시 히트 시 할당 없음
    public async ValueTask<string> GetWithValueTaskAsync(int key)
    {
        if (_cache.TryGetValue(key, out var value))
        {
            return value;  // 할당 없음! 값만 반환
        }

        // 실제 비동기 로드
        var result = await LoadFromDatabaseAsync(key);
        _cache[key] = result;
        return result;
    }
}

실측 벤치마크 (BenchmarkDotNet 결과):

캐시 히트율 90% 시나리오 (100만 요청):

메서드 평균 시간 할당 GC Gen0
Task 142 ms 96 MB 24 회
ValueTask 48 ms 9.6 MB 2 회
개선율 3배 빠름 90% 감소 92% 감소

캐싱 시나리오에서의 실전 활용:

public class UserCache
{
    private readonly ConcurrentDictionary<int, User> _cache = new();
    private readonly IDatabase _database;
    private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5);

    // ValueTask<T>를 활용한 고성능 캐싱
    public ValueTask<User?> GetUserAsync(int userId)
    {
        // 캐시 히트: 동기 반환, 할당 없음
        if (_cache.TryGetValue(userId, out var user))
        {
            return new ValueTask<User?>(user);  // 스택 할당만!
        }

        // 캐시 미스: 비동기 로드
        return LoadAndCacheUserAsync(userId);
    }

    private async ValueTask<User?> LoadAndCacheUserAsync(int userId)
    {
        // 데이터베이스에서 로드 (실제 비동기 I/O)
        var user = await _database.Users.FindAsync(userId);

        if (user != null)
        {
            // 캐시에 저장
            _cache.TryAdd(userId, user);

            // 만료 스케줄링
            _ = Task.Delay(_cacheDuration).ContinueWith(_ => 
            {
                _cache.TryRemove(userId, out _);
            });
        }

        return user;
    }

    // 통계 정보
    public CacheStatistics GetStatistics()
    {
        return new CacheStatistics
        {
            TotalRequests = _totalRequests,
            CacheHits = _cacheHits,
            CacheMisses = _totalRequests - _cacheHits,
            HitRate = (double)_cacheHits / _totalRequests
        };
    }

    private long _totalRequests;
    private long _cacheHits;
}

ValueTask 사용 시 주의사항 - 중요한 제약:

ValueTask는 Task와 달리 엄격한 사용 규칙이 있습니다. 이를 위반하면 정의되지 않은 동작이나 미묘한 버그가 발생할 수 있습니다:

public class ValueTaskConstraints
{
    // ❌ 잘못된 사용 1: 여러 번 await
    public async Task BadMultipleAwaitAsync()
    {
        var valueTask = GetDataAsync();

        var result1 = await valueTask;  // 첫 번째 await
        var result2 = await valueTask;  // 💥 정의되지 않은 동작!

        // ValueTask는 단일 소비(single consumption)만 지원
    }

    // ❌ 잘못된 사용 2: await 없이 결과 접근
    public void BadSyncAccess()
    {
        var valueTask = GetDataAsync();

        // var result = valueTask.Result;  // 💥 위험! 데드락 가능
    }

    // ❌ 잘못된 사용 3: 저장 후 나중에 사용
    public class BadStorage
    {
        private ValueTask<int> _storedTask;  // 💥 안티패턴!

        public async Task UseStoredTaskAsync()
        {
            _storedTask = GetDataAsync();
            await Task.Delay(1000);
            await _storedTask;  // 위험: 타이밍 이슈 가능
        }
    }

    // ✅ 올바른 사용: 즉시 await
    public async Task GoodImmediateAwaitAsync()
    {
        var result = await GetDataAsync();  // 바로 await
        ProcessResult(result);
    }

    // ✅ 올바른 사용: 여러 번 필요하면 Task로 변환
    public async Task GoodConversionAsync()
    {
        var valueTask = GetDataAsync();
        Task<int> task = valueTask.AsTask();  // Task로 변환

        var result1 = await task;  // OK
        var result2 = await task;  // OK
    }

    // ✅ 올바른 사용: 조건부 await
    public async Task<int> GoodConditionalAsync(bool useCache)
    {
        if (useCache)
        {
            return await GetCachedDataAsync();  // ValueTask
        }
        return await GetFreshDataAsync();  // ValueTask
    }
}

ValueTask의 고급 패턴: IValueTaskSource:

.NET Core 2.1에서는 ValueTask를 더욱 효율적으로 만들기 위해 IValueTaskSource 인터페이스가 도입되었습니다. 이를 통해 ValueTask 객체 자체도 풀링할 수 있습니다:

public class PooledValueTaskSource<T> : IValueTaskSource<T>
{
    private ManualResetValueTaskSourceCore<T> _core;
    private static readonly ConcurrentQueue<PooledValueTaskSource<T>> _pool = new();

    public static PooledValueTaskSource<T> Rent()
    {
        if (!_pool.TryDequeue(out var source))
        {
            source = new PooledValueTaskSource<T>();
        }
        return source;
    }

    public void Return()
    {
        _core.Reset();
        _pool.Enqueue(this);
    }

    public ValueTask<T> Task => new ValueTask<T>(this, _core.Version);

    public void SetResult(T result)
    {
        _core.SetResult(result);
    }

    public void SetException(Exception error)
    {
        _core.SetException(error);
    }

    // IValueTaskSource<T> 구현
    public T GetResult(short token) => _core.GetResult(token);
    public ValueTaskSourceStatus GetStatus(short token) => _core.GetStatus(token);
    public void OnCompleted(Action<object?> continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags)
        => _core.OnCompleted(continuation, state, token, flags);
}

// 사용 예제
public class HighPerformanceAsyncService
{
    public async ValueTask<int> ProcessAsync(int input)
    {
        var source = PooledValueTaskSource<int>.Rent();

        try
        {
            // 비동기 작업 시작
            _ = Task.Run(() =>
            {
                // 복잡한 계산
                var result = ComputeComplexValue(input);
                source.SetResult(result);
            });

            return await source.Task;
        }
        finally
        {
            source.Return();
        }
    }
}

ValueTask vs ValueTask - 비제네릭 버전:

.NET Core 2.1에서는 반환 값이 없는 비동기 메서드를 위한 ValueTask도 도입되었습니다:

public class NonGenericValueTask
{
    private readonly Dictionary<string, byte[]> _cache = new();

    // Task 버전
    public async Task SaveTaskAsync(string key, byte[] data)
    {
        _cache[key] = data;
        await Task.CompletedTask;  // 할당 발생
    }

    // ValueTask 버전 (더 효율적)
    public async ValueTask SaveValueTaskAsync(string key, byte[] data)
    {
        _cache[key] = data;
        // 동기 완료, 할당 없음
    }

    // 조건부 비동기
    public async ValueTask SaveConditionalAsync(string key, byte[] data, bool persist)
    {
        _cache[key] = data;

        if (persist)
        {
            await File.WriteAllBytesAsync($"{key}.dat", data);  // 실제 비동기
        }
        // persist가 false면 동기 완료, 할당 없음
    }
}

실무에서의 선택 기준:

public class ChoosingBetweenTaskAndValueTask
{
    // ✅ Task<T> 사용이 적절한 경우:

    // 1. 항상 비동기: I/O 작업
    public async Task<string> ReadFileAsync(string path)
    {
        return await File.ReadAllTextAsync(path);  // 항상 비동기
    }

    // 2. 여러 번 await 필요
    public async Task ProcessMultipleTimesAsync()
    {
        var task = LongRunningOperationAsync();

        await task;  // 첫 번째 사용
        LogResult(task.Result);

        await task;  // 두 번째 사용 - Task만 가능
    }

    // 3. Task 조합 API 사용
    public async Task<string> FirstCompletedAsync(IEnumerable<Task<string>> tasks)
    {
        var task = await Task.WhenAny(tasks);  // Task 전용 API
        return await task;
    }

    // ✅ ValueTask<T> 사용이 적절한 경우:

    // 1. 캐싱 with 높은 히트율
    public ValueTask<User> GetUserAsync(int id)
    {
        return _cache.TryGetValue(id, out var user)
            ? new ValueTask<User>(user)  // 캐시 히트: 할당 없음
            : new ValueTask<User>(LoadUserAsync(id));  // 캐시 미스: Task 래핑
    }

    // 2. 버퍼링된 스트림
    public async ValueTask<int> ReadAsync(byte[] buffer)
    {
        if (_bufferedBytes > 0)
        {
            // 버퍼에 데이터 있음: 동기 반환
            return CopyFromBuffer(buffer);
        }

        // 버퍼 비어있음: 실제 I/O
        return await _stream.ReadAsync(buffer);
    }

    // 3. 조건부 비동기
    public ValueTask LogAsync(string message, bool immediate)
    {
        if (immediate)
        {
            return new ValueTask(FlushLogsAsync());  // 비동기
        }

        _logBuffer.Add(message);  // 동기
        return default;  // 완료된 ValueTask
    }
}

성능 측정과 의사결정:

ValueTask의 사용 여부는 프로파일링 데이터를 기반으로 결정해야 합니다:

public class PerformanceMeasurement
{
    private long _syncCompletions;
    private long _asyncCompletions;

    public async ValueTask<T> MeasuredOperationAsync<T>(Func<ValueTask<T>> operation)
    {
        var sw = Stopwatch.StartNew();
        var result = await operation();
        sw.Stop();

        if (sw.ElapsedTicks < 100)  // 매우 빠름 = 동기 완료
        {
            Interlocked.Increment(ref _syncCompletions);
        }
        else
        {
            Interlocked.Increment(ref _asyncCompletions);
        }

        return result;
    }

    public (double SyncRatio, double AsyncRatio) GetCompletionRatios()
    {
        var total = _syncCompletions + _asyncCompletions;
        return (
            (double)_syncCompletions / total,
            (double)_asyncCompletions / total
        );
    }
}

// 의사결정 가이드:
// - 동기 완료 > 70%: ValueTask<T> 강력 권장
// - 동기 완료 30-70%: ValueTask<T> 권장
// - 동기 완료 < 30%: Task<T>도 충분

var valueTask = GetDataAsync();
await valueTask;  // 첫 번째 await
await valueTask;  // ⚠️ 오류! ValueTask는 한 번만 await 가능

}

// ✅ 올바른 사용 - 한 번만 await public async Task GoodExampleAsync() { var result = await GetDataAsync(); // result를 여러 번 사용 가능 }

// ✅ 여러 번 사용해야 하면 Task로 변환 public async Task MultipleAwaitAsync() { var valueTask = GetDataAsync(); var task = valueTask.AsTask(); // Task로 변환

await task;
await task;  // OK

} **성능 비교:**csharp // 벤치마크 예제 public class CachePerformance { private readonly Dictionary _cache = new(); // Task 버전 public async Task GetWithTaskAsync(string key) { if (_cache.TryGetValue(key, out var value)) { return await Task.FromResult(value); // 힙 할당 발생 } return await LoadAsync(key); } // ValueTask 버전 public async ValueTask GetWithValueTaskAsync(string key) { if (_cache.TryGetValue(key, out var value)) { return value; // 할당 없음 } return await LoadAsync(key); } private async Task LoadAsync(string key) { await Task.Delay(10); return $"Value for {key}"; } } // 결과: ValueTask가 캐시 히트 시 약 10배 빠름 ```


29.3 구조체 최적화

값 타입의 성능 특성과 메모리 모델

구조체(struct)는 .NET의 값 타입(value type) 범주에 속하며, 클래스(class)와는 근본적으로 다른 메모리 할당 및 복사 의미론(copy semantics)을 가집니다. Eric Lippert는 그의 저명한 블로그 시리즈 "Mutating Readonly Structs"에서 구조체를 "바보같이 단순한(stupidly simple)" 것으로 묘사했습니다 - 이는 칭찬입니다. 구조체의 단순성은 예측 가능한 성능과 직접적인 메모리 제어를 가능하게 합니다.

struct vs class - 메모리 배치와 성능 영향:

// 클래스 - 참조 타입
public class PointClass
{
    public int X { get; set; }
    public int Y { get; set; }
}

// 구조체 - 값 타입  
public struct PointStruct
{
    public int X { get; set; }
    public int Y { get; set; }
}

public class MemoryLayoutComparison
{
    public void CompareAllocation()
    {
        // 클래스: 힙 할당
        var pointClass = new PointClass { X = 10, Y = 20 };
        // 메모리 구성:
        // 스택: [참조 포인터 8바이트]
        // 힙: [객체 헤더 16바이트][X: 4바이트][Y: 4바이트] = 24바이트
        // 총: 32바이트 + GC 추적 오버헤드

        // 구조체: 스택 할당 (로컬 변수인 경우)
        var pointStruct = new PointStruct { X = 10, Y = 20 };
        // 메모리 구성:
        // 스택: [X: 4바이트][Y: 4바이트] = 8바이트
        // 힙: 할당 없음, GC 추적 없음
        // 총: 8바이트
    }

    // 배열에서의 차이
    public void ArrayAllocation()
    {
        // 클래스 배열: 간접 참조
        var classArray = new PointClass[1000];
        for (int i = 0; i < classArray.Length; i++)
        {
            classArray[i] = new PointClass { X = i, Y = i };
        }
        // 메모리: 배열 객체 + 1000개의 개별 객체
        // 캐시 미스 많음 - 각 객체가 힙의 다른 위치

        // 구조체 배열: 연속 메모리
        var structArray = new PointStruct[1000];
        for (int i = 0; i < structArray.Length; i++)
        {
            structArray[i] = new PointStruct { X = i, Y = i };
        }
        // 메모리: 단일 연속 블록
        // 캐시 친화적 - 모든 데이터가 연속적
    }
}

성능 벤치마크:

[MemoryDiagnoser]
public class StructVsClassBenchmark
{
    private const int Iterations = 10000;

    [Benchmark]
    public long SumClassPoints()
    {
        var points = new PointClass[Iterations];
        for (int i = 0; i < Iterations; i++)
        {
            points[i] = new PointClass { X = i, Y = i };
        }

        long sum = 0;
        foreach (var p in points)
        {
            sum += p.X + p.Y;
        }
        return sum;
    }

    [Benchmark]
    public long SumStructPoints()
    {
        var points = new PointStruct[Iterations];
        for (int i = 0; i < Iterations; i++)
        {
            points[i] = new PointStruct { X = i, Y = i };
        }

        long sum = 0;
        foreach (var p in points)
        {
            sum += p.X + p.Y;
        }
        return sum;
    }
}

// 결과 (대략적):
// | Method           | Mean     | Allocated |
// |----------------- |---------:|----------:|
// | SumClassPoints   | 185 μs   | 160 KB    |
// | SumStructPoints  |  42 μs   |  20 KB    |
// | 개선             | 4.4배    | 8배       |

struct 선택 기준 - Microsoft의 공식 가이드라인:

Framework Design Guidelines에 따르면, 다음 조건을 모두 만족할 때 struct를 사용해야 합니다:

  1. 크기: 16바이트 이하 (포인터 2개 분량)
  2. 불변성: 생성 후 변경되지 않음
  3. 값 의미: 참조 의미가 아닌 값 의미
  4. 수명: 단기 수명 또는 다른 객체에 포함
  5. 박싱 빈도: 박싱이 거의 발생하지 않음
// ✅ 이상적인 struct 사례
public readonly struct Money
{
    public decimal Amount { get; }
    public string Currency { get; }  // 참조이지만 불변

    public Money(decimal amount, string currency)
    {
        Amount = amount;
        Currency = currency ?? throw new ArgumentNullException(nameof(currency));
    }

    // 값 의미: 두 Money가 같은 값이면 동일
    public override bool Equals(object? obj) =>
        obj is Money other && Amount == other.Amount && Currency == other.Currency;

    public override int GetHashCode() => HashCode.Combine(Amount, Currency);
}

// ❌ struct로 만들면 안 되는 경우
public struct BadLargeStruct  // 💥 크기가 너무 큼
{
    public long Field1, Field2, Field3, Field4, Field5;  // 40바이트
    // 복사 비용이 힙 할당보다 클 수 있음
}

public struct BadMutableStruct  // 💥 가변 상태
{
    public int Value { get; set; }

    public void Increment()
    {
        Value++;  // 예상치 못한 동작 가능
    }
}

readonly struct - 방어적 복사 제거:

C# 7.2에서 도입된 readonly struct는 불변성을 보장하고 성능을 최적화합니다:

// 일반 struct - 방어적 복사 발생 가능
public struct MutableVector
{
    public float X { get; set; }
    public float Y { get; set; }
    public float Z { get; set; }

    public float Length()
    {
        return MathF.Sqrt(X * X + Y * Y + Z * Z);
    }
}

public class DefensiveCopyIssue
{
    private MutableVector _vector;

    public float GetLength()
    {
        // 💥 방어적 복사 발생!
        // 컴파일러는 _vector가 변경될 수 있다고 가정하여
        // Length() 호출 전에 복사본을 만듭니다
        return _vector.Length();
    }
}

// readonly struct - 방어적 복사 없음
public readonly struct ImmutableVector
{
    public float X { get; }
    public float Y { get; }
    public float Z { get; }

    public ImmutableVector(float x, float y, float z)
    {
        X = x;
        Y = y;
        Z = z;
    }

    public float Length()
    {
        return MathF.Sqrt(X * X + Y * Y + Z * Z);
    }
}

public class NoDefensiveCopy
{
    private ImmutableVector _vector;

    public float GetLength()
    {
        // ✅ 복사 없음!
        // readonly struct이므로 안전하게 직접 호출
        return _vector.Length();
    }
}

방어적 복사의 성능 영향:

[MemoryDiagnoser]
public class DefensiveCopyBenchmark
{
    private MutableVector _mutable = new() { X = 1, Y = 2, Z = 3 };
    private ImmutableVector _immutable = new(1, 2, 3);

    [Benchmark]
    public float MutableLength()
    {
        float sum = 0;
        for (int i = 0; i < 10000; i++)
        {
            sum += _mutable.Length();  // 매번 복사!
        }
        return sum;
    }

    [Benchmark]
    public float ImmutableLength()
    {
        float sum = 0;
        for (int i = 0; i < 10000; i++)
        {
            sum += _immutable.Length();  // 복사 없음
        }
        return sum;
    }
}

// 결과:
// | Method          | Mean    |
// |---------------- |--------:|
// | MutableLength   | 145 μs  |
// | ImmutableLength |  42 μs  |
// | 개선            | 3.5배   |

in 매개변수와 ref readonly 반환:

C# 7.2는 큰 구조체를 효율적으로 전달하기 위한 in 매개변수를 도입했습니다:

public readonly struct Matrix4x4
{
    private readonly float _m11, _m12, _m13, _m14;
    private readonly float _m21, _m22, _m23, _m24;
    private readonly float _m31, _m32, _m33, _m34;
    private readonly float _m41, _m42, _m43, _m44;

    // 64바이트 - 큰 구조체!

    // ❌ 값 전달 - 64바이트 복사
    public static Matrix4x4 Multiply(Matrix4x4 left, Matrix4x4 right)
    {
        // 두 매개변수 = 128바이트 복사!
        // ... 행렬 곱셈 ...
        return default;
    }

    // ✅ in 매개변수 - 참조로 전달
    public static Matrix4x4 MultiplyOptimized(in Matrix4x4 left, in Matrix4x4 right)
    {
        // 두 매개변수 = 16바이트 (포인터만)
        // ... 행렬 곱셈 ...
        return default;
    }
}

// 사용 예
public class MatrixOperations
{
    public void Transform(in Matrix4x4 matrix, Span<Vector3> vertices)
    {
        for (int i = 0; i < vertices.Length; i++)
        {
            vertices[i] = Transform(in matrix, in vertices[i]);
        }
    }

    private Vector3 Transform(in Matrix4x4 matrix, in Vector3 vector)
    {
        // in으로 전달받아 복사 최소화
        return new Vector3(
            matrix.M11 * vector.X + matrix.M12 * vector.Y + matrix.M13 * vector.Z,
            matrix.M21 * vector.X + matrix.M22 * vector.Y + matrix.M23 * vector.Z,
            matrix.M31 * vector.X + matrix.M32 * vector.Y + matrix.M33 * vector.Z
        );
    }
}

ref readonly 반환 - 제로 복사 접근:

public class LargeStructArray
{
    private readonly Matrix4x4[] _matrices = new Matrix4x4[1000];

    // ❌ 값 반환 - 64바이트 복사
    public Matrix4x4 GetMatrix(int index)
    {
        return _matrices[index];
    }

    // ✅ ref readonly 반환 - 복사 없음
    public ref readonly Matrix4x4 GetMatrixOptimized(int index)
    {
        return ref _matrices[index];
    }

    public void Usage()
    {
        // 값 복사
        Matrix4x4 matrix1 = GetMatrix(0);  // 64바이트 복사

        // 참조 접근
        ref readonly Matrix4x4 matrix2 = ref GetMatrixOptimized(0);  // 복사 없음

        // in 매개변수와 함께 사용
        ProcessMatrix(in matrix2);
    }

    private void ProcessMatrix(in Matrix4x4 matrix)
    {
        // matrix를 읽기 전용으로 사용
    }
}

ref struct - 스택 전용 타입의 강력함:

ref struct는 C# 7.2에서 Span을 지원하기 위해 도입되었으며, 스택에만 존재할 수 있는 특수한 값 타입입니다:

// ref struct의 제약사항
public ref struct StackOnlyType
{
    private Span<byte> _buffer;

    public StackOnlyType(Span<byte> buffer)
    {
        _buffer = buffer;
    }

    // ✅ 스택 변수로 사용 가능
    // ✅ 메서드 매개변수로 전달 가능
    // ✅ 메서드 반환값으로 사용 가능

    // ❌ 힙에 할당 불가
    // ❌ 클래스 필드 불가
    // ❌ async/await 불가
    // ❌ LINQ 불가
    // ❌ 박싱 불가
}

// 실전 활용: 고성능 파서
public ref struct JsonParser
{
    private ReadOnlySpan<char> _json;
    private int _position;

    public JsonParser(ReadOnlySpan<char> json)
    {
        _json = json;
        _position = 0;
    }

    public bool TryParseObject(out ReadOnlySpan<char> content)
    {
        // 힙 할당 없이 JSON 파싱
        int start = _position;
        int depth = 0;

        while (_position < _json.Length)
        {
            char c = _json[_position++];

            if (c == '{') depth++;
            else if (c == '}')
            {
                depth--;
                if (depth == 0)
                {
                    content = _json.Slice(start, _position - start);
                    return true;
                }
            }
        }

        content = default;
        return false;
    }
}

record struct - C# 10의 간결한 불변 구조체:

C# 10은 record struct를 도입하여 불변 값 타입을 더 쉽게 정의할 수 있게 했습니다:

// 전통적 방식
public readonly struct PointOld
{
    public int X { get; }
    public int Y { get; }

    public PointOld(int x, int y)
    {
        X = x;
        Y = y;
    }

    public override bool Equals(object? obj) =>
        obj is PointOld other && X == other.X && Y == other.Y;

    public override int GetHashCode() => HashCode.Combine(X, Y);

    public override string ToString() => $"Point({X}, {Y})";
}

// record struct - 간결함
public readonly record struct PointNew(int X, int Y);

// 자동으로 생성:
// - 생성자
// - 프로퍼티
// - Equals / GetHashCode
// - ToString
// - with 식 지원
// - 해체(Deconstruction) 지원

// 사용 예
public class RecordStructUsage
{
    public void Demo()
    {
        var p1 = new PointNew(10, 20);
        var p2 = p1 with { X = 30 };  // 새 인스턴스 생성

        var (x, y) = p1;  // 해체
        Console.WriteLine($"X: {x}, Y: {y}");

        bool equal = p1 == new PointNew(10, 20);  // true - 값 비교
    }
}

구조체 최적화 체크리스트:

public class StructOptimizationChecklist
{
    // ✅ 작은 크기 (16바이트 이하)
    public readonly struct GoodSize
    {
        public long Id { get; }
        public int Type { get; }
        public int Flags { get; }
        // 총 16바이트 - 이상적!
    }

    // ✅ 불변성 (readonly)
    public readonly struct Immutable
    {
        public int Value { get; }
        public Immutable(int value) => Value = value;
    }

    // ✅ Equals/GetHashCode 오버라이드
    public readonly struct WithProperEquality
    {
        public int Value { get; }

        public override bool Equals(object? obj) =>
            obj is WithProperEquality other && Value == other.Value;

        public override int GetHashCode() => Value.GetHashCode();
    }

    // ✅ IEquatable<T> 구현 (박싱 방지)
    public readonly struct EquatableStruct : IEquatable<EquatableStruct>
    {
        public int Value { get; }

        public bool Equals(EquatableStruct other) => Value == other.Value;
        public override bool Equals(object? obj) => obj is EquatableStruct other && Equals(other);
        public override int GetHashCode() => Value.GetHashCode();
    }
}

29.4 string vs StringBuilder 성능

문자열은 불변(immutable)이므로 반복적인 연결 작업은 성능 문제를 일으킬 수 있습니다.

string 연결의 문제점:

// ❌ 비효율적 - 매번 새 string 생성
public string BuildMessageBad(List<string> items)
{
    string result = "";

    foreach (var item in items)
    {
        result += item + ", ";  // 매번 새 객체 생성
    }

    return result;
}

// 100개 항목: 100번의 string 객체 생성
// 1000개 항목: 1000번의 string 객체 생성

StringBuilder 사용:

// ✅ 효율적 - 내부 버퍼 사용
public string BuildMessageGood(List<string> items)
{
    var sb = new StringBuilder();

    foreach (var item in items)
    {
        sb.Append(item);
        sb.Append(", ");
    }

    return sb.ToString();
}

// 초기 용량 지정으로 더 최적화
public string BuildMessageBetter(List<string> items)
{
    var estimatedLength = items.Count * 20;  // 예상 크기
    var sb = new StringBuilder(estimatedLength);

    foreach (var item in items)
    {
        sb.Append(item);
        sb.Append(", ");
    }

    return sb.ToString();
}

선택 기준:

public class StringConcatenationGuide
{
    // ✅ 소수의 문자열 연결 - string 사용
    public string FormatName(string first, string last)
    {
        return $"{first} {last}";  // 간단하고 읽기 쉬움
    }

    // ✅ 반복문에서 연결 - StringBuilder 사용
    public string BuildCsv(List<string> values)
    {
        var sb = new StringBuilder();
        for (int i = 0; i < values.Count; i++)
        {
            if (i > 0) sb.Append(',');
            sb.Append(values[i]);
        }
        return sb.ToString();
    }

    // ✅ 고정된 개수 - string.Join 사용
    public string JoinWords(params string[] words)
    {
        return string.Join(" ", words);  // 내부적으로 최적화됨
    }
}

성능 비교 예제:

public class PerformanceComparison
{
    public void ComparePerformance()
    {
        const int iterations = 10000;

        // string 연결 - 매우 느림
        var sw1 = Stopwatch.StartNew();
        string result1 = "";
        for (int i = 0; i < iterations; i++)
        {
            result1 += i.ToString();
        }
        sw1.Stop();
        Console.WriteLine($"String concat: {sw1.ElapsedMilliseconds}ms");

        // StringBuilder - 빠름
        var sw2 = Stopwatch.StartNew();
        var sb = new StringBuilder();
        for (int i = 0; i < iterations; i++)
        {
            sb.Append(i);
        }
        string result2 = sb.ToString();
        sw2.Stop();
        Console.WriteLine($"StringBuilder: {sw2.ElapsedMilliseconds}ms");

        // 결과: StringBuilder가 100배 이상 빠름
    }
}

29.5 컬렉션 선택 가이드

적절한 컬렉션을 선택하면 성능과 메모리 효율을 크게 개선할 수 있습니다.

List vs Array:

// Array - 크기 고정, 약간 더 빠름
public void UseArray()
{
    int[] numbers = new int[100];
    numbers[0] = 1;  // 직접 접근

    // 크기 변경 불가
    // 메모리 오버헤드 최소
}

// List<T> - 동적 크기, 편리한 메서드
public void UseList()
{
    var numbers = new List<int>();
    numbers.Add(1);  // 동적 추가
    numbers.Remove(1);  // 제거

    // 크기 자동 조정
    // 약간의 메모리 오버헤드
}

Dictionary vs List 검색:

public class SearchPerformance
{
    // ❌ List - O(n) 검색
    public bool FindInList(List<int> list, int target)
    {
        return list.Contains(target);  // 전체 순회
    }

    // ✅ HashSet - O(1) 검색
    public bool FindInHashSet(HashSet<int> set, int target)
    {
        return set.Contains(target);  // 즉시 찾기
    }

    // ✅ Dictionary - 키-값 쌍 저장 및 O(1) 검색
    public string? FindInDictionary(Dictionary<int, string> dict, int key)
    {
        return dict.TryGetValue(key, out var value) ? value : null;
    }
}

Stack vs Queue:

// Stack - LIFO (후입선출)
public void UseStack()
{
    var stack = new Stack<string>();
    stack.Push("first");
    stack.Push("second");

    var last = stack.Pop();  // "second"
    var first = stack.Pop();  // "first"
}

// Queue - FIFO (선입선출)
public void UseQueue()
{
    var queue = new Queue<string>();
    queue.Enqueue("first");
    queue.Enqueue("second");

    var first = queue.Dequeue();  // "first"
    var second = queue.Dequeue();  // "second"
}

컬렉션 선택 체크리스트:

public class CollectionSelector
{
    // 1. 순서가 중요하고 인덱스 접근이 필요한가?
    // → List<T> 또는 Array

    // 2. 빠른 검색이 필요한가?
    // → HashSet<T> (값만) 또는 Dictionary<TKey, TValue> (키-값)

    // 3. 중복을 허용하지 않는가?
    // → HashSet<T>

    // 4. 정렬된 상태를 유지해야 하는가?
    // → SortedSet<T> 또는 SortedDictionary<TKey, TValue>

    // 5. LIFO 동작이 필요한가?
    // → Stack<T>

    // 6. FIFO 동작이 필요한가?
    // → Queue<T>

    // 7. 동시성이 필요한가?
    // → ConcurrentDictionary<TKey, TValue>, ConcurrentQueue<T> 등
}

실전 예제 - 캐시 구현:

public class DataCache<TKey, TValue> where TKey : notnull
{
    // Dictionary - 빠른 조회를 위해
    private readonly Dictionary<TKey, CacheEntry<TValue>> _cache = new();

    // Queue - FIFO 제거를 위해
    private readonly Queue<TKey> _accessOrder = new();

    private readonly int _maxSize;

    public DataCache(int maxSize)
    {
        _maxSize = maxSize;
    }

    public void Add(TKey key, TValue value)
    {
        if (_cache.ContainsKey(key))
        {
            _cache[key] = new CacheEntry<TValue>(value, DateTime.Now);
            return;
        }

        // 크기 제한 확인
        if (_cache.Count >= _maxSize)
        {
            // 가장 오래된 항목 제거
            var oldestKey = _accessOrder.Dequeue();
            _cache.Remove(oldestKey);
        }

        _cache[key] = new CacheEntry<TValue>(value, DateTime.Now);
        _accessOrder.Enqueue(key);
    }

    public bool TryGet(TKey key, out TValue? value)
    {
        if (_cache.TryGetValue(key, out var entry))
        {
            value = entry.Value;
            return true;
        }

        value = default;
        return false;
    }

    private record CacheEntry<T>(T Value, DateTime AddedAt);
}

마치며

이 장에서는 C#의 성능과 메모리 최적화 기법을 학습했습니다:

  1. Span과 Memory: 힙 할당을 줄이는 고성능 메모리 타입
  2. ValueTask: 비동기 작업의 메모리 효율성 향상
  3. 구조체 최적화: readonly struct와 in 매개변수 활용
  4. string vs StringBuilder: 문자열 처리 성능 개선
  5. 컬렉션 선택: 용도에 맞는 최적의 컬렉션 선택

다음 단계: - 실제 프로젝트에 성능 측정 도구를 도입하세요 - BenchmarkDotNet을 사용하여 최적화 전후를 비교하세요 - 메모리 프로파일러로 메모리 사용 패턴을 분석하세요

실습 과제: 1. Span을 사용하여 문자열 파싱 최적화하기 2. StringBuilder를 활용한 CSV 파일 생성기 만들기 3. 다양한 컬렉션의 성능 비교 벤치마크 작성하기


참고 자료: - Microsoft Docs - Span - Microsoft Docs - Memory - Performance improvements in .NET - BenchmarkDotNet Documentation