본문으로 건너뛰기

C#에서 Thread와 Parallel.ForEach 안전하게 중단하는 방법

· 약 9분
Jeongyong Park
쌍팔년생 개발자

safe-thread-cancellation-in-csharp

안녕하세요, 쌍팔년생 개발자입니다.

작년에 대용량 데이터 처리 시스템을 구축하면서 정말 당황스러운 일이 있었어요. 사용자가 "취소" 버튼을 눌렀는데 프로그램이 멈추지 않는 거예요... 😅

당시 저희는 무거운 파일 변환 작업을 멀티스레딩으로 처리하고 있었는데, Thread.Abort()로 중단 기능을 구현했었거든요. 그런데 .NET 5로 업그레이드하니까 갑자기 "Thread.Abort()가 지원되지 않습니다"라는 에러가...

팀원들과 함께 밤늦게 고민한 결과, 안전한 스레드 중단 방법을 완전히 새로 구현해야 했어요.

TL;DR: Thread.Abort() 대신 CancellationToken이나 플래그 패턴을 사용하여 Thread와 Parallel.ForEach를 안전하게 중단할 수 있습니다. 이 방법들은 리소스 누수와 데이터 손상을 방지하며 .NET의 표준 취소 패턴을 따릅니다.

이 글은 그때의 경험을 바탕으로, 저희가 겪었던 시행착오와 최종적으로 정착한 해결 방법들을 공유해드리려고 해요. 완벽한 해결책은 아니지만, 비슷한 문제로 고민하고 계신 분들께 도움이 되길 바라며 정리했습니다.

Thread.Abort()를 사용하면 안 되는 이유

Thread.Abort()는 여러 심각한 문제를 야기할 수 있습니다:

  • .NET 5 이상에서 지원 중단: 더 이상 사용할 수 없는 방법입니다
  • 리소스 누수: 파일 핸들, 데이터베이스 연결 등이 제대로 해제되지 않을 수 있습니다
  • 데이터 손상: 작업 중간에 강제 종료되어 일관성이 깨질 수 있습니다
  • 예측 불가능한 동작: ThreadAbortException이 예상치 못한 곳에서 발생할 수 있습니다
Thread.Abort() 사용 금지

Thread.Abort()는 .NET 5 이상에서 지원되지 않으며, 이전 버전에서도 안전하지 않습니다. 절대 사용하지 마세요.

Thread 안전하게 중단하기

1. 플래그 패턴 사용

가장 간단한 방법은 boolean 플래그를 사용하는 것입니다:

플래그 패턴을 사용한 Thread 중단
public class SafeThread
{
private volatile bool _stopRequested = false;
private Thread _thread;

public void Start()
{
_thread = new Thread(DoWork);
_thread.Start();
}

public void Stop()
{
_stopRequested = true;
_thread?.Join(); // 스레드가 완전히 종료될 때까지 대기
}

private void DoWork()
{
while (!_stopRequested)
{
// 실제 작업 수행
ProcessItem();

// 주기적으로 중단 요청 확인
if (_stopRequested)
break;

Thread.Sleep(100); // 예시: 100ms 대기
}

Console.WriteLine("스레드가 안전하게 종료되었습니다.");
}

private void ProcessItem()
{
// 실제 작업 로직
Console.WriteLine($"작업 처리 중... (Thread ID: {Thread.CurrentThread.ManagedThreadId})");
}
}

2. CancellationToken 사용 (권장)

.NET의 표준 취소 패턴인 CancellationToken을 사용하는 방법입니다 (.NET Framework 4.0 이상):

CancellationToken을 사용한 Thread 중단
public class CancellableThread
{
private Thread _thread;
private CancellationTokenSource _cancellationTokenSource;

public void Start()
{
_cancellationTokenSource = new CancellationTokenSource();
_thread = new Thread(() => DoWork(_cancellationTokenSource.Token));
_thread.Start();
}

public void Stop()
{
_cancellationTokenSource?.Cancel();
_thread?.Join();
_cancellationTokenSource?.Dispose();
}

private void DoWork(CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
ProcessItem();

// 취소 요청 확인 및 예외 발생
cancellationToken.ThrowIfCancellationRequested();

// 취소 가능한 대기 (.NET Framework 4.8에서는 Thread.Sleep 사용)
if (cancellationToken.WaitHandle.WaitOne(100))
{
break; // 취소 요청됨
}
}
}
catch (OperationCanceledException)
{
Console.WriteLine("작업이 취소되었습니다.");
}
finally
{
Console.WriteLine("스레드가 안전하게 종료되었습니다.");
}
}

private void ProcessItem()
{
Console.WriteLine($"작업 처리 중... (Thread ID: {Thread.CurrentThread.ManagedThreadId})");
}
}

