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

· 업데이트: · · .NET, .NETFramework, CSharp, FSharp, AlgebraicDataTypes, DiscriminatedUnion, DomainModeling, 기존 자산 활용

수정 이력(4건, 최종 수정 2026년 09월 03일)

이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.

permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
기사 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
외부 리뷰(1283건)에 대응해 본문을 업데이트했습니다. 개별 변경 내용은 아래 이력을 참고하세요.
목적별로 어느 장을 읽으면 되는지 안내와, 구현 수단 6가지 비교표(대상 환경·작성량·라이브러리 의존·케이스 누락 검출)를 추가했습니다. 더불어 제안·프리뷰 단계 기능을 다루는 장에, 사양이 바뀔 수 있다는 점과 업데이트 방침에 대한 단서를 달았습니다.
최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635308)

이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.

Go Komura (2026). 「.NET Framework / .NET에서 대수적 데이터 타입 사용하기 ── 상태와 결과를 타입으로 표현하는 설계」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635308 https://comcomponent.com/ko/blog/dotnet-algebraic-data-types/

DOI(최신 버전)
10.5281/zenodo.21635308
DOI(이 버전)
10.5281/zenodo.21635309

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#이라면 discriminated union, C#이라면 sealed 클래스 계층, record 계층, OneOf 같은 라이브러리, 또는 앞으로의 C# union 타입으로 이 생각을 구현할 수 있습니다.

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

참고로 이 글에 나오는 코드는 빌드·실행할 수 있는 샘플 전체(라이브러리, 각 구현 패턴을 보여주는 데모, Match의 망라·상태 전이·DTO 변환을 검증하는 단위 테스트)으로 GitHub에 공개해 두었습니다.

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

이 글 읽는 법

긴 글이므로 처음부터 순서대로 읽을 필요는 없습니다. 목적에 맞는 장으로 건너뛰세요.

목적 읽을 장
ADT가 무엇인지, 왜 필요한지 먼저 알고 싶다 1〜3
C# / F#에서 어떻게 구현할지 비교해 고르고 싶다 4〜10
Option, Result, 상태 전이, API 경계를 실무에서 쓰고 싶다 11〜14
도입 이점을 팀이나 상사에게 설명하고 싶다 15〜19
.NET Framework를 포함한 기존 시스템에 넣고 싶다 20〜21
enum, bool, 상속과의 쓰임새를 고민 중이다 22〜25
설계상 함정을 미리 알고 싶다 26〜29
기존 코드를 실제로 고치는 절차가 필요하다 30〜31

전제로 하는 지식

6장 이후 코드 예에서는 C#의 패턴 매칭과 switch 식을 사용합니다. 용어만 먼저 정리합니다.

용어 의미 작성 예
switch 분기 자체가 값을 반환하는 작성법. 문의 switch와 달리 각 분기의 오른쪽이 결과 값이 됩니다 result switch { ... }
타입 패턴 값이 그 타입인지로 분기하고 변수에 바인딩합니다 Created x => ...
프로퍼티 패턴 타입에 더해 프로퍼티 값도 봅니다. var로 내용을 꺼낼 수 있습니다 Created { User: var user } => ...
무시 패턴 어느 패턴에도 해당하지 않을 때의 분기 _ => throw ...
when 패턴에 추가 조건을 붙입니다 OutOfStock x when x.Available == 0 => ...

1차 정보는 Microsoft Learn의 Pattern matching overviewswitch expression입니다.

참고로 4장과 5장은 오래된 C#에서도 쓸 수 있는 작성법을 보이기 위해, switch 식이 아니라 asif로 적었습니다. 패턴 매칭을 몰라도 읽을 수 있습니다.

이 글의 지식 맵

이 글은 값이 취할 수 있는 형태를 주석이 아니라 타입으로 나타내는 대수적 데이터 타입을, .NET Framework를 포함한 기존 시스템과 현재 .NET 양쪽에서 어떻게 구현하는지를 정리합니다. 합 타입은 private 생성자가 있는 닫힌 클래스 계층, C# 9 이후의 record 계층, F# 판별 공용체, OneOf 같은 라이브러리, Source Generator, 미리 보기 단계의 C# 15 union 타입 중 어느 것으로도 표현할 수 있으며, Match 메서드와 완전성 검사를 함께 쓰면 새 케이스를 추가할 때 처리 누락을 컴파일 오류로 검출할 수 있습니다. Option 타입은 null 대신 값의 유무를, Result 타입은 예상 범위의 업무상 실패를 타입으로 돌려주고, 보통은 복구할 수 없는 이상은 예외로 역할을 나눔으로써 기존 코드의 안전성과 파악하기 쉬움을 단계적으로 개선할 수 있습니다.

