CodeGym /행동 /C# SELF /fire and forget 작업에서의 예외

fire and forget 작업에서의 예외

C# SELF
레벨 61 , 레슨 0
사용 가능

1. "fire and forget"이 뭐야?

프로그래밍에서 fire and forget는 작업을 시작하고 그 완료를 기다리지 않는 것을 뜻해. C#과 .NET에서는 보통 Task를 실행해두고 어디서도 기다리지(await) 않거나, 참조를 남기지 않고 그냥 잊어버리는 방식으로 많이 쓴다.

// 버튼이 백그라운드로 작업을 시작하지만 아무데서도 await되지 않습니다.
button.Click += (s, e) =>
{
    Task.Run(() => 긴작업());
};

"백그라운드에서 돌리면 되지"라고 생각하기 쉬운데, 이런 방식으로 내부에서 예외가 발생하면 아무도 제때 알지 못해 — 예외가 조용히 사라질 수 있어.

2. Task에서 예외 처리는 어떻게 작동하나

클래식: await와 에러 처리

비동기 작업을 다루는 표준 방식은 await를 사용하는 거야. 작업 안에서 에러가 나면, 기다리는 지점에서 그 에러가 던져져:

try
{
    await SomeOperationAsync(); // 여기서 내부에서 Exception이 발생하면 catch로 넘어갑니다
}
catch(Exception ex)
{
    Console.WriteLine("앗! 작업에서 에러가 발생했어: " + ex.Message);
}

즉, 작업을 await하면 예외를 놓치지 않겠지.

하지만 "fire and forget" 작업은 아무도 기다리지 않아!

public void 대기없이실행()
{
    // 작업이 독립적으로 실행됩니다. 아무도 기다리지 않아요...
    Task.Run(() => {
        // 어딘가에서 문제가 발생:
        throw new InvalidOperationException("아이고, 다 망했어!");
    });
    // 메서드는 끝나고, 작업은 조용히 백그라운드에서 실행중입니다.
}

이런 작업 안에서 예외가 발생하면, 그 예외는 메인 스레드로 던져지지 않습니다. 프로그램은 아무 일 없던 것처럼 계속 돌아가.

중요

.NET에서 처리되지 않은 예외가 있는 작업은 Faulted 상태로 전환됩니다. 하지만 만약 당신이 그 작업을 기다리지 않으면 (await, .Result, .Wait() 등), 예외를 읽는 사람이 없어서 호출자 코드에서는 드러나지 않아.

속에서 실제로 무슨 일이 일어날까?

아무도 기다리지 않는 Task들이 유일하게 눈에 띌 수 있는 기회는 TaskScheduler.UnobservedTaskException 이벤트뿐이야. GC가 처리되지 않은 예외가 있는 Task 객체를 발견했을 때 이벤트가 발생해. 하지만 이건 즉시 일어나지 않고, 당신이 기대하는 지점에서 발생하지 않을 수도 있어 — 이걸 믿고만 있으면 안 돼.

3. 데모: fire-and-forget 에러

// 예제: Main에서 바로 fire-and-forget 작업을 실행
using System;
using System.Threading.Tasks;

class Program
{
    static void Main(string[] args)
    {
        FireAndForgetExample();

        Console.WriteLine("메인 스레드는 계속 돌아가...");
        // 작업이 끝날 시간을 잠깐 줍니다
        Task.Delay(2000).Wait();
    }

    static void FireAndForgetExample()
    {
        Task.Run(() =>
        {
            Console.WriteLine("Fire-and-forget 작업이 시작됐어!");
            Task.Delay(500).Wait();
            throw new InvalidOperationException("fire-and-forget 작업 내부에서 에러 발생!");
        });
    }
}

이 코드를 실행하면... 특별한 일이 일어나지 않는 것처럼 보일 거야. 에러는 발생하지만 프로그램은 그걸 인지하지 못해. IDE의 Output 창에서 경고가 보일 때도 있지만, 사용자 입장에서는 아무런 정보가 없을 수 있어.

왜 실제 프로젝트에서 위험한가?

  • 재현하기 힘든 복잡한 버그 ("가끔 동작하지 않음 — 이유 불명").
  • 데이터나 로직의 조용한 손실(예: 고객에게 메일이 안 가는 경우).
  • 프로덕션에서 로깅이 없으면 문제 신호를 전혀 받지 못함.

4. fire-and-forget에서 오류를 제대로 처리하는 방법

작업 내부에서 로그와 에러 처리

최소한의 안전한 수준은 fire-and-forget 작업 내부에서 예외를 잡는 거야:

Task.Run(() =>
{
    try
    {
        // 위험한/오래 걸리는 코드
        throw new InvalidOperationException("뭔가 잘못됐어!");
    }
    catch (Exception ex)
    {
        // 에러를 로그하거나 사용자에게 알림
        Console.WriteLine("Fire-and-forget: 잡힌 예외: " + ex.Message);
        // 로그 파일에 쓰거나 모니터링 시스템에 전송할 수 있음
    }
});

비동기 void 메서드(그리고 왜 쓰면 안 되는가)

async void DangerousFireAndForget()
{
    // 뭔가 위험한 일
    throw new Exception("퍽!");
}

async void 메서드는 사실상 fire-and-forget와 같아: 기다릴 수 없고 Task를 반환하지 않아. 이들에서 발생한 예외는 애플리케이션의 전역 핸들러로 넘어가곤 해(예: AppDomain.UnhandledException) 그리고 종종 프로세스를 종료시킬 수 있어. 이벤트 핸들러 같은 경우에만 async void를 쓰고, 그마저도 조심해야 해.

