검증 (Validation )
도메인 규칙(혹은 비지니스 규칙)의 상당 부분은 검증(validation)입니다.
검증의 실무적 방식에는 크게 두 가지가 있습니다.
- 외부 검증기(Validator)
- 설계에 검증 반영
둘 다 각자의 장단점이 있지만, 이 글에서는 주로 후자에 대해 알아 봅니다.
도메인 문제
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)를 예방하기 위해서입니다.
그런데, record 의 with 로 인해 이 의도가 방해를 받습니다.
이는 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;
}
이전과 달리, 도메인의 의미가 풍부하게 구현되어 아리송한 것이 없습니다.
- 시작하지 않은 것은 시작 일이 없습니다.
- 시작한 것은 종료 일이 없고, 시작 일을 고칠 수 없습니다.
- 종료된 것은 시작 일과 종료 일을 고칠 수 없습니다.
구현이 확장에 열려 있어, 확장의 자유도도 높습니다.
고객: 취소도 넣어 주세요.
이 요구 사항은 두 가지 의미로 해석할 수 있습니다.
(어떤 해석이 맞는지는 고객과 소통해야 합니다.)
- 행위
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)}"),
};
}
- 상태
새로운 파생 객체를 생성합니다.
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);
}
마치며
흔하디 흔한 "검증"이라는 도메인 문제라도, 객체의 설계 방향에 막대한 영향을 미친다는 점을 알 수 있습니다.
- 대충 지어 놓고, 나중에 누덕누덕 고칠 것인지,
- 처음부터 제대로 지을 것인지는
선택의 문제입니다.
전자는 시간이 지날 수록( = 요구 사항이 많아지고 정밀해질 수록) 해결하기 힘든 코드 덩어리가 될 지 모릅니다. 이는 AI도 예외는 아니며, 주로 AI 에게 아키텍트를 일임할 때 더 자주 발생할 수 있습니다.
후자는 설계 시에 정성과 시간이 많이 드는 단점이 있는데, 노련한 설계자라면 그리 큰 단점도 아닙니다. 설령 많이 노련하지 않더라도 AI를 적절하게 사용한다면 크게 완화할 수 있습니다.
후자의 가장 큰 장점은 도메인 규칙 = 컴파일 규칙이기 때문에 AI 가 후속 코드에서 사고 칠 일이 많지 않고, 생성된 코드에 문제가 발생할 경우, 상태의 유효성(=검증) 이 외의 원인에 집중할 수 있어 보다 신속한 원인 규명이 가능할 것입니다.