mLog 명판이 있는 직조대에서 두 일정 리본의 겹치는 구간을 살펴보는 흰색 3D 유령 일정 직조공

PostgreSQL 기간 겹침 조회: 시작일·종료일과 날짜 경계를 정확히 비교하기

데이터베이스 PostgreSQL 2026년 10월 2일

혜택 기간이 9월 1일부터 10월 31일까지인데, 화면에서 8월 31일~9월 10일을 검색하면 조회되지 않는 경우를 생각해 보자. 두 기간은 9월 1일~10일에 겹친다. 그런데 “혜택이 검색 기간 전체를 포함하는가”라는 조건을 쓰면 해당 행이 빠진다.

부분적으로라도 겹치는 기간을 찾으려면 저장 시작시각과 검색 끝 경계, 저장 종료시각과 검색 시작 경계를 교차 비교한다. 검색 종료일은 다음 날 0시를 제외하는 경계로 만들면 소수 초를 빠뜨리지 않는다. 다만 저장된 종료시각을 포함하는 설계인지부터 확인해야 한다.

이 글은 설명용 데이터를 사용한다. 2026-10-02 Node.js 24.19.0, PGlite 0.5.8에서 15개 검사를 실행했다. 엔진은 PostgreSQL 18.3 (PGlite 0.5.8)로 응답했다. WebAssembly PostgreSQL에서 SQL 의미를 검증한 결과이며, 운영 서버·JDBC·MyBatis 연동이나 성능을 검증한 결과는 아니다. 공식 근거는 같은 날 확인한 PostgreSQL 18 문서다.

먼저 검색의 뜻을 한 문장으로 정한다

기간 검색에는 서로 다른 요구가 섞이기 쉽다.

요구 찾으려는 행
조금이라도 겹침 검색 기간과 공통 시각이 있는 혜택
혜택이 검색 기간 전체를 포함 검색 기간 내내 유효한 혜택
혜택 기간이 검색 기간 안에 들어감 시작부터 종료까지 검색 범위 안인 혜택

이 글은 첫 번째를 구현한다. 시작일 하나로 최신 요금 이력을 선택하는 문제라면 요금 우선순위·기준일 이력 설계처럼 다른 조회 모델을 적용해야 한다.

다음 전제를 사용한다.

  • start_at, end_at은 NULL이 아닌 timestamp without time zone이다.
  • 저장 기간은 양끝을 포함한다. end_at 시각에도 유효하다는 뜻이다.
  • 화면의 시작일·종료일은 둘 다 포함하는 달력 날짜다.
  • 시작이 종료보다 늦은 값은 입력 단계에서 거부한다.

저장 기간을 [start_at, end_at], 검색 기간을 [from_at, until_at)로 표현하겠다. 대괄호는 포함, 소괄호는 제외다. until_at은 화면 종료일의 다음 날 0시다.

종료시각을 포함하는 기존 데이터의 조건

겹치지 않는 상황은 두 가지다. 저장 기간이 검색 시작 전에 끝났거나, 검색의 제외 경계 이후에 시작한 경우다. 이를 제외하면 다음 조건이 된다.

WHERE start_at < until_at
  AND end_at >= from_at

예를 들어 화면 입력이 2026-08-31~2026-09-10이라면 실제 검색 경계는 8월 31일 0시 이상, 9월 11일 0시 미만이다.

SELECT b.*
FROM benefit AS b
WHERE b.start_at < DATE '2026-09-10' + INTERVAL '1 day'
  AND b.end_at >= DATE '2026-08-31';

저장 기간이 2026-09-01 00:00:00~2026-10-31 23:59:59인 행은 두 조건이 모두 참이다. 이 예제에서 조회되지 않는다면 비교 연산자를 무작정 바꾸기보다 실제 파라미터, 대상 행, 추가 필터를 확인해야 한다.

복사해 확인하는 7개 검색 범위

다음 SQL은 테이블을 만들거나 운영 데이터를 바꾸지 않는다. VALUES로 한 혜택과 일곱 검색 범위를 만든다.

-- Synthetic data; read-only CTE, no production table modifications.
WITH benefit(id, start_at, end_at) AS (
  VALUES ('base', TIMESTAMP '2026-09-01 00:00:00',
                  TIMESTAMP '2026-10-31 23:59:59')
), searches(label, from_date, through_date) AS (
  VALUES
    ('left_partial', DATE '2026-08-31', DATE '2026-09-10'),
    ('inside',       DATE '2026-09-10', DATE '2026-09-17'),
    ('contains',     DATE '2026-08-01', DATE '2026-11-30'),
    ('right_partial',DATE '2026-10-31', DATE '2026-11-02'),
    ('before',       DATE '2026-08-01', DATE '2026-08-31'),
    ('after',        DATE '2026-11-01', DATE '2026-11-02'),
    ('single_day',   DATE '2026-10-31', DATE '2026-10-31')
)
SELECT q.label,
       b.start_at < q.through_date + INTERVAL '1 day'
       AND b.end_at >= q.from_date AS hit