에러 처리를 위한 헬퍼(래퍼) 사용

fire-and-forget를 안전하게 실행하는 코드를 별도 래퍼로 빼두면 편리해:

// 안전하게 fire-and-forget를 실행하는 범용 메서드
public static void RunSafeFireAndForget(Func<Task> taskFactory)
{
    Task.Run(async () =>
    {
        try
        {
            await taskFactory();
        }
        catch (Exception ex)
        {
            // 예외 로깅
            Console.WriteLine("Fire-and-forget (safe): " + ex);
            // 모니터링 시스템으로 전송 등 추가 처리 가능
        }
    });
}

// 사용 예:
RunSafeFireAndForget(async () =>
{
    await Task.Delay(1000);
    throw new InvalidOperationException("fire-and-forget 내부!");
});

실전 예: 이메일 전송

// 메일 전송 버튼:
private void buttonSend_Click(object sender, EventArgs e)
{
    Task.Run(() => SendEmail());
}

// 전송 메서드:
private void SendEmail()
{
    try
    {
        // 실제 전송 로직이 여기에 있을 수 있음
        throw new Exception("SMTP 서버를 사용할 수 없음!");
    }
    catch (Exception ex)
    {
        // 로깅
        File.AppendAllText("errors.log", $"전송 오류: {ex.Message}\n");
    }
}

5. 그럼 UnobservedTaskException은 뭐야?

최후의 수단으로 .NET은 TaskScheduler.UnobservedTaskException 이벤트를 제공해. 이 이벤트는 작업이 에러로 끝났고 아무도 기다리지 않았으며 그 Task 객체가 GC에 의해 수거될 때 호출돼. 하지만 이건 믿고만 있을 수 있는 메커니즘이 아니야 — "마지막 기회" 수준일 뿐.

TaskScheduler.UnobservedTaskException += (sender, e) =>
{
    Console.WriteLine("전역 UnobservedTaskException: " + e.Exception);
    e.SetObserved(); // 이걸 호출하는 걸 잊지 마세요, 안 그러면 앱이 비정상 종료될 수 있어!
};

자세한 내용: TaskScheduler.UnobservedTaskException.

6. 유용한 팁

방법 비교(개략)

방법 예외 처리됨? 어디서 에러를 잡나 에러를 '잃어버릴' 위험
await
호출자 쪽 낮음
Fire-and-forget (try/catch 없음) 아니오 어디에서도 매우 높음
Fire-and-forget (try/catch 있음) 작업 내부 낮음 (로그를 남기면)
async void 메서드 아니오 (전역으로 전달) 전역 핸들러 높음

fire-and-forget를 설계할 때의 권장사항

  • 작업의 결과나 상태가 중요하면 절대 fire-and-forget 하지 마. await 하거나 Task 참조를 보관해서 나중에 기다려라.
  • fire-and-forget는 진짜로 중요하지 않은 백그라운드 작업(예: 텔레메트리 전송)에서만 정당화된다.
  • 항상 fire-and-forget를 자체 메서드로 래핑해서 예외를 잡고 로깅하자.
  • 복잡한 백그라운드 시나리오에는 큐와 워커를 사용하라: Hangfire, Quartz.NET.

실무/면접에서

면접에서 자주 묻는 질문: "fire-and-forget 작업에서 예외가 나면 어떻게 돼?" 또는 "왜 async void를 아무 데서나 쓰면 안 되지?" 정답은: 백그라운드 작업의 에러는 전적으로 너희가 책임져야 한다 — 잡아서 로그로 남기고 분석하든가, 아니면 유령 버그를 얻든가.

fire-and-forget와 await 비교

시나리오 에러 처리 신뢰성 적용성
일반적인 await 매우 좋음 결과가 필요하거나 성공/실패가 중요한 곳에 사용
Fire-and-forget 안좋음 (수동 처리하지 않으면) 정말로 백그라운드이고 중요하지 않은 작업에만
Fire-and-forget (try/catch) 괜찮음 (로그를 남기면) 결과가 필요 없지만 실패 정보를 알고 싶을 때

다음 강의에서는 여러 결과를 반환하는 병렬 작업에서의 에러 처리에 대해 이야기할게. 그전까지는: 어딘가로 "발사"했으면, 목표에 도달했는지 꼭 확인하자!

7. fire-and-forget 작업에서 흔한 실수

실수 #1: fire-and-forget에서 예외 무시하기.
초보자들은 예외가 "어디선가 떠오를 것"이라고 기대하지만, try-catch와 로깅 없이는 예외가 사라져서 발견하기 힘든 버그가 된다.

실수 #2: 이벤트 핸들러 외부에서 async void 사용하기.
이런 메서드는 예외를 전역 핸들러(AppDomain.UnhandledException 같은)로 던져서 앱이 비정상 종료될 수 있다.

실수 #3: 과도한 예외 처리.
작업 내부에서 모든 예외를 무조건 잡아버리면 호출자에서 더 잘 처리해야 할 문제를 숨길 수 있어 — 디버깅이 어려워진다.

실수 #4: 로깅을 등한시.
fire-and-forget 작업에서 로그를 남기지 않으면, 특히 프로덕션 환경에서 실패를 알 방법이 없다.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION