.NET Framework / .NET에서 대수적 데이터 타입 사용하기 ── 상태와 결과를 타입으로 표현하는 설계

· · .NET, .NET Framework, C#, F#, 대수적 데이터 타입, 구별 합집합, 도메인 모델링, 기존 자산 활용

1. 최초에 짚어둬야 할 것

.NET 업무 애플리케이션을 작성하다 보면, 이런 반환값이나 상태를 자주 보게 됩니다.

public class CreateUserResult
{
    public bool IsSuccess { get; set; }
    public User User { get; set; }
    public string ErrorCode { get; set; }
    public string ErrorMessage { get; set; }
}

겉보기에는 이해하기 쉽지만, 이 타입에는 「있어서는 안 될 상태」가 많이 들어올 수 있습니다.

예를 들어, 이런 값을 만들 수 있습니다.

  • IsSuccess == true인데 User == null
  • IsSuccess == true인데 ErrorCode가 들어 있음
  • IsSuccess == false인데 User가 들어 있음
  • ErrorCode == "DuplicateEmail"인데 ErrorMessage == null
  • 새로운 에러 코드를 추가했는데 호출하는 쪽의 처리가 갱신되지 않음

이런 타입은 처음에는 편리해도, 규모가 커질수록 읽는 사람과 유지보수하는 사람에게 부담을 줍니다.

여기서 쓰고 싶은 사고방식이 대수적 데이터 타입입니다.

대수적 데이터 타입이라는 이름은 조금 딱딱하게 들리지만, 실무에서의 느낌으로는 이렇게 생각하면 이해하기 쉽습니다.

「이 값이 취할 수 있는 형태가 미리 정해져 있다」는 것을, 주석이나 명명 규칙이 아니라 타입으로 표현한다.

예를 들어, 사용자 생성 결과는 다음 중 딱 하나뿐이라고 표현할 수 있습니다.

CreateUserResult =
  Created(User)
  또는 DuplicateEmail(email)
  또는 WeakPassword(reason)
  또는 SystemFailure(message)

성공 시에는 User가 있습니다. 이메일 중복 시에는 email이 있습니다. 비밀번호가 약할 때는 reason이 있습니다. 시스템 에러 시에는 message가 있습니다.

각각의 케이스는 필요한 데이터만을 가집니다. 성공과 실패가 동시에 성립하는 일은 없습니다. 성공인데 User가 없는 상태도 만들 수 없습니다.

.NET에서는 F#이라면 구별 합집합, C#이라면 sealed 클래스 계층, record 계층, OneOf 같은 라이브러리, 그리고 앞으로 등장할 C# union 타입으로 이 사고방식을 구현할 수 있습니다.

이 글에서는 대수적 데이터 타입을 .NET Framework와 현행 .NET 양쪽에서 사용하는 방법과, 실무상의 장점·주의점을 정리합니다.

또한 이 글에 등장하는 코드는, 빌드·실행할 수 있는 샘플 일체(라이브러리, 각 구현 패턴을 시연하는 데모, Match의 전수 검사·상태 전이·DTO 변환을 검증하는 유닛 테스트)로 GitHub에 공개하고 있습니다.

dotnet-algebraic-data-types - komurasoft-blog-samples (GitHub)

2. 대수적 데이터 타입이란 무엇인가

대수적 데이터 타입은 영어로 Algebraic Data Type, 줄여서 ADT라고 불립니다.

ADT는 대략적으로 말하면 2종류의 타입의 조합입니다.

  • 곱 타입: A와 B를 모두 가지는 타입
  • 합 타입: A 또는 B 중 하나인 타입

.NET의 클래스, 구조체, record는 많은 경우 「곱 타입」으로 사용됩니다.

public sealed class Address
{
    public string PostalCode { get; }
    public string Prefecture { get; }
    public string City { get; }
    public string Street { get; }

    public Address(string postalCode, string prefecture, string city, string street)
    {
        PostalCode = postalCode;
        Prefecture = prefecture;
        City = city;
        Street = street;
    }
}

의미로는 이렇습니다.

Address = PostalCode 이며 Prefecture 이며 City 이며 Street

한편, 합 타입은 「그 중 하나」입니다.

PaymentResult =
  Succeeded(receiptNo)
  또는 InsufficientFunds(shortage)
  또는 Rejected(reason)
  또는 NetworkFailure(message)

이쪽의 의미는 이렇습니다.

PaymentResult = Succeeded 또는 InsufficientFunds 또는 Rejected 또는 NetworkFailure

이 「또는」을 타입으로 표현하는 것이, 대수적 데이터 타입 중에서도 특히 실무에서 자주 쓰이는 부분입니다.

F#에서는 이것을 언어 기능으로 자연스럽게 작성할 수 있습니다.

type PaymentResult =
    | Succeeded of receiptNo: string
    | InsufficientFunds of shortage: decimal
    | Rejected of reason: string
    | NetworkFailure of message: string

C#에는 오랫동안 F#과 같은 구별 합집합이 표준 기능으로 존재하지 않았습니다. 그래서 C#에서는 클래스 계층이나 라이브러리로 표현해 왔습니다.

다만, 사고방식 자체는 C#에서도 충분히 사용할 수 있습니다.

중요한 것은 특정 문법을 사용하는 것이 아니라, 이 한 가지입니다.

「잘못된 상태 자체를 만들 수 없게 한다」

3. bool이나 enum만으로는 왜 부족한가

작은 처리에서는 bool이나 enum만으로도 충분해 보이는 경우가 있습니다.

예를 들어, 이런 반환값입니다.

public enum PaymentStatus
{
    Succeeded,
    InsufficientFunds,
    Rejected,
    NetworkFailure
}

public sealed class PaymentResponse
{
    public PaymentStatus Status { get; set; }
    public string ReceiptNo { get; set; }
    public decimal? Shortage { get; set; }
    public string Reason { get; set; }
    public string Message { get; set; }
}

하지만 이 형태에서는 Status와 각 속성의 관계가 타입으로 표현되어 있지 않습니다.

Status == Succeeded일 때만 ReceiptNo가 필요합니다. Status == InsufficientFunds일 때만 Shortage가 필요합니다. Status == Rejected일 때만 Reason이 필요합니다. Status == NetworkFailure일 때만 Message가 필요합니다.

이 규칙은 코드 바깥에 있습니다.

주석, 명세서, 테스트, 암묵적인 약속, 구현자의 기억에 의존하고 있습니다.

그 결과, 이런 방어 코드가 늘어납니다.

if (response.Status == PaymentStatus.Succeeded)
{
    if (string.IsNullOrEmpty(response.ReceiptNo))
    {
        throw new InvalidOperationException("ReceiptNo is required.");
    }

    return response.ReceiptNo;
}

이런 방어 코드가 필요한 경우도 있지만, 본래는 「타입 설계」로 막을 수 있는 경우도 많습니다.

대수적 데이터 타입으로 표현하면, 케이스마다 필요한 데이터만을 가지게 할 수 있습니다.

Succeeded는 receiptNo를 가진다
InsufficientFunds는 shortage를 가진다
Rejected는 reason을 가진다
NetworkFailure는 message를 가진다

이 설계에서는 Succeeded인데 receiptNo를 가지지 않는 값을 만들 수 없습니다.

즉, 상태 검사를 나중에 열심히 하는 것이 아니라, 처음부터 잘못된 상태를 만들 수 없게 합니다.

4. .NET Framework에서도 쓸 수 있는 구현: sealed 클래스 계층

.NET Framework를 포함한 기존 시스템에서 가장 도입하기 쉬운 것은 abstract 기반 클래스 + 중첩된 sealed 클래스 + Match 메서드입니다.

C#의 오래된 버전에서도 사용하기 쉽고, 특별한 런타임 기능도 필요하지 않습니다.

예로, 사용자 생성 결과를 표현해 보겠습니다.

public abstract class CreateUserResult
{
    private CreateUserResult()
    {
    }

    public sealed class Created : CreateUserResult
    {
        internal Created(User user)
        {
            if (user == null) throw new ArgumentNullException(nameof(user));
            User = user;
        }

        public User User { get; }
    }

    public sealed class DuplicateEmail : CreateUserResult
    {
        internal DuplicateEmail(string email)
        {
            if (email == null) throw new ArgumentNullException(nameof(email));
            Email = email;
        }

        public string Email { get; }
    }

    public sealed class WeakPassword : CreateUserResult
    {
        internal WeakPassword(string reason)
        {
            if (reason == null) throw new ArgumentNullException(nameof(reason));
            Reason = reason;
        }

        public string Reason { get; }
    }

    public sealed class SystemFailure : CreateUserResult
    {
        internal SystemFailure(string message)
        {
            if (message == null) throw new ArgumentNullException(nameof(message));
            Message = message;
        }

        public string Message { get; }
    }

    public static CreateUserResult Ok(User user)
        => new Created(user);

    public static CreateUserResult EmailAlreadyUsed(string email)
        => new DuplicateEmail(email);

    public static CreateUserResult PasswordIsWeak(string reason)
        => new WeakPassword(reason);

    public static CreateUserResult Failed(string message)
        => new SystemFailure(message);

