초등학생도 이해하는 CancellationTokenSource 비유: "엄마의 확성기와 게임하는 아이"
방에서 신나게 컴퓨터 게임을 하는 아이(작업 스레드)가 있습니다. 저녁 식사 시간이 되어서 엄마가 아이의 게임을 멈춰야 합니다. 여기서 게임을 멈추는 방법은 두 가지가 있습니다.
- 나쁜 방법 (Thread.Abort - 강제 종료): 엄마가 방에 들어가서 컴퓨터 전원 플러그를 확 뽑아버립니다.
- 결과: 게임은 즉시 멈추겠지만, 세이브 파일이 날아가고, 컴퓨터 하드디스크가 망가질 수 있습니다. 우리 공장으로 치면 카메라 연결이 엉키고 메모리가 터져서 프로그램이 뻗어버리는 대참사입니다.
- 좋은 방법 (CancellationTokenSource - 협력적 취소): 엄마가 거실에서 확성기(CancellationTokenSource)의 '취소 버튼'을 누릅니다. 그러면 아이의 귀에 꽂힌 이어폰(CancellationToken)으로 "밥 먹어라, 게임 꺼라!"라는 신호가 전달됩니다. 아이는 이어폰에서 신호가 왔는지 틈틈이 확인하다가(token.IsCancellationRequested), 신호가 오면 하던 판을 안전하게 저장하고, 컴퓨터를 정상적으로 끄고 나옵니다.
- 결과: 세이브 파일도 지켰고 컴퓨터도 안전합니다. 아무것도 고장 나지 않았죠!
정리하자면, CancellationTokenSource는 신호를 보내는 '엄마의 확성기'이고, CancellationToken은 신호를 듣는 '아이의 이어폰'입니다. 스스로 안전하게 정리하고 끝낼 수 있도록 기회를 주는 것, 이것을 어려운 말로 협력적 취소(Cooperative Cancellation)라고 부릅니다.
이제 이 확성기가 실제 머신비전 코드에서 어떻게 설비의 셧다운 에러를 완벽하게 막아내는지 현업용 코드로 확인해 보겠습니다.
Step 1: 현업용 전체 코드 (The Production Code)
초고속 검사 루프를 돌리다가 장비 일시 정지(Stop) 버튼을 눌렀을 때, 카메라와 메모리 자원을 안전하게 내려놓고 스레드를 종료하는 프로덕션 레벨의 뼈대입니다.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace FactoryAutomation.Core
{
public class VisionInspectionEngine : IDisposable
{
private readonly object _syncObj = new object();
// 엄마의 확성기 (취소 신호를 발생시키는 주체)
private CancellationTokenSource _inspectionCts;
private Task _inspectionTask;
private bool _isDisposed = false;
/// <summary>
/// 검사 루프 시작 (Start 버튼 클릭 시)
/// </summary>
public void StartInspection()
{
lock (_syncObj)
{
if (_isDisposed) throw new ObjectDisposedException(nameof(VisionInspectionEngine));
// 이미 실행 중이면 무시 (레이스 컨디션 방어)
if (_inspectionCts != null && !_inspectionCts.IsCancellationRequested)
{
Console.WriteLine("[Warning] Inspection is already running.");
return;
}
// 1. 새로운 확성기(CancellationTokenSource) 장만
_inspectionCts = new CancellationTokenSource();
// 아이의 이어폰 (CancellationToken) 꺼내기
CancellationToken token = _inspectionCts.Token;
Console.WriteLine("[System] Starting inspection loop...");
// 2. 검사 스레드(아이)에게 이어폰(Token)을 쥐어주고 방으로 보냄
_inspectionTask = Task.Run(() => InspectionLoop(token), token);
}
}
/// <summary>
/// 검사 루프 정지 (Stop 버튼 클릭 시)
/// </summary>
public void StopInspection()
{
lock (_syncObj)
{
if (_inspectionCts == null || _inspectionCts.IsCancellationRequested)
{
return; // 이미 정지되었거나 정지 중임
}
Console.WriteLine("[System] Stop requested. Notifying thread...");
// 3. 엄마가 거실에서 확성기의 취소 버튼을 누름! (Cancel)
_inspectionCts.Cancel();
try
{
// 스레드가 '안전하게 세이브하고 나올 때까지' 최대 3초간 기다려줌 (데드락 방어)
if (_inspectionTask != null && !_inspectionTask.Wait(TimeSpan.FromSeconds(3)))
{
Console.WriteLine("[Error] Inspection thread did not terminate in time. Potential Hang.");
}
}
catch (AggregateException ex)
{
// Task 내부에서 발생한 예외 처리 (정상적인 취소 예외는 무시)
ex.Handle(inner => inner is TaskCanceledException || inner is OperationCanceledException);
}
finally
{
// 확성기 다 썼으니 분리수거 (메모리 릭 방지)
_inspectionCts.Dispose();
_inspectionCts = null;
_inspectionTask = null;
Console.WriteLine("[System] Inspection stopped safely.");
}
}
}
/// <summary>
/// 핵심 검사 루프 (백그라운드 스레드에서 실행)
/// </summary>
private void InspectionLoop(CancellationToken token)
{
try
{
// 4. 아이가 게임 중 틈틈이 이어폰(token)을 확인!
while (!token.IsCancellationRequested)
{
// --- (가상) 고속 비전 검사 사이클 ---
// 4-1. 카메라 영상 취득 (I/O 대기 시에도 token을 넘겨줘야 대기 중 취소 가능)
// var frame = camera.GrabFrameAsync(token).GetAwaiter().GetResult();
// 4-2. 영상 처리 알고리즘 수행
// ProcessImage(frame);
// 4-3. 결과를 PLC로 전송
// SendResultToPlc();
// 시뮬레이션을 위한 딜레이 (Delay에도 token을 넣어주면 즉시 깨어남)
Task.Delay(100, token).Wait();
}
// 루프를 빠져나옴 = "아! 밥 먹으라고 하셨네. 끄자."
Console.WriteLine("[Thread] Cancellation detected. Exiting loop cleanly.");
}
catch (OperationCanceledException)
{
// Task.Delay 등 내장 함수들이 취소 신호를 받으면 던지는 정상적인 예외
Console.WriteLine("[Thread] Wait was canceled. Exiting safely.");
}
catch (Exception ex)
{
// 예상치 못한 알고리즘 에러 방어
Console.WriteLine($"[Thread Error] {ex.Message}");
}
finally
{
// 5. 플러그를 뽑지 않았기 때문에 이 Clean-up 코드가 100% 보장됨!
Console.WriteLine("[Thread] Cleaning up Unmanaged Resources (Camera, Buffers)...");
// camera?.StopAcquisition();
// frame?.Dispose();
}
}
public void Dispose()
{
if (_isDisposed) return;
StopInspection(); // 객체 파괴 시 묻지도 따지지도 않고 안전 종료
_isDisposed = true;
GC.SuppressFinalize(this);
}
}
}
Step 2: 심층 아키텍처 및 엣지 케이스 검증 (Code Breakdown & Risk Audit)
과거 Thread.Abort()를 쓰던 시절에는 언제 스레드가 죽을지 몰라 finally 블록의 실행을 장담할 수 없었고, 이로 인해 할당된 byte[]나 포인터가 메모리에 남는 고아 객체(Zombie Object) 현상이 잦았습니다. CancellationToken은 이 문제를 아키텍처 레벨에서 근절합니다.
극한 엣지 케이스 및 방어 분석
- [Edge Case 1] 무한 대기 (Hang) 상태에서의 취소 무시:
- 위험성: 카메라 트리거를 기다리는 camera.Grab() 함수에서 스레드가 멈춰있습니다. 사용자가 Stop을 눌렀지만, 코드가 IsCancellationRequested를 검사하는 while문의 시작점까지 도달하지 못해 프로그램이 멈춥니다.
- 방어책: Task.Delay(100, token) 처럼, 닷넷의 많은 비동기/동기 I/O 함수들은 매개변수로 token을 받습니다. I/O 대기 중이어도 엄마가 확성기를 누르는 순간 대기 상태를 찢고 OperationCanceledException을 발생시켜 스레드를 즉시 탈출시킵니다.
- [Edge Case 2] CancellationTokenSource 메모리 누수 (LOH/Gen2 리스크):
- 위험성: Start와 Stop을 하루에 수천 번 반복하는 인덱스 테이블 장비에서, _inspectionCts = new CancellationTokenSource();만 하고 Dispose()를 안 해주면, 시스템 내부의 타이머 타이(Timer Queue)에 객체들이 쌓여 심각한 메모리 릭이 발생합니다.
- 방어책: StopInspection() 의 finally 블록에서 _inspectionCts.Dispose();를 명시적으로 호출하고 null로 초기화하여 참조를 완전히 끊었습니다.
- [Edge Case 3] 데드락 (UI Thread Blocking):
- 위험성: Stop 버튼을 누른 UI 스레드가 _inspectionTask.Wait()를 하염없이 기다리다 UI가 하얗게 굳어버립니다(응답 없음).
- 방어책: Wait(TimeSpan.FromSeconds(3))로 타임아웃을 주어, 혹시라도 스레드가 모종의 이유로 3초 안에 안 끝나면 기다림을 포기하고 UI가 살아나도록 방어했습니다.
Step 3: 시니어 엔지니어 현장 노하우 (Pro Tips)
1. 타사 라이브러리(SDK) 강제 중단 트릭 (token.Register) Cognex나 Basler 등 외부 카메라 SDK 중에는 CancellationToken을 매개변수로 안 받는 구형 함수들이 많습니다. 이럴 때는 확성기 버튼이 눌리는 순간 발동하는 콜백(Callback)을 등록할 수 있습니다.
// Token에 콜백을 달아놓음: 취소 버튼이 눌리면 이 안의 코드가 즉시 실행됨
using (token.Register(() => { externalCameraApi.ForceAbort(); }))
{
// CancellationToken을 안 받는 외부 구형 API 호출 (무한 대기할 수 있음)
externalCameraApi.WaitGrab();
}
2. 고속 루프(100fps 이상)에서의 성능 최적화 가이드 문서들을 보면 token.ThrowIfCancellationRequested()를 써서 예외를 던지며 종료하라고 많이 나옵니다. 현장에선 절대 비추천합니다. 예외(Exception)를 생성하고 콜스택을 캡처하는 과정은 리소스를 굉장히 많이 먹습니다. 고속 검사 루프에서는 위 코드처럼 if (!token.IsCancellationRequested) 불리언(boolean) 값만 체크하고 자연스럽게 break나 return으로 빠져나오는 것이 성능 유지의 핵심입니다.
이제 확성기와 이어폰의 원리를 아셨으니, FA 현장에서 갑작스러운 스레드 강제 종료로 인해 발생하는 메모리 블루스크린을 원천 차단하실 수 있을 겁니다.
'C#' 카테고리의 다른 글
| 복잡성은 숨기고(Facade), 규칙은 강제하며(FSM), 흐름은 지배하라(Orchestrator). (0) | 2026.10.21 |
|---|---|
| readonly (0) | 2026.10.21 |
| “C# readonly: 생성 후 불변을 보장해 버그를 줄이고 성능을 챙기는 설계 키워드” (0) | 2025.10.10 |
| “IDisposable: 가비지 컬렉터가 다 못하는 ‘자원 반납’을 결정적으로 보장하는 계약” (0) | 2025.10.10 |
| C# “??는 ‘null이면 우변’ — 안전한 기본값 지정의 한 줄 공식” (0) | 2025.10.10 |
| “C# 값 형식에도 없음(null)을 — T? = Nullable<T> 제대로 쓰는 법” (0) | 2025.10.10 |
| error. 이벤트핸들러 이름 충돌 (0) | 2025.09.22 |
| volatile는 멀티스레드에서 “가벼운 동기화”를 위한 읽기/쓰기 가시성 보장 장치 (0) | 2025.09.19 |