본문 바로가기

C#

C# 커스텀 예외(Custom Exception) 설계

커스텀 예외는 도메인 오류를 더 명확히 표현하고, 호출자에게 처리 힌트를 주기 위해 설계합니다. 단, 인자 검증이나 상태 오류 등은 가능한 한 기존 예외(ArgumentException, InvalidOperationException 등)를 우선 사용합니다.

1. 언제 커스텀 예외를 만들까요?

도메인 규칙 위반, 외부 시스템 상태를 의미하는 에러(예: 결제 실패, 재고 부족)처럼 의미 있는 분기 처리가 필요한 경우에 만듭니다. 반면, 잘못된 인자/범위를 나타낼 땐 ArgumentException/ArgumentOutOfRangeException, 아직 호출할 수 없는 상태면 InvalidOperationException이 적절합니다.

2. 네이밍과 기본 원칙

이름은 반드시 Exception으로 끝나야 합니다. 필요한 최소 정보만 불변 속성으로 노출합니다. 메시지는 개발자용 설명이며, UI 문구는 별도 계층에서 처리합니다. 확장을 의도하지 않으면 sealed로 고정합니다.

3. 최소 구현 예제

using System;

public sealed class OrderNotFoundException : Exception
{
    public string OrderId { get; }

    private const int DefaultHResult = unchecked((int)0x80131500); // 선택: 디버깅용 식별자

    public OrderNotFoundException(string orderId)
        : base($"Order '{orderId}' was not found.")
    {
        OrderId = orderId;
        HResult = DefaultHResult; // 선택
    }

    public OrderNotFoundException(string orderId, Exception? inner)
        : base($"Order '{orderId}' was not found.", inner)
    {
        OrderId = orderId;
    }
}

핵심은 메시지, InnerException 체인 유지, 도메인 식별자(OrderId) 같은 컨텍스트를 제공하는 것입니다.

4. 추가 데이터와 예외 필터

추가 속성을 두면 예외 필터와 함께 정교하게 처리할 수 있습니다.

using System;

public enum PaymentFailureReason
{
    Unknown,
    ExpiredCard,
    InsufficientFunds,
    Network
}

public sealed class PaymentFailedException : Exception
{
    public PaymentFailureReason Reason { get; }

    public PaymentFailedException(PaymentFailureReason reason)
        : base($"Payment failed: {reason}")
    {
        Reason = reason;
    }

    public PaymentFailedException(PaymentFailureReason reason, Exception? inner)
        : base($"Payment failed: {reason}", inner)
    {
        Reason = reason;
    }
}

// 사용 예시
try
{
    // ... 결제 로직
}
catch (PaymentFailedException ex) when (ex.Reason == PaymentFailureReason.ExpiredCard)
{
    // 카드 갱신 안내
}
catch (PaymentFailedException ex) when (ex.Reason == PaymentFailureReason.Network)
{
    // 재시도 로직
}

5. 직렬화가 필요한 경우(.NET Framework 호환 등)

프로세스 경계를 넘는 예외 전파가 필요하거나 .NET Framework를 지원한다면 [Serializable]과 직렬화 생성자를 추가합니다. 최신 .NET에서 BinaryFormatter는 사용 금지지만, 하위 호환이나 일부 인프라에서 여전히 요구될 수 있습니다.

using System;
using System.Runtime.Serialization;

[Serializable]
public sealed class LegacyCompatibleException : Exception
{
    public string Code { get; }

    public LegacyCompatibleException(string code, string message)
        : base(message) => Code = code;

    public LegacyCompatibleException(string code, string message, Exception? inner)
        : base(message, inner) => Code = code;

    protected LegacyCompatibleException(SerializationInfo info, StreamingContext context)
        : base(info, context)
        => Code = info.GetString(nameof(Code))!;

    public override void GetObjectData(SerializationInfo info, StreamingContext context)
    {
        info.AddValue(nameof(Code), Code);
        base.GetObjectData(info, context);
    }
}

6. 성능과 Throw Helper

예외는 비용이 큽니다. 정상 플로우 제어에 사용하지 않습니다. 반복 경로에서 throw 생성을 분리해 JIT 최적화를 돕습니다.

using System.Diagnostics.CodeAnalysis;

internal static class ThrowHelper
{
    [DoesNotReturn]
    public static void ThrowOrderNotFound(string orderId)
        => throw new OrderNotFoundException(orderId);
}

// 사용
if (!orders.TryGetValue(id, out var order))
{
    ThrowHelper.ThrowOrderNotFound(id);
}

7. 스택 트레이스 보존

재던질 때는 throw;를 사용해야 원래 스택 트레이스를 보존합니다. throw ex;는 스택을 초기화하므로 피합니다.

try
{
    Process();
}
catch (Exception)
{
    // 로깅 등 처리
    throw; // 올바름: 기존 스택 유지
}

// 잘못된 예시
catch (Exception ex)
{
    throw ex; // 스택 트레이스가 리셋됨
}

8. 표준 예외를 먼저 고려

인자 검증은 ArgumentException/ArgumentNullException/ArgumentOutOfRangeException, 잘못된 상태는 InvalidOperationException, 미구현은 NotSupportedException/NotImplementedException을 사용합니다. 커스텀 예외는 이들로 표현하기 어려운 도메인 상황에 집중합니다.

9. 간단 체크리스트

이름은 Exception으로 끝나는가? 메시지와 InnerException을 제공하는 생성자가 있는가? 필요한 최소한의 불변 속성을 노출하는가? 필요 시 [Serializable]과 직렬화 생성자를 제공하는가? 재현 가능한 식별자(HResult/Code)가 필요한가? 예외는 정상 흐름 제어나 반복 경로에 사용하지 않았는가?

10. 요약

커스텀 예외는 도메인 의미를 드러내고 호출자 분기를 단순화합니다. 표준 예외를 우선 고려하고, 불변 속성·메시지·InnerException·필요 시 직렬화를 갖춘 간결한 클래스로 설계하면 유지보수성과 진단 가능성이 크게 올라갑니다.