← 문제로

43. 백그라운드 워커의 수명 관리 부재 (Dispose 후 잔존 작업) (C#)

난이도 상
내 리뷰 · C#
해설 · C#

해설 — 백그라운드 워커의 수명 관리 부재 (Dispose 후 잔존 작업) (C#)

난이도: 상

요약

생성자에서 Task.Run(RunLoop) 로 띄운 백그라운드 루프 (A) 는 종료 조건도 취소 토큰도 없고, Dispose (B) 는 그 작업에 정지 신호를 보내거나 완료를 기다리지 않고 _stats.Clear() 만 한다. 결과적으로 루프는 Dispose 이후에도 계속 돌며 이미 정리된(그리고 동기화 없이 공유되는) _stats 를 수정한다. 게다가 버려진 Task 의 예외는 관측되지 않아 조용히 사라진다.

문제점

  • [분류: 작업 수명 관리 부재 · fire-and-forget · Dispose 이후 접근 · 데이터 레이스]
    • 증상: Dispose 후에도 CPU/타이머가 계속 돌고, _stats 가 정리됐다가 다시 채워지며, 백그라운드 스레드와 Dispose 스레드가 _stats 를 동시 접근해 Dictionary 손상/InvalidOperationException. 루프 내 예외는 미관측으로 은폐.
    • 재현: 매니저를 Dispose → Clear() 직후 루프가 _stats[0] = _tick 실행 → "정리했는데 살아있는" 상태. 여러 매니저를 생성/폐기 반복하면 좀비 루프가 누적돼 누수처럼 보인다.
    • 근본 원인: 백그라운드 작업의 시작만 있고 정지·합류(await)가 없다. 소유 객체의 수명과 작업의 수명이 분리돼, 객체가 사라져도 작업이 살아남는다.

수정안

취소 토큰으로 루프를 멈추고, Task 핸들을 보관해 Dispose 에서 취소 후 완료를 대기한다. 공유 상태 접근도 동기화한다.

public sealed class ZoneManager : IAsyncDisposable
{
    private readonly CancellationTokenSource _cts = new();
    private readonly Task _loop;
    private long _tick;

    public ZoneManager() => _loop = Task.Run(() => RunLoop(_cts.Token));

    private async Task RunLoop(CancellationToken ct)
    {
        try
        {
            while (!ct.IsCancellationRequested)
            {
                Interlocked.Increment(ref _tick);        // 동기화된 갱신
                await Task.Delay(10, ct).ConfigureAwait(false);
            }
        }
        catch (OperationCanceledException) { /* 정상 종료 */ }
        // 그 외 예외는 _loop 를 통해 관측된다(삼켜지지 않음)
    }

    public async ValueTask DisposeAsync()
    {
        _cts.Cancel();
        try { await _loop.ConfigureAwait(false); }       // 루프 종료를 기다린 뒤 정리
        catch (OperationCanceledException) { }
        _cts.Dispose();
    }
}

핵심: (1) CancellationToken 으로 종료 조건 제공, (2) Task 를 보관해 Dispose 에서 await(동기 문맥이면 Wait())로 완료 대기 → 정리 시점에 잔존 작업이 없음, (3) 완료 대기가 예외를 관측하게 함, (4) 공유 상태는 원자/락으로 보호.

더 나은 설계

  • 표준 BackgroundService/IHostedService(StartAsync/StopAsync)로 시작·정지 수명을 프레임워크에 위임한다.
  • 생성자에서 무거운 작업을 시작하지 말고 명시적 StartAsync 로 분리(생성자에서 this 캡처 후 즉시 실행은 초기화 미완 객체 노출 위험).
  • 트레이드오프: 취소·대기·IAsyncDisposable 로 코드가 늘지만, 결정적 종료와 누수·경합 제거를 얻는다.

면접 포인트

  • fire-and-forget Task.Run 의 위험(정지 불가·예외 은폐·수명 분리).
  • Dispose 가 취소 신호+완료 대기까지 해야 하는 이유, IAsyncDisposable.
  • 생성자에서 this 캡처 백그라운드 시작의 위험.