― 성능 저하 없이 대규모 비동기/병렬 앱 만들기
비동기(async/await), 병렬(Parallel), 그리고 GC(가비지 컬렉션)는
각각 따로 보면 이해가 쉽지만,
실제 운영 환경에서는 세 가지가 서로 얽혀서 성능 병목을 만든다.
이번 글에서는
✔ 대규모 비동기/병렬 처리 앱의 성능 문제
✔ GC를 유발하는 코드 패턴
✔ 가장 효율적인 C# async+parallel 최적화 패턴
✔ 실전 예제 + 벤치마크 비교
를 완전 예제 중심으로 다루겠습니다.
1. 왜 비동기 + 병렬 + GC가 문제인가?
C#에서
async/await는 비동기 코드 흐름을 쉽게 만들고,
Task는 병렬 작업을 쉽게 만들어 준다.
하지만 문제는 아래와 같은 상황에서 발생한다:
즉,
- 많은 Task
- 반복적인 객체 생성
- GC가 자주 일어남
→ 성능이 오히려 떨어지는 보이지 않는 병목
2. 잘못된 비동기 + 병렬 패턴 (퍼포먼스 데미지)
다음 코드는 얼핏 보면 아무 문제 없어 보인다.
using System.Net.Http;
async Task FetchUrlsAsync(string[] urls)
{
var client = new HttpClient();
foreach (var url in urls)
{
// 순차 처리
var result = await client.GetStringAsync(url);
Console.WriteLine($"{url}: {result.Length}");
}
}
하지만 이런 방식은 완전히 직렬 처리다.
병렬 처리 효과를 얻기 위해 다음처럼 바꾸는 경우도 있다.
async Task FetchAllAsync(string[] urls)
{
var client = new HttpClient();
var tasks = urls.Select(url => client.GetStringAsync(url)).ToArray();
var results = await Task.WhenAll(tasks);
foreach (var r in results)
Console.WriteLine(r.Length);
}
이 코드는 병렬 요청을 발생시키지만,
Task 생성량이 많아지고 메모리 할당이 급증할 수 있다.
실제로 수천 개 요청을 동시에 올리면
✔ GC 빈도 증가
✔ 네트워크 스로틀링 효과
✔ ThreadPool 스레드 고갈
문제가 발생한다.
3. 올바른 비동기 병렬 처리 전략 (Throttle)
병렬 처리에도 제한이 필요하다.
여기서 중요한 패턴은 동시 실행 제한 (Concurrency Throttling) 이다.
using System.Threading.SemaphoreSlim;
async Task FetchWithThrottleAsync(string[] urls, int maxConcurrency)
{
var client = new HttpClient();
var semaphore = new SemaphoreSlim(maxConcurrency);
var tasks = urls.Select(async url =>
{
await semaphore.WaitAsync();
try
{
var data = await client.GetStringAsync(url);
Console.WriteLine($"{url}: {data.Length}");
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
}
✨ 왜 좋은가?
✔ 네트워크 요청 개수 제한
✔ Task 생성량 제한
✔ GC 압력 완화
4. CPU 바운드 병렬 처리 + GC 최적화
CPU 바운드 연산은 Parallel 또는 Task.Run이 기본이 된다.
아래 예제는 병렬로 숫자 계산 + 메모리 할당 최소화 패턴이다.
int[] numbers = Enumerable.Range(0, 3_000_000).ToArray();
Parallel.For(0, numbers.Length, () => new List<int>(),
(i, loop, localList) =>
{
int n = numbers[i];
localList.Add(n * n);
return localList;
},
localList =>
{
lock (numbers)
{
// merge results if needed
}
});
✨ 이 패턴의 핵심
✔ 지역 결과(localList) 생성
✔ 글로벌 공유 자원 접근 시 lock 최소화
✔ GC 개입 최소화
5. Task + Parallel 혼합 전략
CPU 바운드가 섞인 I/O 작업이 있을 때,
그냥 Task만 쓰면 병렬 효과가 떨어진다.
예: 이미지 처리 + 네트워크 요청
async Task ProcessImagesAsync(string[] urls)
{
var client = new HttpClient();
await Parallel.ForEachAsync(urls, new ParallelOptions { MaxDegreeOfParallelism = 8 },
async (url, token) =>
{
var bytes = await client.GetByteArrayAsync(url);
var resized = await Task.Run(() => Resize(bytes));
await SaveAsync(resized, token);
});
}
✔ 포인트 요약
✔ ParallelOptions로 동시 실행 제한
✔ I/O + CPU 처리를 분리
✔ GC 유발 할당 최소화
6. ValueTask를 활용한 GC 부담 완화
Task는 참조 타입이라 GC 압력이 생길 수 있다.
특히 동기 결과가 많은 경우에는 ValueTask가 유리하다.
async ValueTask<int> ComputeAsync(bool isCached)
{
if (isCached)
return 42; // Task 객체 생성 X
return await Task.Run(() => ExpensiveCompute());
}
※ 단, ValueTask는 반복 await 불가, 추적 비용 증가 등 단점도 있다.
7. 실전 벤치마크 비교
다음은 동일 작업을 세 가지 방식으로 측정한 결과다.
| 직렬 Fetch | 18000 | 10 |
| Task.WhenAll | 4500 | 120 |
| Throttle Fetch | 3300 | 30 |
→ 동시 제한을 둔 Fetch 방식이 가장 안정적으로 성능/GC 균형을 달성.
8. GC 문제 진단 도구
GC 문제를 실무에서 진단할 때 아래 도구를 사용하면 매우 유용하다.
✔ PerfView
✔ dotnet-counters
✔ dotnet-trace
✔ Visual Studio Diagnostic Tools
9. 결론: 최적화의 철학
성능은 스레드를 많이 쓰는 게 아니라,
리소스를 유의미하게 활용하는 것이다.
✔ 할당량 줄이기
✔ 동시성 제어
✔ I/O + CPU 분리
✔ GC 유발 패턴 회피
✔ 벤치마크 기반 설계
'C#' 카테고리의 다른 글
| Delegate vs Interface - 설계 관점에서의 선택 기준 (0) | 2026.02.12 |
|---|---|
| C# Reflection 성능 최적화 - Expression Tree와 IL Emit (0) | 2026.02.10 |
| C# 개발자를 위한 고급 메모리 관리: GC를 넘어서 Span과 Memory까지 (0) | 2025.12.14 |
| [GC편 #2] .NET GC 로그 분석 방법 (0) | 2025.12.14 |
| [GC편 #1] .NET GC와 멀티스레딩의 관계 (0) | 2025.12.14 |