> ## Content Index
> Fetch the complete content index at: https://mlog.me/llms.txt
> Use this file to discover other available public pages before exploring further.

# PostgreSQL 기간 겹침 조회: 시작일·종료일과 날짜 경계를 정확히 비교하기
- URL: https://mlog.me/postgresql-date-range-overlap-start-end-boundaries/
- Published: 2026-10-02T06:27:44.000Z
- Updated: 2026-10-02T06:27:44.000Z
- Description: PostgreSQL 시작일·종료일 검색에서 부분 겹침과 전체 포함을 구분합니다. 종료일 다음 날 미만 조건, 소수 초 누락, timestamp·timestamptz와 tsrange 경계를 재현 SQL로 확인합니다.
- Author: mLog
- Tags: 데이터베이스, PostgreSQL, SQL, 날짜·시간, 데이터 모델링

혜택 기간이 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 문서다.

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

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

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

이 글은 첫 번째를 구현한다. 시작일 하나로 최신 요금 이력을 선택하는 문제라면 [요금 우선순위·기준일 이력 설계](https://mlog.me/postgresql-fare-priority-effective-date-query/)처럼 다른 조회 모델을 적용해야 한다.

다음 전제를 사용한다.

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

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

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

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

```sql
WHERE start_at < until_at
  AND end_at >= from_at

```

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

```sql
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`로 한 혜택과 일곱 검색 범위를 만든다.

```sql
-- 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`는 소수 초를 표현할 수 있다. [공식 날짜·시간 자료형 문서](https://www.postgresql.org/docs/18/datatype-datetime.html?ref=mlog.me)는 해상도를 1마이크로초로 설명한다. 다음 비교에서 같은 날짜의 값이 빠지는 것을 확인할 수 있다.

```sql
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)`로 통일했다면 조건은 달라진다.

```sql
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 범위형 문서](https://www.postgresql.org/docs/18/rangetypes.html?ref=mlog.me)는 `timestamp` 범위를 `tsrange`, 시간대가 있는 범위를 `tstzrange`로 제공한다. [범위 연산자 문서](https://www.postgresql.org/docs/18/functions-range.html?ref=mlog.me)의 겹침 연산자 `&&`를 쓰면 의도를 표현하기 좋다.

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

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

```

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

`OVERLAPS`라는 SQL 연산자도 있다. 하지만 일반적인 기간은 끝을 제외하고, 시작·종료가 같은 경우에는 한 시각으로 취급하며, 역순 끝점도 정렬한다. 따라서 이번 종료 포함 데이터에 단순 치환하지 않는다. [공식 OVERLAPS 설명](https://www.postgresql.org/docs/18/functions-datetime.html?ref=mlog.me#FUNCTIONS-DATETIME-OVERLAPS)을 보고 계약이 맞을 때 선택한다.

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

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

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

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

```xml
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 공식 문서](https://mybatis.org/mybatis-3/sqlmap-xml.html?ref=mlog.me)는 `#{}` 파라미터 바인딩과 `${}` 문자열 치환을 구분한다. 날짜 값을 SQL 문자열에 직접 이어 붙이지 않는다. 위 Mapper는 설명용이며 이번 검증에 JDBC 드라이버와 MyBatis 실행은 포함하지 않았다.

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

```sql
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`를 적용하면 값의 타입과 비교 방식이 바뀐다. 우선 파라미터 쪽을 적절한 타입과 경계로 만들고 원본 컬럼을 비교하는 형태로 작성한다. 표현식으로 검색해야 한다면 [표현식 인덱스](https://www.postgresql.org/docs/18/indexes-expressional.html?ref=mlog.me)를 별도로 검토해야 한다.

그렇다고 `(start_at, end_at)` B-tree 인덱스 하나가 모든 겹침 조회를 빠르게 만든다고 단정할 수는 없다. [다중 컬럼 인덱스 문서](https://www.postgresql.org/docs/18/indexes-multicolumn.html?ref=mlog.me)를 기준으로 조건·선택도·실행 계획을 확인한다. 범위형과 GiST도 후보지만 이번 예제는 성능 비교를 하지 않았다.

상태 조건과 준비된 쿼리가 함께 있다면 [부분 인덱스와 generic plan 진단](https://mlog.me/postgresql-partial-index-prepared-statement-generic-plan/)을 참고하고, 변경 전후 실제 부하는 [pg\_stat\_statements 최근 구간 비교](https://mlog.me/postgresql-pg-stat-statements-snapshot-delta-slow-queries/)로 별도 측정한다.

## 적용 전 확인과 재현 자료

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

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

[PostgreSQL 기간 겹침 재현 SQL·검증 코드합성 데이터 SQL과 15개 검사 결과입니다. Node.js와 의존성 다운로드가 필요합니다.postgresql-period-overlap-repro-2026-10-02.zip4 KBdownload-circle](https://mlog.me/content/files/2026/10/postgresql-period-overlap-repro-2026-10-02.zip "Download")