실용적인 OOP: 현대 C#에서의 상속, 레코드 및 패턴 매칭에 대한 구성 | Sudhir Mangla


#C# oop 패턴매칭 #구성 도메인주도설계


핵심 요약

모던 C#에서의 객체지향 프로그래밍은 더 이상 깊은 상속 계층 구조가 아니라 합성, 레코드, 패턴 매칭으로 이루어진다. 오랫동안 교육받아온 상속 중심의 설계 패러다임을 벗어나 더 작고, 합성 가능하며, 테스트하기 쉬운 타입들로 점 조직화되는 실용적인 접근이 현대의 C# 개발 방식이다. 이는 도메인 모델링부터 I/O 경계, 실제 구독 및 청구 시스템까지 확장 가능성과 안전성을 동시에 확보하는 방법론을 제시한다.


1단계 상세 요약

1.1 전통적 상속 중심 설계의 문제점

상속-먼저 설계가 현대 C# 시스템의 장애물인 이유

기존 상속 기반 패턴의 핵심 문제들:

긴밀한 결합과 깨지기 쉬운 계층 구조

  • 결제 게이트웨이 예제: 기본 클래스에 로깅 추가 → 모든 파생 클래스가 강제로 같은 생명주기를 따름
  • 재시도, 캐싱, 메트릭 같은 교차 관심사가 기본 클래스에 누적되면서 계층이 통제 불가능하게 성장
  • 기본 클래스의 단 한 줄 변경이 수십 개 타입에 영향을 미치는 파급 효과

테스트 마찰과 모킹의 고통

  • 깊은 계층 구조의 보호된 가상 메서드들은 테스트 프레임워크가 취약함
  • 정교한 부분 오버라이드 체인을 재현해야 하는 테스트 복잡성
  • 합성 기반 설계에서는 인터페이스를 통한 의존성 주입으로 모킹이 간단해짐

진화의 병목

  • 상속은 설계 결정을 타입 계층 구조에 인코딩 → 이후 변경이 매우 어려움
  • 소비자가 기본 클래스에 의존하면 생성자 파라미터 추가나 가상 메서드 변경도 주요 변경사항이 됨
  • 합성은 이러한 접점을 명시적으로 유지하여 새로운 동작 = 새 컴포넌트 (파괴적 변경 아님)

1.2 합성을 기본값으로: 작은 타입, 명확한 경계, 안전한 변경

합성 중심의 현대적 접근

더 작고 명확한 책임을 가진 각 타입:

  • “상속 후 오버라이드” → "합성 후 위임"으로 패러다임 전환
  • IPaymentProcessor 인터페이스를 통해 로깅, 복원력, 메트릭을 데코레이터 패턴으로 래핑
  • 각 계층이 단일 책임만 담당하므로 조합이 자유로움

대체를 위한 명확한 접점

  • 합성은 생성자를 통해 의존성을 명시적으로 드러냄
  • 반영이나 상속 결합이 없이 순수한 계약(인터페이스)로 동작
  • DI 컨테이너에서 동적 데코레이션: services.Decorate<IPaymentProcessor, LoggingProcessor>()

안전한 변경과 병렬 작업

  • 컴포넌트가 인터페이스로 통신하므로 팀은 독립적으로 구현을 진화
  • 규칙 엔진, 영속성 계층, API 어댑터를 교체해도 핵심 로직에 영향 없음

1.3 레코드와 값 의미론: 불변성과 동등성을 통한 버그 감소

왜 값 의미론이 중요한가

뮤테이션의 숨겨진 부작용 제거:

  • 기존: order.Total += discount; → 예상치 못한 부수 효과
  • 레코드: var updatedOrder = order with { Total = order.Total - discount }; → 새 값 생성
  • 동시성과 함수형 프로그래밍 스타일에서 자연스럽게 확장

내장된 구조적 동등성

  • 레코드는 자동으로 Equals, GetHashCode, ToString 생성 (참조 ID가 아닌 프로퍼티 기반)
  • Money(100, "USD") == Money(100, "USD")true (테스트, 중복 제거, 도메인 불변 검증에 crucial)

더 적은 null, 더 많은 도메인 명확성

  • 레코드로 생성 시점에 정확성을 보장하는 값 객체 설계
  • 널러블한 문자열/십진수 대신 Email, Money, OrderId 같은 명시적 타입 정의
  • 생성 불가능한 잘못된 상태를 제거

1.4 패턴 매칭: 현대의 다형성

데이터 주도 다형성

폐쇄된 변형(Variant) 집합에서의 컴파일 타임 안전성:

PaymentMethod switch {
    CreditCard card → "카드 ~로 결제",
    PayPal email → "페이팔로 결제",
    CryptoWallet addr → "암호화폐로 결제",
    _ → 알 수 없음
}
  • CryptoWallet 추가 시 컴파일러가 모든 switch에서 경고 발생 (완전성 검사)
  • 가상 메서드 호출 방식의 동적 다형성보다 명시적이고 안전함

패턴 매칭 vs. 전략 패턴