FROM benefit b CROSS JOIN searches q;

실행 결과에서 left_partial, inside, contains, right_partial, single_day는 true, before, after는 false였다. 왼쪽 일부만 겹치는 경우뿐 아니라 검색 기간이 혜택을 감싸는 경우도 포함된다.

반대로 start_at <= 검색시작을 요구하면 9월 1일 시작 혜택은 8월 31일 검색 시작을 충족하지 못한다. SQL 자체가 실패한 것이 아니라 “검색 기간의 시작부터 유효한 행”이라는 다른 요구를 구현한 것이다.

왜 23:59:59 이하로 끝내지 않는가

PostgreSQL timestamp는 소수 초를 표현할 수 있다. 공식 날짜·시간 자료형 문서는 해상도를 1마이크로초로 설명한다. 다음 비교에서 같은 날짜의 값이 빠지는 것을 확인할 수 있다.

SELECT
  TIMESTAMP '2026-10-31 23:59:59.5'
    <= TIMESTAMP '2026-10-31 23:59:59' AS old_hit,
  TIMESTAMP '2026-10-31 23:59:59.5'
    < TIMESTAMP '2026-11-01 00:00:00' AS new_hit;

실행 결과는 old_hit=false, new_hit=true였다. 달력 날짜 전체를 검색하려면 마지막 소수 초를 계산하기보다 다음 날 0시를 제외하는 편이 명확하다.

이것이 기존 혜택의 end_at을 자동으로 다음 날로 바꾸라는 뜻은 아니다. 이미 23:59:59까지로 저장했다면 그 값이 데이터의 종료시각이다. “날짜 전체 유효”라는 업무 규칙과 저장 값이 다르면 별도의 데이터 점검이 필요하다. 조회 조건만 고쳐서 원래 없던 유효 시간을 만들어서는 안 된다.

저장 종료시각도 제외한다면 >=를 >로 바꾼다

새 설계에서 저장 기간까지 [start_at, end_at)로 통일했다면 조건은 달라진다.

WHERE start_at < until_at
  AND end_at > from_at

예를 들어 9월 10일 0시에 끝나며 그 시각은 제외하는 혜택은, 9월 10일 0시에 시작하는 검색과 겹치지 않는다. 기존의 >=를 그대로 쓰면 경계에서 잘못 포함한다.

저장 종료 경계 검색 시작과 종료시각이 같을 때 비교
포함 그 한 시각을 공유 end_at >= from_at
제외 공통 시각 없음 end_at > from_at

반열린 저장 기간에는 start_at < end_at인 비어 있지 않은 행이라는 전제도 필요하다. 시작과 종료가 같은 [x,x)는 빈 기간이다. 비교식만 쓰면 이 빈 기간이 검색 범위 안에 들어가는 경우 잘못 포함될 수 있으므로, 제약이나 입력 검증으로 막거나 명시적으로 제외한다. 반면 [x,x]는 한 시각을 포함한다. 이 차이도 재현 코드에서 확인했다.

tsrange로 바꾸어도 경계 약속은 유지한다

PostgreSQL 범위형 문서는 timestamp 범위를 tsrange, 시간대가 있는 범위를 tstzrange로 제공한다. 범위 연산자 문서의 겹침 연산자 &&를 쓰면 의도를 표현하기 좋다.

종료시각을 포함하는 기존 데이터라면 다음처럼 경계를 각각 지정한다.

WHERE tsrange(start_at, end_at, '[]')
   && tsrange(from_at, until_at, '[)')

양쪽 모두 반열린 기간으로 저장한 설계라면 첫 번째도 '[)'다. 기본값에 기대기보다 기존 데이터의 뜻을 명시해 두는 편이 검토하기 쉽다. 재현에서는 종료 포함 데이터 네 사례에서 일반 비교식과 범위형 조건의 결과가 같은지 확인했다.

OVERLAPS라는 SQL 연산자도 있다. 하지만 일반적인 기간은 끝을 제외하고, 시작·종료가 같은 경우에는 한 시각으로 취급하며, 역순 끝점도 정렬한다. 따라서 이번 종료 포함 데이터에 단순 치환하지 않는다. 공식 OVERLAPS 설명을 보고 계약이 맞을 때 선택한다.

NULL도 주의해야 한다. end_at >= from_at에서 NULL 비교는 참이 되지 않지만, 범위 생성자의 NULL 경계는 끝이 없는 범위를 표현할 수 있다. NULL이 “무기한”인지 “미입력 오류”인지 정하지 않은 상태에서 범위형으로 바꾸면 조회 결과가 늘어날 수 있다. 이 글의 기본 예제는 두 컬럼 모두 NOT NULL을 전제로 한다.

화면 파라미터와 시간대도 같은 계약을 사용한다