    public T Match<T>(
        Func<Created, T> created,
        Func<DuplicateEmail, T> duplicateEmail,
        Func<WeakPassword, T> weakPassword,
        Func<SystemFailure, T> systemFailure)
    {
        if (created == null) throw new ArgumentNullException(nameof(created));
        if (duplicateEmail == null) throw new ArgumentNullException(nameof(duplicateEmail));
        if (weakPassword == null) throw new ArgumentNullException(nameof(weakPassword));
        if (systemFailure == null) throw new ArgumentNullException(nameof(systemFailure));

        var c = this as Created;
        if (c != null) return created(c);

        var d = this as DuplicateEmail;
        if (d != null) return duplicateEmail(d);

        var w = this as WeakPassword;
        if (w != null) return weakPassword(w);

        var f = this as SystemFailure;
        if (f != null) return systemFailure(f);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

이용하는 쪽은 이렇게 작성할 수 있습니다.

CreateUserResult result = service.CreateUser(command);

string message = result.Match(
    created => "사용자를 생성했습니다: " + created.User.Id,
    duplicate => "이 이메일 주소는 이미 사용 중입니다: " + duplicate.Email,
    weak => "비밀번호가 너무 약합니다: " + weak.Reason,
    failure => "사용자 생성에 실패했습니다: " + failure.Message);

이 형태의 장점은 .NET Framework에서도 현행 .NET에서도 사용할 수 있다는 점입니다.

Created, DuplicateEmail, WeakPassword, SystemFailure는 모두 CreateUserResult이지만, 각각이 가지는 데이터는 다릅니다.

Created만이 User를 가집니다. DuplicateEmail만이 Email을 가집니다. WeakPassword만이 Reason을 가집니다. SystemFailure만이 Message를 가집니다.

성공과 실패를 동시에 나타내는 값은 만들 수 없습니다.

게다가 이용하는 쪽이 Match를 사용하도록 해두면, 모든 케이스를 처리하는 형태를 강제할 수 있습니다.

예를 들어, 새로 TemporaryBlocked라는 케이스를 추가했다고 합시다.

public sealed class TemporaryBlocked : CreateUserResult
{
    internal TemporaryBlocked(DateTimeOffset until)
    {
        Until = until;
    }

    public DateTimeOffset Until { get; }
}

이때, Match 메서드의 인자에도 Func<TemporaryBlocked, T>를 추가합니다.

그러면 기존의 result.Match(...) 호출이 컴파일 에러가 됩니다. 이것은 좋은 에러입니다. 「새로운 케이스를 추가했는데 호출하는 쪽이 대응하지 않았다」는 것을 컴파일 시점에 발견할 수 있기 때문입니다.

5. private 생성자로 닫힌 집합으로 만들기

C#에서 합 타입을 표현할 때 중요한 것은 케이스의 집합을 가능한 한 닫아두는 것입니다.

기반 클래스의 생성자를 protected로 하면, 외부에서 상속될 여지가 남습니다.

public abstract class PaymentResult
{
    protected PaymentResult()
    {
    }
}

이 형태에서는 다른 어셈블리나 다른 곳에서 이런 타입을 만들 수 있게 됩니다.

public sealed class UnknownPaymentResult : PaymentResult
{
}

그러면 PaymentResult의 케이스 집합이 닫히지 않습니다.

「이 타입은 Succeeded / InsufficientFunds / Rejected / NetworkFailure 중 하나입니다」라고 말하고 싶은데, 다른 케이스가 늘어나 버립니다.

.NET Framework에서도 사용할 수 있는 현실적인 대책은, 기반 클래스의 생성자를 private로 하고, 케이스 타입을 기반 클래스의 중첩 타입으로 정의하는 것입니다.

public abstract class PaymentResult
{
    private PaymentResult()
    {
    }

    public sealed class Succeeded : PaymentResult
    {
        internal Succeeded(string receiptNo)
        {
            ReceiptNo = receiptNo;
        }

        public string ReceiptNo { get; }
    }

    public sealed class InsufficientFunds : PaymentResult
    {
        internal InsufficientFunds(decimal shortage)
        {
            Shortage = shortage;
        }

        public decimal Shortage { get; }
    }

    public static PaymentResult Success(string receiptNo)
        => new Succeeded(receiptNo);

    public static PaymentResult Insufficient(decimal shortage)
        => new InsufficientFunds(shortage);
}

중첩된 타입은 바깥 타입의 private 멤버에 접근할 수 있습니다. 그래서 중첩된 케이스 타입만이 PaymentResult를 상속할 수 있습니다.

이 패턴을 사용하면, C#에서도 「닫힌 케이스 집합」에 가까운 것을 만들 수 있습니다.

다만, C# 컴파일러가 F#처럼 완전한 전수성 검사를 해주는 것은 아닙니다.

그래서 C#에서 이 패턴을 사용할 경우에는, 가능한 한 switch를 여기저기 분산시키지 않고 Match 메서드로 처리를 모으는 것을 추천합니다.

6. 현행 .NET이라면 record 계층으로 간결하게 작성할 수 있다

.NET 5 이후를 전제로 할 수 있다면, C#의 record를 사용하면 데이터 중심의 케이스 타입을 꽤 짧게 작성할 수 있습니다.

public abstract record CreateUserResult
{
    private CreateUserResult()
    {
    }

    public sealed record Created(User User) : CreateUserResult;
    public sealed record DuplicateEmail(string Email) : CreateUserResult;
    public sealed record WeakPassword(string Reason) : CreateUserResult;
    public sealed record SystemFailure(string Message) : CreateUserResult;
}

이용하는 쪽에서는 패턴 매칭과 switch 식을 사용할 수 있습니다.

static string ToMessage(CreateUserResult result)
{
    return result switch
    {
        CreateUserResult.Created { User: var user }
            => $"사용자를 생성했습니다: {user.Id}",

        CreateUserResult.DuplicateEmail { Email: var email }
            => $"이 이메일 주소는 이미 사용 중입니다: {email}",

        CreateUserResult.WeakPassword { Reason: var reason }
            => $"비밀번호가 너무 약합니다: {reason}",

        CreateUserResult.SystemFailure { Message: var message }
            => $"사용자 생성에 실패했습니다: {message}",

        _ => throw new InvalidOperationException("알 수 없는 결과입니다.")
    };
}

이 작성 방식은 C#답게 읽기 쉽습니다.

한편, 주의점도 있습니다.

record 계층은 값 비교나 표시를 위한 정형 코드를 줄이는 데는 편리합니다. 다만, 앞 장에서 보여준 「일반 class + private 생성자 + 중첩된 sealed 케이스」와 같은 정도로 케이스 집합을 닫는 메커니즘이라고는 생각하지 않는 것이 안전합니다.

특히 non-sealed record class에서는, 복사용 생성자 등 record 고유의 생성 멤버가 관련됩니다. 「외부에서 절대로 파생시키고 싶지 않다」, 「케이스 집합을 엄격하게 닫고 싶다」는 용도에서는, 앞 장의 class 계층이나 F#의 구별 합집합, 또는 실적이 있는 union / source generator 계열 라이브러리를 선택하는 것이 견실합니다.

또한, 이 switch 식에 _를 넣으면 알려지지 않은 파생 타입을 받을 수 있는 것처럼 보입니다. 그러나 케이스 집합을 닫아서 다루는 설계라면, _는 본래 「도달하지 않아야 할」 분기입니다.

C#에서는 오래된 안정 버전 범위에서는 F#의 구별 합집합만큼 엄격한 전수성 검사는 기대할 수 없습니다. 그래서 C#에서 record 계층을 사용할 경우에도, 다음 중 하나에 맞추는 것이 안전합니다.

  • Match 메서드를 준비해서, 호출하는 쪽에 모든 케이스 처리를 강제한다
  • switch를 한곳에 모으고, 여기저기 분산시키지 않는다

예를 들어, record 계층에도 Match를 추가할 수 있습니다.

public abstract record CreateUserResult
{
    private CreateUserResult()
    {
    }

    public sealed record Created(User User) : CreateUserResult;
    public sealed record DuplicateEmail(string Email) : CreateUserResult;
    public sealed record WeakPassword(string Reason) : CreateUserResult;
    public sealed record SystemFailure(string Message) : CreateUserResult;

    public T Match<T>(
        Func<Created, T> created,
        Func<DuplicateEmail, T> duplicateEmail,
        Func<WeakPassword, T> weakPassword,
        Func<SystemFailure, T> systemFailure)
    {
        return this switch
        {
            Created x => created(x),
            DuplicateEmail x => duplicateEmail(x),
            WeakPassword x => weakPassword(x),
            SystemFailure x => systemFailure(x),
            _ => throw new InvalidOperationException("알 수 없는 결과입니다.")
        };
    }
}

이렇게 해두면, 이용하는 쪽은 항상 모든 케이스를 의식하며 처리할 수 있습니다.

var message = result.Match(
    created => $"생성했습니다: {created.User.Id}",
    duplicate => $"중복되었습니다: {duplicate.Email}",
    weak => $"비밀번호가 약합니다: {weak.Reason}",
    failure => $"실패했습니다: {failure.Message}");

record를 사용하는 장점은 값 비교, 표시, 복사에 관한 정형 코드가 줄어드는 것입니다. 다만, .NET Framework도 대상으로 하는 공유 라이브러리에서는 record나 init-only 속성을 억지로 사용하기보다 일반 class로 작성하는 편이 다루기 쉬운 경우가 있습니다.

「새로운 문법을 사용하는 것」보다 「표현하고 싶은 상태를 타입에 담는 것」을 우선하는 것이 좋습니다.

7. F#의 구별 합집합 사용하기

.NET에서 대수적 데이터 타입을 가장 자연스럽게 다룰 수 있는 언어는 F#입니다.

F#에는 구별 합집합이 언어 기능으로 마련되어 있습니다.

type CreateUserResult =
    | Created of user: User
    | DuplicateEmail of email: string
    | WeakPassword of reason: string
    | SystemFailure of message: string

이용하는 쪽도 자연스럽습니다.

let toMessage result =
    match result with
    | Created user -> $"사용자를 생성했습니다: {user.Id}"
    | DuplicateEmail email -> $"이 이메일 주소는 이미 사용 중입니다: {email}"
    | WeakPassword reason -> $"비밀번호가 너무 약합니다: {reason}"
    | SystemFailure message -> $"사용자 생성에 실패했습니다: {message}"

F#의 좋은 점은 케이스의 열거와 패턴 매칭이 언어에 통합되어 있다는 것입니다.

케이스를 추가했을 때, match 쪽의 처리 누락을 발견하기 쉬워집니다. 또한, Option<'T>처럼 값이 있는지 없는지를 나타내는 타입도 구별 합집합으로 자연스럽게 사용할 수 있습니다.

let tryFindUser id : User option =
    // 찾으면 Some user, 찾지 못하면 None
    failwith "sample"

null을 반환하는 대신 option을 반환함으로써, 「존재하지 않을 가능성이 있다」는 것이 타입에 나타납니다.

F#의 구별 합집합은 .NET의 타입으로 컴파일되기 때문에, .NET Framework용 F# 프로젝트에서도, 현행 .NET용 F# 프로젝트에서도 사용할 수 있습니다.

다만, C#에서 F#의 구별 합집합을 직접 다룰 경우, F# 안에서 다루는 것만큼 자연스럽지 않은 경우가 있습니다.

그래서 이런 구분이 현실적입니다.

  • F# 내부의 도메인 로직에서는 F#의 구별 합집합을 적극적으로 사용한다
  • C#에서 자주 호출되는 공개 API에서는 C#에서도 다루기 쉬운 DTO나 class 계층으로 변환한다
  • 경계에서는 JSON이나 DB의 사정에 맞춘 별도의 표현으로 매핑한다

「도메인 내부에서는 강한 타입, 외부 경계에서는 다루기 쉬운 타입」이라는 분리를 할 수 있으면, F#과 C#이 혼재해도 사용하기 쉬워집니다.

8. OneOf 같은 라이브러리 사용하기

C#에서 손쉽게 합 타입을 표현하고 싶은 경우에는, OneOf 같은 라이브러리도 선택지가 됩니다.

예를 들어, 반환값을 이렇게 표현할 수 있습니다.

using OneOf;

public sealed class DuplicateEmail
{
    public DuplicateEmail(string email)
    {
        Email = email;
    }

    public string Email { get; }
}

public sealed class WeakPassword
{
    public WeakPassword(string reason)
    {
        Reason = reason;
    }

    public string Reason { get; }
}

public OneOf<User, DuplicateEmail, WeakPassword> CreateUser(CreateUserCommand command)
{
    if (EmailExists(command.Email))
    {
        return new DuplicateEmail(command.Email);
    }

    if (!IsStrongPassword(command.Password))
    {
        return new WeakPassword("12자 이상으로 해 주세요.");
    }

    return CreateUserCore(command);
}

이용하는 쪽은 Match로 처리할 수 있습니다.

var result = service.CreateUser(command);

var message = result.Match(
    user => $"생성했습니다: {user.Id}",
    duplicate => $"중복되었습니다: {duplicate.Email}",
    weak => $"비밀번호가 약합니다: {weak.Reason}");

OneOf<User, DuplicateEmail, WeakPassword>는 「이 값은 User, DuplicateEmail, WeakPassword 중 하나」라는 의미가 됩니다.

이 방법의 장점은 전용 기반 클래스를 만들지 않아도 지역적인 반환값으로 사용하기 쉽다는 것입니다.

특히, 애플리케이션 서비스나 유스케이스 계층에서 이런 반환값을 표현하는 데 적합합니다.

사용자 생성 결과 = User 또는 DuplicateEmail 또는 WeakPassword
상품 조회 결과 = Product 또는 NotFound 또는 AccessDenied
결제 결과 = Receipt 또는 InsufficientFunds 또는 PaymentRejected

한편, 주의점도 있습니다.

OneOf<A, B, C> 같은 타입을 공개 API에 그대로 노출하면, 도메인상의 이름이 희미해지는 경우가 있습니다.

예를 들어, 다음 2개는 타입 인자만 보면 같은 구조처럼 보입니다.

OneOf<User, NotFound, AccessDenied> GetUser(...)
OneOf<Order, NotFound, AccessDenied> GetOrder(...)

작은 범위에서는 편리하지만, 도메인상의 의미를 명확히 하고 싶다면 전용 타입을 만드는 편이 읽기 쉽습니다.

public abstract class GetUserResult
{
    // Found / NotFound / AccessDenied
}

구분하는 기준은 이렇습니다.

  • 지역적인 반환값이라면 OneOf가 편리
  • 도메인에서 반복적으로 등장하는 개념이라면 전용 타입을 만든다
  • 공개 API의 안정성을 중시한다면 이름을 가진 결과 타입으로 한다

또한, OneOf는 .NET Framework나 .NET Standard를 포함한 다양한 타깃에서 사용할 수 있기 때문에, 기존의 .NET Framework 자산에도 도입하기 쉬운 선택지입니다.

9. Source Generator 계열 라이브러리 사용하기

현행 .NET에서는 Source Generator를 사용해 구별 합집합 스타일의 타입을 생성하는 라이브러리도 있습니다.

예를 들어, 속성(attribute)을 붙이기만 하면 Switch, Map, 검증, 직렬화 연동 등의 코드를 생성해 주는 것이 있습니다.

이미지로는 이런 형태입니다.

[Union]
public partial record Result<T>
{
    public sealed record Success(T Value) : Result<T>;
    public sealed record Failure(string Error) : Result<T>;
}

이런 라이브러리는 손으로 작성한 MatchSwitch의 정형 코드를 줄일 수 있습니다. 또한, Analyzer와 조합해 처리 누락을 경고해 주는 것도 있습니다.

다만, .NET Framework를 포함한 기존 시스템에서 사용할 경우에는 다음을 확인해 주세요.

  • 대상 TFM이 .NET Framework를 지원하는가
  • Source Generator를 사용하기 위한 SDK / Visual Studio / MSBuild 환경이 갖춰져 있는가
  • CI 환경에서 동일한 생성 결과가 나오는가
  • 생성된 코드를 디버깅할 수 있는가
  • 애플리케이션 경계의 JSON / DB / OpenAPI 연동이 기대한 대로인가

특히, 오래된 .NET Framework 프로젝트에서는 Source Generator를 전제로 한 패키지를 그대로 사용할 수 없는 경우가 있습니다.

.NET Framework를 강하게 지원하고 싶은 경우에는, 처음에는 손으로 작성한 class 계층이나 OneOf부터 시작하는 것이 안전합니다.

10. C# 15의 union 타입에 대하여

2026년 6월 시점에는 C# 15의 union 타입이 프리뷰 기능으로 등장해 있습니다.

프리뷰의 방향성에서는, 「이 타입은 지정한 타입 중 하나이다」라고 선언할 수 있습니다.

public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);

public union Pet(Cat, Dog, Bird);

이용하는 쪽에서는 패턴 매칭으로 각 케이스를 다룹니다.

static string Describe(Pet pet)
{
    return pet switch
    {
        Cat cat => $"Cat: {cat.Name}",
        Dog dog => $"Dog: {dog.Name}",
        Bird bird => $"Bird: {bird.Name}",
        Pet { Value: null } => "Unknown pet"
    };
}

이 기능이 안정되면, C#에서도 「닫힌 타입의 집합」과 「전수적인 pattern matching」을 더 자연스럽게 다룰 수 있게 됩니다.

프리뷰 시점의 생성 타입이 struct인 경우, default(Pet)처럼 내부의 Valuenull인 값도 생길 수 있습니다. 공개 메서드에서 union 값을 받을 경우에는, 이런 기본값도 방어적으로 다뤄야 합니다.

다만, 프리뷰 기능은 실무의 프로덕션 코드에 넣기 전에 신중하게 평가해야 합니다.

언어 명세, IDE 지원, 런타임 측의 보조 타입, Analyzer, 직렬화 연동 등이 정식 릴리스까지 바뀔 가능성이 있습니다.

그래서 현재 시점의 실무에서는 다음과 같은 위치가 현실적입니다.

