구간이 핵심이다: .NET에서 범위 타입을 일급 도메인 객체로 모델링

발행: (2026년 6월 12일 AM 06:27 GMT+9)
11 분 소요
원문: Dev.to

출처: Dev.to

완전한 솔루션: 도메인 레이어에서 표현력 있는 구간 타입, 데이터 레이어에서 PostgreSQL 전체 변환 – 어느 쪽도 타협하지 않음

두 컬럼 함정

거의 모든 개발자는 최소 한 번은 이런 코드를 작성해 본 적이 있습니다. 두 개의 날짜 속성을 가진 객체:

public class MemberSubscription
{
    public int      Id        { get; set; }
    public int      MemberId  { get; set; }
    public DateTime StartDate { get; set; }
    public DateTime EndDate   { get; set; }
}

예약 시스템에서 “이 구독이 아직 활성 상태인지, 그리고 제안된 새로운 구독과 충돌하는지”와 같은 겉보기에 간단한 질문에 답해야 한다고 상상해 보세요. 두 개의 필드만 있으면 코드는 다음과 같이 보이게 됩니다:

// 두 개의 순수 DateTime 필드 — 언제든지 작성하게 되는 검사
public static bool IsActive(MemberSubscription sub, DateTime at)
    => sub.StartDate  at);

public static bool ConflictsWith(MemberSubscription a, MemberSubscription b)
{
    // 부분 겹침: a가 b 안에서 시작함
    if (a.StartDate >= b.StartDate && a.StartDate = a.StartDate && b.StartDate = b.EndDate) return true;
    // 무한 기간 구독은 어떻게 할까? 같은 날 경계는?
    // 포함형(end) vs 배제형(end) 날짜는?
    // …
    return false;
}

겉보기엔 전혀 이상해 보이지 않습니다. 하지만 스티브 스미스(Ardalis)가 그의 에세이 암묵적인 것을 명시적으로 만들기 에서 하듯이 질문을 던지면, 이 설계가 얼마나 많은 보이지 않는 지식을 요구하는지 알 수 있습니다. EndDateStartDate보다 앞설 수 있을까요? 타입 시스템은 알려주지 않습니다. 구독이 null 종료일을 가질 수 있다면 이는 “절대 만료되지 않는다”는 의미일까요? 모델 어디에도 그런 것이 없습니다. 오늘 종료되는 구독은 오후 11시 59분에 아직도 활성 상태일까요? 개발자 세 명에게 물어보면 세 가지 다른 답이 나옵니다.

