본문 바로가기

C#

C# 개발자를 위한 고급 메모리 관리: GC를 넘어서 Span과 Memory까지

1. 왜 메모리 관리가 다시 중요한가?

C# 개발자에게 메모리 관리는 오랫동안 GC(Garbage Collector)가 알아서 해주는 영역으로 인식되어 왔습니다. 하지만 최근에는 다음과 같은 변화로 인해 메모리 이해도가 곧 성능 경쟁력이 되었습니다.

  • 대규모 트래픽을 처리하는 서버 애플리케이션
  • 실시간 처리가 중요한 게임, 금융 시스템
  • Blazor, MAUI 같은 UI 기반 애플리케이션
  • 클라우드 비용 최적화 요구 증가

이 글에서는 단순히 GC를 설명하는 수준을 넘어서, Span, Memory, ArrayPool 까지 포함한 현대적인 C# 메모리 관리 기법을 깊이 있게 다뤄보겠습니다.


2. C# 메모리 구조 다시 보기

2.1 스택(Stack)과 힙(Heap)

void Foo()
{
    int a = 10;           // Stack
    MyClass obj = new(); // obj 참조는 Stack, 실제 객체는 Heap
}
  • Stack: 빠르지만 생명주기가 짧음
  • Heap: GC 대상, 상대적으로 느림

참조 타입이 많아질수록 GC 부담은 기하급수적으로 증가합니다.


3. GC는 만능이 아니다

3.1 GC의 기본 동작

  • Generation 0: 단명 객체
  • Generation 1: 중간
  • Generation 2: 장수 객체
for (int i = 0; i < 1_000_000; i++)
{
    var s = new string('a', 100);
}

위 코드는 Gen0 GC 폭탄의 전형적인 예입니다.

3.2 GC가 문제를 일으키는 순간

  • Full GC 발생 → 모든 스레드 Stop-the-world
  • UI 프리징
  • 서버 응답 지연

즉, GC를 줄이는 설계 자체가 중요합니다.


4. 구조체(struct)와 값 타입의 재발견

4.1 class vs struct

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

class PointClass
{
    public int X;
    public int Y;
}
  • struct: Stack 또는 Inline 저장
  • class: Heap + GC 대상

하지만 struct 남용은 복사 비용을 증가시킵니다.


5. Span: GC를 우회하는 혁명

5.1 Span란?

연속된 메모리를 안전하게 참조하는 Stack-only 타입

Span<int> span = stackalloc int[5];
span[0] = 42;
  • Heap 할당 ❌
  • GC 대상 ❌
  • 매우 빠름

5.2 문자열 처리 최적화

ReadOnlySpan<char> span = "HelloWorld";
var slice = span.Slice(0, 5);

기존 Substring 대비 메모리 할당 0


6. Memory: 비동기와 Span의 다리

Span는 async 메서드에서 사용할 수 없습니다.

async Task Foo()
{
    Span<int> span; // 컴파일 에러
}

이를 해결하는 것이 Memory 입니다.

Memory<int> memory = new int[10];
await Task.Delay(100);
memory.Span[0] = 1;
  • Heap 기반
  • async/await 가능
  • 필요할 때만 Span으로 변환

7. ArrayPool: 할당 비용 제거

7.1 기존 방식의 문제

byte[] buffer = new byte[1024];

빈번한 배열 생성은 GC 압박의 원인입니다.

7.2 ArrayPool 사용

var pool = ArrayPool<byte>.Shared;
byte[] buffer = pool.Rent(1024);

try
{
    // 사용
}
finally
{
    pool.Return(buffer);
}
  • 재사용
  • GC 부담 감소
  • 서버 개발에서 필수 패턴

8. 실전 예제: 고성능 문자열 파서

static int ParseNumber(ReadOnlySpan<char> span)
{
    int result = 0;
    foreach (var c in span)
    {
        result = result * 10 + (c - '0');
    }
    return result;
}
  • 문자열 할당 ❌
  • Substring ❌
  • GC 영향 ❌

9. 언제 이런 최적화가 필요한가?

상황필요성

