1. 소개
오늘은 큰 데이터량을 가능한 빨리 처리하기 위해 컴퓨터(또는 서버)의 사용 가능한 모든 CPU 코어를 활용하는 방법을 살펴봅니다. 이를 위해 System.Threading.Tasks.Parallel 네임스페이스의 클래스들, 특히 Parallel.For와 Parallel.ForEach가 유용합니다.
작업이 순수한 CPU-bound일 때는?
전통적인 for나 foreach 루프는 요소들을 하나씩 처리합니다. 단순하고 안전하죠. 하지만 멀티코어 CPU에서는 루프가 하나의 코어만 쓰고 나머지 코어는 놀고 있을 때가 많습니다. 배열을 여러 코어에 분배해 동시에 처리하면 어떨까요?
예시:
// 1부터 N까지 제곱의 합을 계산
long sum = 0;
for (int i = 1; i <= 1_000_000; i++)
{
sum += i * i;
}
이 코드는 단순하지만 순차적으로 동작합니다. 작업을 코어들에 흩어보면?
패밀리 소개: Parallel.For와 Parallel.ForEach
이게 뭐냐?
- Parallel.For — 일반적인 for처럼 동작하지만 작업을 부분으로 나눠 스레드에 자동으로 분배해 사용 가능한 코어를 활용합니다.
- Parallel.ForEach — 컬렉션을 일반적인 foreach처럼 처리하되 병렬로 실행합니다.
공식 문서:
왜 편리한가?
직접 스레드를 만들고 시작하고 제어할 필요가 없습니다. 프레임워크가 그 무거운 일을 대신해 줍니다. 일반 루프와 비슷한 코드를 쓰면 내부적으로 병렬 처리가 일어납니다.
2. 문법: 기본 예제
Parallel.For
long total = 0;
Parallel.For(1, 1_000_001, i =>
{
// 이 람다식은 서로 다른 스레드에서 동시에 실행될 수 있다
Interlocked.Add(ref total, i * i); // 레이스를 피하기 위해
});
Console.WriteLine($"제곱의 합: {total}");
주의: 변수 total은 Interlocked.Add를 통해 업데이트해서 데이터 레이스를 방지합니다.
Parallel.ForEach
var numbers = Enumerable.Range(1, 10_000_000).ToArray();
long sum = 0;
Parallel.ForEach(numbers, num =>
{
Interlocked.Add(ref sum, num * num); // 안전한 합산
});
Console.WriteLine($"제곱의 합: {sum}");
내부 구조 보기 (비주얼 다이어그램)
+-------------------+
|컬렉션/범위 |
+---------+---------+
|
v
+----------------------+
| Parallel.ForEach |
+----------+-----------+
|
+----+----+----+----+
| | |
v v v
Task #1 Task #2 Task #3 ... (사용 가능한 코어)
| | |
+--+----+ +--+-----+ +--+-----+
|처리 | |처리 | |처리 |
+-------+ +--------+ +--------+
\ | /
+--------+--------+
|
v
결과
3. 큰 파일 분석 (CPU-bound 처리)
예를 들어 수만 줄의 텍스트 파일이 있고 각 줄에 숫자가 들어 있다고 합시다. 파일을 읽어 각 숫자의 제곱을 구하고 제곱의 합을 계산해야 합니다.
동기 버전
string[] lines = File.ReadAllLines("numbers.txt");
long sum = 0;
foreach (var line in lines)
{
if (long.TryParse(line, out long n))
{
sum += n * n;
}
}
Console.WriteLine($"제곱의 합: {sum}");
Parallel.For를 이용한 병렬 버전
string[] lines = File.ReadAllLines("numbers.txt");
long sum = 0;
Parallel.For(0, lines.Length, i =>
{
if (long.TryParse(lines[i], out long n))
{
Interlocked.Add(ref sum, n * n);
}
});
Console.WriteLine($"제곱의 합: {sum}");
바뀐 점: 일반 루프를 병렬 루프로 바꿨고, sum은 Interlocked.Add로 증가시켜 스레드 간 충돌을 피합니다.
4. 내부에서 무슨 일이 벌어지나?
Parallel.For나 Parallel.ForEach를 호출하면 .NET이 작업을 조각으로 나누어 스레드 풀을 사용해 사용 가능한 CPU 코어들에 분배합니다. 각 조각은 독립적인 스레드에서 처리됩니다.
장점: 코어가 4개라면 작업이 거의 4배 빨라질 수 있습니다(작업이 외부 자원에 의존하지 않고 메모리나 디스크 I/O 같은 병목이 없을 때).
실행 시간 비교
var numbers = Enumerable.Range(1, 100_000_000).ToArray();
long sumSync = 0;
var sw = System.Diagnostics.Stopwatch.StartNew();
foreach (var n in numbers)
sumSync += n * n;
sw.Stop();
Console.WriteLine($"Sync: {sw.ElapsedMilliseconds} ms, 합: {sumSync}");
long sumParallel = 0;
sw.Restart();
Parallel.ForEach(numbers, n =>
Interlocked.Add(ref sumParallel, n * n)
);
sw.Stop();
Console.WriteLine($"Parallel: {sw.ElapsedMilliseconds} ms, 합: {sumParallel}");
직접 해보세요! 강력한 머신에서는 가속이 몇 배가 될 수 있지만, 결과는 작업 특성과 병목에 따라 달라집니다.
5. 유용한 팁
병렬도 제어
시스템 과부하를 막기 위해 사용 스레드 수를 제한하는 것이 유용할 때가 있습니다. MaxDegreeOfParallelism을 사용하세요:
using System.Threading.Tasks;
long sum = 0;
var options = new ParallelOptions {
MaxDegreeOfParallelism = 2
};
Parallel.For(0, 100, options, i =>
{
Interlocked.Add(ref sum, i * i);
});
Console.WriteLine($"제곱의 합: {sum}");
언제 유용한가: 계산 일부가 디스크를 많이 사용해 CPU가 병목이 아닌 경우 스레드 수를 줄여 성능 영향을 평가해 보세요.
언제 병렬 루프를 써야 하나
| 일반 for | Parallel.For/Parallel.ForEach | |
|---|---|---|
| 프로세서 | 하나의 코어 사용 | 모든 코어 사용 |
| 순서 | 보장됨 | 보장되지 않음 |
| 속도 | 보통 더 느림 | 대부분 매우 빠름 |
| 단순성 | 아주 단순 | 스레드 안전 고려 필요 |
| 최적 적용 | 작은 데이터량, I/O-bound | 큰 데이터량, CPU-bound |
확장: Parallel이 할 수 있는 다른 것들
Parallel.Invoke() — 여러 독립적인 메서드를 동시에 실행합니다:
static void DoTask1() => Console.WriteLine("작업 1 완료");
static void DoTask2() => Console.WriteLine("작업 2 완료");
static void DoTask3() => Console.WriteLine("작업 3 완료");
Parallel.Invoke(
() => DoTask1(),
() => DoTask2(),
() => DoTask3()
);
각 메서드는 가능한 경우 각기 다른 코어에서 실행됩니다.
실무 적용
- 이미지 처리: 서로 다른 블록을 동시에 처리(예: 필터 적용).
- 배열 기반 계산: 금융 계산, 시뮬레이션(시나리오별 포트폴리오 평가).
- 대용량 로그 처리: 검색 및 집계를 여러 코어에서 수행.
- 머신러닝: 독립적인 작업(데이터 배치, feature engineering)으로 분할.
그리고 면접에서 병렬 루프가 무엇인지 설명할 뿐 아니라 장단점도 솔직히 말할 수 있을 겁니다.
6. Parallel.For와 Parallel.ForEach 사용 시 흔한 실수
실수 #1: 데이터 레이스 무시하기.
공유 변수를 Interlocked나 lock 없이 업데이트하면 스레드 간 동시 접근으로 잘못된 결과가 나옵니다.
실수 #2: I/O-bound 작업에 사용하기.
병렬 루프는 디스크나 네트워크에 의존하는 작업을 가속하지 못하고, 오버헤드 때문에 오히려 성능을 떨어뜨릴 수 있습니다.
실수 #3: 실행 순서에 대한 잘못된 가정.
병렬 루프는 요소 처리 순서를 보장하지 않으므로 순서에 의존하는 로직은 깨질 수 있습니다.
실수 #4: 부작용 무시하기.
병렬 루프에서 공유 상태(예: 컬렉션)를 변경하면, 스레드 안전한 구조를 사용하지 않으면 에러가 발생할 수 있습니다.
GO TO FULL VERSION