  • 신규 검증이나 기술 조사에서는 C# union을 시도해 볼 가치가 있다
  • 프로덕션에서 장기간 유지보수하는 코드에서는 F# DU, class / record 계층, OneOf, Source Generator 등 안정된 선택지를 사용한다
  • 앞으로 C# union으로 이전하기 쉽도록, 반환값이나 상태를 「그 중 하나」라는 타입으로 정리해 둔다

즉, C# union을 기다리지 않아도 오늘부터 ADT적인 설계는 할 수 있습니다.

오히려, 지금부터 Result, Option, 상태 타입, 도메인 이벤트 타입 등을 정리해 두면, 앞으로 언어 기능으로 이전하기 쉬워집니다.

11. Option 타입: null 대신 「없음」을 표현하다

대수적 데이터 타입의 대표적인 예가 Option<T>입니다.

Option<T>는 다음 중 하나를 표현합니다.

Some(value)
None

C#에서는 null로 「없음」을 표현하는 경우가 많지만, null은 타입에서 보이지 않는다는 문제가 있습니다.

User user = repository.FindById(id);

// user가 null인지 여부는 호출하는 쪽이 기억하고 있어야 한다
Console.WriteLine(user.Name);

Option<User>로 하면, 「찾지 못할 가능성이 있다」는 것이 타입에 나타납니다.

.NET Framework에서도 사용할 수 있는 간단한 구현을 보여줍니다.

public abstract class Option<T>
{
    private Option()
    {
    }

    public sealed class Some : Option<T>
    {
        internal Some(T value)
        {
            Value = value;
        }

        public T Value { get; }
    }

    public sealed class None : Option<T>
    {
        internal None()
        {
        }
    }

    private static readonly None NoneValue = new None();