Parallel.ForEach 안전하게 중단하기

Parallel.ForEach는 ParallelOptions를 통해 CancellationToken을 지원합니다 (.NET Framework 4.0 이상):

CancellationToken을 사용한 Parallel.ForEach 중단
public class ParallelProcessor
{
public async Task ProcessItemsAsync(IEnumerable<int> items)
{
var cancellationTokenSource = new CancellationTokenSource();

// 5초 후 자동 취소 (예시)
cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(5));

var parallelOptions = new ParallelOptions
{
CancellationToken = cancellationTokenSource.Token,
MaxDegreeOfParallelism = Environment.ProcessorCount
};

try
{
Parallel.ForEach(items, parallelOptions, (item, loopState) =>
{
// 각 반복에서 취소 요청 확인
parallelOptions.CancellationToken.ThrowIfCancellationRequested();

ProcessSingleItem(item, parallelOptions.CancellationToken);
});

Console.WriteLine("모든 작업이 완료되었습니다.");
}
catch (OperationCanceledException)
{
Console.WriteLine("병렬 작업이 취소되었습니다.");
}
finally
{
cancellationTokenSource.Dispose();
}
}

private void ProcessSingleItem(int item, CancellationToken cancellationToken)
{
// 긴 작업 시뮬레이션
for (int i = 0; i < 10; i++)
{
cancellationToken.ThrowIfCancellationRequested();

Thread.Sleep(100); // 실제 작업 시뮬레이션
Console.WriteLine($"Item {item}, Step {i + 1}/10 (Thread: {Thread.CurrentThread.ManagedThreadId})");
}
}
}

외부에서 수동으로 취소하기

사용자 입력이나 다른 조건에 따라 수동으로 취소하는 예시입니다:

수동 취소가 가능한 병렬 처리
public class ManualCancellationExample
{
private CancellationTokenSource _cancellationTokenSource;

public async Task StartProcessingAsync()
{
_cancellationTokenSource = new CancellationTokenSource();
var items = Enumerable.Range(1, 1000);

var parallelOptions = new ParallelOptions
{
CancellationToken = _cancellationTokenSource.Token,
MaxDegreeOfParallelism = 4
};

try
{
await Task.Run(() =>
{
Parallel.ForEach(items, parallelOptions, (item, loopState) =>
{
parallelOptions.CancellationToken.ThrowIfCancellationRequested();

// 무거운 작업 시뮬레이션
Thread.Sleep(200);
Console.WriteLine($"처리 완료: {item}");
});
});
}
catch (OperationCanceledException)
{
Console.WriteLine("사용자에 의해 작업이 취소되었습니다.");
}
}

public void CancelProcessing()
{
_cancellationTokenSource?.Cancel();
Console.WriteLine("취소 요청이 전송되었습니다.");
}
}

// 사용 예시
class Program
{
static async Task Main(string[] args)
{
var processor = new ManualCancellationExample();

// 백그라운드에서 처리 시작
var processingTask = processor.StartProcessingAsync();

Console.WriteLine("Enter 키를 누르면 작업을 취소합니다...");
Console.ReadLine();

// 사용자 입력으로 취소
processor.CancelProcessing();

await processingTask;
Console.WriteLine("프로그램이 종료되었습니다.");
}
}

.NET Framework 4.8 호환 버전

.NET Framework 4.8에서는 일부 최신 API가 없어서 약간 다른 접근이 필요합니다:

.NET Framework 4.8 호환 CancellationToken 사용
public class NetFramework48Thread
{
private Thread _thread;
private CancellationTokenSource _cancellationTokenSource;

public void Start()
{
_cancellationTokenSource = new CancellationTokenSource();
_thread = new Thread(() => DoWork(_cancellationTokenSource.Token));
_thread.Start();
}

public void Stop()
{
_cancellationTokenSource?.Cancel();
_thread?.Join();
_cancellationTokenSource?.Dispose();
}

private void DoWork(CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
ProcessItem();

// 취소 요청 확인
cancellationToken.ThrowIfCancellationRequested();

// .NET Framework 4.8에서 취소 가능한 대기
// Task.Delay 대신 WaitHandle 사용
if (cancellationToken.WaitHandle.WaitOne(100))
{
break; // 취소 요청됨
}
}
}
catch (OperationCanceledException)
{
Console.WriteLine("작업이 취소되었습니다.");
}
finally
{
Console.WriteLine("스레드가 안전하게 종료되었습니다.");
}
}

private void ProcessItem()
{
Console.WriteLine($"작업 처리 중... (Thread ID: {Thread.CurrentThread.ManagedThreadId})");
}
}

.NET Framework 4.8에서 Parallel.ForEach 사용