특성 패턴 매칭 전략
변형이 컴파일 타임에 알려짐 :white_check_mark: :cross_mark:
런타임 동적 변형 발견 :cross_mark: :white_check_mark:
데이터 모양에 따른 동작 :white_check_mark: :cross_mark:
런타임 구성/설정 :cross_mark: :white_check_mark:
  • 폐쇄된 계층 구조 → 패턴 매칭 (내부 할인, 결제 상태)
  • 개방된 확장 가능 → 전략 (플러그인 프로모션, 결제 게이트웨이)

1.5 C# 12/13의 새로운 기능들

주 생성자 (Primary Constructor)

  • 클래스와 레코드 모두 지원: 간결하고 불변 설계에 불변식 인라인 검사
public class Money(decimal amount, string currency)
{
    public decimal Amount { get; } = amount >= 0 
        ? amount 
        : throw new ArgumentOutOfRangeException(nameof(amount));
}

컬렉션 표현식

  • [2, 3, 5, 7] 문법으로 초기화 간소화
  • [..existing, 11, 13] 형태의 병합으로 불변성과 통합

리스트 패턴과 슬라이스 패턴

  • 구조적 매칭: if (items is [var first, .., var last]) → 첫/마지막 요소 추출
  • 도메인 검증에 useful (빈 주문 줄, 단일 할인 요소)

Params Collections (C# 13)

  • params ReadOnlySpan<T> → 고성능 변수 API의 할당 감소
  • DSL 같은 설정 빌더에 ideal

주의사항

  • 주 생성자: 기존 DI와 섞일 때 생성자 파라미터 이름 충돌 주의
  • 리스트 패턴: 핫 패스에서 아직 JIT 최적화 미흡 (할당 가능)
  • 레코드 구조체: 값 동등성 좋지만 큰 구조체 복사 시 성능 저하

2단계 상세 요약

섹션 2: 기초 - 레코드, 값 객체, 불변식

2.1 Class vs. Struct vs. Record Class vs. Record Struct

선택 기준표

타입 의미론 뮤테이션 동등성 사용 사례
class 참조 가능 참조 엔터티 (ID 있음)
record class 참조 불가능 (기본) 구조적 값 의미론 (복사 비용 없음)
struct 가능 구조적 (수동) 작고 성능 중요한 데이터
record struct 불가능 (기본) 구조적 값 객체 (ID 없음)

결정 규칙

  • 대부분의 도메인 값 객체: record class (복사 오버헤드 없이 값 의미론)
  • 작고 자주 생성되는 타입: record struct (예: Money)
  • 연산 전반에 뮤테이션하는 엔터티만 class 사용

2.2 도메인 안정성을 위한 봉인된 레코드

봉인이 중요한 이유

미묘한 동등성 깨짐 방지:

public record Money(decimal Amount, string Currency);
public record DiscountedMoney(...) : Money(...);

→ 두 가지 다른 개념이 프로퍼티 모양으로 같아져 동등성 위반

해결: public sealed record Money(...);

생성된 의미론

  • 값 기반 Equals, GetHashCode
  • 분해자 (Deconstructor)
  • with-표현식 (money with { Amount = 200 })
  • 컴파일러 친화적 불변성

2.3 값 객체로 불변식 모델링

가드 절 vs. 검증 라이브러리

도메인 불변식은 타입 내부에 (가드 절):

public sealed record Email(string Value)
{
    init {
        if (!Value.Contains('@'))
            throw new ArgumentException("Invalid email");
    }
}

FluentValidation은 입력 경계 (API, DTO)에만:

  • 사용자 입력 유효성 검사 (UI 규칙, 지역화)
  • 도메인 코드는 유효한 데이터만 수신

강타입 ID로 원시 집착 끝내기

StronglyTypedId 사용:

[StronglyTypedId]
public partial struct OrderId;

OrderIdCustomerId에 실수로 할당할 수 없음 (컴파일 타임 안전)

2.4 보일러플레이트 줄이는 C# 기능

주 생성자로 간결한 불변식 강제

public sealed class Money(decimal amount, string currency)
{
    public decimal Amount { get; } = amount >= 0 
        ? amount 
        : throw new ArgumentOutOfRangeException(nameof(amount));
}

컬렉션 표현식과 리스트 패턴

복잡한 유효성 검사:

public sealed record OrderLines(IReadOnlyList<OrderLine> Items)
{
    public bool HasDuplicateProducts() => Items.GroupBy(x => x.ProductId)
        .Any(g => g.Count() > 1);
}

선언적이고 패턴-매칭 가능하며 읽기 쉬움

2.5 Money, Email, OrderId 빌드 예제

완전한 예제: 불변식 없음, 명확한 동등성, 직렬화/ORM 친화적

public sealed record struct Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        if (amount < 0) throw new ArgumentOutOfRangeException(nameof(amount));
        if (string.IsNullOrWhiteSpace(currency)) 
            throw new ArgumentNullException(nameof(currency));
        Amount = amount;
        Currency = currency;
    }

    public static Money operator +(Money a, Money b)
    {
        if (a.Currency != b.Currency) 
            throw new InvalidOperationException("Currency mismatch.");
        return new Money(a.Amount + b.Amount, a.Currency);
    }
}

public sealed record Email(string Value)
{
    init {
        if (string.IsNullOrWhiteSpace(Value) || !Value.Contains('@'))
            throw new ArgumentException("Invalid email.");
    }
}

[StronglyTypedId]
public partial struct OrderId;

섹션 3: 합성 실전