    public static Option<T> Of(T value)
    {
        if (object.Equals(value, null))
        {
            return NoneValue;
        }

        return new Some(value);
    }

    public static Option<T> Empty()
    {
        return NoneValue;
    }

    public TResult Match<TResult>(Func<T, TResult> some, Func<TResult> none)
    {
        if (some == null) throw new ArgumentNullException(nameof(some));
        if (none == null) throw new ArgumentNullException(nameof(none));

        var s = this as Some;
        if (s != null) return some(s.Value);

        return none();
    }
}

이용하는 쪽은 이렇게 됩니다.

Option<User> user = repository.FindById(id);

string displayName = user.Match(
    some: u => u.Name,
    none: () => "게스트");

null을 완전히 없앨 필요는 없습니다. .NET의 기존 API나 데이터베이스, JSON에서는 null이 등장합니다.

하지만, 도메인 로직 내부에서는 null보다 Option<T>가 의도를 더 명확하게 드러내는 경우가 많습니다.

특히 이런 메서드에서는 Option<T>가 적합합니다.

Option<User> TryFindUser(UserId id);
Option<Customer> FindCustomerByEmail(Email email);
Option<Discount> GetApplicableDiscount(Order order);

메서드 이름에 Try를 붙이는 것뿐만 아니라, 반환값의 타입에도 「없을 가능성」을 표현하는 것이 핵심입니다.

12. Result 타입: 예상 범위 내의 실패를 타입으로 반환하다

또 하나 자주 쓰이는 것이 Result<TSuccess, TError>입니다.

이것은 다음 중 하나를 표현합니다.

Success(value)
Failure(error)

예외는 예상 밖의 실패나, 일반적인 제어 흐름에 태우고 싶지 않은 실패에 적합합니다. 한편, 업무상 자주 일어나는 실패는 타입으로 반환하는 편이 읽기 쉬운 경우가 있습니다.

예를 들어, 로그인 처리에서는 이런 실패가 예상 범위 내입니다.

  • 사용자가 존재하지 않음
  • 비밀번호가 틀림
  • 계정이 잠겨 있음
  • 다요소 인증이 필요함

이것을 예외만으로 표현하면, 호출하는 쪽은 catch에 업무 분기를 작성하게 됩니다.

try
{
    var session = auth.Login(userName, password);
    return Ok(session);
}
catch (InvalidPasswordException)
{
    return Unauthorized();
}
catch (AccountLockedException)
{
    return Forbid();
}

예외로 작성해도 동작하지만, 업무상의 분기가 예외 처리에 묻히기 쉬워집니다.

ADT적으로 표현하면 이렇게 됩니다.

public abstract class LoginResult
{
    private LoginResult()
    {
    }

    public sealed class Succeeded : LoginResult
    {
        internal Succeeded(Session session)
        {
            Session = session;
        }

        public Session Session { get; }
    }

    public sealed class InvalidPassword : LoginResult
    {
        internal InvalidPassword()
        {
        }
    }

    public sealed class AccountLocked : LoginResult
    {
        internal AccountLocked(DateTimeOffset until)
        {
            Until = until;
        }

        public DateTimeOffset Until { get; }
    }

    public sealed class MfaRequired : LoginResult
    {
        internal MfaRequired(string challengeId)
        {
            ChallengeId = challengeId;
        }

        public string ChallengeId { get; }
    }

    public static LoginResult Success(Session session)
        => new Succeeded(session);

    public static LoginResult WrongPassword()
        => new InvalidPassword();

    public static LoginResult Locked(DateTimeOffset until)
        => new AccountLocked(until);

    public static LoginResult RequireMfa(string challengeId)
        => new MfaRequired(challengeId);