.NET Framework 4.8 Parallel.ForEach 예제
public class NetFramework48ParallelProcessor
{
public void ProcessItems(IEnumerable<int> items)
{
var cancellationTokenSource = new CancellationTokenSource();

// 5초 후 자동 취소
var timer = new System.Threading.Timer(_ => cancellationTokenSource.Cancel(),
null, 5000, Timeout.Infinite);

var parallelOptions = new ParallelOptions
{
CancellationToken = cancellationTokenSource.Token,
MaxDegreeOfParallelism = Environment.ProcessorCount
};

try
{
Parallel.ForEach(items, parallelOptions, (item, loopState) =>
{
parallelOptions.CancellationToken.ThrowIfCancellationRequested();

ProcessSingleItem(item, parallelOptions.CancellationToken);
});

Console.WriteLine("모든 작업이 완료되었습니다.");
}
catch (OperationCanceledException)
{
Console.WriteLine("병렬 작업이 취소되었습니다.");
}
finally
{
timer?.Dispose();
cancellationTokenSource.Dispose();
}
}

private void ProcessSingleItem(int item, CancellationToken cancellationToken)
{
for (int i = 0; i < 10; i++)
{
cancellationToken.ThrowIfCancellationRequested();

Thread.Sleep(100);
Console.WriteLine($"Item {item}, Step {i + 1}/10 (Thread: {Thread.CurrentThread.ManagedThreadId})");
}
}
}

.NET Framework 4.8에서 Task 기반 접근

.NET Framework 4.8에서는 Task를 사용한 접근도 가능합니다:

.NET Framework 4.8 Task 기반 처리
public class NetFramework48TaskProcessor
{
private CancellationTokenSource _cancellationTokenSource;

public async Task StartProcessingAsync()
{
_cancellationTokenSource = new CancellationTokenSource();
var items = Enumerable.Range(1, 1000);

try
{
// .NET Framework 4.8에서는 Task.Run 대신 Task.Factory.StartNew 사용 권장
await Task.Factory.StartNew(() =>
{
var parallelOptions = new ParallelOptions
{
CancellationToken = _cancellationTokenSource.Token,
MaxDegreeOfParallelism = 4
};

Parallel.ForEach(items, parallelOptions, (item, loopState) =>
{
parallelOptions.CancellationToken.ThrowIfCancellationRequested();

Thread.Sleep(200);
Console.WriteLine($"처리 완료: {item}");
});
}, _cancellationTokenSource.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("사용자에 의해 작업이 취소되었습니다.");
}
}

public void CancelProcessing()
{
_cancellationTokenSource?.Cancel();
Console.WriteLine("취소 요청이 전송되었습니다.");
}
}

Thread와 Parallel.ForEach 조합하기

별도 스레드에서 Parallel.ForEach를 실행하고 전체를 안전하게 중단하는 방법입니다:

Thread 내에서 Parallel.ForEach 사용
public class ThreadedParallelProcessor
{
private Thread _workerThread;
private CancellationTokenSource _cancellationTokenSource;

public void Start(IEnumerable<int> items)
{
_cancellationTokenSource = new CancellationTokenSource();
_workerThread = new Thread(() => ProcessInBackground(items, _cancellationTokenSource.Token));
_workerThread.Start();
}

public void Stop()
{
_cancellationTokenSource?.Cancel();
_workerThread?.Join(TimeSpan.FromSeconds(10)); // 최대 10초 대기
_cancellationTokenSource?.Dispose();
}

private void ProcessInBackground(IEnumerable<int> items, CancellationToken cancellationToken)
{
try
{
var parallelOptions = new ParallelOptions
{
CancellationToken = cancellationToken,
MaxDegreeOfParallelism = Environment.ProcessorCount / 2
};

Parallel.ForEach(items, parallelOptions, (item, loopState) =>
{
cancellationToken.ThrowIfCancellationRequested();

// 실제 작업 처리
ProcessItem(item, cancellationToken);
});

Console.WriteLine("백그라운드 처리가 완료되었습니다.");
}
catch (OperationCanceledException)
{
Console.WriteLine("백그라운드 작업이 취소되었습니다.");
}
}

private void ProcessItem(int item, CancellationToken cancellationToken)
{
// 긴 작업 중간중간 취소 확인
for (int i = 0; i < 5; i++)
{
cancellationToken.ThrowIfCancellationRequested();
Thread.Sleep(100);
}

Console.WriteLine($"Item {item} 처리 완료");
}
}

.NET Framework vs .NET 5+ 차이점

API 차이점 요약

기능.NET Framework 4.8.NET 5+
CancellationToken✅ 지원✅ 지원
Task.Delay with CancellationToken❌ 제한적✅ 완전 지원
Task.Run❌ 없음✅ 지원
CancellationTokenSource.CancelAfter❌ 없음✅ 지원
Thread.Abort⚠️ 사용 가능하지만 위험❌ Obsolete

.NET Framework 4.8 권장 패턴

.NET Framework 4.8 권장 패턴
public class RecommendedNetFramework48Pattern
{
private readonly CancellationTokenSource _cancellationTokenSource;
private readonly ManualResetEventSlim _completedEvent;

public RecommendedNetFramework48Pattern()
{
_cancellationTokenSource = new CancellationTokenSource();
_completedEvent = new ManualResetEventSlim(false);
}

public void StartWork()
{
Task.Factory.StartNew(() => DoWork(_cancellationTokenSource.Token),
_cancellationTokenSource.Token,
TaskCreationOptions.LongRunning,
TaskScheduler.Default);
}

public void StopWork(int timeoutMs = 5000)
{
_cancellationTokenSource.Cancel();

if (!_completedEvent.Wait(timeoutMs))
{
Console.WriteLine("작업이 제한 시간 내에 완료되지 않았습니다.");
}
}

private void DoWork(CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
// 실제 작업 수행
ProcessWorkItem();

// 주기적 취소 확인 (.NET Framework 4.8 호환)
if (cancellationToken.WaitHandle.WaitOne(100))
{
break;
}
}
}
catch (OperationCanceledException)
{
Console.WriteLine("작업이 취소되었습니다.");
}
finally
{
_completedEvent.Set();
Console.WriteLine("작업이 완료되었습니다.");
}
}