3.1 접점 식별: 정책, 계산기, 제공자, 어댑터

변동 지점마다 합성의 접점이 생김:

public interface IShippingRule {
    decimal Calculate(Order order);
}

흔한 패턴들:

  • 정책: 가격, 배송, 결제
  • 계산기: 세금, 할인, 부과금
  • 제공자: 외부 데이터, 설정
  • 어댑터: I/O 경계, 게이트웨이, API

3.2 전략을 일급 타입으로

객체 전략 (복잡, stateful, DI 관리):

public class WeightBasedShipping : IShippingRule
{
    public decimal Calculate(Order order) => order.TotalWeight * 0.5m;
}

함수 전략 (간단, 합성 가능):

Func<Order, decimal> shippingRule = o => o.TotalWeight * 0.5m;

→ 객체 전략: 복잡한 로직; 함수 전략: 간단한 규칙

3.3 합성 배선: DI + Scrutor 데코레이션

클래스 자동 등록:

services.Scan(scan => scan
    .FromAssemblyOf<IShippingRule>()
    .AddClasses(c => c.AssignableTo<IShippingRule>())
    .AsImplementedInterfaces()
    .WithScopedLifetime());

교차 관심사 추가 (캐싱, 로깅) - 소스 코드 건드리지 않음:

services.Decorate<IShippingRule, CachingShippingRule>();
services.Decorate<IShippingRule, LoggingShippingRule>();

3.4 합성을 통한 교차 관심사 (Polly)

상속 기반 복원력 래퍼는 취약함 → Polly + 합성 사용:

public class ResilientShippingRule : IShippingRule
{
    private readonly IShippingRule _inner;
    private readonly IAsyncPolicy _policy;

    public ResilientShippingRule(IShippingRule inner, IAsyncPolicy policy)
    {
        _inner = inner;
        _policy = policy;
    }

    public decimal Calculate(Order order)
        => _policy.Execute(() => _inner.Calculate(order));
}

재시도, 타임아웃, 서킷 브레이커를 독립적으로 쌓을 수 있음

3.5 배송 비용 계산 예제

배포 빌트:

var rule = new CachingShippingRule(
    new ResilientShippingRule(
        new WeightBasedShipping(),
        Policy.Handle<Exception>().Retry(3)),
    cache);

기본 클래스 없음, 깨지기 쉬운 상속 없음. 각 동작은 독립적인 블록으로 테스트 가능

섹션 4: 현대의 다형성 - 패턴 매칭 + 전략

4.1 데이터 주도 다형성으로서의 패턴 매칭

프로퍼티와 위치 패턴

조건을 읽기 쉽고 선언적으로:

if (order is { Total: > 100, IsPriority: true })
    Console.WriteLine("VIP 할인 적용");

위치 분해:

var (amount, currency) = new Money(250, "USD");
if (amount is > 0 and < 1000 && currency is "USD")
    Console.WriteLine("표준 가격대");

집계 규칙을 위한 리스트 패턴

선언적이고 간결함:

if (cart.Items is [var first, ..] && first.Category == "Premium")
    Console.WriteLine("조기 프리미엄 할인");

수동 반복 없이 도메인 의도가 명확함

폐쇄된 변형 집합의 안전성

public abstract record PaymentStatus;
public sealed record Pending(DateTime CreatedAt) : PaymentStatus;
public sealed record Completed(DateTime CompletedAt) : PaymentStatus;
public sealed record Failed(string Reason) : PaymentStatus;

public static string Describe(PaymentStatus status) => status switch {
    Pending(var created) => $"대기 중 {created:g}",
    Completed(var done) => $"완료됨 {done:g}",
    Failed(var reason) => $"{reason} 때문에 실패",
    _ => throw new InvalidOperationException("Unknown status")
};

새 레코드 추가 시 컴파일러가 모든 switch에서 경고 → 누락된 케이스 방지

4.2 전략이 패턴 매칭을 이기는 때 (그리고 그 반대)

기준 패턴 매칭 전략
컴파일 타임에 알려진 변형 :white_check_mark: :cross_mark:
동적으로 발견되는 변형 (DI, 플러그인) :cross_mark: :white_check_mark:
데이터 모양에 따른 동작 :white_check_mark: :cross_mark:
런타임 주입/설정 :cross_mark: :white_check_mark:

패턴 매칭: 내부, 폐쇄된 도메인
전략: 외부 확장, 플러그인 아키텍처

4.3 프로모션과 가격 책정 도메인 예제

프로모션 변형에 for 봉인된 레코드 계층

public abstract record Promotion;
public sealed record PercentageOff(decimal Percent) : Promotion;
public sealed record FixedAmount(decimal Amount) : Promotion;
public sealed record FreeShipping() : Promotion;

public static Money ApplyPromotion(Promotion promo, Money subtotal) => promo switch {
    PercentageOff(var percent) => subtotal with { Amount = subtotal.Amount * (1 - percent / 100) },
    FixedAmount(var amount) => subtotal with { Amount = Math.Max(subtotal.Amount - amount, 0) },
    FreeShipping => subtotal,
    _ => throw new NotSupportedException()
};

선언적, 테스트 가능, 컴파일러 완전성 검사

합성을 통한 확장: 새 전략 등록

프로모션이 기본 계산을 넘어 복잡해지면:

public interface IPromotionStrategy {
    bool AppliesTo(Order order);
    Money Apply(Order order);
}

public class LoyaltyPromotion : IPromotionStrategy {
    public bool AppliesTo(Order order) => order.Customer.IsLoyal;
    public Money Apply(Order order) => order.Total * 0.9m;
}

// DI로 동적 등록
services.Scan(...);

public class PromotionEngine {
    private readonly IEnumerable<IPromotionStrategy> _strategies;
    
    public Money ApplyBest(Order order) =>
        _strategies
            .Where(s => s.AppliesTo(order))
            .Select(s => s.Apply(order))
            .OrderBy(m => m.Amount)
            .FirstOrDefault(order.Total);
}

패턴 매칭은 폐쇄된 도메인 내 명확성; 전략은 개방된 확장 아키텍처

4.4 Result 형태 처리 (예외 없이)

명령형 C#은 흐름 제어로 예외 사용 → anti-pattern in domain logic

FluentResults/OneOf과 함께:

public static Result<Money> ApplyDiscount(Order order)
{
    if (order.Total <= 0) return Result.Fail("Invalid order total");
    return Result.Ok(order.Total * 0.9m);
}

var result = ApplyDiscount(order);
var message = result switch {
    { IsSuccess: true, Value: var value } => $"새 총액: {value}",
    { IsFailed: true } => $"할인 실패: {result.Errors.First().Message}",
    _ => "예상 밖의 결과"
};

또는 OneOf:

public OneOf<Success, ValidationError, NotFound> Process(Order order) =>
    order switch {
        { Total: <= 0 } => new ValidationError("Invalid total"),
        { } when order.Customer == null => new NotFound("Customer not found"),
        _ => new Success()
    };

예외 중심 흐름 제어 제거 = 명확한 의도

섹션 5: 도메인 서비스, 팩토리, 애그리게이트 (God 객체 없이)

5.1 엔터티 vs. 값 객체 vs. 도메인 서비스

엔터티

  • 아이디와 생명 주기 소유
  • 뮤테이션, 애그리게이트 루트의 일관성 경계
public class Order {
    public OrderId Id { get; init; }
    private readonly List<OrderLine> _lines = [];
    public IReadOnlyCollection<OrderLine> Lines => _lines;
    
    public void AddLine(Product product, int quantity)
        => _lines.Add(new OrderLine(product, quantity));
}

값 객체

  • 불변, 구조로 동등
  • 불변식 강제 (Money, Email)

도메인 서비스

  • Stateless, 여러 애그리게이트에 걸친 동작 캡슐화
public class PricingService {
    private readonly IEnumerable<IPromotionStrategy> _promotions;
    
    public Money CalculateTotal(Order order) =>
        _promotions
            .Where(p => p.AppliesTo(order))
            .Aggregate(order.Subtotal, (current, promo) => promo.Apply(order));
}

명확한 경계 = 인지 부하 감소 + 테스트 용이성

5.2 불변식 풍부한 생성을 위한 팩토리

정적 팩토리

public static class OrderFactory {
    public static Order Create(CustomerId customerId, IEnumerable<OrderLine> lines)
    {
        if (!lines.Any()) throw new ArgumentException("Empty order");
        return new Order(customerId, lines.ToList());
    }
}

전용 팩토리 타입

서비스를 필요로 하는 생성:

public interface IOrderFactory {
    Order Create(CustomerId customerId, IEnumerable<OrderLine> lines);
}

public class OrderFactory : IOrderFactory {
    private readonly IInventoryPolicy _inventory;
    
    public Order Create(CustomerId customerId, IEnumerable<OrderLine> lines)
    {
        foreach (var line in lines)
            _inventory.Reserve(line.ProductId, line.Quantity);
        
        return new Order(customerId, lines.ToList());
    }
}

팩토리는 생성 오케스트레이션을 캡슐화하면서 애그리게이트 불변식 유지

5.3 애그리게이트 경계와 합성

애그리게이트는 단일 진입점(루트)으로 묶인 엔터티와 값 객체의 클러스터:

  • 목표: 지역 추론
  • 단일 트랜잭션은 정확히 하나의 애그리게이트 수정
public class Order {
    private readonly List<OrderLine> _lines = [];
    public IReadOnlyCollection<OrderLine> Lines => _lines;
    public Money Total => _lines.Sum(l => l.LineTotal);
    
    public void AddLine(Product product, int quantity)
    {
        if (_lines.Any(l => l.ProductId == product.Id))
            throw new InvalidOperationException("Duplicate line item.");
        _lines.Add(new OrderLine(product, quantity));
    }
}

합성은 이 경계를 강화: 원시 컬렉션 노출 대신 불변식 유지 명시 메서드

5.4 도메인 서비스로 워크플로우 오케스트레이션

체크아웃, 구독 청구, 환불 - 여러 애그리게이트 span의 대규모 워크플로우:

public class CheckoutService {
    private readonly IPaymentPolicy _payment;
    private readonly IInventoryPolicy _inventory;
    
    public async Task<Result> CheckoutAsync(Order order)
    {
        if (!_inventory.CanFulfill(order)) 
            return Result.Fail("Insufficient inventory");
        
        var paymentResult = await _payment.ChargeAsync(order);
        return paymentResult.IsSuccess ? Result.Ok() : paymentResult;
    }
}