    public T Match<T>(
        Func<Succeeded, T> succeeded,
        Func<InvalidPassword, T> invalidPassword,
        Func<AccountLocked, T> accountLocked,
        Func<MfaRequired, T> mfaRequired)
    {
        if (succeeded == null) throw new ArgumentNullException(nameof(succeeded));
        if (invalidPassword == null) throw new ArgumentNullException(nameof(invalidPassword));
        if (accountLocked == null) throw new ArgumentNullException(nameof(accountLocked));
        if (mfaRequired == null) throw new ArgumentNullException(nameof(mfaRequired));

        var s = this as Succeeded;
        if (s != null) return succeeded(s);

        var i = this as InvalidPassword;
        if (i != null) return invalidPassword(i);

        var l = this as AccountLocked;
        if (l != null) return accountLocked(l);

        var m = this as MfaRequired;
        if (m != null) return mfaRequired(m);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

이 형태로 하면, 호출하는 쪽은 「로그인 처리가 취할 수 있는 결과」를 보면서 구현할 수 있습니다.

var result = auth.Login(userName, password);

return result.Match(
    succeeded => Ok(succeeded.Session),
    invalidPassword => Unauthorized(),
    accountLocked => StatusCode(423),
    mfaRequired => Accepted(new { mfaRequired.ChallengeId }));

포인트는 예외를 그만두는 것이 아닙니다.

예상 범위 내의 업무 분기는 Result로, 예상 밖의 이상은 예외로 처리한다는 역할 분담을 한다.

이것만으로도 애플리케이션 서비스 계층이나 API 계층의 가시성이 상당히 좋아집니다.

13. 상태 전이를 타입으로 표현하기

ADT는 반환값뿐만 아니라, 상태를 표현하는 데도 적합합니다.

예를 들어, 주문 상태를 생각해 봅니다.

public enum OrderStatus
{
    Draft,
    Submitted,
    Paid,
    Shipped,
    Cancelled
}

enum만으로는 상태마다 필요한 데이터를 표현하기 어렵습니다.

  • Draft에는 작성자가 필요
  • Submitted에는 제출 일시가 필요
  • Paid에는 결제 번호가 필요
  • Shipped에는 배송 번호가 필요
  • Cancelled에는 취소 이유가 필요

OrderStatus와 별도의 속성으로 표현하려고 하면, 또다시 nullable한 속성이 늘어납니다.

public sealed class Order
{
    public OrderStatus Status { get; set; }
    public DateTimeOffset? SubmittedAt { get; set; }
    public string PaymentNo { get; set; }
    public string TrackingNo { get; set; }
    public string CancelReason { get; set; }
}

이 설계에서는 Status == Draft인데 TrackingNo가 들어 있다는 상태를 만들 수 있게 됩니다.

ADT적으로 표현하면, 상태 자체를 타입으로 만듭니다.

public abstract class OrderState
{
    private OrderState()
    {
    }

    public sealed class Draft : OrderState
    {
        internal Draft(UserId createdBy)
        {
            CreatedBy = createdBy;
        }

        public UserId CreatedBy { get; }
    }

    public sealed class Submitted : OrderState
    {
        internal Submitted(DateTimeOffset submittedAt)
        {
            SubmittedAt = submittedAt;
        }

        public DateTimeOffset SubmittedAt { get; }
    }

    public sealed class Paid : OrderState
    {
        internal Paid(string paymentNo)
        {
            PaymentNo = paymentNo;
        }

        public string PaymentNo { get; }
    }

    public sealed class Shipped : OrderState
    {
        internal Shipped(string trackingNo)
        {
            TrackingNo = trackingNo;
        }

        public string TrackingNo { get; }
    }

    public sealed class Cancelled : OrderState
    {
        internal Cancelled(string reason)
        {
            Reason = reason;
        }

        public string Reason { get; }
    }
}

주문은 OrderState를 가집니다.

public sealed class Order
{
    public OrderId Id { get; }
    public OrderState State { get; private set; }

    public Order(OrderId id, UserId createdBy)
    {
        Id = id;
        State = new OrderState.Draft(createdBy);
    }
}

또한, 상태 전이를 메서드에 담습니다.

public void Submit(IClock clock)
{
    if (!(State is OrderState.Draft))
    {
        throw new InvalidOperationException("초안 상태의 주문만 제출할 수 있습니다.");
    }

    State = new OrderState.Submitted(clock.Now);
}

public void MarkAsPaid(string paymentNo)
{
    if (!(State is OrderState.Submitted))
    {
        throw new InvalidOperationException("제출된 주문만 결제 완료로 만들 수 있습니다.");
    }

    State = new OrderState.Paid(paymentNo);
}

이 형태로 하면, 상태마다의 데이터와 상태 전이 규칙을 읽기 쉬워집니다.

물론, 영속화할 때는 OrderStatus와 보조 컬럼으로 나눠서 저장하는 경우도 있습니다.

그 경우에도, 도메인 내부에서는 OrderState로 다루고, DB와의 경계에서 변환하면 됩니다.

DB상의 표현
  status = "Paid"
  payment_no = "PAY-001"

도메인 내부의 표현
  OrderState.Paid("PAY-001")

DB 스키마에 맞춰 도메인 모델을 약하게 만들 필요는 없습니다.

14. API 경계에서는 DTO로 변환하기

ADT적인 타입은 도메인 내부에서는 매우 편리합니다.

한편, JSON API, DB, 메시지 큐, OpenAPI, 외부 연동에서는 조금 주의가 필요합니다.

예를 들어, 이 ADT를 그대로 JSON으로 출력한다고 합니다.

public abstract record PaymentResult
{
    public sealed record Succeeded(string ReceiptNo) : PaymentResult;
    public sealed record Rejected(string Reason) : PaymentResult;
    public sealed record NetworkFailure(string Message) : PaymentResult;
}

JSON으로는 이런 형태로 하고 싶을 수 있습니다.

{
  "type": "succeeded",
  "receiptNo": "R-001"
}

실패라면, 이런 형태입니다.

{
  "type": "rejected",
  "reason": "card_expired"
}

type은 JSON 쪽의 판별자입니다.

도메인의 ADT와 JSON의 표현은 비슷하지만, 같은 것은 아닙니다.

그래서 외부 경계에서는 DTO로 변환하는 설계가 안전합니다.

public sealed class PaymentResultDto
{
    public string Type { get; set; }
    public string ReceiptNo { get; set; }
    public string Reason { get; set; }
    public string Message { get; set; }
}

변환 처리에서, ADT의 케이스마다 DTO를 만듭니다.

public static PaymentResultDto ToDto(PaymentResult result)
{
    return result switch
    {
        PaymentResult.Succeeded x => new PaymentResultDto
        {
            Type = "succeeded",
            ReceiptNo = x.ReceiptNo
        },

        PaymentResult.Rejected x => new PaymentResultDto
        {
            Type = "rejected",
            Reason = x.Reason
        },

        PaymentResult.NetworkFailure x => new PaymentResultDto
        {
            Type = "network_failure",
            Message = x.Message
        },

        _ => throw new InvalidOperationException("알 수 없는 결제 결과입니다.")
    };
}

물론, System.Text.Json의 폴리모픽 직렬화나 커스텀 컨버터를 사용하는 방법도 있습니다.

다만, 장기간 유지보수하는 API에서는 JSON의 형태를 도메인 타입의 내부 구조에 밀접하게 결합시키지 않는 편이 안전한 경우가 많습니다.

추천하는 것은 이 분리입니다.

도메인 내부
  PaymentResult.Succeeded
  PaymentResult.Rejected
  PaymentResult.NetworkFailure

API 경계
  PaymentResultDto
  type: "succeeded" | "rejected" | "network_failure"

도메인 타입은 업무의 표현에 집중시키고, 외부 표현은 DTO로 안정시킨다.

이 분리를 해두면, 도메인 내부를 개선해도 API 호환성을 유지하기 쉬워집니다.

15. 장점 1: 잘못된 상태를 만들기 어려워진다

ADT의 가장 큰 장점은 잘못된 상태를 만들기 어려워지는 것입니다.

예를 들어, 이런 타입은 잘못된 조합을 쉽게 만들 수 있습니다.

public sealed class Reservation
{
    public bool IsCancelled { get; set; }
    public DateTimeOffset? CancelledAt { get; set; }
    public string CancelReason { get; set; }
    public DateTimeOffset? ConfirmedAt { get; set; }
}

이 타입에서는 이런 상태를 만들 수 있습니다.

  • 취소되지 않았는데 CancelledAt이 있음
  • 취소되었는데 CancelReason이 없음
  • 취소된 상태인데 ConfirmedAt이 있음
  • 확정 전인데 확정 일시가 있음

ADT적으로 표현하면, 상태마다 필요한 데이터를 나눌 수 있습니다.

public abstract class ReservationState
{
    private ReservationState()
    {
    }

    public sealed class Requested : ReservationState
    {
        internal Requested(DateTimeOffset requestedAt)
        {
            RequestedAt = requestedAt;
        }

        public DateTimeOffset RequestedAt { get; }
    }

    public sealed class Confirmed : ReservationState
    {
        internal Confirmed(DateTimeOffset confirmedAt)
        {
            ConfirmedAt = confirmedAt;
        }

        public DateTimeOffset ConfirmedAt { get; }
    }

    public sealed class Cancelled : ReservationState
    {
        internal Cancelled(DateTimeOffset cancelledAt, string reason)
        {
            CancelledAt = cancelledAt;
            Reason = reason;
        }

        public DateTimeOffset CancelledAt { get; }
        public string Reason { get; }
    }
}

이렇게 하면, 취소된 상태만이 취소 일시와 이유를 가집니다.

잘못된 조합을 나중에 점검하는 것이 아니라, 설계 시점에서 줄일 수 있습니다.

이것은 테스트 관점에서도 큽니다.

bool과 nullable한 속성이 늘어나면, 조합의 수가 폭발합니다. ADT로 하면, 테스트해야 할 케이스가 「정의된 케이스」로 정리됩니다.

16. 장점 2: 호출하는 쪽에 처리 누락을 의식시킬 수 있다

ADT는 「이 값에는 어떤 케이스가 있는지」를 호출하는 쪽에 보여줍니다.

예를 들어, 다음 반환값을 보면 호출하는 쪽은 Found, NotFound, Forbidden을 처리해야 한다는 것을 알 수 있습니다.

public abstract class GetDocumentResult
{
    private GetDocumentResult()
    {
    }

    public sealed class Found : GetDocumentResult
    {
        internal Found(Document document)
        {
            Document = document;
        }

        public Document Document { get; }
    }

    public sealed class NotFound : GetDocumentResult
    {
        internal NotFound(DocumentId id)
        {
            Id = id;
        }

        public DocumentId Id { get; }
    }

    public sealed class Forbidden : GetDocumentResult
    {
        internal Forbidden(UserId userId)
        {
            UserId = userId;
        }

        public UserId UserId { get; }
    }

    public static GetDocumentResult DocumentFound(Document document)
        => new Found(document);

    public static GetDocumentResult DocumentNotFound(DocumentId id)
        => new NotFound(id);

    public static GetDocumentResult AccessForbidden(UserId userId)
        => new Forbidden(userId);

    public T Match<T>(
        Func<Found, T> found,
        Func<NotFound, T> notFound,
        Func<Forbidden, T> forbidden)
    {
        if (found == null) throw new ArgumentNullException(nameof(found));
        if (notFound == null) throw new ArgumentNullException(nameof(notFound));
        if (forbidden == null) throw new ArgumentNullException(nameof(forbidden));

        var f = this as Found;
        if (f != null) return found(f);

        var n = this as NotFound;
        if (n != null) return notFound(n);

        var d = this as Forbidden;
        if (d != null) return forbidden(d);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

null을 반환하는 것만으로는 「존재하지 않는」 것인지 「권한이 없는」 것인지 「조회 처리에 실패한」 것인지 알 수 없습니다.

예외만으로는 어느 예외가 업무상 예상되는지 알기 어려워집니다.

GetDocumentResult로 표현하면, 메서드의 시그니처가 명세가 됩니다.

GetDocumentResult GetDocument(UserId userId, DocumentId documentId);

이 메서드는 문서를 반환하는 것만이 아닙니다.

「찾았다」, 「찾지 못했다」, 「권한이 없다」 중 하나를 반환한다는 API 계약을 가집니다.

또한 Match를 사용하면, 처리 누락을 알아채기 쉬워집니다.

return result.Match(
    found => Ok(found.Document),
    notFound => NotFound(),
    forbidden => Forbid());

새로운 케이스를 추가했을 때, Match의 인자가 늘어나면, 호출하는 쪽의 갱신 누락을 컴파일 시점에 발견하기 쉬워집니다.

이것은 장기 유지보수에서 매우 효과적입니다.

17. 장점 3: 도메인 용어가 코드에 남는다

bool, int, string, null만으로 상태를 표현하면, 업무상의 의미가 코드에서 사라집니다.

return false;

false는 무엇을 의미하는 것일까요.

  • 찾지 못했다
  • 입력이 잘못되었다
  • 권한이 없었다
  • 외부 서비스가 다운되어 있었다
  • 이미 처리되어 있었다

호출하는 쪽이 맥락을 모르면 알 수 없습니다.

ADT를 사용하면, 업무상의 말이 타입으로 남습니다.

return GetDocumentResult.DocumentNotFound(documentId);
return GetDocumentResult.AccessForbidden(userId);
return SubmitOrderResult.AlreadySubmitted(orderId);
return SubmitOrderResult.CreditLimitExceeded(limit);

이 차이는 큽니다.

코드 리뷰에서도, 로그에서도, 테스트에서도, 도메인 용어가 보이게 됩니다.

예를 들어, 테스트 이름도 자연스러워집니다.

[Fact]
public void 제출된_주문을_재제출하면_AlreadySubmitted_반환한다()
{
    var result = service.Submit(orderId);

    Assert.IsType<SubmitOrderResult.AlreadySubmitted>(result);
}

이것은 단순한 구현 기법이 아니라, 업무 명세를 코드에 남기는 방법입니다.

18. 장점 4: 예외의 과다 사용을 줄일 수 있다

.NET의 예외는 강력합니다.

하지만, 업무상 자주 일어나는 분기까지 예외로 만들면, 처리의 가시성이 나빠지는 경우가 있습니다.

예를 들어, 재고 할당을 생각해 봅니다.

재고 부족은 시스템으로서는 이상이 아닙니다. 업무상, 보통 일어나는 결과입니다.

public abstract class ReserveStockResult
{
    private ReserveStockResult()
    {
    }

    public sealed class Reserved : ReserveStockResult
    {
        internal Reserved(ReservationId reservationId)
        {
            ReservationId = reservationId;
        }

        public ReservationId ReservationId { get; }
    }

    public sealed class OutOfStock : ReserveStockResult
    {
        internal OutOfStock(Sku sku, int requested, int available)
        {
            Sku = sku;
            Requested = requested;
            Available = available;
        }

        public Sku Sku { get; }
        public int Requested { get; }
        public int Available { get; }
    }

    public T Match<T>(
        Func<Reserved, T> reserved,
        Func<OutOfStock, T> outOfStock)
    {
        if (reserved == null) throw new ArgumentNullException(nameof(reserved));
        if (outOfStock == null) throw new ArgumentNullException(nameof(outOfStock));

        var r = this as Reserved;
        if (r != null) return reserved(r);

        var o = this as OutOfStock;
        if (o != null) return outOfStock(o);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

이렇게 표현하면, 재고 부족은 OutOfStock이라는 통상적인 결과가 됩니다.

var result = stock.Reserve(sku, quantity);

return result.Match(
    reserved => Ok(reserved.ReservationId),
    outOfStock => Conflict(new
    {
        sku = outOfStock.Sku.Value,
        requested = outOfStock.Requested,
        available = outOfStock.Available
    }));

한편, DB 연결이 끊겼다, 설정 파일이 손상되어 있다, 예기치 않은 불일치가 발생했다는 것들은 예외로 처리해도 됩니다.

판단 기준으로는, 이 정도의 선긋기가 실무적입니다.

호출하는 쪽이 통상적인 분기로 처리해야 할 것
  => Result / ADT로 반환

통상적인 처리로는 복구할 수 없는 것
  => 예외로 처리

이 분담으로 하면, try-catch가 업무 분기의 대체품이 되는 것을 피할 수 있습니다.

19. 장점 5: 테스트를 작성하기 쉬워진다

ADT를 사용하면, 테스트 대상이 되는 케이스가 명확해집니다.

예를 들어, 다음 결과 타입이 있다고 합니다.

SubmitOrderResult =
  Submitted(orderId)
  또는 AlreadySubmitted(orderId)
  또는 InvalidOrder(reason)
  또는 CreditLimitExceeded(limit)

이 경우, 테스트는 자연스럽게 케이스별로 나뉩니다.

정상적인 주문이라면 Submitted를 반환한다
이미 제출되었다면 AlreadySubmitted를 반환한다
잘못된 주문이라면 InvalidOrder를 반환한다
신용 한도를 초과했다면 CreditLimitExceeded를 반환한다

상태를 nullable한 속성의 조합으로 표현하고 있으면, 「어떤 조합이 유효한지」를 테스트 쪽도 이해해야 합니다.

ADT라면, 케이스 자체가 테스트 관점이 됩니다.

또한, 테스트 데이터도 만들기 쉬워집니다.

var result = SubmitOrderResult.CreditLimitExceeded(limit);

이 한 줄로, 「신용 초과」라는 의미를 가진 데이터를 만들 수 있습니다.

StatusErrorCodeMessageLimit을 조합해서 비슷한 오브젝트를 만드는 것보다, 의도가 명확합니다.

20. .NET Framework를 위한 도입 방침

.NET Framework의 기존 시스템에 ADT적인 설계를 도입할 때는, 갑자기 크게 바꾸지 않는 것이 좋습니다.

추천하는 것은 먼저 반환값부터 시작하는 것입니다. 기존 코드에서 이런 것들을 찾습니다.

  • bool TryXxx(...)이지만 실패 이유도 필요해진 것
  • null을 반환하고 있지만, 찾지 못하는 이유가 여러 개인 것
  • enum Status와 nullable한 보조 속성이 늘어나고 있는 것
  • 예외로 업무상의 분기를 표현하고 있는 것
  • ErrorCode의 문자열 비교가 넓어지고 있는 것

이런 부분은 ADT화의 효과가 나오기 쉽습니다.

다음으로, 전용 결과 타입을 만듭니다.

public abstract class RegisterMemberResult
{
    private RegisterMemberResult()
    {
    }

    public sealed class Registered : RegisterMemberResult
    {
        internal Registered(MemberId memberId)
        {
            MemberId = memberId;
        }

        public MemberId MemberId { get; }
    }

    public sealed class DuplicateEmail : RegisterMemberResult
    {
        internal DuplicateEmail(string email)
        {
            Email = email;
        }

        public string Email { get; }
    }

    public sealed class InvalidInvitationCode : RegisterMemberResult
    {
        internal InvalidInvitationCode(string code)
        {
            Code = code;
        }

        public string Code { get; }
    }

    public T Match<T>(
        Func<Registered, T> registered,
        Func<DuplicateEmail, T> duplicateEmail,
        Func<InvalidInvitationCode, T> invalidInvitationCode)
    {
        if (registered == null) throw new ArgumentNullException(nameof(registered));
        if (duplicateEmail == null) throw new ArgumentNullException(nameof(duplicateEmail));
        if (invalidInvitationCode == null) throw new ArgumentNullException(nameof(invalidInvitationCode));

        var r = this as Registered;
        if (r != null) return registered(r);

        var d = this as DuplicateEmail;
        if (d != null) return duplicateEmail(d);

        var i = this as InvalidInvitationCode;
        if (i != null) return invalidInvitationCode(i);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

그리고, 기존 API 경계에서는 즉시 DTO나 구 형식으로 변환합니다.

var result = service.Register(command);

return result.Match(
    registered => new RegisterMemberResponse
    {
        Success = true,
        MemberId = registered.MemberId.Value
    },
    duplicate => new RegisterMemberResponse
    {
        Success = false,
        ErrorCode = "DuplicateEmail",
        ErrorMessage = duplicate.Email + " 은 이미 사용 중입니다."
    },
    invalidCode => new RegisterMemberResponse
    {
        Success = false,
        ErrorCode = "InvalidInvitationCode",
        ErrorMessage = "초대 코드가 유효하지 않습니다."
    });

외부 인터페이스를 곧바로 바꾸지 않아도, 내부 로직만 먼저 강화할 수 있습니다.

이것은 기존 시스템에서 매우 중요합니다.

외부 API나 화면의 사정
  기존 응답 형식을 유지

내부 도메인 로직
  ADT적인 타입으로 안전하게 다룬다

경계에서 변환하는 것만으로도, 내부 분기는 꽤 정리할 수 있습니다.

21. .NET Standard로 공유 라이브러리 만들기

.NET Framework와 현행 .NET 양쪽에서 사용하는 라이브러리에서는 .NET Standard를 사용하는 선택지가 있습니다.

특히, 넓은 호환성을 중시한다면 .NET Standard 2.0이 현실적인 후보가 됩니다.

예를 들어, 도메인 모델이나 결과 타입을 이런 구성의 라이브러리에 둡니다.

MyApp.Domain
  TargetFramework: netstandard2.0

MyApp.LegacyWeb
  TargetFramework: net472
  MyApp.Domain 을 참조

MyApp.Api
  TargetFramework: net8.0
  MyApp.Domain 을 참조

이 구성으로 하면, 오래된 .NET Framework 애플리케이션과 새로운 .NET 애플리케이션 사이에서, 같은 도메인 타입을 공유하기 쉬워집니다.

다만, .NET Standard 2.0을 타깃으로 할 경우에는, 새로운 C# / .NET API에 지나치게 의존하지 않도록 합니다.

예를 들어, 이런 설계는 공유 라이브러리에서는 피하는 것이 무난한 경우가 있습니다.

  • recordinit에 강하게 의존한다
  • .NET 6 이후의 API를 직접 사용한다
  • Source Generator를 전제로 한 코드를 널리 공개한다
  • ASP.NET Core 고유의 타입을 도메인 계층에 넣는다

공유 라이브러리에서는 단순한 class, 값 객체, ADT적인 결과 타입을 중심으로 하면, 오래 사용하기 쉬워집니다.

public abstract class PaymentResult
{
    private PaymentResult()
    {
    }

    // .NET Framework에서도 .NET에서도 다루기 쉬운, 일반 class로 표현
}

새로운 .NET 전용 애플리케이션 계층에서는 record나 switch expression을 사용하면 됩니다.

공유 도메인 계층
  오래된 환경에서도 읽을 수 있는 일반 타입

새로운 애플리케이션 계층
  record / pattern matching / minimal API 등을 활용

이 분리를 해두면, 기존 자산과 새로운 개발의 균형을 잡기 쉬워집니다.

22. 어디까지 ADT로 해야 하는가

ADT는 편리하지만, 무엇이든 ADT로 하면 되는 것은 아닙니다.

적합한 것은 케이스의 집합이 업무상 거의 닫혀 있는 것입니다. 예를 들어 이런 것들입니다.

  • 처리 결과
  • 입력 검증 결과
  • 주문 상태
  • 결제 결과
  • 인증 결과
  • 외부 서비스 호출 결과
  • 도메인 이벤트
  • 명령의 종류
  • 화면 상태

반대로, 주의가 필요한 것도 있습니다.

  • 플러그인으로 외부에서 종류가 늘어나는 것
  • 사용자 정의로 종류가 늘어나는 것
  • DB의 마스터 데이터로 운영 중에 늘어나는 것
  • 상속 확장을 전제로 한 프레임워크 연동 타입
  • 단순한 CRUD의 DTO

케이스가 외부에서 늘어나는 설계라면, 닫힌 ADT보다 인터페이스나 일반적인 상속 계층이 적합합니다.

예를 들어, 보고서 출력 형식이 플러그인으로 늘어난다면, 이런 설계가 자연스럽습니다.

public interface IReportExporter
{
    string FormatName { get; }
    void Export(Report report, Stream output);
}

이 경우, PdfExporter | ExcelExporter | CsvExporter 같은 닫힌 합 타입으로 만들면, 외부 확장이 어려워집니다.

ADT는 「닫힌 세계」에 강한 설계입니다.

업무상, 정말로 닫혀 있는가. 앞으로, 외부에서 늘어날 가능성이 있는가.

그것을 판단하는 것이 중요합니다.

23. enum과의 구분 사용

enum이 나쁜 것은 아닙니다.

enum은 각 케이스가 추가 데이터를 가지지 않고, 단순한 라벨로 충분할 때 적합합니다. 예를 들어 이런 것들입니다.

public enum Gender
{
    Unknown,
    Male,
    Female,
    Other
}

또는, 로그 레벨 같은 것입니다.

public enum LogLevel
{
    Trace,
    Debug,
    Information,
    Warning,
    Error,
    Critical
}

한편, 케이스마다 필요한 데이터가 다르다면, ADT적인 타입을 검토합니다.

PaymentStatus enum
  Succeeded
  Rejected
  Failed

PaymentResult ADT
  Succeeded(receiptNo)
  Rejected(reason)
  Failed(message)

구분하는 기준은 단순합니다.

케이스만 구분되면 충분하다
  => enum

케이스마다 가지는 데이터가 다르다
  => ADT

케이스마다 동작이나 제약이 다르다
  => ADT 또는 class 계층

enum + nullable 속성 그룹이 늘어나기 시작했다면, ADT화의 신호입니다.

24. bool과의 구분 사용

bool도 나쁜 것은 아닙니다.

정말로 yes / no만으로 의미가 완결된다면, bool로 충분합니다.

bool IsEnabled { get; }
bool IsDeleted { get; }

하지만, 실패 이유가 여러 개라면 bool은 약해집니다.

bool TryCreateUser(CreateUserCommand command);

이 메서드는 실패했을 때 이유를 알 수 없습니다.

out 인자로 보완할 수도 있습니다.

bool TryCreateUser(CreateUserCommand command, out User user, out string errorCode);

하지만, 점점 복잡해집니다.

이 경우에는 결과 타입으로 만드는 편이 읽기 쉽습니다.

CreateUserResult CreateUser(CreateUserCommand command);

호출하는 쪽도 성공·실패뿐만 아니라, 실패의 종류를 타입으로 다룰 수 있습니다.

return result.Match(
    created => Ok(created.User),
    duplicate => Conflict(),
    weak => BadRequest(),
    failure => StatusCode(500));

판단 기준은 다음과 같습니다.

정말로 2택이고, 추가 정보도 필요 없다
  => bool

2택이지만 성공값이나 실패 이유가 필요하다
  => Result

3택 이상, 또는 케이스마다 데이터가 다르다
  => ADT

25. 상속과 ADT의 차이

C#에서 ADT적인 타입을 만들면, 외형은 일반적인 상속에 가까워집니다.

public abstract class PaymentResult
{
}

public sealed class Succeeded : PaymentResult
{
}

public sealed class Rejected : PaymentResult
{
}

다만, 목적은 조금 다릅니다.

일반적인 객체지향의 상속은 동작을 교체하기 위해 사용되는 경우가 많습니다.

public abstract class Shape
{
    public abstract double Area();
}

public sealed class Circle : Shape
{
    public override double Area() => ...;
}

한편, ADT적인 상속은 「취할 수 있는 데이터의 형태」를 표현하기 위해 사용합니다.

public abstract class PaymentResult
{
    public sealed class Succeeded : PaymentResult
    {
        public string ReceiptNo { get; }
    }

    public sealed class Rejected : PaymentResult
    {
        public string Reason { get; }
    }
}

어느 쪽이 옳다는 이야기는 아닙니다.

처리를 각 케이스 쪽에 두고 싶다면, 일반적인 폴리모피즘이 적합합니다.

public abstract class Notification
{
    public abstract void Send();
}

호출하는 쪽에서 모든 케이스를 보면서 분기하고 싶다면, ADT + pattern matching / Match가 적합합니다.

return notification.Match(
    email => SendEmail(email),
    sms => SendSms(sms),
    push => SendPush(push));

업무 애플리케이션에서는, 반환값이나 상태는 ADT, 동작의 교체는 인터페이스라는 구분 사용이 이해하기 쉽습니다.

26. 패턴 매칭을 너무 흩뜨리지 않는다

ADT를 사용하기 시작하면, 여기저기서 switchMatch를 작성하고 싶어집니다.

하지만, 같은 분기가 여러 곳에 흩어지면, 케이스 추가 시의 수정 지점이 늘어납니다.

예를 들어, PaymentResult를 여러 장소에서 switch하고 있다고 합니다.

API 응답 변환
로그 출력
화면 메시지 생성
메트릭 기록
감사 로그 생성

케이스를 추가하면, 모든 switch를 고쳐야 합니다.

이것은 피할 수 없는 경우도 있지만, 가능한 한 분기의 책임을 모으면 유지보수하기 쉬워집니다.

public static class PaymentResultMapper
{
    public static PaymentResultDto ToDto(PaymentResult result)
    {
        return result.Match(
            succeeded => ...,
            rejected => ...,
            failure => ...);
    }

    public static string ToLogMessage(PaymentResult result)
    {
        return result.Match(
            succeeded => ...,
            rejected => ...,
            failure => ...);
    }
}

또한, 분기하지 않고 케이스 자신에게 처리를 갖게 하는 편이 좋은 경우도 있습니다.

public abstract class PaymentResult
{
    public abstract bool IsSuccess { get; }
}

다만, 케이스 쪽에 처리를 너무 많이 갖게 하면, 도메인 타입이 API나 UI의 사정을 알기 시작합니다.

이런 처리는 도메인 타입에 직접 넣지 않는 편이 좋은 경우가 많습니다.

  • HTTP 상태 코드로의 변환
  • JSON DTO로의 변환
  • 화면 표시용 메시지
  • 로그의 서식
  • OpenAPI용 표현

도메인 타입은 업무의 의미를 표현한다. 경계 변환은 Mapper에 둔다.

이 분리를 의식하면, ADT가 장기적으로 유지보수하기 쉬워집니다.

27. 이름 붙이는 방법

ADT적인 타입은 이름이 중요합니다.

Result, Error, Response 같은 일반적인 이름만으로는 의미가 희미해집니다.

자주 쓰이는 명명은 이런 것들입니다.

CreateUserResult
RegisterMemberResult
SubmitOrderResult
ReserveStockResult
PaymentResult
LoginResult
GetDocumentResult
OrderState
ReservationState

케이스 이름은 업무 용어에 맞춥니다.

Created
DuplicateEmail
WeakPassword
SystemFailure
AlreadySubmitted
CreditLimitExceeded
OutOfStock
MfaRequired
AccountLocked

Error1, Error2, Failed만으로는, 호출하는 쪽이 의미를 이해하기 어려워집니다.

또한, 케이스에 가지게 하는 데이터도 가능한 한 업무상의 타입으로 합니다.

public sealed class CreditLimitExceeded : SubmitOrderResult
{
    public Money Limit { get; }
    public Money RequestedAmount { get; }
}

decimal이나 string 그대로도 동작하지만, Money, Email, UserId, OrderId 같은 값 객체와 조합하면 더욱 의도가 명확해집니다.

ADT와 값 객체는 상성이 좋습니다.

값 객체
  하나의 값의 의미와 제약을 표현한다

ADT
  여러 개의 취할 수 있는 형태를 표현한다

이 2가지를 조합하면, 업무 규칙을 타입에 담기 쉬워집니다.

28. 버저닝에 주의하기

ADT는 케이스 집합을 명시하기 때문에, 케이스의 추가는 호출하는 쪽에 영향을 줍니다.

이것은 장점이기도 하고, 주의점이기도 합니다.

내부 코드라면, 케이스 추가 시 컴파일 에러가 나오는 것은 반가운 일입니다. 처리 누락을 발견할 수 있기 때문입니다.

한편, NuGet 패키지나 공개 API로 외부에 제공하고 있는 타입에서는, 케이스 추가가 파괴적 변경에 가까운 의미를 가지는 경우가 있습니다.

예를 들어, 라이브러리 이용자가 다음과 같이 모든 케이스 처리를 작성해 두었다고 합니다.

var text = result.Match(
    success => ...,
    validationError => ...,
    permissionDenied => ...);

라이브러리 쪽이 RateLimited 케이스를 추가하고, Match의 시그니처도 변경한 경우, 이용자의 코드는 컴파일 에러가 됩니다.

이것은 안전하기는 하지만, 공개 API의 호환성이라는 의미에서는 영향이 있습니다.

그래서 공개 라이브러리에서는 다음과 같이 생각합니다.

  • 케이스 추가를 허용한다면, 버전을 올려 파괴적 변경으로 취급한다
  • 외부 이용자에게 default적인 처리를 허용하고 싶다면, 닫힌 ADT가 아니라 다른 설계로 한다
  • 내부 도메인에서는 엄격하게, 외부 API에서는 DTO와 버전이 붙은 계약으로 한다

업무 애플리케이션 내부에서는, 케이스 추가로 컴파일 에러가 나오는 편이 고마운 일입니다.

공개 API에서는 호환성 설계도 함께 고려해야 합니다.

29. 성능에 대하여

ADT적인 설계는 표현력을 위해 오브젝트를 늘리는 경우가 있습니다.

.NET Framework에서 class 계층을 사용할 경우, 케이스마다 오브젝트가 생성됩니다.

return PaymentResult.Success(receiptNo);

이것은 일반적인 업무 애플리케이션에서는 큰 문제가 되지 않는 경우가 많습니다.

다만, 이런 곳에서는 주의합니다.

  • 고빈도로 호출되는 저수준 처리
  • 대량의 이벤트를 처리하는 스트림 처리
  • 게임이나 실시간 처리
  • 할당을 극단적으로 줄이고 싶은 처리
  • 거대한 컬렉션에 ADT를 대량으로 저장하는 처리

성능이 중요한 경우의 선택지는 여러 가지가 있습니다.

  • struct 기반의 Result 타입을 사용한다
  • F#의 struct discriminated union을 검토한다
  • Source Generator로 할당을 줄인다
  • 핫패스에서는 enum + 전용 필드를 사용하고, 경계에서 ADT로 변환한다
  • 계측한 후에 최적화한다

처음부터 과도하게 최적화할 필요는 없습니다.

많은 업무 시스템에서는, ADT에 의한 설계의 명확함이 약간의 오브젝트 생성 비용보다 큰 가치를 가집니다.

다만, 성능 요건이 엄격한 곳에서는 설계와 계측을 세트로 생각해야 합니다.

30. 기존 코드로의 리팩터링 예시

마지막으로, 흔히 있는 기존 코드를 ADT적으로 고치는 흐름을 봅니다.

원래 코드는 이렇습니다.

public bool TryReserveStock(string sku, int quantity, out string errorCode)
{
    errorCode = null;

    var stock = stockRepository.Find(sku);
    if (stock == null)
    {
        errorCode = "SKU_NOT_FOUND";
        return false;
    }

    if (stock.Available < quantity)
    {
        errorCode = "OUT_OF_STOCK";
        return false;
    }

    stock.Reserve(quantity);
    return true;
}

이 코드에서는 실패 이유가 string으로 표현되어 있습니다. 호출하는 쪽은 문자열을 비교해야 합니다.

string errorCode;
if (!service.TryReserveStock(sku, quantity, out errorCode))
{
    if (errorCode == "SKU_NOT_FOUND")
    {
        ...
    }
    else if (errorCode == "OUT_OF_STOCK")
    {
        ...
    }
}

이것을 결과 타입으로 만듭니다.

public abstract class ReserveStockResult
{
    private ReserveStockResult()
    {
    }

    public sealed class Reserved : ReserveStockResult
    {
        internal Reserved(ReservationId reservationId)
        {
            ReservationId = reservationId;
        }

        public ReservationId ReservationId { get; }
    }

    public sealed class SkuNotFound : ReserveStockResult
    {
        internal SkuNotFound(Sku sku)
        {
            Sku = sku;
        }

        public Sku Sku { get; }
    }

    public sealed class OutOfStock : ReserveStockResult
    {
        internal OutOfStock(Sku sku, int requested, int available)
        {
            Sku = sku;
            Requested = requested;
            Available = available;
        }

        public Sku Sku { get; }
        public int Requested { get; }
        public int Available { get; }
    }

    public static ReserveStockResult Success(ReservationId reservationId)
        => new Reserved(reservationId);

    public static ReserveStockResult NotFound(Sku sku)
        => new SkuNotFound(sku);

    public static ReserveStockResult NotEnough(Sku sku, int requested, int available)
        => new OutOfStock(sku, requested, available);

    public T Match<T>(
        Func<Reserved, T> reserved,
        Func<SkuNotFound, T> skuNotFound,
        Func<OutOfStock, T> outOfStock)
    {
        var r = this as Reserved;
        if (r != null) return reserved(r);

        var n = this as SkuNotFound;
        if (n != null) return skuNotFound(n);

        var o = this as OutOfStock;
        if (o != null) return outOfStock(o);

        throw new InvalidOperationException("알 수 없는 재고 할당 결과입니다.") ;
    }
}

서비스 메서드는 이렇게 됩니다.

public ReserveStockResult ReserveStock(Sku sku, int quantity)
{
    var stock = stockRepository.Find(sku);
    if (stock == null)
    {
        return ReserveStockResult.NotFound(sku);
    }

    if (stock.Available < quantity)
    {
        return ReserveStockResult.NotEnough(sku, quantity, stock.Available);
    }

    var reservationId = stock.Reserve(quantity);
    return ReserveStockResult.Success(reservationId);
}

호출하는 쪽은 문자열 비교를 그만둘 수 있습니다.

var result = service.ReserveStock(sku, quantity);

return result.Match(
    reserved => Ok(new { reserved.ReservationId }),
    notFound => NotFound(new { sku = notFound.Sku.Value }),
    outOfStock => Conflict(new
    {
        sku = outOfStock.Sku.Value,
        requested = outOfStock.Requested,
        available = outOfStock.Available
    }));

이 리팩터링의 포인트는, 외부의 동작을 바꾸지 않아도 내부의 의미를 타입으로 옮길 수 있다는 것입니다.

먼저 반환값을 강화한다. 다음으로 호출하는 쪽을 Match로 모은다. 마지막으로 문자열 에러 코드나 nullable한 보조 속성을 줄여간다.

이 순서라면, 기존 시스템에서도 단계적으로 도입할 수 있습니다.

31. 도입 시 체크리스트

ADT적인 타입을 만들 때는, 이런 부분을 확인해 둡니다.

그 타입은 「그 중 하나」를 표현하고 있는가
케이스의 집합은 업무상 닫혀 있는가
케이스마다 필요한 데이터가 다른가
bool / enum / null / string error code로는 의미가 무너지지 않는가
호출하는 쪽에 모든 케이스 처리를 의식시키고 싶은가
공개 API의 호환성에 영향을 주지 않는가
JSON / DB / 화면 DTO와의 변환 방침은 있는가
.NET Framework에서도 사용한다면, 일반 class로 충분한가
현행 .NET 전용이라면 record나 Source Generator를 사용할 가치가 있는가

구현 방침은 이렇게 고를 수 있습니다.

F# 프로젝트
  F#의 구별 합집합을 사용한다

.NET Framework의 C#
  abstract class + private constructor + nested sealed classes + Match

.NET 5 이후의 C#
  abstract record + sealed record cases + pattern matching

지역적인 반환값
  OneOf 같은 라이브러리

현행 .NET에서 정형 코드를 줄이고 싶다
  Source Generator 계열 라이브러리

향후 검증
  C# 15 union preview

어느 방법을 선택하든, 목표로 하는 것은 같습니다.

주석으로 지키는 규칙을, 타입으로 지킨다.

이것이 ADT를 사용하는 가장 큰 의미입니다.

32. 마무리

대수적 데이터 타입은 함수형 언어만의 것이 아닙니다.

.NET Framework의 C#에서도, abstract 클래스와 sealed 클래스를 사용하면 충분히 실용할 수 있습니다. 현행 .NET의 C#이라면, record와 pattern matching으로 더 간결하게 작성할 수 있습니다. F#이라면, 구별 합집합이라는 언어 기능 자체를 사용할 수 있습니다. 라이브러리를 사용하면, C#에서도 손쉽게 OneOfResult를 다룰 수 있습니다.

중요한 것은 문법이 아니라 설계의 사고방식입니다.

bool, null, enum + nullable 속성, string ErrorCode로 표현하고 있던 것을 다시 살펴보고, 이렇게 되물어 봅니다.

이 값은 어떤 케이스 중 하나인가
각 케이스에 필요한 데이터는 무엇인가
그 케이스 이외에는 존재해서는 안 되는 데이터는 무엇인가
호출하는 쪽에 반드시 처리하게 하고 싶은 것은 무엇인가

이 질문에 답하는 형태로 타입을 만들면, 잘못된 상태가 줄어들고, 분기의 가시성이 좋아지고, 업무 용어가 코드에 남습니다.

기존 시스템에서는 먼저 반환값부터 시작하는 것을 추천합니다.

TryXxx, null, ErrorCode, 예외에 의한 업무 분기가 늘어나고 있는 부분을, 전용 결과 타입으로 바꿔 봅니다.

그것만으로도, 코드의 가독성과 안전성은 크게 달라집니다.

참고

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

대수적 데이터 타입(ADT)이란 무엇인가요?
대수적 데이터 타입은 곱 타입(A와 B를 모두 가지는 타입)과 합 타입(A 또는 B 중 하나인 타입)의 조합입니다. 실무에서의 느낌으로 말하면 「이 값이 취할 수 있는 형태가 미리 정해져 있다」는 것을 주석이나 명명 규칙이 아니라 타입으로 표현하는 사고방식입니다. 특히 자주 쓰는 것이 합 타입으로, 예를 들어 결제 결과를 「Succeeded(receiptNo) 또는 Rejected(reason) 또는 NetworkFailure(message) 중 하나」로 타입으로 표현하여, 잘못된 상태 자체를 만들 수 없게 합니다.
C#에서 구별 합집합(합 타입)을 구현하려면 어떻게 해야 하나요?
.NET Framework를 포함한 기존 시스템에서 가장 도입하기 쉬운 방법은 private 생성자를 가진 abstract 기반 클래스 + 중첩된 sealed 클래스 + Match 메서드 패턴입니다. 중첩된 타입만이 기반 클래스를 상속할 수 있으므로 닫힌 케이스 집합을 만들 수 있습니다. .NET 5 이후라면 abstract record와 sealed record 계층으로 더 간결하게 작성할 수 있습니다. 지역적인 반환값이라면 OneOf 같은 라이브러리도 선택지가 되며, F#이라면 구별 합집합을 언어 기능으로 자연스럽게 사용할 수 있습니다.
enum이나 bool이 아니라 대수적 데이터 타입을 써야 하는 것은 언제인가요?
케이스만 구분되면 충분하다면 enum, 정말로 둘 중 하나이고 추가 정보도 필요 없다면 bool로 충분합니다. 반면 케이스마다 가지는 데이터가 다르다면(성공 시에는 receiptNo, 잔액 부족 시에는 shortage 등) ADT가 적합합니다. enum과 nullable 속성 그룹의 조합이 늘어나기 시작하거나, 실패 이유를 문자열 에러 코드로 비교하기 시작했다면 ADT화의 신호입니다. 다만 플러그인 등으로 외부에서 케이스가 늘어나는 설계라면, 닫힌 ADT보다 인터페이스가 적합합니다.
업무상의 실패는 예외와 Result 타입 중 어느 쪽으로 표현해야 하나요?
호출하는 쪽이 통상적인 분기로 처리해야 할, 예상 범위 내의 실패(재고 부족, 이메일 중복, 비밀번호 오류 등)는 Result / ADT로 반환하고, 통상적인 처리로는 복구할 수 없는 예상 밖의 이상(DB 연결 끊김, 설정 파일 손상 등)은 예외로 처리한다는 역할 분담이 실무적입니다. 업무상 자주 발생하는 분기까지 예외로 만들면 try-catch가 업무 분기의 대체품이 되어 처리의 가시성이 나빠집니다. 이 분담만으로도 애플리케이션 서비스 계층이나 API 계층의 가시성이 상당히 좋아집니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기