도메인 문제
회원의 주간 단위 팔굽혀 펴기 목표를 관리한다.
주말에는 목표를 세우지 않는다.
이 문제에 대한 도메인 모델링은 아래와 같이 하기 쉽습니다.
class User
{
public Dictionary<DayOfWeek, int> DayGoals { get; } = [];
public void AddGoal(DayOfWeek dayOfWeek, int goals)
{
if (dayOfWeek is DayOfWeek.Sunday or DayOfWeek.Saturday) return;
if (DayGoals.Contains(dayOfWeek)) return;
DayGoals[dayOfWeek] = goals < 0 ? 0 : goals;
}
위의 모델링은 몇 가지 문제가 있습니다.
- 소유 객체의 과도한 책임
소유 객체가 자신의 모든 속성들, 특히 컬렉션 속성의 하위 규칙까지 담당한다면 클래스 코드는 매우 방대해질 것입니다. 방대한 코드는 위임 분산이 적절하지 않다는 스멜입니다.
책임이 여러 (컬렉션) 속성에 걸쳐 있는 경우라면, 소유 객체가 담당해야 합니다. 그러나, 이 경우 모델 설계가 적절한 지 먼저 살펴봐야 합니다.
- 도메인 규칙 강제 실패
사전 객체를 공개하고 있기 때문에AddGoal의 호출이 안될 수도 있습니다.
이는 도메인 규칙이 강제되지 않음을 의미합니다.
해결
데이터 묶음
우선 부족한 추상화를 채워넣습니다.
public record DayGoal(DayOfWeek DayOfWeek, int Count)
{
public DayOfWeek DayOfWeek { get; init; }
= DayOfWeek is DayOfWeek.Sunday or DayOfWeek.Saturday ?
throw new ArgumentException("주말에는 목표를 설정할 수 없습니다")
: DayOfWeek;
public int Count { get; init; } = Count < 0 ? 0 : Count;
}
생성자 예외
얼핏 보면 제대로 한 것 같지만,
생성자에서 에외를 던지는
치명적인 문제가 있습니다.
생성 시조차 예외를 던지는 코드를 과연 누가 갖다 쓰고 싶을까요?
(안트로픽, OpenAI에게는 금맥이겠지만요)
try {
var goal = new DayGoal(dayOfWeek, count);
// ...
}
그런데, 임의로 던지는 예외의 상당수는 예측이 가능하다는 특징이 있어 예외라기 보다는 "에러"인 경우가 많습니다.
도메인 규칙 위반
이 코드는 "도메인 규칙 위반"이라는 에러를 예외로 던지고 있다고 할 수 있습니다.
에러를 예외로 던지는 코드 패턴은 부족한 추상화가 주요 원인입니다.
구체적으로는, 도메인 요구 사항은 주말을 제외하고 있었는데, 이를 포함하고 있는 DayOfWeek 를 사용한 것이 원입니다.
재설계
우리 도메인 문제를 보다 잘 다룰 수 있는 (값) 객체를 정의하여 이 문제를 해결합니다.
public enum WeekDay { Mon = 1, Tue, Wed, Thu, Fri }
public record DayGoal(WeekDay WeekDay, int Count)
{
public int Count { get; init; } = Count < 0 ? 0 : Count;
}
또한 중복 요소를 막는 전용 컬렉션을 정의합니다.
public class DayGoalSet : HashSet<DayGoal>
{
public DayGoalSet() : base(EqualityComparer<DayGoal>.Create(
(x, y) => {
if (ReferenceEquals(x, y)) return true;
if (x == null || y == null) return false;
return x.WeekDay == y.WeekDay;
},
x => (int)x.WeekDay
)) { }
}
이 모델을 사용하는 소유 모델입니다.
class User
{
public DayGoalSet DayGoals { get; } = [];
}
이 객체는
- 모든 요구 사항을 충족하고 있고,
- 예외도 던지지 않아 안전하게 사용할 수 있습니다.
그리고,
- 단순합니다.
이러한 단순성은 이 객체에 의존하는 모든 코드에게 전파될 것입니다.
해결2
앞서 WeekDay 를 별도로 정의했는데, 이는 아래의 도메인 요구 사항이 변하지 않을 것이라고 가정했기 때문입니다.
주말에는 목표를 세우지 않는다.
과연 그럴까요? ^^
우리의 짬은 아래의 변화를 예견합니다.
고객 : 생각해보니 주말에도 목표를 설정하는 것이 필요할 것 같습니다.
다가올 것이 거의 확실한 미래를 대비해 봅니다.
// public enum WeekDay { Mon = 1, Tue, Wed, Thu, Fri }
// public record DayGoal(WeekDay WeekDay, int Count)
public record DayGoal(DayOfWeek DayOfWeek, int Count)
{
public int Count { get; init; } = Count < 0 ? 0 : Count;
}
public class DayGoalSet : HashSet<DayGoal>
{
// ...
new public bool Add(DayGoal dayGoal) =>
CanContain(dayGoal) && base.Add(dayGoal);
protected virtual bool CanContain(dayGoal dayGoal) => true;
}
당연히 현재의 당면한 규칙을 준수해야 합니다.
public sealed class InWeekDayGoalSet : DayGoalSet
{
public InWeekDayGoalSet() : base() { }
protected override bool CanContain(DayGoal dayGoal) =>
!(dayGoal.DayOfWeek is DayOfWeek.Sunday or DayOfWeek.Saturday);
}
class User
{
// public DayGoalSet DayGoals { get; } = [];
public DayGoalSet DayGoals { get; } = new InWeekDayGoalSet();
}
마치며
컬렉션 객체는 어찌 보면 쉽고 단순하지만, 어찌보면 유연성이 매우 높은 객체입니다.
그 유연성을 적절히 사용한다면 도메인 규칙을 보다 단순하고 능률적으로 구현해낼 수 있습니다.
이 글을 끝으로 컬렉션에 관한 도메인모델링 편은 마무리할까합니다.
되돌아 보니
글을 마치려다가 서운한 게 있어서 덧붙입니다.
글의 취지로 인해 발생한 불필요한 오해를 해소할 필요가 있을 것 같습니다.
이글의 해결2는, 현재의 요구(월 ~ 금)를 넘어, 미래에 발생할 것이라 예상되는 요구(토, 일)까지 감당할 객체를 정의하고, 컬렉션에 저장될 때만 제한을 두는 방식을 취했습니다.
고객 : 휴일도 별도로 관리하고 싶습니다.
그렇습니다. 고객의 요구는 끝이 없습니다.
이 새로운 요구를 반영하려면, 기존 코드를 수정해야 해서, 기존 코드는 OCP 위반 설계입니다. (헛짬을 먹었네요)
코드 스멜 - enum
이러한 설계 실패의 원인은 간단합니다.
너무 조급하게 미래의 변화를 예단하는, 다시 말하면 enum 을 선택했기 때문입니다.
신중한 고민 없이 선택한 enum (과 union) 은 OCP 위반 유발자입니다.
그럼 절대적 악, 악의 축이냐?
반드시 그런 것은 아닙니다만, 광역 수정이 필요한 순간이 올 수 있다는 염려는 하는 것이 좋습니다..
변화에 열린
enum 을 마커 class 나 record 로 대체하면, 미래에 변화(멤버 추가)를 줄 수 있습니다.
둘 중에서 해시에 대한 인프라가 다 갖춰진 record 가 좀 더 편리합니다. (record struct는 상속을 통한 그룹화를 할 수 없으므로 해당되지 않습니다.)
이 문제를 OCP 준수하도록 - 변화에 열린 방식으로 다시 해결한다면,
최초에는, 미래에 대해 어떠한 예측을 하지 않고, 제시된 요구 사항을 액면 그대로 받아들이고 해결합니다.
namespace Values;
public abstract record ExerciseDay;
public sealed record Mon : ExerciseDay;
public sealed record Tue : ExerciseDay;
public sealed record Wed : ExerciseDay;
public sealed record Thu : ExerciseDay;
public sealed record Fri : ExerciseDay;
public record DayGoal(ExerciseDay Day, int Count)
{
public int Count { get; init; } = Count < 0 ? 0 : Count;
}
public class DayGoalSet : HashSet<DayGoal>
{
public DayGoalSet() : base(EqualityComparer<DayGoal>.Create(
(x, y) => {
if (ReferenceEquals(x, y)) return true;
if (x == null || y == null) return false;
return x.Day == y.Day;
},
x => x.Day.GetHashCode()
)) { }
}
class User
{
public DayGoalSet DayGoals { get; } = [];
}
나중에 주말을 추가해달라는 요구사항을 반영해도 기존 코드는 수정할 필요가 없습니다.
namespace Values.V2;
public sealed record Sat : ExerciseDay;
public sealed record Sun : ExerciseDay;
휴일이나, 생일을 추가해도 마찬가지입니다.
namespace Values.V3;
public sealed record Hol : ExerciseDay;
public sealed record DoB : ExerciseDay;
public sealed record GirlFriendDoB : ExerciseDay;
public sealed record PuppyDoB : ExerciseDay;
public sealed record NED : ExerciseDay; // 총선