CRUD 위주 업무 시스템 낮음
대규모 서버 매우 높음
게임/실시간 처리 필수
UI 프레임 드랍 매우 유용

모든 코드에 적용하지 말 것이 가장 중요합니다.


10. 정리

  • GC는 편리하지만 공짜가 아니다
  • 메모리 할당을 줄이는 설계가 성능을 만든다
  • Span/Memory는 선택이 아니라 무기
  • 측정 없는 최적화는 독이다

다음 글에서는 **“C#에서 성능 측정과 BenchmarkDotNet 실전 활용”**을 다뤄보겠습니다.


11. BenchmarkDotNet으로 성능을 수치로 증명하라

최적화의 출발점은 항상 측정입니다. 감으로 빠르다고 느끼는 코드는 대부분 틀립니다.

11.1 BenchmarkDotNet 설치

dotnet add package BenchmarkDotNet
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

11.2 가장 단순한 벤치마크

[MemoryDiagnoser]
public class StringBenchmark
{
    [Benchmark]
    public string Substring()
    {
        var s = "HelloWorld";
        return s.Substring(0, 5);
    }

    [Benchmark]
    public string SpanSlice()
    {
        ReadOnlySpan<char> span = "HelloWorld";
        return new string(span.Slice(0, 5));
    }
}

BenchmarkRunner.Run<StringBenchmark>();

11.3 결과 해석 포인트

  • Allocated 컬럼이 핵심
  • 속도보다 할당량 감소가 장기 성능에 더 중요

12. LINQ는 왜 느릴까?

LINQ는 생산성의 왕이지만, 성능의 적이 되기도 합니다.

12.1 LINQ의 숨은 비용

var result = list
    .Where(x => x > 10)
    .Select(x => x * 2)
    .ToList();
  • Iterator 객체 생성
  • Delegate 캡처
  • 중간 컬렉션

12.2 루프 기반 코드와 비교

var result = new List<int>();
foreach (var x in list)
{
    if (x > 10)
        result.Add(x * 2);
}

대량 데이터 처리에서는 2~5배 차이가 발생합니다.


13. LINQ 최적화 패턴

13.1 ToList() 최소화

// 나쁜 예
var count = list.Where(x => x > 10).ToList().Count;

// 좋은 예
var count = list.Count(x => x > 10);

13.2 IEnumerable 대신 Span

public static int Sum(ReadOnlySpan<int> span)
{
    int sum = 0;
    foreach (var v in span)
        sum += v;
    return sum;
}

14. async/await가 느려지는 이유

14.1 async는 공짜가 아니다

public async Task<int> FooAsync()
{
    return 10;
}
  • 상태 머신 생성
  • 힙 할당 발생 가능

14.2 ValueTask 활용

public ValueTask<int> FooAsync()
{
    return ValueTask.FromResult(10);
}
  • 동기 완료 경로에서 할당 제거
  • 고빈도 호출에 매우 효과적

15. 병목은 CPU가 아니라 메모리다

대부분의 성능 문제는 연산이 아니라 메모리 접근 패턴에서 발생합니다.

15.1 캐시 친화적 코드

// 비효율적
int[,] matrix = new int[1000,1000];
for (int j = 0; j < 1000; j++)
    for (int i = 0; i < 1000; i++)
        matrix[i, j]++;

// 효율적
for (int i = 0; i < 1000; i++)
    for (int j = 0; j < 1000; j++)
        matrix[i, j]++;

16. 실전 사례: 서버 GC 튜닝 전략

16.1 Server GC 활성화

<runtime>
  <gcServer enabled="true" />
</runtime>

16.2 Workstation vs Server GC

  • Web API → Server GC
  • Desktop UI → Workstation GC

17. 성능 최적화 체크리스트

  • 할당량 측정했는가?
  • Span으로 대체 가능한가?
  • LINQ가 병목인가?
  • async가 꼭 필요한가?
  • 캐시 친화적인가?

18. 결론: 빠른 C# 코드는 설계에서 결정된다

  • 최신 C#은 제로 할당이 가능하다
  • GC를 이해하면 튜닝이 보인다
  • 측정 → 개선 → 검증의 반복이 핵심

 

반응형