.NET에서 대수적 데이터 타입을 쓰기 위한 지식 맵곱 타입과 합 타입으로 이루어진 대수적 데이터 타입이 닫힌 클래스 계층·record 계층·F# 판별 공용체·OneOf·Source Generator·C# 15의 union 타입 미리 보기라는 여러 구현 패턴으로 어떻게 표현되고, Match 메서드와 완전성 검사가 케이스 처리 누락을 어떻게 막으며, Option 타입과 Result 타입이 업무상 실패와 예외의 역할 분담으로 어떻게 이어지는지를 보여주는 그림이용한다이용한다구현을 담당한다구현을 담당한다구현을 담당한다구현을 담당한다구현을 담당한다구현을 담당한다방지한다완화한다원인이 될 수 있다이용한다이용한다구현을 담당한다구현을 담당한다구현을 담당한다권장되는 대응권장되는 대응사용은 비권장전제로 한다전제로 한다이용한다양립하지 않는다이용한다대수적 데이터 형식직합형곱타입(Product Type)F# 판별 공용체닫힌 클래스 계층 패턴record 계층OneOf 라이브러리Source Generator 계열 라이브러리에 의한 직합형 생성C# 15 union 형식(미리 보기)Match 메서드 패턴케이스 처리 누락Option 형식Result 형식예상된 업무상 실패.NET 예외예상 밖의 이상닫힌 케이스 집합.NET Framework.NET(Core 이후)

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 24건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle

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

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

ADT는 거칠게 말하면 두 종류의 타입 조합입니다.

  • 곱 타입: 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# 같은 discriminated union이 표준 기능으로 없었습니다. 그래서 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장〜10장)

여기서부터 10장까지는 이 합 타입을 .NET에서 어떻게 구현할지에 대한 각론입니다. 먼저 전체를 늘어놓습니다.

구현 수단 대상 환경 작성량 라이브러리 의존 케이스 누락 검출
class 계층(private 생성자 + 중첩된 sealed 클래스 + Match) 4·5 .NET Framework에서도 현재 .NET에서도 쓸 수 있다 많다. 케이스마다 클래스, 팩터리, Match를 손으로 쓴다 없음 Match의 인수가 늘어나므로, 케이스 추가 시 호출 쪽이 컴파일 오류가 된다
record 계층 6 record는 C# 9 이후 기능. 이 글에서는 .NET 5 이후를 가정 적다. 케이스 하나가 한 줄로 쓰인다 없음 switch 식만으로는 약하다. Match를 직접 마련하면 강제할 수 있다
F# discriminated union 7 F# 프로젝트. .NET Framework용이든 현재 .NET용이든 쓸 수 있다 최소. 타입 정의가 곧 케이스 목록이 된다 없음(언어 기능) 컴파일러가 match의 망라성을 검사하고, 부족하면 경고한다
OneOf 8 .NET Framework나 .NET Standard를 포함한 넓은 타깃 적다. 전용 기본 클래스를 만들지 않아도 된다 있음(NuGet 패키지) Match가 모든 케이스분의 델리게이트를 요구하므로, 타입 인수를 늘리면 호출 쪽이 컴파일 오류가 된다
Source Generator 계열 9 현재 .NET이 중심. .NET Framework 지원은 라이브러리마다 확인 필요 적다. 특성을 붙이기만 하면 된다 있음(패키지 + 빌드 환경) Analyzer와 조합해 처리 누락을 경고할 수 있는 것이 있다
C# 15 union 타입 10 프리뷰 기능. 프로덕션 코드용이 아니다 최소 없음(언어 기능) 사양이 확정되지 않아 미정

고를 때의 출발점은 이렇습니다.

  • .NET Framework를 포함한 기존 시스템에 오늘부터 넣는다면 4장·5장의 class 계층
  • 현재 .NET만 대상으로 할 수 있다면 6장의 record 계층
  • 국소적인 반환값 하나를 나타내고 싶을 뿐이라면 8장의 OneOf

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

.NET Framework를 포함한 기존 시스템에서 가장 도입하기 쉬운 것은 추상 기본 클래스 + 중첩된 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이지만, 각각이 가지는 데이터는 다릅니다.

CreatedUser를 가집니다. DuplicateEmailEmail을 가집니다. WeakPasswordReason을 가집니다. SystemFailureMessage를 가집니다.

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

게다가 사용하는 쪽이 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 케이스」와 같은 강도로 케이스 집합을 닫는 장치라고 생각하지 않는 편이 안전합니다.

특히 sealed가 아닌 record class에서는, 복사 생성자처럼 record 고유의 생성 멤버가 관여합니다. 「외부에서 절대 파생시키고 싶지 않다」「케이스 집합을 엄격히 닫고 싶다」는 용도에서는, 앞 장의 class 계층이나 F# discriminated union, 또는 실적이 있는 union / source generator 계열 라이브러리를 고르는 편이 견실합니다.

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

C#에서는 오래된 안정판 범위에서는 F# discriminated union만큼 엄격한 망라성 검사를 기대하기 어렵습니다. 그래서 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 property를 무리하게 쓰기보다, 평범한 class로 쓰는 편이 다루기 쉬운 경우가 있습니다.

「새 구문을 쓰는 것」보다 「나타내고 싶은 상태를 타입에 가두는 것」을 우선하는 편이 좋습니다.