이 모델은 “God 서비스” 회피: 검증과 상태 변경을 전문화된 정책과 애그리게이트에 위임

5.5 체크아웃 워크플로우 예제

Order 애그리게이트

public class Order {
    public OrderId Id { get; init; } = OrderId.New();
    public IReadOnlyList<OrderLine> Lines { get; init; } = [];
    public Money Total => Lines.Aggregate(new Money(0, "USD"), (sum, l) => sum + l.LineTotal);
    public bool IsPaid { get; private set; }
    
    public void MarkPaid() => IsPaid = true;
}

교환 가능한 정책

public interface IPaymentPolicy {
    Task<Result> ChargeAsync(Order order);
}

public interface IInventoryPolicy {
    bool CanFulfill(Order order);
}

public class StripePaymentPolicy : IPaymentPolicy {
    public Task<Result> ChargeAsync(Order order)
        => Task.FromResult(Result.Ok().WithSuccess("Stripe를 통해 청구"));
}

public class BasicInventoryPolicy : IInventoryPolicy {
    public bool CanFulfill(Order order) => order.Lines.All(l => l.Quantity < 10);
}

CheckoutService로 오케스트레이션

public class CheckoutService {
    private readonly IPaymentPolicy _payment;
    private readonly IInventoryPolicy _inventory;
    
    public CheckoutService(IPaymentPolicy payment, IInventoryPolicy inventory)
    {
        _payment = payment;
        _inventory = inventory;
    }
    
    public async Task<Result> ExecuteAsync(Order order)
    {
        if (!_inventory.CanFulfill(order))
            return Result.Fail("재고 부족");
        
        var paymentResult = await _payment.ChargeAsync(order);
        if (paymentResult.IsFailed)
            return paymentResult;
        
        order.MarkPaid();
        return Result.Ok().WithSuccess("주문 완료");
    }
}

이 워크플로우는 복원력 있고 합성 가능: Polly와 Scrutor 데코레이터로 재시도/로깅 추가 가능 - 상속/중복 없음

섹션 6: 작업 경계 - I/O 경계, 매핑, 유효성 검사

6.1 DTO를 핵심에서 멀리 유지

Mapster vs. AutoMapper와 소스 생성

Mapster는 소스 생성기 지원 덕에 선호됨:

  • 컴파일 타임 생성, 리플렉션 없음, 최소 할당, 핫 패스에서 빠름
TypeAdapterConfig<OrderDto, Order>.NewConfig()
    .Map(dest => dest.Id, src => new OrderId(src.Id))
    .Map(dest => dest.Lines, src => src.Lines.Adapt<List<OrderLine>>());

AutoMapper는 성숙하지만 런타임 기반 → 더 느림
상업 준수 관점: Mapster는 MIT, AutoMapper는 최근 라이선스 변경 조심

도메인은 Order, Money, Email 같은 도메인 타입으로 말하고, 매핑 도구가 DTO ↔ 도메인 간 변환

6.2 전략적 유효성 검사 배치

검증 계층화:

  • 도메인 불변식: 타입 내부 (생성 시점에 강제)
  • 입력 검증: 경계 밖 (DTO/요청)

FluentValidation for request validation:

public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderRequestValidator()
    {
        RuleFor(x => x.Email).NotEmpty().EmailAddress();
        RuleForEach(x => x.Lines).SetValidator(new OrderLineValidator());
    }
}

도메인 측 불변식:

public sealed record Email(string Value)
{
    init {
        if (!Value.Contains('@'))
            throw new ArgumentException("Invalid email", nameof(Value));
    }
}

이 책임 분리: 검증 프레임워크를 도메인 어셈블리에 주입하지 않음 (경계 관심사)

6.3 API 계층 매핑과 검증 파이프라인 예제

전체 흐름:

[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    private readonly CheckoutService _checkout;
    private readonly IValidator<CreateOrderRequest> _validator;
    
    [HttpPost]
    public async Task<IActionResult> Create(CreateOrderRequest request)
    {
        var validation = await _validator.ValidateAsync(request);
        if (!validation.IsValid)
            return BadRequest(validation.Errors);
        
        var order = request.Adapt<Order>();  // Mapster
        var result = await _checkout.ExecuteAsync(order);
        
        return result.IsSuccess ? Ok(result.Successes) : BadRequest(result.Errors);
    }
}

흐름:

  1. DTO 유효성 검사 (입력 경계)
  2. Mapster로 도메인 모델로 변환
  3. 도메인 로직 실행 (CheckoutService)
  4. DTO → 도메인은 검증 이후만

DTO는 검증 이후 변환되므로 도메인은 유효한 데이터만 수신 = 안전한 경계

섹션 7: 엔드-투-엔드 예제 - 구독 및 청구

7.1 도메인 스케치: 플랜, 구독, 청구 사이클, 결제 방법

명확한 개념 경계로 시작:

public sealed record Plan(string Name, Money MonthlyPrice, IReadOnlyList<string> Features);

public sealed record BillingCycle(DateOnly StartDate, DateOnly EndDate)
{
    public int Days => EndDate.DayNumber - StartDate.DayNumber;
    public bool Contains(DateOnly date) => date >= StartDate && date <= EndDate;
}

