PostgreSQL DAU·MAU 집계 쿼리 최적화: 조인 폭증과 인덱스 설계
서비스 초기에는 금방 끝나던 통계 쿼리가 어느 순간부터 수 초, 길게는 수십 초씩 걸리기 시작한다. 특히 사용자 테이블과 접속 로그를 조인해 DAU와 MAU를 계산하는 쿼리에서 자주 만나는 문제다.
원인은 SQL 문장의 길이보다 데이터베이스가 중간 단계에서 처리해야 하는 행의 수에 있다. 한 사용자에게 로그가 여러 건 존재하는 상태로 원본 테이블을 조인한 뒤 COUNT(DISTINCT ...)를 수행하면 최종 숫자는 맞을 수 있지만, 그 숫자를 얻기 전까지 만들어진 거대한 조인 결과는 그대로 처리해야 한다.
이 글에서는 다음 내용을 차례로 살펴본다.
- DAU·MAU 집계에서 조인 행이 폭증하는 이유
- 대상 사용자와 로그를 결합한 직후 사용자당 1행으로 줄이는 방법
- 대상 사용자 범위에 따라 달라지는 인덱스 순서
- 날짜와 시간대 경계를 안전하게 처리하는 방법
EXPLAIN (ANALYZE, BUFFERS)로 실제 개선 여부를 검증하는 방법
예제의 테이블명과 컬럼명은 공개 글에 맞게 일반화했다. SQL 예제는 PostgreSQL 12 이상을 기준으로 하며, INCLUDE 자체는 PostgreSQL 11부터 사용할 수 있다.
먼저 DAU와 MAU의 기준부터 정하자
쿼리를 고치기 전에 지표의 정의를 확정해야 한다. 같은 이름을 사용하더라도 조회 범위가 다르면 결과도 달라진다.
| 지표 | 이 글에서 사용하는 기준 |
|---|---|
| DAU | 업무 시간대 기준 하루 동안 한 번 이상 활동한 고유 사용자 |
| MTD MAU | 해당 월 1일부터 기준 시점까지 한 번 이상 활동한 고유 사용자 |
| Rolling 30-day | 기준 시점 직전 30일의 고유 사용자로, 달력 월 MAU와 별도 지표 |
이 글의 MAU는 엄밀히 말해 월 누적 MAU, 즉 MTD MAU다. 달력 월의 1일부터 기준 시점까지를 계산하며 최근 30일 활성 사용자와는 다르다.
또한 오늘 데이터를 현재 시각까지만 포함할지, 다음 날 자정 전까지의 완결된 하루로 계산할지도 정해야 한다. 예제는 조회 시점의 현재 회원 상태를 적용한다. 활동 당시의 소속이나 상태를 기준으로 과거 지표를 재현해야 한다면 이력 테이블이나 일별 스냅샷이 필요하다.
업무 시간대와 보고서의 기준 시점을 명시적으로 고정하려면 month_start, day_start, range_end를 애플리케이션에서 한 번 생성해 바인드 파라미터로 전달하는 방식이 재현하기 쉽다.
COUNT(DISTINCT)를 사용했는데도 왜 느릴까?
다음은 흔히 볼 수 있는 형태를 단순화한 예다.
SELECT
u.tenant_id,
u.group_code,
u.user_type,
COUNT(DISTINCT CASE
WHEN l.occurred_at >= :day_start
AND l.occurred_at < :range_end
THEN l.user_id
END) AS dau_count,
COUNT(DISTINCT l.user_id) AS mtd_mau_count
FROM app_user u
LEFT JOIN user_activity_log l
ON l.tenant_id = u.tenant_id
AND l.user_id = u.user_id
AND l.channel = 'MOBILE'
AND l.occurred_at >= :month_start
AND l.occurred_at < :range_end
WHERE u.tenant_id = :tenant_id
AND u.user_status IN ('ACTIVE', 'APPROVED')
GROUP BY u.tenant_id, u.group_code, u.user_type;
문법적으로는 문제가 없어 보인다. 하지만 한 사용자가 한 달 동안 500번 접속했다면 조인 결과에도 그 사용자가 500행으로 나타난다. 사용자 수가 늘고 로그가 누적될수록 집계 전에 처리해야 하는 행이 급격히 많아진다.
여기에 사용자당 여러 행을 가진 기기, 권한, 접속 이력 같은 테이블을 추가로 조인하면 상황은 더 심각해진다. 예를 들어 한 사용자의 접속 로그가 10행이고 기기 이력이 6행이면 중간 결과가 최대 60행으로 늘어날 수 있다.
COUNT(DISTINCT)는 최종 결과에서 같은 사용자를 한 번만 세도록 해준다. 그러나 그 전에 발생한 조인, 해시, 정렬, 메모리 사용과 임시 디스크 I/O까지 없애 주지는 않는다. 문제는 COUNT(DISTINCT) 자체가 아니라 행이 크게 늘어난 뒤에 중복을 제거하는 쿼리 구조다.
핵심 원칙: 추가 조인과 최종 집계 전에 사용자당 1행으로 줄인다
DAU와 MTD MAU는 모두 “고유 사용자 수”다. 이 예제는 대상 사용자와 로그를 결합한 직후 결과 그룹과 사용자 기준으로 묶는다. 축소된 결과만 후속 집계나 다른 다건 테이블 조인에 사용하면 중간 행의 증가를 억제할 수 있다.
처리 흐름은 다음과 같다.
| 단계 | 데이터 형태 | 목적 |
|---|---|---|
member_scope |
결과 그룹과 사용자당 1행 | 대상 조직·상태·사용자 유형 확정 |
member_group |
결과 그룹당 1행 | 활동이 없는 그룹도 0으로 출력 |
user_activity |
결과 그룹과 사용자당 1행 | 월 활동 여부와 당일 활동 여부 압축 |
activity_agg |
결과 그룹당 1행 | 단순 COUNT(*)로 DAU·MTD MAU 계산 |
WITH member_scope AS (
SELECT DISTINCT
tenant_id,
group_code,
user_type,
user_id
FROM app_user
WHERE tenant_id = :tenant_id
AND user_status IN ('ACTIVE', 'APPROVED')
),
member_group AS (
SELECT DISTINCT
tenant_id,
group_code,
user_type
FROM member_scope
),
user_activity AS (
SELECT
m.tenant_id,
m.group_code,
m.user_type,
l.user_id,
BOOL_OR(
l.occurred_at >= :day_start
AND l.occurred_at < :range_end
) AS is_dau
FROM member_scope m
JOIN user_activity_log l
ON l.tenant_id = m.tenant_id
AND l.user_id = m.user_id
AND l.channel = 'MOBILE'
AND l.occurred_at >= :month_start
AND l.occurred_at < :range_end
GROUP BY
m.tenant_id,
m.group_code,
m.user_type,
l.user_id
),
activity_agg AS (
SELECT
tenant_id,
group_code,
user_type,
COUNT(*) FILTER (WHERE is_dau) AS dau_count,
COUNT(*) AS mtd_mau_count
FROM user_activity
GROUP BY tenant_id, group_code, user_type
)
SELECT
g.tenant_id,
g.group_code,
g.user_type,
COALESCE(a.dau_count, 0) AS dau_count,
COALESCE(a.mtd_mau_count, 0) AS mtd_mau_count
FROM member_group g
LEFT JOIN activity_agg a
ON a.tenant_id = g.tenant_id
AND a.group_code IS NOT DISTINCT FROM g.group_code
AND a.user_type IS NOT DISTINCT FROM g.user_type;
user_activity에는 같은 결과 그룹의 사용자가 최대 한 번만 나타난다. 월 범위에 활동 기록이 있는 사용자는 모두 MTD MAU이므로 최종 단계의 COUNT(*)가 곧 MTD MAU가 된다. 같은 행에 is_dau = true가 기록된 사용자만 세면 DAU가 된다.
이 구조의 장점은 단순히 COUNT(DISTINCT)를 없앤 데 있지 않다. 월 로그를 DAU와 MTD MAU가 함께 사용하고, 대상 사용자와 로그를 결합한 직후 사용자 단위로 축소한 결과만 다음 단계로 넘긴다는 점이 핵심이다. 활동이 없는 그룹은 member_group과의 LEFT JOIN을 통해 0으로 유지한다.
집계 단위의 유일성이 반드시 보장되어야 한다
위 쿼리가 정확하려면 member_scope가 같은 결과 그룹에서 사용자당 한 행이어야 한다. 예제는 SELECT DISTINCT로 같은 (tenant_id, group_code, user_type, user_id) 조합을 제거한다. 다만 DISTINCT도 정렬이나 해시 비용이 들며, 원본 데이터의 중복 문제를 조용히 가릴 수 있다.
따라서 다음 중 하나를 확인해야 한다.
(tenant_id, group_code, user_type, user_id)조합이 원래부터 유일하다면 제약 조건을 확인하고 불필요한DISTINCT를 제거한다.- 업무상 중복이 가능하다면
member_scope에서 올바른 집계 단위로 중복을 제거한다. - 한 사용자가 서로 다른 그룹이나 사용자 유형에 속하면 이는 같은 그룹 안의 중복과 다르다. 각 그룹에 한 번씩 집계하는 것이 의도한 결과인지 확인한다.
클라이언트 유형 통계도 같은 원칙이 적용된다. 이력 테이블에 한 사용자당 여러 기록이 존재한다면 “가장 최근 기록 한 건”, “현재 활성 기록”, “복수 유형에 각각 귀속” 중 어떤 규칙을 사용할지 정한 뒤 사용자 단위로 축소해야 한다. 서로 다른 집계 단위의 데이터를 단순히 한 CTE로 합치면 속도뿐 아니라 결과값도 틀릴 수 있다.
월 전체 로그를 먼저 집계하면 언제나 빠를까?
항상 그렇지는 않다. 어떤 테이블을 먼저 줄이는 것이 좋은지는 선택도에 따라 달라진다.
예를 들어 전체 서비스의 월간 로그가 1억 행인데 이번 보고서의 대상 사용자가 3천 명뿐이라면, 월 로그 전체를 사용자별로 집계한 뒤 대부분을 버리는 방식은 비효율적일 수 있다. 이 경우에는 대상 사용자 집합을 먼저 만들고 해당 사용자의 로그만 읽는 편이 유리할 가능성이 높다.
반대로 전체 테넌트의 활성 사용자를 한꺼번에 계산하거나 대상 사용자가 전체 사용자 대부분을 차지한다면, 월 로그 범위를 순차적으로 읽어 사용자 단위로 집계하는 계획이 더 효율적일 수 있다.
| 조회 형태 | 우선 비교할 접근 방식 |
|---|---|
| 대상 사용자 집합이 작음 | 사용자 집합을 먼저 만들고 해당 사용자의 로그를 조회 |
| 테넌트 전체 월간 통계 | 테넌트의 월 로그를 범위 스캔한 뒤 사용자별 집계 |
| 전체 서비스 대시보드 | 시간 파티션, 요약 테이블 또는 물리화된 집계까지 검토 |
결국 “로그 테이블을 한 번만 읽었다”는 사실만으로 성능을 판단할 수 없다. 읽은 행 수, 집계 중간 행 수, 해시·정렬의 메모리 사용량을 실행 계획에서 비교해야 한다.
복합 인덱스의 순서는 쿼리 형태에 맞춰야 한다
앞선 예제처럼 대상 사용자를 먼저 정한 뒤 각 사용자의 로그를 찾는 형태라면 다음 인덱스가 출발점이 될 수 있다.
CREATE INDEX CONCURRENTLY idx_activity_user_channel_time
ON user_activity_log (
tenant_id,
user_id,
channel,
occurred_at
);
이 쿼리는 tenant_id, user_id, channel에 동등 조건을 사용하고 occurred_at에 범위 조건을 사용한다. B-tree 복합 인덱스는 일반적으로 왼쪽의 동등 조건과 그다음 범위 조건을 이용해 읽을 영역을 줄인다.
반대로 테넌트의 월 로그를 먼저 넓게 읽는 형태라면 다음 후보가 더 자연스럽다.
CREATE INDEX CONCURRENTLY idx_activity_tenant_channel_time
ON user_activity_log (tenant_id, channel, occurred_at)
INCLUDE (user_id);
이때 user_id는 검색 범위를 줄이는 조건이 아니라 집계에 필요한 값이다. 범위 컬럼인 occurred_at 뒤에 검색 키로 추가하는 것보다 INCLUDE 컬럼으로 두는 방식을 비교할 수 있다.
쿼리가 항상 channel = 'MOBILE'이라는 리터럴 조건을 사용한다면 부분 인덱스도 후보가 된다.
CREATE INDEX CONCURRENTLY idx_activity_mobile_time
ON user_activity_log (tenant_id, occurred_at)
INCLUDE (user_id)
WHERE channel = 'MOBILE';
다만 부분 인덱스는 쿼리 조건이 인덱스의 WHERE 조건을 계획 시점에 만족한다고 PostgreSQL이 판단할 수 있어야 한다. channel = :channel처럼 값이 바뀌는 파라미터 쿼리에서는 이 부분 인덱스를 사용하지 못할 수 있다.
또한 covering index가 있다고 해서 항상 index-only scan이 발생하는 것은 아니다. 쿼리에 필요한 값이 인덱스에 모두 있어야 하며, 해당 heap 페이지가 visibility map에서 all-visible 상태여야 실제 heap 접근을 피할 수 있다. 최근 쓰기가 계속 발생하는 로그 테이블의 최신 페이지는 heap fetch가 남을 수 있으므로 실행 계획의 Heap Fetches를 확인해야 한다.
인덱스는 정답 한 개를 외워서 적용하는 영역이 아니다. 작은 대상 사용자 조회와 넓은 기간 집계는 서로 다른 인덱스 순서를 요구할 수 있다.
운영 예제에는 쓰기 차단을 줄이기 위해 CONCURRENTLY를 표시했다. 이 방식도 추가 I/O와 CPU를 사용하고 일반 생성보다 오래 걸릴 수 있으며 트랜잭션 블록 안에서는 실행할 수 없다. 읽기 성능만 보지 말고 인덱스 크기, INSERT 비용, VACUUM 부담과 생성 작업의 운영 영향까지 함께 검토해야 한다.
날짜 조건은 [시작, 종료) 형태로 작성한다
날짜와 시각 범위는 시작을 포함하고 종료를 제외하는 반열린 구간이 안전하다.
occurred_at >= :start_at
AND occurred_at < :end_at
일반 B-tree 인덱스의 범위 탐색을 활용하려면 다음처럼 로그 컬럼에 함수나 형 변환을 적용하는 조건은 피하는 편이 좋다.
-- 피해야 할 예
occurred_at::date = :target_date
date(occurred_at) = :target_date
to_char(occurred_at, 'YYYY-MM-DD') = :target_date
컬럼 원형을 유지한 범위 조건은 일반 B-tree 인덱스가 탐색 범위를 잡기 쉽고, 자정의 경계값도 명확하게 처리한다. 함수 조건이 꼭 필요하다면 쿼리와 정확히 맞는 표현식 인덱스를 검토할 수 있지만, 인덱스 표현식은 immutable이어야 한다. timestamptz::date는 세션 시간대에 따라 결과가 달라지므로 그대로 인덱싱할 수 없다. 이 경우에는 명시적인 시간대 처리 방식을 설계하거나 반열린 범위 조건을 사용하는 편이 명확하다. BETWEEN은 양 끝값을 모두 포함하므로 연속된 기간을 나눠 조회할 때 자정 값이 두 구간에 중복될 수 있다.
CURRENT_DATE를 사용하면 코드가 짧아지지만 두 가지를 기억해야 한다.
첫째, PostgreSQL의 CURRENT_DATE와 CURRENT_TIMESTAMP는 호출 순간이 아니라 현재 트랜잭션의 시작 시각을 기준으로 고정된다. 자정 전에 시작한 장기 트랜잭션에서는 다음 날에도 전날 값을 반환할 수 있다.
둘째, occurred_at이 timestamptz라면 날짜 절삭과 암시적 형 변환은 세션 TimeZone의 영향을 받는다. 서비스 기준은 한국 시간인데 DB 세션이 UTC라면 UTC 자정 주변의 사용자가 전날 또는 다음 날로 잘못 분류될 수 있다.
보고서 기준일과 업무 시간대를 명시하고 경계 시각을 파라미터로 전달하면 두 문제를 함께 피할 수 있다.
Java와 Spring JDBC에서 시간 경계값 계산하기
occurred_at이 PostgreSQL의 timestamptz이고 업무 기준 시간대가 한국이라면, 하나의 기준 시각에서 실시간 경계를 계산한 뒤 OffsetDateTime으로 바인딩할 수 있다. pgJDBC의 표준 JDBC 4.2 매핑에서 timestamptz에 대응하는 타입은 OffsetDateTime이다.
ZoneId businessZone = ZoneId.of("Asia/Seoul");
Clock clock = Clock.systemUTC();
Instant cutoff = clock.instant();
ZonedDateTime businessCutoff = cutoff.atZone(businessZone);
LocalDate baseDate = businessCutoff.toLocalDate();
OffsetDateTime monthStart = baseDate
.withDayOfMonth(1)
.atStartOfDay(businessZone)
.toOffsetDateTime();
OffsetDateTime dayStart = baseDate
.atStartOfDay(businessZone)
.toOffsetDateTime();
OffsetDateTime rangeEnd = businessCutoff.toOffsetDateTime();
MapSqlParameterSource parameters = new MapSqlParameterSource()
.addValue("month_start", monthStart)
.addValue("day_start", dayStart)
.addValue("range_end", rangeEnd);
완결된 특정 날짜를 조회할 때는 rangeEnd를 baseDate.plusDays(1).atStartOfDay(businessZone).toOffsetDateTime()으로 정한다. 실시간과 완결 일자 중 어느 방식을 선택하더라도 한 요청에서는 기준 시각을 한 번만 만들고 모든 집계가 같은 값을 사용해야 한다. Hibernate/JPA를 사용할 때는 적용 중인 버전과 시간 타입 매핑도 별도로 확인한다.
occurred_at이 timestamp without time zone이라면 컬럼 값이 UTC 시각인지 한국 현지 시각인지 먼저 확인해야 한다. 시간대 정보가 없는 컬럼은 스키마와 애플리케이션 사이의 저장 규칙이 정확성의 전부다.
CTE가 성능을 자동으로 높여 주는 것은 아니다
반복해서 사용하는 대상 사용자 조건을 base_user 같은 CTE로 분리하면 쿼리의 의도가 명확해지고 중복 코드를 줄일 수 있다. 비재귀적이고 volatile 함수가 없는 SELECT CTE가 여러 번 참조되면 현재 PostgreSQL은 기본적으로 한 번 계산해 재사용한다.
그러나 다중 참조 CTE의 materialization은 바깥쪽 조건을 원본 테이블까지 밀어 넣는 최적화를 막을 수 있다. 각 사용처가 CTE 전체가 아닌 극히 일부만 필요로 한다면 NOT MATERIALIZED가 더 유리할 수도 있다. 반대로 비싼 계산을 여러 번 반복하지 않으려면 materialization이 도움이 된다. MATERIALIZED와 NOT MATERIALIZED 지정은 PostgreSQL 12 이상을 기준으로 검토한다.
따라서 다음처럼 단정하면 안 된다.
- “CTE로 바꾸면 무조건 한 번만 읽으니 빨라진다.”
- “
MATERIALIZED를 붙이면 항상 성능이 좋아진다.” - “CTE를 여러 개로 나누면 그 자체로 최적화된다.”
성능 개선의 핵심은 CTE 문법이 아니라 필터를 얼마나 일찍 적용하고, 후속 조인과 최종 집계 전에 카디널리티를 얼마나 줄였는가에 있다.
EXPLAIN (ANALYZE, BUFFERS)로 검증한다
쿼리를 고친 뒤 실행 시간만 한 번 비교해서는 충분하지 않다. PostgreSQL이 어떤 순서로 테이블을 읽고 몇 행을 다음 단계로 넘겼는지 확인해야 한다.
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
WITH member_scope AS (
-- 생략
)
SELECT ...;
다음 항목을 기존 쿼리와 개선 쿼리에서 비교한다.
- 로그 테이블을 몇 번 읽는가?
- 조인 직후
actual rows가 얼마나 증가하는가? - 예상 행 수와 실제 행 수의 차이가 큰가?
- 각 노드의
loops가 예상보다 많지 않은가? shared hit,shared read와 heap fetch가 얼마나 발생하는가?- 정렬이나 해시 집계가 임시 디스크로 spill되는가?
- 사전 집계 뒤의 행 수가 실제 고유 사용자 수에 가까운가?
EXPLAIN ANALYZE는 쿼리를 실제로 실행한다. 조회 쿼리라도 대형 로그를 읽으면 운영 부하가 발생할 수 있으므로 가능하면 스테이징이나 읽기 복제본에서 먼저 확인한다.
또한 Seq Scan이 나타났다는 이유만으로 실패라고 판단해서는 안 된다. 조회 대상이 테이블의 큰 비율이라면 순차 스캔이 인덱스 스캔보다 효율적일 수 있다. 통계가 오래되면 플래너의 행 수 추정도 부정확해지므로 대량 적재나 데이터 분포 변화 뒤에는 ANALYZE 상태를 확인한다.
결과 정확성도 함께 회귀 테스트한다
성능이 빨라졌더라도 결과가 달라지면 사용할 수 없다. 다음 데이터를 준비해 기존 쿼리와 개선 쿼리의 결과를 비교하는 것이 좋다.
- 한 사용자가 같은 날 여러 번 접속한 경우
- 한 사용자가 월 초와 오늘 모두 접속한 경우
- 정확히 자정에 이벤트가 발생한 경우
- 월 첫날과 다음 달 첫날의 경계값
- 활동이 전혀 없는 그룹
- 원본 회원 데이터에 같은 그룹의 사용자가 중복된 경우
- 탈퇴·비활성 사용자의 포함 기준
- 오늘 현재 시각까지의 실시간 집계와 완결 일자 집계
이 테스트는 단순한 SQL 튜닝 검증이 아니라 DAU·MAU라는 업무 지표의 정의를 고정하는 과정이기도 하다.
그래도 느리다면 다음 단계를 검토한다
쿼리 구조와 인덱스를 정리했는데도 매 요청마다 한 달 치 대형 로그를 다시 읽어야 한다면 실시간 원본 집계의 한계에 도달한 것일 수 있다.
이때는 다음 선택지를 검토할 수 있다.
- 날짜 기준 파티셔닝으로 읽을 파티션 범위를 제한한다.
- 일별 고유 사용자 또는 사용자별 최근 활동 요약 테이블을 운영한다.
- 갱신 주기가 허용된다면 materialized view나 별도 집계 배치를 사용한다.
- 반복되는 운영 쿼리는
pg_stat_statements로 호출 횟수와 누적 비용을 관찰한다.
파티셔닝이나 집계 테이블은 쿼리 한 줄을 고치는 것보다 운영 복잡도가 높다. 먼저 실행 계획으로 병목을 확인하고, 데이터 증가 속도와 허용 가능한 최신성에 맞춰 도입해야 한다.
마무리
DAU와 MTD MAU 집계 최적화의 핵심은 COUNT(DISTINCT)를 무조건 제거하는 데 있지 않다. 대상 사용자와 로그를 결합한 직후 올바른 집계 단위로 줄여, 후속 단계가 불필요한 중복 행을 처리하지 않게 만드는 것이 핵심이다.
마지막으로 아래 항목을 확인해 보자.
- DAU와 MAU의 기간 정의를 확정했는가?
- 시간 범위를
[시작, 종료)형태로 작성했는가? - 사용자와 활동 로그의 집계 단위를 확인했는가?
- 추가 다건 테이블 조인과 최종 집계 전에 사용자당 한 행으로 축소했는가?
- 대상 사용자 규모에 맞는 쿼리 방향을 선택했는가?
- 쿼리 형태에 맞춰 복합 인덱스 순서를 정했는가?
EXPLAIN (ANALYZE, BUFFERS)로 실제 읽은 행과 버퍼를 비교했는가?- 중복·자정·월 경계 데이터로 결과 정확성을 검증했는가?
이 진단 원칙은 DAU·MAU뿐 아니라 가입자 수, 권한, 결제 이력처럼 한 사용자에게 여러 행이 연결되는 통계 쿼리에도 적용할 수 있다.
참고 문서
- PostgreSQL 공식 문서: WITH Queries와 CTE Materialization
- PostgreSQL 공식 문서: Multicolumn Indexes
- PostgreSQL 공식 문서: Index-Only Scans and Covering Indexes
- PostgreSQL 공식 문서: Partial Indexes
- PostgreSQL 공식 문서: Date/Time Functions and Operators
- PostgreSQL 공식 문서: Using EXPLAIN
- PostgreSQL 공식 문서: ANALYZE
- PostgreSQL 공식 문서: CREATE INDEX
- PostgreSQL 공식 문서: pg_stat_statements
- pgJDBC 공식 문서: Java 날짜·시간 타입 매핑