EndDate == default 라는 sentinel 값은 “무한 기간”을 나타내기 위해 모든 호출 지점에 퍼집니다. 반쯤 열려 있는 구간 vs 닫힌 구간이라는 질문(Period.Contains(date); // 구간 경계 의미가 타입에 내장됨)도 마찬가지입니다.

public bool IsActiveOn(DateOnly date) => Period.Contains(date);
public bool ConflictsWith(MemberSubscription other) => Period.Overlaps(other.Period);
// 무한 기간 구독 — null도, sentinel도, 마법 날짜도 없음
var lifetime = new MemberSubscription(42, DateRange.CreateUnboundedEnd(DateOnly.FromDateTime(DateTime.Today)));

// 정해진 기간 구독
var annual = new MemberSubscription(
    7, DateRange.CreateFinite(
       new DateOnly(2025, 1, 1),
       new DateOnly(2025, 12, 31)
    )
);

lifetime.IsActiveOn(new DateOnly(2099, 1, 1));   // true — 무한 종료가 올바르게 처리됨
annual.ConflictsWith(lifetime);                  // true — 겹침이 정확히 감지됨

“절대 만료되지 않는다”는 sentinel 값이 없습니다. 작성, 감사, 테스트해야 할 별도 겹침 로직도 없습니다. EndDate가 포함형인지 배제형인지에 대한 숨은 가정도 없으며, 타입 자체가 이를 인코딩합니다. 구독이 최소 하루는 커버해야 한다는 불변 조건은 생성자에서 강제되며, 호출자에게 흩어지지 않습니다. 규칙은 이제 설계에 존재하고, 부족한 지식에 머무르지 않습니다.

PostgreSQL이 이미 알고 있는 것

PostgreSQL은 9.2 버전부터 이 문제를 이해하고 있었습니다. int4range, int8range, numrange, daterange, tsrange, tstzrange 같은 구간 타입을 통해 하나의 불가분 컬럼 값으로 구간을 저장하고, 포함(@>), 겹침(&&), 인접(-|-), 합집합(+), 교집합(*), 차집합(-) 등 전체 연산자를 사용할 수 있습니다.

daterange 컬럼을 이용하면 겹치는 예약을 구조적으로 불가능하게 만드는 제외 제약을 데이터베이스 수준에서 선언할 수 있습니다:

ALTER TABLE bookings ADD CONSTRAINT bookings_no_overlap
    EXCLUDE USING gist (resource_id WITH =, period WITH &&);

이 제약은 단순히 나쁜 데이터를 막는 것이 아니라, 저장 계층에서 원자적으로 방지합니다. 동시에 실행되는 여러 애플리케이션 인스턴스가 있더라도 레이스 컨디션, 재시도 루프, Redis 락 같은 부가 작업이 필요 없습니다. 데이터베이스가 직접 강제합니다. Radim Marek이 그의 글 시작과 끝을 넘어서 에서 말하듯이, “여기서 진짜 승리는 데이터 무결성입니다 — 데이터베이스에 잘못된 상태가 존재하는 것을 거의 불가능하게 만드는 것이죠.”

PostgreSQL 14에서는 멀티레인지 타입(int4multirange, datemultirange 등)이 추가되어, 하나의 컬럼에 서로 겹치지 않는 구간 집합을 저장하고 동일한 연산자로 조회할 수 있게 되었습니다.

이 모든 기능은 정말 강력합니다. .NET 개발자에게 남은 질문은: 이 표현력을 도메인 모델에 어떻게 가져올 것인가? 입니다.

NpgsqlRange가 실제로 무엇인가

PostgreSQL용 .NET 드라이버인 Npgsql은 구간 타입을 NpgsqlRange를 통해 노출합니다. 이는 readonly struct이며 PostgreSQL의 와이어 표현을 그대로 반영합니다: 두 개의 nullable 경계와 RangeFlags 바이트‑enum이 포함성·무한성을 비트 필드에 압축합니다. LowerBound에 대한 문서는 이렇게 솔직합니다:

“구간의 하한. LowerBoundInfinite가 false일 때만 유효합니다.”

즉, 속성에 대한 유효성 전제조건이며, 런타임 가드가 접근자를 가장합니다. 값을 사용하기 전에 플래그를 확인해야 합니다. 구간의 형태(한정, 시작 무한, 종료 무한, 무한, 빈)는 타입에 있지 않고 비트에 들어 있습니다.

와이어 타입으로서는 올바른 설계입니다. NpgsqlRange는 .NET과 PostgreSQL 사이에 구간 값을 전달하고 LINQ‑to‑SQL 변환에 참여하도록 만들어졌으며, 두 역할을 모두 훌륭히 수행합니다. 하지만 이러한 목표가 도메인 모델 기본형으로 사용하기엔 제약을 만들게 됩니다.

가장 명확한 증거는 도메인 로직에서 사용하려 할 때 나타납니다. Npgsql의 EF Core 패키지(Npgsql.EntityFrameworkCore.PostgreSQL)는 구간 연산을 위한 확장 메서드(Contains, Overlaps, IsAdjacentTo, Union, Intersect, Except 등)를 제공하는데, 모든 메서드의 구현은 다음과 같습니다:

public static bool Contains(this NpgsqlRange range, T value)
    => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(Contains)));

public static bool Overlaps(this NpgsqlRange a, NpgsqlRange b)
    => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(Overlaps)));

public static NpgsqlRange Intersect(this NpgsqlRange a, NpgsqlRange b)
    => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(Intersect)));

// … 그리고 다른 모든 구간 연산도 동일하게 예외를 던짐

이 메서드들은 LINQ 표현식 마커—EF Core 번역기가 SQL 연산자로 바꾸어 주는 스텁—일 뿐이며, 실제 프로세스에서 실행되도록 설계되지 않았습니다. EF Core DbSet에 대한 LINQ 쿼리 외부에서 호출하면 런타임에 InvalidOperationException이 발생합니다.

이는 결함이 아니라 NpgsqlRange가 의도한 바에 맞는 올바른 설계 선택입니다. 하지만 .NET 개발자는 메모리 내에서 사용할 수 있는 구간 타입이 없게 됩니다. NpgsqlRange로 순수 도메인 메서드를 작성할 수 없고, 데이터베이스 없이 구간 로직을 단위 테스트할 수도 없으며, 애플리케이션 레이어 경계를 넘을 때마다 Npgsql을 전역에 가져와야 합니다. Vladimir Khorikov가 말하듯, 이는 불순한 의존성이며, 그는 도메인 모델 순수성 에서 불순한 의존성은 가장자리에 두어야 한다고 주장합니다.

결국, .NET에는 메모리 내에서 도메인 수준의 구간 타입이 없었습니다. 이제는 없습니다.

CodoMetis.ValueRanges 등장

… (이하 내용은 원문에 따라 계속됩니다)

0 조회
Back to Blog

관련 글

더 보기 »

Eidentic 소개

Today we're releasing Eidentic, an open-source TypeScript SDK for building AI agents with self-improving memory and the production fundamentals built in — not b...

Typescript의 타입

Introdução Tipos são uma forma de definir a “forma” ou o contrato dos dados que estamos usando no código. Pensando em Javascript puro, ele é dinâmico: você pode...