public abstract record PaymentMethod;
public sealed record CreditCard(string Last4, string Brand) : PaymentMethod;
public sealed record PayPal(string Email) : PaymentMethod;
public sealed record CryptoWallet(string Address) : PaymentMethod;

public sealed record Subscription(
    Guid Id,
    Plan Plan,
    BillingCycle Cycle,
    PaymentMethod Method,
    DateOnly CreatedOn)
{
    public bool IsActive => Cycle.Contains(DateOnly.FromDateTime(DateTime.UtcNow));
}

이 타입들은 불변, 간결, 기본 정확성 강제 (날짜 범위 etc) 및 구조적 동등성

7.2 합성 맵

가격 책정 전략 (IPricingStrategy)

변동: 플랜, 지속 기간, 프로모션

public interface IPricingStrategy {
    Money Calculate(Subscription subscription, DateOnly renewalDate);
}

// 구체적 전략들
public sealed class StandardPricing : IPricingStrategy {
    public Money Calculate(Subscription sub, DateOnly date) => sub.Plan.MonthlyPrice;
}

public sealed class ProratedPricing : IPricingStrategy {
    public Money Calculate(Subscription sub, DateOnly date)
    {
        var daysUsed = (date.DayNumber - sub.Cycle.StartDate.DayNumber);
        var totalDays = sub.Cycle.Days;
        return new Money(sub.Plan.MonthlyPrice.Amount * daysUsed / totalDays, 
                         sub.Plan.MonthlyPrice.Currency);
    }
}

public sealed class DiscountPricing(decimal percentage) : IPricingStrategy {
    public Money Calculate(Subscription sub, DateOnly date)
        => sub.Plan.MonthlyPrice with { 
            Amount = sub.Plan.MonthlyPrice.Amount * (1 - percentage / 100) 
        };
}

이들은 조합되거나 데코레이션됨 (캐싱, 로깅) - 구현 수정 없음

결제 제공자 어댑터 (IPaymentGateway) with Polly

public interface IPaymentGateway {
    Task<Result> ChargeAsync(Guid subscriptionId, Money amount, PaymentMethod method);
}

public class StripeGateway : IPaymentGateway {
    public Task<Result> ChargeAsync(Guid id, Money amount, PaymentMethod method)
        => Task.FromResult(Result.Ok().WithSuccess($"Stripe를 통해 {amount} 청구"));
}

// 복원력 추가 - Polly 데코레이터
public class ResilientPaymentGateway : IPaymentGateway {
    private readonly IPaymentGateway _inner;
    private readonly IAsyncPolicy _policy;
    
    public ResilientPaymentGateway(IPaymentGateway inner)
    {
        _inner = inner;
        _policy = Policy.Handle<Exception>()
            .WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(attempt));
    }
    
    public async Task<Result> ChargeAsync(Guid id, Money amount, PaymentMethod method)
        => await _policy.ExecuteAsync(() => _inner.ChargeAsync(id, amount, method));
}

알림 전략과 재시도/백오프

public interface INotificationChannel {
    Task NotifyAsync(string recipient, string message);
}

public class EmailNotification : INotificationChannel {
    public Task NotifyAsync(string recipient, string message)
        => Console.Out.WriteLineAsync($"이메일 {recipient}: {message}");
}

public class SlackNotification : INotificationChannel {
    public Task NotifyAsync(string recipient, string message)
        => Console.Out.WriteLineAsync($"Slack {recipient}: {message}");
}

// Polly 데코레이터
public class ResilientNotifier : INotificationChannel {
    private readonly INotificationChannel _inner;
    private readonly IAsyncPolicy _policy;
    
    public ResilientNotifier(INotificationChannel inner)
    {
        _inner = inner;
        _policy = Policy.Handle<Exception>()
            .WaitAndRetryAsync(2, i => TimeSpan.FromMilliseconds(200 * i));
    }
    
    public async Task NotifyAsync(string recipient, string message)
        => await _policy.ExecuteAsync(() => _inner.NotifyAsync(recipient, message));
}

7.3 생성 경로와 불변식

팩토리는 도메인 생성을 안전하게 조정:

public interface ISubscriptionFactory {
    Subscription Create(Plan plan, PaymentMethod method, DateOnly start);
}

public class SubscriptionFactory : ISubscriptionFactory {
    public Subscription Create(Plan plan, PaymentMethod method, DateOnly start)
    {
        var end = start.AddMonths(1);
        Guard.Against.Null(plan);
        Guard.Against.Null(method);
        return new Subscription(Guid.NewGuid(), plan, 
                               new BillingCycle(start, end), method, 
                               DateOnly.FromDateTime(DateTime.UtcNow));
    }
}

모든 Subscription은 출생 시 유효. 가드 절(Ardalis.GuardClauses)은 일관된 상태 보장

7.4 패턴 매칭을 통한 다형성

PaymentMethod 봉인된 레코드 위 switch

public static string Describe(PaymentMethod method) => method switch {
    CreditCard(var last4, var brand) => $"{brand} {last4}로 끝남",
    PayPal(var email) => $"PayPal 계정 {email}",
    CryptoWallet(var address) => $"지갑 {address[..6]}...",
    _ => throw new InvalidOperationException("Unknown payment method")
};

