들어가며
C#과 .NET 환경에서 성능 문제를 이야기할 때 빠지지 않고 등장하는 주제가 GC(Garbage Collection) 와 멀티스레딩입니다. 각각은 익숙하지만, 이 둘이 서로 어떻게 영향을 주는지를 명확히 이해하는 개발자는 생각보다 많지 않습니다.
이 글에서는 단순히 "GC는 느리다" 혹은 "멀티스레드는 어렵다"는 수준을 넘어서,
- .NET GC가 내부적으로 어떻게 동작하는지
- 멀티스레드 환경에서 GC가 어떤 전략을 사용하는지
- 잘못된 사용이 성능에 어떤 문제를 일으키는지
- 실무에서 반드시 알아야 할 튜닝 포인트
를 예제 코드와 함께 깊이 있게 정리해보겠습니다.
.NET 가비지 수집 - .NET
.NET의 가비지 수집에 대해 알아봅니다. .NET 가비지 수집기는 애플리케이션에 대한 메모리 할당 및 릴리스를 관리합니다.
learn.microsoft.com
1. .NET GC 기본 개념 요약
.NET GC는 관리 힙(Managed Heap) 에 할당된 객체를 자동으로 정리합니다. 핵심 목표는 다음 두 가지입니다.
- 개발자가 메모리 해제를 신경 쓰지 않도록 한다
- 애플리케이션 전체 성능을 최대한 유지한다
이를 위해 GC는 객체를 세대(Generation) 로 나눕니다.
세대설명
| Gen 0 | 매우 짧게 사용되는 객체 |
| Gen 1 | Gen 0에서 살아남은 객체 |
| Gen 2 | 오래 살아남은 객체 |
| LOH | 85KB 이상의 대형 객체 |
👉 중요한 점은 GC는 모든 힙을 매번 수집하지 않는다는 것입니다.
2. 멀티스레딩 환경에서 GC는 왜 더 중요할까?
멀티스레딩 환경에서는 다음과 같은 특징이 있습니다.
- 여러 스레드가 동시에 객체를 생성
- 힙 할당 속도가 매우 빨라짐
- GC 발생 빈도가 급격히 증가
즉, GC는 단일 스레드보다 훨씬 자주, 더 복잡한 상황에서 실행됩니다.
간단한 예제
Parallel.For(0, 1_000_000, i =>
{
var obj = new byte[1024];
});
위 코드는 겉보기엔 단순하지만,
- 수십 개 스레드가 동시에
- 대량의 객체를 힙에 할당
하게 됩니다. 이때 GC는 모든 스레드의 상태를 고려해야 합니다.
3. Workstation GC vs Server GC
.NET에는 두 가지 주요 GC 모드가 존재합니다.
3.1 Workstation GC
- 기본 모드
- UI 애플리케이션(WPF, WinForms)에 적합
- GC 중 일시 정지(Pause) 가 짧음
3.2 Server GC
- 서버 및 백엔드용
- 스레드 수만큼 GC 힙 생성
- 병렬 수집으로 처리량 극대화
<configuration>
<runtime>
<gcServer enabled="true" />
</runtime>
</configuration>
👉 멀티코어 + 멀티스레드 환경에서는 Server GC가 압도적으로 유리합니다.
4. GC 중에는 모든 스레드가 멈춘다?
많은 개발자가 오해하는 부분입니다.
Stop-The-World (STW)
- GC가 시작되면
- 관리 코드 실행 스레드 대부분이 일시 중단됨
멀티스레드라고 해서 예외는 아닙니다.
while (true)
{
Allocate();
}
위 코드가 여러 스레드에서 실행된다면,
- GC 발생 빈도 증가
- STW 시간 증가
- 전체 애플리케이션 응답성 저하
로 이어질 수 있습니다.
5. Background GC와 멀티스레딩
.NET은 이를 개선하기 위해 Background GC 를 제공합니다.
특징
- Gen 2 GC를 백그라운드에서 수행
- 애플리케이션 스레드와 병행 실행
- 완전한 STW는 아님 (부분 정지 존재)
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;
UI나 실시간 시스템에서는 매우 중요합니다.
6. 멀티스레딩 환경에서 흔한 GC 성능 문제
6.1 불필요한 객체 생성
public void Process()
{
var list = new List<int>();
// 매번 새 객체 생성
}
➡ 스레드 수 × 호출 횟수 만큼 GC 부담 증가
6.2 Large Object Heap 남용
byte[] buffer = new byte[100_000];
- LOH는 압축되지 않음
- 멀티스레드에서 메모리 단편화 심각
6.3 lock + GC 조합
lock (_sync)
{
var data = new byte[50_000];
}
- GC 정지 + 락 대기
- 스레드 경합 폭증
7. 실무에서 반드시 지켜야 할 가이드라인
✔ 객체 재사용
private static readonly byte[] Buffer = new byte[1024];
✔ ThreadLocal 활용
ThreadLocal<List<int>> cache = new(() => new List<int>());
✔ Span / ArrayPool 사용
byte[] buffer = ArrayPool<byte>.Shared.Rent(1024);
try
{
// 사용
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
8. GC와 멀티스레딩을 이해하면 보이는 것들
- 왜 서버가 특정 시간마다 멈추는지
- 왜 CPU는 여유로운데 응답이 느린지
- 왜 스레드를 늘리면 오히려 성능이 나빠지는지
이 모든 질문의 상당수는 GC와 멀티스레딩의 상호작용에서 답을 찾을 수 있습니다.
마무리
GC는 적이 아닙니다. 하지만 이해하지 못하면 언제든지 성능 병목이 됩니다.
멀티스레딩 환경에서 진짜 실력은
"스레드를 얼마나 많이 쓰느냐"가 아니라
"GC가 방해하지 않도록 얼마나 설계했느냐"
에서 갈립니다.
다음 글에서는 GC 로그 분석 방법과 실제 장애 사례를 기반으로 더 깊이 들어가 보겠습니다.
'C#' 카테고리의 다른 글
| C# 개발자를 위한 고급 메모리 관리: GC를 넘어서 Span과 Memory까지 (0) | 2025.12.14 |
|---|---|
| [GC편 #2] .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 |