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를 이해하면 튜닝이 보인다
- 측정 → 개선 → 검증의 반복이 핵심
반응형
'C#' 카테고리의 다른 글
| [GC편 #2] .NET GC 로그 분석 방법 (0) | 2025.12.14 |
|---|---|
| [GC편 #1] .NET GC와 멀티스레딩의 관계 (0) | 2025.12.14 |
| C# 성능 최적화 실무편: Async 패턴과 병렬 처리 완벽 가이드 (0) | 2025.10.12 |
| C# 멀티스레딩 실무편: Task, Parallel, ThreadPool 완벽 비교와 적용 전략 (0) | 2025.10.08 |
| C# 로그 아키텍처 설계: Serilog, NLog, ILogger 통합 전략 (0) | 2025.10.05 |