private void ProcessWorkItem()
{
// 실제 작업 로직
Thread.Sleep(50);
Console.WriteLine($"작업 처리 중... (Thread: {Thread.CurrentThread.ManagedThreadId})");
}

public void Dispose()
{
_cancellationTokenSource?.Dispose();
_completedEvent?.Dispose();
}
}

성능 고려사항

취소 확인 빈도

취소 확인을 너무 자주 하면 성능에 영향을 줄 수 있습니다:

효율적인 취소 확인
private void OptimizedWork(CancellationToken cancellationToken)
{
const int checkInterval = 100; // 100번 작업마다 한 번씩 확인
int counter = 0;

while (true)
{
// 실제 작업
DoSomeWork();

// 주기적으로만 취소 확인
if (++counter % checkInterval == 0)
{
cancellationToken.ThrowIfCancellationRequested();
}
}
}

리소스 정리

취소 시에도 리소스가 제대로 정리되도록 해야 합니다:

리소스 안전 정리
private void SafeResourceHandling(CancellationToken cancellationToken)
{
using var fileStream = new FileStream("data.txt", FileMode.Open);
using var reader = new StreamReader(fileStream);

try
{
while (!reader.EndOfStream)
{
cancellationToken.ThrowIfCancellationRequested();

var line = reader.ReadLine();
ProcessLine(line);
}
}
catch (OperationCanceledException)
{
Console.WriteLine("파일 처리가 취소되었습니다.");
// using 문에 의해 자동으로 리소스 정리됨
}
}

결론

C#에서 Thread와 Parallel.ForEach를 안전하게 중단하는 핵심은 다음과 같습니다:

  1. Thread.Abort() 사용 금지: .NET 5 이상에서 지원되지 않으며, .NET Framework에서도 안전하지 않습니다
  2. CancellationToken 활용: .NET Framework 4.0부터 지원되는 표준 취소 패턴으로 가장 권장되는 방법입니다
  3. 플래그 패턴: 간단한 시나리오에서 사용할 수 있는 대안입니다
  4. 프레임워크별 차이점 고려: .NET Framework 4.8과 .NET 5+ 간의 API 차이를 이해해야 합니다
  5. 적절한 취소 확인: 성능과 반응성의 균형을 맞춰야 합니다
  6. 리소스 정리: 취소 시에도 모든 리소스가 안전하게 해제되도록 해야 합니다

이러한 패턴들을 사용하면 리소스 누수나 데이터 손상 없이 멀티스레딩 작업을 안전하게 제어할 수 있습니다.

저희 팀에서 이 방법들을 도입한 후로는 사용자 취소 요청에 즉시 반응하는 안정적인 시스템을 구축할 수 있었어요. 특히 동료 개발자분이 제안해주신 CancellationToken 패턴 덕분에 코드도 훨씬 깔끔해졌습니다.

아직 개선할 부분이 많지만, 지속적으로 더 나은 방법을 찾아가겠습니다. 혹시 더 좋은 멀티스레딩 중단 방법이나 궁금한 점이 있으시면 언제든 댓글로 남겨주세요! 함께 고민해보면 좋겠어요.

참고 자료

공식 문서

관련 기술 문서

추가 학습 자료

📢 AdSense 광고 영역로딩 중...

💬 댓글 시스템