새 결제 방법 추가 시 컴파일러가 모든 switch 경고 = 자동 exhaustiveness

라인 항목 검증 위한 리스트 패턴

public static bool HasInvalidAddOns(IReadOnlyList<Plan> plans)
    => plans is [] or [_, .., { Name: "LegacyPlan" }];

읽음: 유효하지 않음 if 추가 상품 없거나 레거시 플랜으로 끝남 - 루프 없이 명확한 의도

7.5 애플리케이션 배선

DI 등록 with Scrutor

services.Scan(scan => scan
    .FromAssemblyOf<IPricingStrategy>()
    .AddClasses(c => c.AssignableTo<IPricingStrategy>())
    .AsImplementedInterfaces()
    .WithScopedLifetime());

services.Decorate<IPaymentGateway, ResilientPaymentGateway>();
services.Decorate<INotificationChannel, ResilientNotifier>();

드롭인 합성 가능. 관찰성 필요? 추가:

services.Decorate<IPaymentGateway, LoggingPaymentGateway>();

기존 코드 수정 없음

검증 파이프라인 + 도메인 가드

FluentValidation for DTOs:

public class SubscriptionRequestValidator : AbstractValidator<CreateSubscriptionRequest>
{
    public SubscriptionRequestValidator()
    {
        RuleFor(x => x.PlanName).NotEmpty();
        RuleFor(x => x.PaymentMethod).NotNull();
    }
}

도메인 가드:

Guard.Against.NegativeOrZero(price.Amount, nameof(price.Amount));

Mapster로 경계 매핑

TypeAdapterConfig<CreateSubscriptionRequest, Subscription>.NewConfig()
    .Map(dest => dest.Plan, src => 
        new Plan(src.PlanName, new Money(src.Price, "USD"), src.Features))
    .Map(dest => dest.Method, src => src.MethodType switch {
        "CreditCard" => new CreditCard(src.Last4, src.Brand),
        "PayPal" => new PayPal(src.Email),
        _ => throw new NotSupportedException()
    });

도메인 오염 없음; 매핑과 검증은 경계에

7.6 테스팅 전략

값 객체 위의 단위 테스트

순수하고 쉬움:

[Fact]
public void Money_Equality_Works()
{
    var m1 = new Money(100, "USD");
    var m2 = new Money(100, "USD");
    Assert.Equal(m1, m2);
}

불변성 = 결정적 테스트

전략과 어댑터 위의 계약 테스트

각 전략은 독립적:

[Fact]
public void DiscountPricing_Applies_Percentage()
{
    var plan = new Plan("Pro", new Money(100, "USD"), []);
    var sub = new Subscription(...);
    var pricing = new DiscountPricing(10);
    var result = pricing.Calculate(sub, DateOnly.FromDateTime(DateTime.Today));
    Assert.Equal(90, result.Amount);
}

어댑터는 계약 픽스처로 테스트 - 모든 구현이 동일 동작 준수 보장

복원력 정책 테스트

제어된 실패로 Polly 동작 결정적으로 테스트:

[Fact]
public async Task ResilientPaymentGateway_Retries_On_Exception()
{
    var inner = Substitute.For<IPaymentGateway>();
    inner.ChargeAsync(default, default, default).ThrowsAsync(new TimeoutException());
    var gateway = new ResilientPaymentGateway(inner);
    
    await gateway.ChargeAsync(Guid.NewGuid(), new Money(10, "USD"), new PayPal("x@y.com"));
    await inner.Received(3).ChargeAsync(Arg.Any<Guid>(), Arg.Any<Money>(), Arg.Any<PaymentMethod>());
}

7.7 운영 관심사: 로깅, 메트릭, 기능 전환

관찰성은 데코레이터로 (도메인 코드 외부):

public class LoggingPaymentGateway : IPaymentGateway {
    private readonly IPaymentGateway _inner;
    private readonly ILogger<LoggingPaymentGateway> _logger;
    
    public LoggingPaymentGateway(IPaymentGateway inner, ILogger<LoggingPaymentGateway> logger)
    {
        _inner = inner;
        _logger = logger;
    }
    
    public async Task<Result> ChargeAsync(Guid id, Money amount, PaymentMethod method)
    {
        _logger.LogInformation("청구 중 {Id} with {Amount}", id, amount);
        var result = await _inner.ChargeAsync(id, amount, method);
        _logger.LogInformation("청구 결과: {Result}", result.IsSuccess);
        return result;
    }
}

메트릭: OpenTelemetry와 데코레이터로 통합 - 재시도, 실패, 지속 기간 기록

기능 전환 (FeatureManagement, LaunchDarkly): 조건부 분기 대신 메서드 교체

합성은 전환이 동작을 스왑하도록 보장함 (branches 아님)

섹션 8: Anti-Pattern, 트레이드오프, 테스트 가능한 디커플된 OOP 체크리스트

8.1 빈약한 도메인 모델

데이터를 보유하지만 동작이 없음:

public class Order { public decimal Total; }
public class OrderService { public void ApplyDiscount(Order o) => o.Total *= 0.9m; }

로직과 데이터 분리 = 불변식 흩어짐. 수정: 값 객체에 로직 넣기:

public sealed record Money(decimal Amount, string Currency)
{
    public Money ApplyDiscount(decimal percent) 
        => this with { Amount = Amount * (1 - percent / 100) };
}