7. F# discriminated union을 쓴다

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

F#에는 discriminated union이 언어 기능으로 마련되어 있습니다.

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>처럼 값이 있는지 없는지를 나타내는 타입도 discriminated union으로 자연스럽게 씁니다.

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

null을 반환하는 대신 option을 반환하면 「존재하지 않을 가능성」이 타입에 드러납니다.

F# discriminated union은 .NET 타입으로 컴파일되므로, .NET Framework용 F# 프로젝트에서도 현재 .NET용 F# 프로젝트에서도 사용할 수 있습니다.

다만 C#에서 F# discriminated union을 직접 다룰 때는, F# 안에서 다루는 것만큼 자연스럽지 않을 때가 있습니다.

그래서 이런 쓰임새가 현실적입니다.

  • F# 내부 도메인 로직에서는 F# discriminated union을 적극적으로 쓴다
  • 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에 그대로 내면, 도메인상의 이름이 옅어질 때가 있습니다.

예를 들어 다음 둘은 타입 인수만 보면 비슷한 구조입니다.

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로 discriminated union 풍의 타입을 생성하는 라이브러리도 있습니다.

예를 들어 특성을 붙이기만 하면 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# 기능 사양 제안(Unions - C# feature specifications)과 .NET Blog 해설 글이며, 모두 제안 단계 문서입니다.

이 글에서는 C# 15 union 타입이 정식 릴리스된 시점에, 확정된 사양에 맞춰 이 장을 다시 씁니다. 그때까지는 이 장 내용을 설계 판단의 근거로 삼지 마세요. 판단의 근거로 삼을 수 있는 것은 4장부터 9장까지의 안정된 선택지입니다.

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

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));

판단 기준은 이렇습니다.

정말 둘 중 하나이고, 추가 정보도 필요 없다
  => bool

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

셋 이상, 또는 케이스마다 데이터가 다르다
  => 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
  여러 취할 수 있는 형태를 나타낸다

이 둘을 조합하면 업무 규칙을 타입에 가두기 쉬워집니다.

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)
{
    // (1) 성패는 bool, 이유는 out string. 둘의 관계는 타입에 드러나지 않는다
    errorCode = null;

    var stock = stockRepository.Find(sku);
    if (stock == null)
    {
        errorCode = "SKU_NOT_FOUND"; // (2) 실패 이유가 문자열 리터럴
        return false;
    }

    if (stock.Available < quantity)
    {
        errorCode = "OUT_OF_STOCK"; // (3) 몇 개가 부족한지는 호출 쪽에 전해지지 않는다
        return false;
    }

    stock.Reserve(quantity);
    return true; // (4) 성공해도 어떤 확보가 이루어졌는지는 반환되지 않는다
}

이 코드에서는 실패 이유가 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)
{
    // (1) 반환값 하나로 취할 수 있는 결과의 전 케이스를 나타낸다. out 인수는 필요 없다
    var stock = stockRepository.Find(sku);
    if (stock == null)
    {
        return ReserveStockResult.NotFound(sku); // (2) 문자열이 아니라 케이스 타입
    }

    if (stock.Available < quantity)
    {
        // (3) 부족 상황(요구 수와 재고 수)도 케이스가 가진다
        return ReserveStockResult.NotEnough(sku, quantity, stock.Available);
    }

    var reservationId = stock.Reserve(quantity);
    return ReserveStockResult.Success(reservationId); // (4) 성공 케이스만 확보 ID를 가진다
}

변경점을 늘어놓으면 이렇습니다.

# before after
(1) bool 반환값 + out string errorCode 결과 타입 ReserveStockResult 하나
(2) "SKU_NOT_FOUND"라는 문자열 SkuNotFound 케이스(Sku를 가진다)
(3) "OUT_OF_STOCK"만이고 부족 수는 알 수 없다 OutOfStock 케이스(RequestedAvailable을 가진다)
(4) 성공해도 true만 반환된다 Reserved 케이스가 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# discriminated union을 쓴다

.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#에서도 추상 클래스와 sealed 클래스를 쓰면 충분히 실용할 수 있습니다. 현재 .NET의 C#이라면 record와 pattern matching으로 더 짧게 쓸 수 있습니다. F#이라면 discriminated union으로서 언어 기능 자체를 쓸 수 있습니다. 라이브러리를 쓰면 C#에서도 OneOfResult를 손쉽게 다룰 수 있습니다.

중요한 것은 구문이 아니라 설계의 생각입니다.

bool, null, enum + nullable 프로퍼티, string ErrorCode로 나타내던 것을 다시 보고, 이렇게 물어 봅니다.

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

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

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

TryXxx, null, ErrorCode, 예외에 의한 업무 분기가 늘고 있는 지점을 전용 결과 타입으로 바꿔 본다.

그것만으로도 코드의 읽기 쉬움과 안전성은 크게 바뀝니다.

참고

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

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

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

자주 묻는 질문

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

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

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기