검증과 설계

검증 (Validation )

도메인 규칙(혹은 비지니스 규칙)의 상당 부분은 검증(validation)입니다.

검증의 실무적 방식에는 크게 두 가지가 있습니다.

  1. 외부 검증기(Validator)
  2. 설계에 검증 반영

둘 다 각자의 장단점이 있지만, 이 글에서는 주로 후자에 대해 알아 봅니다.

도메인 문제

record ToDo(string Name, DateTime Start, DateTime End);

검증기

우선 외부 검증기 사용 시 단점을 짤막하게 알아 봅니다.

이 모델에 어떤 규칙이 요구되더라도, 검증기의 가장 큰 단점은 아무래도 장황성입니다.

var toDo = // ...
if (validator.IsValid(toDo))
{
   // toDo 로 작업
}
else
{
   // ??

엔티티라면 그나마 장황성을 참을 수 있지만 값 객체라면 얼마나 많은 곳이 지저분해질 지 감도 안 옵니다.

두 번째 문제는 아키텍트 입장에서 검증의 책임을 누구한테 부여해야 할지 정하기도 어렵고, 정했다 한들 일관적으로 지키는 것도 쉽지가 않습니다.

// 호출자 검증, 함수는 ToDo 신뢰
ToDo Do(ToDo toDo) { } 

// 함수 검증, 함수는 ToDo 불신뢰
ToDo Do(ToDo toDo, IValidator<ToDo> validator) { } 

설계에 반영

이제 검증 규칙을 도메인 설계에 반영해봅니다.

ToDo.Start <= ToDo.End

record ToDo(string Name, DateTime Start, DateTime End)
{
    const int BaseSpanMinutes = 30;

    public ToDo(string name, DateTime start) 
       : this(name, start, start) { }
       
    public DateTime End { get; init; } = Start <= End ? End
       : Start.AddMinutes(BaseSpanMinutes);
}

미숙한 구현

위 정의는 일견 괜찮아 보입니다.

static class TodoExt
{
    public static bool DatesValid(this ToDo toDo) =>
       toDo.Start <= toDo.End;
}
var now = DateTime.Now;
var toDo = new ToDo("Design Todo", now);

Assert.True(toDo.DatesValid()); // success

아래 코드를 만나기 전까지는요.

오류1

var updated = toDo with { End = toDo.Start.AddHours(-1)};

Assert.True(updated.DatesValid()); // fail

변경이 상태를 무효화시킵니다.

위 문제는 검증 로직에 명시적으로 걸러지기라도 하지만 아래와 같은 경우는 어떨까요?

오류2

var longLongAgo = now.AddYears(-1000);
updated = toDo with { Start = longLongAgo };

Assert.True(updated.DatesValid()); // success

시작 일을 아주 먼 과거로 설정했는데, 문제인 지 아닌 지 아리송합니다.

개선

오류1 은 생성 시 뿐만 아니라, 변경 시에도 유효 상태를 유지하게끔 강제하면 해결할 수 있습니다.

setter 에 검증 코드 추가

record ToDo(string Name, DateTime Start, DateTime End)
{
   // ...

   // public DateTime End { get; init; } = Start <= End ? End 
   //    : Start.AddMinutes(BaseSpanMinutes);
   public DateTime End 
   {
       get;
       init
       {
           if(value != field && Start <= value)
           {
               field = value;
           }
       }
   } = Start <= End ? End : Start.AddMinutes(BaseSpanMinutes);
}
var updated = toDo with { End = toDo.Start.AddHours(-1)};
Assert.True(updated.DatesValid()); // success

엣지 케이스

updated = toDo with 
{ 
   Start = toDo.Start.AddHours(1), 
   End = toDo.Start.AddHours(1)
};

Assert.True(updated.DatesValid()); // success

updated = toDo with 
{ 
   End = toDo.Start.AddHours(1),
   Start = toDo.Start.AddHours(1) 
};

Assert.True(updated.DatesValid()); // fail

같은 설정이라도, 어떤 속성을 먼저 설정하는 지에 따라 결과가 달라지는 새로운 오류가 발견되었습니다.

이런 케이스를 테스트에서 못 잡아 내면 많이 우울해질 것입니다.

여기에, 오류2 는 도메인 문제로부터 충분한 규칙을 정의하지 못한 경우에 발생하는 문제입니다.

  • Start 변경을 허용할 것인가?
  • 허용한다 해도 현재보다 이전으로 돌리는 것은 좀 그렇지 않나?
  • 이전으로 돌리는 것을 허용한다 해도 1000년은 좀 그렇지 않나?

결정하기 어려운 문제입니다.

일단 결정을 (미래의 누군가에게) 미루기로 결정했습니다.

record ToDo(string Name, DateTime Start, DateTime End)
{
    // ... 
    public DateTime Start { get; } = Start;
}

위의 코드는 Start를 재설정하는 모든 코드를 무효화시킵니다.

updated = toDo with 
{ 
   // Start = toDo.Start.AddHours(1), // 컴파일 에러
   End = toDo.Start.AddHours(1)
};

Assert.True(updated.DatesValid()); // success

updated = toDo with 
{ 
   End = toDo.Start.AddHours(1),
   // Start = toDo.Start.AddHours(1)  // 컴파일 에러
};

Assert.True(updated.DatesValid()); // success

var longLongAgo = now.AddYears(-1000);
// updated = toDo with { Start = longLongAgo }; // 컴파일 에러

이 변경으로,

도메인 규칙 = 컴파일 규칙

이 되었기 때문에 누가 코드를 적더라도 런타임에 오류가 나는 일은 없습니다.

개선 2

사실 class 대신에 record 를 사용하는 건 별칭 오류(Aliasing bug)를 예방하기 위해서입니다.

그런데, recordwith 로 인해 이 의도가 방해를 받습니다.

이는 with가 새로운 객체를 생성(하고 속성을 설정)할 때, 생성자와 달리, 모든 매개 변수를 탐색할 수 없기 때문에 나타난 현상입니다.

따라서, with 의 기능을 죽여, 언제나 검증이 작동하게 만드는 것도 나쁘지 않은 선택입니다.

record ToDo(string Name, DateTime Start, DateTime End)
{
    const int BaseSpanMinutes = 30;    
    public DateTime Start { get; } = Start;
    public DateTime End { get; } = 
        Start <= End ? End : Start.AddMinutes(BaseSpanMinutes);
}

개선 3

설계 자체를 개선해보도록 하겠습니다.

먼저, 상태의 유효성이 다른 속성에 의해 영향을 받지 않는, 다시 말하면, 서로 독립적인 속성들은 유효성 문제를 일으키지 않습니다.

따라서 이들을 먼저 모아 둡니다.

// 서로 독립적인 상태들의 모음. 
abstract record Todo(string Title, string Description,
 TimeSpan Estimate)
{
   protected const int DefaultSpanMinutes = 30;
}
var todo = todo with 
{
   Description = "어려움",
   Estimate = TimeSpan.FromHours(2),
}

var todo2 = todo with 
{
   Estimate = TimeSpan.FromHours(2),
   Description = "어려움",
}

각 상태를 대변하는 객체들을 파생합니다.

sealed record Planned(string Title, string Description, 
   TimeSpan Estimate) : Todo(Title, Description, Estimate)
{
    public Todo Start(DateTime startedAt) =>
       new Started(Title, Description, Estimate, startedAt);
}

sealed record Started(string Title, string Description,
    TimeSpan Estimate, DateTime StartedAt)
   : Todo(Title, Description, Estimate)
{
    public DateTime StartedAt { get; } = StartedAt;
    public Todo End(DateTime endedAt) =>
        new Ended(Title, Description, Estimate, StartedAt, endedAt);
}

sealed record Ended(string Title, string Description,
    TimeSpan Estimate, DateTime StartedAt, DateTime EndedAt)
   : Todo(Title, Description, Estimate)
{
    public DateTime StartedAt { get; } = StartedAt;
    public DateTime EndedAt { get; } = 
        StartedAt <= EndedAt ? EndedAt : StartedAt;
}

이전과 달리, 도메인의 의미가 풍부하게 구현되어 아리송한 것이 없습니다.

  • 시작하지 않은 것은 시작 일이 없습니다.
  • 시작한 것은 종료 일이 없고, 시작 일을 고칠 수 없습니다.
  • 종료된 것은 시작 일과 종료 일을 고칠 수 없습니다.

구현이 확장에 열려 있어, 확장의 자유도도 높습니다.

고객: 취소도 넣어 주세요.

이 요구 사항은 두 가지 의미로 해석할 수 있습니다.
(어떤 해석이 맞는지는 고객과 소통해야 합니다.)

  1. 행위
static class TodoExt
{
    public static Todo Cancel(this Todo todo) => todo switch
    {
        Planned p => p,

        // 전단계로 변경함
        Started s => new Planned(s.Title, s.Description, s.Estimate),
        Ended e => new Started(e.Title, e.Description, e.Estimate, e.StartedAt),
        
        _ => throw new($"{nameof(Cancel)} is not implemented for {typeof(Todo)}"),
    };
}
  1. 상태

새로운 파생 객체를 생성합니다.

sealed record Canceled(string Title, string Description, TimeSpan Estimate, DateTime CancelledAt)
   : Todo(Title, Description, Estimate)
{
    public DateTime CancelledAt { get; } = CancelledAt;
    public Todo Resume(DateTime startedAt, TimeSpan? estimate = null) =>
       new Started(Title, Description, estimate ?? Estimate, startedAt);
}

마치며

흔하디 흔한 "검증"이라는 도메인 문제라도, 객체의 설계 방향에 막대한 영향을 미친다는 점을 알 수 있습니다.

  1. 대충 지어 놓고, 나중에 누덕누덕 고칠 것인지,
  2. 처음부터 제대로 지을 것인지는

선택의 문제입니다.

전자는 시간이 지날 수록( = 요구 사항이 많아지고 정밀해질 수록) 해결하기 힘든 코드 덩어리가 될 지 모릅니다. 이는 AI도 예외는 아니며, 주로 AI 에게 아키텍트를 일임할 때 더 자주 발생할 수 있습니다.

후자는 설계 시에 정성과 시간이 많이 드는 단점이 있는데, 노련한 설계자라면 그리 큰 단점도 아닙니다. 설령 많이 노련하지 않더라도 AI를 적절하게 사용한다면 크게 완화할 수 있습니다.

Grill Me | Claude Code Skills

후자의 가장 큰 장점은 도메인 규칙 = 컴파일 규칙이기 때문에 AI 가 후속 코드에서 사고 칠 일이 많지 않고, 생성된 코드에 문제가 발생할 경우, 상태의 유효성(=검증) 이 외의 원인에 집중할 수 있어 보다 신속한 원인 규명이 가능할 것입니다.

6 Likes