화면 문자열이 20260910이라면 서버에서 유효한 날짜로 엄격하게 파싱하고, 검색 시작일이 종료일보다 늦지 않은지 확인한다. 빈 문자열을 임의의 최소·최대 날짜로 바꾸는 처리는 별도 요구가 있을 때만 추가한다.

MyBatis에서는 날짜로 검증된 값을 #{...}로 바인딩할 수 있다. 다음은 LocalDate 등 DATE로 전달되는 값과 종료 포함 저장 모델을 전제로 한 Mapper 예시다. XML의 <는 &lt;로 쓴다.

WHERE b.start_at &lt; CAST(#{throughDate,jdbcType=DATE} AS date)
                     + INTERVAL '1 day'
  AND b.end_at >= CAST(#{fromDate,jdbcType=DATE} AS date)

MyBatis 공식 문서는 #{} 파라미터 바인딩과 ${} 문자열 치환을 구분한다. 날짜 값을 SQL 문자열에 직접 이어 붙이지 않는다. 위 Mapper는 설명용이며 이번 검증에 JDBC 드라이버와 MyBatis 실행은 포함하지 않았다.

컬럼이 timestamptz라면 화면의 날짜가 어느 지역의 하루인지 정해야 한다. 한국 날짜 기준 경계는 다음처럼 명시할 수 있다.

SELECT
  DATE '2026-09-10'::timestamp
    AT TIME ZONE 'Asia/Seoul' AS from_at,
  (DATE '2026-09-17' + 1)::timestamp
    AT TIME ZONE 'Asia/Seoul' AS until_at;

위 from_at이 UTC 2026-09-09 15:00:00+00와 같은 시각인 것을 검사했다. 출력 문자열은 세션 시간대에 따라 다를 수 있다. timestamp와 timestamptz를 섞으면 세션 TimeZone이 변환에 관여할 수 있으므로 경계 타입까지 맞춘다. 여러 지역을 지원할 때는 날짜를 먼저 하루 증가시킨 뒤 그 지역의 자정으로 변환한다. 서머타임 지역의 하루가 항상 24시간이라는 가정은 피한다.

조건이 맞는데 결과가 없을 때 확인할 순서

  1. API가 받은 날짜와 DB에 바인딩한 값을 구분해 기록한다. 민감한 업무 식별자는 가린다.
  2. 대상 행의 원래 start_at, end_at 값을 확인한다. 화면에 표시된 날짜만 보지 않는다.
  3. 컬럼 타입과 SHOW TimeZone 결과를 확인한다.
  4. 상태·사용 여부·조직·JOIN 필터를 하나씩 분리해 어떤 조건에서 행이 빠지는지 확인한다.
  5. 같은 SQL·파라미터인지, 읽기 DB와 쓰기 DB가 다른지 확인한다.

9월 1일~10월 31일이라는 설명과 실제 값이 같고 이번 전제를 충족한다면 8월 31일~9월 10일 검색은 참이다. 이 작은 예제를 기준점으로 삼아 업무 쿼리에서 달라진 부분을 찾는다.

정확성을 확인한 다음 인덱스를 판단한다

날짜 비교를 위해 컬럼마다 to_char를 적용하면 값의 타입과 비교 방식이 바뀐다. 우선 파라미터 쪽을 적절한 타입과 경계로 만들고 원본 컬럼을 비교하는 형태로 작성한다. 표현식으로 검색해야 한다면 표현식 인덱스를 별도로 검토해야 한다.

그렇다고 (start_at, end_at) B-tree 인덱스 하나가 모든 겹침 조회를 빠르게 만든다고 단정할 수는 없다. 다중 컬럼 인덱스 문서를 기준으로 조건·선택도·실행 계획을 확인한다. 범위형과 GiST도 후보지만 이번 예제는 성능 비교를 하지 않았다.

상태 조건과 준비된 쿼리가 함께 있다면 부분 인덱스와 generic plan 진단을 참고하고, 변경 전후 실제 부하는 pg_stat_statements 최근 구간 비교로 별도 측정한다.

적용 전 확인과 재현 자료

적용 전에는 겹침인지 전체 포함인지, 저장 종료시각을 포함하는지, NULL과 역순 값을 어떻게 처리하는지를 먼저 확정한다. 이후 앞부분 겹침·뒷부분 겹침·완전 포함·같은 날·인접 경계·소수 초를 테스트한다.

글 끝에 첨부한 재현 묶음에는 example.sql, 실행 스크립트, 고정 의존성, 15개 검사 결과가 들어 있다. npm ci 후 node verify.mjs로 실행하며 외부 운영 DB에 연결하지 않는다. Node.js와 의존성 다운로드가 필요하다. 실제 서비스 적용 전에는 사용하는 PostgreSQL 서버와 JDBC/MyBatis 경로에서도 같은 경계를 확인해야 한다.

태그

mLog

웹 개발과 서버 운영 과정에서 마주친 문제와 해결 과정을 기록합니다. 직접 확인한 설정과 실행 결과를 함께 정리해, 비슷한 문제를 겪는 분들이 참고할 수 있도록 합니다.