동작이 데이터와 함께 이동 = 캡슐화 복구

8.2 "God 서비스"와 메가 오케스트레이터

서비스가 “너무 많이” 알면 응집 붕괴.

리팩터: 각 결정 지점에서 전략/패턴 매칭 추출

예: “BillingService” → 가격/청구/이메일/갱신
해결:

  • 가격 → IPricingStrategy
  • 청구 → IPaymentGateway
  • 알림 → INotificationChannel

합성과 DI 배선 = 절차적 확산 제거

8.3 합성으로 교체할 상속 냄새

템플릿 메서드 → 전략

틀린 패턴:

public abstract class ReportGenerator {
    public void Generate() { FetchData(); Format(); Export(); }
    protected abstract void FetchData();
    // ...
}

올바른 패턴:

public class ReportPipeline(IFetcher f, IFormatter fmt, IExporter e) {
    public void Run() { f.Fetch(); fmt.Format(); e.Export(); }
}

각 단계 = 합성 가능 인터페이스 (테스트 가능, 확장 가능)

기본 엔터티 함정

공유 BaseEntity 클래스 피하기 (timestamp, ID 필드로 경계 모호).
작고 명시적 컴포넌트 사용 (Auditable 믹스인 또는 데코레이터)

8.4 레코드가 잘못된 경우

불변성 비용:

  • 고빈도 업데이트 (금융 틱) → 레코드 복사 expensive → 성능 저하 가능

Rule of thumb:

  • record struct: 작음, 단기, 고성능
  • record class: 아이디 없음, 스코프 내 지속
  • class: 오래된 엔터티, 뮤테이션 behavior

많은 타입에서 기본으로 레코드 사용 → 숨겨진 GC 압력 (프로파일 before)

8.5 라이브러리 in Context

이러한 도구들은 의도적 사용 시 실용 OOP를 강화:

  • Ardalis.GuardClauses: 뒤섞임 없는 인라인 불변식
  • FluentValidation: DTO 검증, 도메인 로직 아님
  • Scrutor: DI 자동 등록과 데코레이션
  • Polly: 복원력 (재시도, 타임아웃, 서킷 브레이커, 헤징)
  • OneOf / FluentResults: 패턴-매칭 친화적 success/failure
  • StronglyTypedId / Strongly: 원시 집착 제거
  • Mapster: 빠른 생성 매퍼로 깨끗한 anti-corruption 계층

과도한 계측 피하기; 복잡성 감소할 때만 채택 (의존성 그래프 maintain 아님)

8.6 “실용적 OOP” 체크리스트

인쇄 가능한 요약 - 설계/코드 검토 중 검토:

모델링

  • 불변식이 값 객체에서 포착되고 생성 시 강제되는가?
  • 모든 상속 결정이 필요성으로 정당화되는가 (편의 아님)?
  • 레코드가 의도적 확장 아닌 한 기본으로 봉인되는가?

동작 & 다형성

  • 다형성이 전략/패턴 매칭 대신 깊은 상속으로 표현되지 않는가?
  • 패턴-매칭 계층에 exhaustive switch expressions가 있는가?

경계

  • DTO 매핑과 검증이 도메인 핵심 외부인가?
  • 복원력과 교차 관심사가 합성 (데코레이터, Polly)으로 추가되는가?

테스트 가능성

  • 대부분 테스트가 순수 값 의미론에 초점인가?
  • 어댑터/전략이 계약 픽스처로 테스트되는가?
  • 복원력 정책이 결정적 백오프로 테스트 가능한가?

운영 가능성

  • 정책이 관찰 가능한가 (메트릭, 로그)?
  • 설정 변경과 기능 전환이 런타임 구동되고 코드 구동 아닌가?

핵심 팁과 주의사항

실용적 팁

  • 기본으로 합성 선택: 상속은 정말 필요할 때만 (거의 드묾)
  • 레코드 봉인 기본: 의도적 확장 아닌 한 sealed record로 도메인 안정성 확보
  • DI 데코레이션 활용: 캐싱, 로깅, 복원력은 소스 수정 없이 추가
  • 팩토리로 오케스트레이션: 생성이 복잡하면 팩토리에서 조정 (생성자 아님)
  • 경계 명확화: DTOs는 입력 경계에서만 변환 → 도메인은 항상 유효한 상태

주의사항

  • 주 생성자와 DI 섞을 때 파라미터 이름 충돌 주의
  • 리스트 패턴은 아직 핫 패스에서 JIT 최적화가 덜 됨
  • 레코드 구조체는 큰 타입 복사에서 성능 저하 가능
  • 과도한 라이브러리 도입 피하기 (의존성 복잡도 증가)
  • 모든 엔터티를 record로 만들지 말기 (고빈도 뮤테이션은 class)

참고 자료 및 링크


최종 정리: 모던 C#의 실용적 OOP는 기술의 조합이다 - 작고 봉인된 타입(레코드), 명시적 합성(인터페이스/DI), 패턴 매칭(폐쇄 영역), 그리고 데코레이터 아키텍처(교차 관심사). 이들이 함께 일할 때, 도메인 모델은 테스트 가능하고, 진화 가능하며, 이해하기 쉬운 코드를 생성한다.

2개의 좋아요