재편집 전 원문 백업 | SECTION 09 | 2026-10-14
마지막 값, 마지막 주문 한 건, 그룹별 마지막 데이터는 서로 다른 요구사항이다.
먼저 무엇을 기준으로 마지막을 판단할지 정하고, 그다음 인덱스와 SQL 패턴을 선택하자.
SECTION 09는 SECTION 01. SQL REMINDER 소개을 중심으로 MAX, ORDER BY ... LIMIT, ROW_NUMBER, DISTINCT ON, NOT EXISTS, LATERAL의 마지막 데이터 검색 패턴을 비교한다. PostgreSQL 실습 교재의 LAST1~4, WH서브쿼리2, 날짜6을 주요 근거로 사용한다.
예제·실행계획 전제
•
실행계획은 이 페이지를 작성하면서 PostgreSQL에서 실행한 것이 아닌 예상 실행계획이다. cost, 실제 시간, actual rows, Buffers 등 측정값을 임의로 작성하지 않는다.
•
tr_ord_big_x12(mbr_id,ord_dtm), 주문번호 PK 인덱스 등은 교재의 실습 설정에서 사용된 예시다. 현재 실제 DB에 존재하는지는 별도로 확인해야 한다.
•
최신 시각 ord_dtm / 가장 큰 주문번호 ord_no / 최신 일자의 주문 전체를 혼동하지 않는다. 주문번호 증가와 주문일시 증가가 항상 일치한다고 가정하지 않는다.
•
ROW_NUMBER, DISTINCT ON, LATERAL, NOT EXISTS 등은 요구사항의 동률·NULL 처리와 입력 범위가 같을 때만 결과를 비교할 수 있다.
•
PDF에 있는 실측 계획은 근거로 참고하되 아래 GOOD/BAD로 새로 구성한 예제의 실측 결과라고 표시하지 않는다.
이번 섹션의 핵심 규칙
ID | 구분 | 기억할 규칙 |
09-01 | MUST | 최댓값만 필요한지, 마지막 행 한 건이 필요한지 구분하자 |
09-02 | MUST | MAX 내부의 컬럼 가공을 피하자 |
09-03 | MUST | 최신 한 건에는 동률 처리 기준까지 ORDER BY에 넣자 |
09-04 | MUST | 상관 MAX를 행마다 반복하기보다 후보 집합을 먼저 줄이자 |
09-05 | TIP | ROW_NUMBER로 그룹별 마지막 한 건을 선택하자 |
09-06 | TIP | PostgreSQL DISTINCT ON을 그룹별 한 건 조회에 활용하자 |
09-07 | TIP | 외부 그룹 수가 적으면 LATERAL + LIMIT 1을 검토하자 |
09-08 | CHECK | NOT EXISTS로 더 나중 행이 없는 레코드를 찾을 수 있다 |
09-09 | CHECK | 월별 마지막 날짜의 전체 데이터를 따로 처리하자 |
09-10 | MUST | MAX(번호)+1을 신규 키 채번에 사용하지 말자 |
MUST: 결과 정확성과 기본 SQL 작성 규칙 / TIP: 재사용 가능한 검색 패턴 / CHECK: 실제 데이터·인덱스·실행계획 검증 필요
먼저 구분할 세 가지 '마지막'
요구사항 | 반환 결과 | 대표 방식 |
회원의 마지막 주문 시각 | 시각 한 값 또는 NULL | MAX(ord_dtm) |
회원의 마지막 주문 한 건 | 주문번호·시각·금액 등 한 행 | ORDER BY ord_dtm DESC, ord_no DESC LIMIT 1 |
회원 각각의 마지막 주문 한 건 | 회원당 최대 한 행 | ROW_NUMBER, DISTINCT ON, LATERAL, NOT EXISTS |
월별 마지막 일자에 발생한 모든 주문 | 월별 마지막 날짜의 주문 여러 건을 집계 | MAX(날짜) 후 원본 주문과 결합 |
REMINDER 09-01. MAX 값과 마지막 행의 조회는 다르다
MUST · 상황: 회원 M00100의 가장 최근 주문 시각만 필요하다.
BAD 후보 — 최댓값 계산에 불필요한 GROUP BY와 추가 출력
SELECT o.mbr_id, MAX(o.ord_dtm) AS last_ord_dtm
FROM tr_ord_big AS o
WHERE o.mbr_id = 'M00100'
GROUP BY o.mbr_id;
SQL
복사
GOOD — 시각 값만 집계
SELECT MAX(o.ord_dtm) AS last_ord_dtm
FROM tr_ord_big AS o
WHERE o.mbr_id = 'M00100';
SQL
복사
같은 '최신 시각'을 LIMIT으로 구하려면
SELECT (
SELECT o.ord_dtm
FROM tr_ord_big AS o
WHERE o.mbr_id = 'M00100'
AND o.ord_dtm IS NOT NULL
ORDER BY o.ord_dtm DESC
LIMIT 1
) AS last_ord_dtm;
SQL
복사
예상 실행계획
BAD (회원 주문이 있는 경우)
GroupAggregate 또는 Aggregate
-> Index Only Scan / Index Scan (mbr_id 조건)
GOOD MAX (x12 인덱스를 선택하고 최댓값 최적화가 적용되면)
Result
InitPlan
-> Limit
-> Index Only Scan Backward using tr_ord_big_x12
Index Cond: (mbr_id = 'M00100' AND ord_dtm IS NOT NULL)
LIMIT 방식 (같은 인덱스 사용 가능)
Result
InitPlan
-> Limit
-> Index Only Scan Backward (mbr_id = 'M00100')
Plain Text
복사
핵심: 단일 회원의 최신 시각만 구한다면 MAX(ord_dtm)이나 ORDER BY ... LIMIT 1이 명확하다. 불필요한 GROUP BY는 제거할 수 있다.
주의: 일치 주문이 0건이면 GROUP BY는 0행, MAX는 NULL 1행이므로 BAD/GOOD이 항상 결과까지 동일하지는 않다. LIMIT을 단독 SELECT로 작성하면 0행을 반환하지만 위처럼 스칼라 서브쿼리로 감싸면 NULL 1행이다. ORDER BY DESC의 기본 NULL 정렬을 피하기 위해 IS NOT NULL을 명시했다.
근거: PG PDF p.245~247, SQL-맛튜닝-LAST1/LAST2. 원본에서 MAX와 LIMIT 모두 인덱스 역방향 접근을 확인할 수 있다.
REMINDER 09-02. MAX 내부의 컬럼 가공을 피하자
MUST · 상황: 회원의 마지막 주문일을 YYYYMMDD 문자열로 표시한다.
BAD — 인덱스 컬럼을 먼저 TO_CHAR로 가공하여 MAX
SELECT MAX(TO_CHAR(o.ord_dtm, 'YYYYMMDD')) AS last_ord_ymd
FROM tr_ord_big AS o
WHERE o.mbr_id = 'M00100';
SQL
복사
GOOD — MAX로 먼저 시각을 찾고 나중에 표시 형식 변환
SELECT TO_CHAR(MAX(o.ord_dtm), 'YYYYMMDD') AS last_ord_ymd
FROM tr_ord_big AS o
WHERE o.mbr_id = 'M00100';
SQL
복사
예상 실행계획
BAD (실습 x12: mbr_id,ord_dtm)
Aggregate
-> Index Only Scan using tr_ord_big_x12
Index Cond: (mbr_id = 'M00100')
-> 찾은 여러 행에서 TO_CHAR 후 MAX 계산
GOOD (가능한 최댓값 최적화)
Result
InitPlan
-> Limit
-> Index Only Scan Backward using tr_ord_big_x12
Index Cond: (mbr_id = 'M00100' AND ord_dtm IS NOT NULL)
-> 최댓값 1개를 TO_CHAR로 변환
Plain Text
복사
핵심: 원본 B-tree가 ord_dtm 순서로 정렬되어 있는데, MAX(TO_CHAR(ord_dtm))로 가공하면 그 정렬을 직접 활용한 최댓값 검색이 어려울 수 있다.
주의: 일반적인 TIMESTAMP의 YYYYMMDD 표시가 목적이라는 전제다. 데이터 타입·타임존에 따라 표현과 의미를 검증해야 한다. 다른 컬럼 가공·표현식 인덱스가 있다면 계획은 달라진다.
근거: PG PDF p.248, SQL-맛튜닝-LAST3. 실제 교재에서 가공된 MAX는 별도 Aggregate로 여러 후보를 읽었다.
REMINDER 09-03. 마지막 '한 행'이면 ORDER BY의 동률 기준을 정하자
MUST · 상황: 회원 M00100의 최근 주문 한 건의 번호, 시각, 주문금액이 필요하다.
BAD — MAX 시각에 다시 결합하면 동률에서 두 건 이상 반환 가능
SELECT o.ord_no, o.ord_dtm, o.ord_amt
FROM tr_ord_big AS o
WHERE o.mbr_id = 'M00100'
AND o.ord_dtm = (
SELECT MAX(x.ord_dtm)
FROM tr_ord_big AS x
WHERE x.mbr_id = o.mbr_id
);
SQL
복사
GOOD — 동률 시 주문번호로 최종 1건 결정
SELECT o.ord_no, o.ord_dtm, o.ord_amt
FROM tr_ord_big AS o
WHERE o.mbr_id = 'M00100'
ORDER BY o.ord_dtm DESC NULLS LAST, o.ord_no DESC
LIMIT 1;
SQL
복사
예상 실행계획
BAD (가능한 형태)
Index Scan on tr_ord_big
Filter: (ord_dtm = SubPlan MAX)
SubPlan -> Result -> Limit -> Index Scan Backward
→ 최대 시각에 같은 주문이 여럿이면 모두 반환 가능
GOOD (회원·일시·번호 순서에 맞는 인덱스가 있는 경우)
Limit
-> Index Scan / Index Only Scan
Index Cond: (mbr_id = 'M00100')
정렬 순서가 맞으면 Sort 생략 가능
GOOD (기존 x12(mbr_id,ord_dtm)만 있는 경우)
Limit -> Sort / Incremental Sort / Index Scan
→ 동률 ord_no 비교를 위한 추가 정렬 가능
Plain Text
복사
핵심: MAX(ord_dtm)은 최댓값만 결정한다. 최신 시각에 주문이 여러 건이면 어느 주문을 선택할지 별도 업무 규칙이 필요하다.
주의: BAD는 동률 주문을 전부 반환할 수 있으므로 GOOD과 원래부터 결과가 다르다. 동률을 모두 보여주는 업무라면 MAX+JOIN 또는 FETCH FIRST 1 ROW WITH TIES 같은 별도 패턴을 검토한다. 실제로 '최신'이 주문일시가 아니라 주문번호 최댓값을 뜻하는 업무도 있을 수 있다.
근거: PG 교재 p.246~247의 역방향 인덱스·LIMIT 원리에서 확장한 교육용 예제. 동률 처리 규칙은 별도 보강 내용이다.
REMINDER 09-04. 상관 MAX를 모든 행에 반복하지 말고 먼저 대상을 좁히자
MUST · 상황: 2023년 1월 5일 주문 중 회원별 가장 큰 주문번호의 주문 한 건을 찾는다.
BAD — 바깥 주문 전체를 대상으로 회원별 MAX를 반복 비교
SELECT o.mbr_id, m.nick_nm, o.ord_no, o.ord_dtm, o.ord_amt
FROM tr_ord_big AS o
JOIN ms_mbr_big AS m ON m.mbr_id = o.mbr_id
WHERE o.ord_no = (
SELECT MAX(x.ord_no)
FROM tr_ord_big AS x
WHERE x.ord_dtm >= TIMESTAMP '2023-01-05'
AND x.ord_dtm < TIMESTAMP '2023-01-06'
AND x.mbr_id = o.mbr_id
)
ORDER BY o.mbr_id;
SQL
복사
GOOD — 해당 날짜의 후보만 먼저 검색해 회원별 최댓값 계산
WITH last_no AS (
SELECT x.mbr_id, MAX(x.ord_no) AS ord_no
FROM tr_ord_big AS x
WHERE x.ord_dtm >= TIMESTAMP '2023-01-05'
AND x.ord_dtm < TIMESTAMP '2023-01-06'
GROUP BY x.mbr_id
)
SELECT o.mbr_id, m.nick_nm, o.ord_no, o.ord_dtm, o.ord_amt
FROM last_no AS l
JOIN tr_ord_big AS o ON o.ord_no = l.ord_no
JOIN ms_mbr_big AS m ON m.mbr_id = o.mbr_id
ORDER BY o.mbr_id;
SQL
복사
예상 실행계획
BAD (상관 SubPlan이 유지되는 경우)
Index Scan / Seq Scan on tr_ord_big o
Filter: (ord_no = SubPlan 1)
SubPlan 1
-> Aggregate 또는 Limit + Index Scan (mbr_id = o.mbr_id)
→ 바깥 주문 행마다 MAX 서브쿼리 실행 위험
GOOD (가능한 형태)
HashAggregate / GroupAggregate (mbr_id, MAX(ord_no))
-> 1월 5일 주문 범위 검색
Nested Loop / Hash Join
-> 회원별 최대 주문번호 결과
-> 주문 PK Index Scan
-> 회원 PK Index Scan
Plain Text
복사
핵심: 상관 서브쿼리가 바깥 대용량 테이블의 각 행마다 반복되지 않도록 관심 날짜의 주문을 먼저 제한한 뒤 회원별 결과를 만든다.
주의: 여기서 '마지막'의 기준은 주문일시가 아니라 주문번호 최댓값이다. 원본 실습 WH서브쿼리2도 MAX(ord_no)를 사용한다. 주문번호 순서가 실제 시각 순서를 보장하지 않는 시스템에서는 최신 주문이라고 부르면 안 된다. ord_no가 주문 PK라는 전제에서 회원별 최대 번호는 하나뿐이다.
실제 교재 근거: PG PDF p.268~269, SQL-맛튜닝-WH서브쿼리2-1/2-3. 원본에는 바깥 주문 약 2,629만 행에 대한 SubPlan 반복이 나타난다. 이 페이지는 원본 측정값을 새로운 예제의 측정치로 옮기지 않았다.
REMINDER 09-05. ROW_NUMBER로 그룹별 마지막 한 건을 선택할 수 있다
TIP · 상황: 09-04와 같은 날짜의 회원별 최대 주문번호 한 건이 필요하다.
BAD 후보 — 상관 MAX 반복
SELECT o.mbr_id, o.ord_no, o.ord_dtm, o.ord_amt
FROM tr_ord_big AS o
WHERE o.ord_no = (
SELECT MAX(x.ord_no)
FROM tr_ord_big AS x
WHERE x.mbr_id = o.mbr_id
AND x.ord_dtm >= TIMESTAMP '2023-01-05'
AND x.ord_dtm < TIMESTAMP '2023-01-06'
);
SQL
복사
GOOD 후보 — 지정한 날짜 데이터에 순번을 부여
SELECT r.mbr_id, r.ord_no, r.ord_dtm, r.ord_amt
FROM (
SELECT o.mbr_id, o.ord_no, o.ord_dtm, o.ord_amt,
ROW_NUMBER() OVER (
PARTITION BY o.mbr_id
ORDER BY o.ord_no DESC
) AS rn
FROM tr_ord_big AS o
WHERE o.ord_dtm >= TIMESTAMP '2023-01-05'
AND o.ord_dtm < TIMESTAMP '2023-01-06'
) AS r
WHERE r.rn = 1;
SQL
복사
예상 실행계획
BAD 가능
tr_ord_big 전체 스캔
Filter: (ord_no = SubPlan)
SubPlan -> MAX 집계 / 인덱스 검색
→ SubPlan 과다 반복 위험
GOOD 가능
Subquery Scan
-> WindowAgg
Run Condition: (row_number() <= 1) [최적화될 수 있는 형태]
-> Sort / Incremental Sort (mbr_id, ord_no DESC)
-> 1월 5일 주문 범위 검색
Filter: (rn = 1)
→ 정렬·윈도 계산 비용이 발생할 수 있음
Plain Text
복사
핵심: 날짜 조건을 먼저 적용한 후 PARTITION BY mbr_id로 묶고, 회원별 ord_no DESC 첫 행을 선택한다.
주의: ROW_NUMBER는 SQL상 회원별 한 건을 만드는 방법일 뿐, 적합한 정렬 인덱스가 없다면 많은 후보 행의 Sort가 발생할 수 있다. MAX+선집계 방식과 성능 비교가 필요하다. 반환 그룹에 여러 건을 허용한다면 RANK/동률 처리 등 다른 요구사항이다.
교재 근거: PG PDF p.269, SQL-맛튜닝-WH서브쿼리2-4; p.268의 상관 MAX와 비교.
REMINDER 09-06. PostgreSQL DISTINCT ON으로 그룹별 한 건을 구하자
TIP · 상황: 2023년 1월 주문 중 회원별 최신 주문일시에 해당하는 한 건을 선택한다.
BAD 후보 — 그룹별 순번 계산을 사용한 방법
SELECT mbr_id, ord_no, ord_dtm, ord_amt
FROM (
SELECT o.mbr_id, o.ord_no, o.ord_dtm, o.ord_amt,
ROW_NUMBER() OVER (
PARTITION BY o.mbr_id
ORDER BY o.ord_dtm DESC NULLS LAST, o.ord_no DESC
) AS rn
FROM tr_ord_big AS o
WHERE o.ord_dtm >= TIMESTAMP '2023-01-01'
AND o.ord_dtm < TIMESTAMP '2023-02-01'
) AS x
WHERE x.rn = 1;
SQL
복사
GOOD 후보 — PostgreSQL 전용 DISTINCT ON
SELECT DISTINCT ON (o.mbr_id)
o.mbr_id, o.ord_no, o.ord_dtm, o.ord_amt
FROM tr_ord_big AS o
WHERE o.ord_dtm >= TIMESTAMP '2023-01-01'
AND o.ord_dtm < TIMESTAMP '2023-02-01'
ORDER BY o.mbr_id,
o.ord_dtm DESC NULLS LAST,
o.ord_no DESC;
SQL
복사
예상 실행계획
ROW_NUMBER (가능한 형태)
Subquery Scan
-> WindowAgg
-> Sort (mbr_id, ord_dtm DESC, ord_no DESC)
-> tr_ord_big의 1월 주문 검색
-> rn = 1
DISTINCT ON (가능한 형태)
Unique
-> Sort (mbr_id, ord_dtm DESC, ord_no DESC)
-> tr_ord_big의 1월 주문 검색
정렬 순서에 맞는 인덱스를 선택한 경우
Unique
-> Index Scan on tr_ord_big
→ 별도 Sort를 생략할 수 있지만, 그룹당 1건만 읽는다는 보장은 없음
Plain Text
복사
핵심: DISTINCT ON (mbr_id)은 PostgreSQL에서 그룹마다 정렬상 첫 행 하나만 선택하는 실용적인 구문이다. DISTINCT ON 표현식은 ORDER BY의 왼쪽 시작 컬럼과 일치해야 한다. 최종 선택을 안정적으로 하려면 ord_no처럼 유일한 키를 동률 기준으로 추가한다.
주의: BAD로 표시한 ROW_NUMBER도 정상적이고 유효한 방법이다. 이는 성능이 검증된 우열 비교가 아니라 동일한 업무 목적의 두 작성 패턴을 보여준다. 어느 쪽이 빠른지는 정렬 가능 인덱스·대상 데이터에 따라 달라진다. DISTINCT ON이 그룹마다 인덱스 최상위 키만 자동으로 건너뛰며 읽는다고 가정하면 안 된다.
근거 구분: ROW_NUMBER는 교재 p.269의 실습 패턴을 확장했다. DISTINCT ON 자체는 제공 PDF의 해당 실습에서 확인한 개선 SQL이 아니며, PostgreSQL 17 SELECT 공식 문서의 규칙을 바탕으로 추가했다.
REMINDER 09-07. 외부 회원이 적다면 LATERAL + LIMIT 1을 검토하자
TIP · 상황: GOLD 회원 중 지정된 두 회원의 가장 최근 주문 한 건을 구한다. 주문이 없어도 회원은 출력한다.
BAD 후보 — 전체 회원의 최신 주문을 먼저 결정하는 구조
SELECT m.mbr_id, m.nick_nm,
x.ord_no, x.ord_dtm, x.ord_amt
FROM ms_mbr_big AS m
LEFT JOIN (
SELECT o.mbr_id, o.ord_no, o.ord_dtm, o.ord_amt,
ROW_NUMBER() OVER (
PARTITION BY o.mbr_id
ORDER BY o.ord_dtm DESC NULLS LAST, o.ord_no DESC
) AS rn
FROM tr_ord_big AS o
) AS x ON x.mbr_id = m.mbr_id AND x.rn = 1
WHERE m.mbr_id IN ('M00019', 'M00020')
AND m.mbr_gd = 'GOLD';
SQL
복사
GOOD 후보 — 회원 두 명을 선별한 뒤 주문 한 건씩 검색
SELECT m.mbr_id, m.nick_nm,
x.ord_no, x.ord_dtm, x.ord_amt
FROM ms_mbr_big AS m
LEFT JOIN LATERAL (
SELECT o.ord_no, o.ord_dtm, o.ord_amt
FROM tr_ord_big AS o
WHERE o.mbr_id = m.mbr_id
ORDER BY o.ord_dtm DESC NULLS LAST, o.ord_no DESC
LIMIT 1
) AS x ON TRUE
WHERE m.mbr_id IN ('M00019', 'M00020')
AND m.mbr_gd = 'GOLD';
SQL
복사
예상 실행계획
BAD (외부 회원 조건이 내부에 전달되지 않는 경우)
WindowAgg
-> Sort (모든 주문의 mbr_id, ord_dtm DESC, ord_no DESC)
-> tr_ord_big 스캔
Join -> 선택 회원 2명
GOOD (적합한 인덱스가 있는 경우)
Nested Loop Left Join
-> 회원 PK 검색 (최대 2명)
-> Limit
-> Index Scan on tr_ord_big
Index Cond: (mbr_id = m.mbr_id)
ORDER BY에 맞는 검색 또는 추가 Sort
=> 주문 탐색을 필요한 회원에 제한 가능
Plain Text
복사
핵심: 외부 회원 집합이 작으면 LATERAL로 회원별 인덱스 검색 후 LIMIT 1을 하는 방법이 유리할 수 있다.
주의: PostgreSQL이 BAD의 회원 필터를 인라인 뷰 내부로 자동 전달하거나 정렬을 최적화할 수 있으므로 무조건 차이가 난다고 보장하지 않는다. 위 GOOD은 날짜·번호 동률 기준을 정했으며, 주문이 없는 회원은 LEFT JOIN LATERAL 덕분에 유지된다. 이 패턴의 상세 설명은 SECTION 07과 연결된다.
근거: 교재 p.246~247의 인덱스 역방향 탐색과 p.240~244의 LATERAL 조건 공급을 조합한 교육용 예제. 동일 SQL의 실측 원본은 아니다.
REMINDER 09-08. NOT EXISTS로 '나보다 나중 행이 없다'를 표현할 수 있다
CHECK · 상황: 모든 회원별로 NULL이 아닌 주문일시 기준 마지막 주문 한 건을 구한다.
BAD 후보 — ROW_NUMBER로 모든 주문을 정렬하는 방법
SELECT mbr_id, ord_no, ord_dtm
FROM (
SELECT o.mbr_id, o.ord_no, o.ord_dtm,
ROW_NUMBER() OVER (
PARTITION BY o.mbr_id
ORDER BY o.ord_dtm DESC, o.ord_no DESC
) AS rn
FROM tr_ord_big AS o
WHERE o.ord_dtm IS NOT NULL
) AS x
WHERE x.rn = 1;
SQL
복사
GOOD 후보 — 같은 회원의 더 나중 주문이 존재하지 않음
SELECT o.mbr_id, o.ord_no, o.ord_dtm
FROM tr_ord_big AS o
WHERE o.ord_dtm IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tr_ord_big AS x
WHERE x.mbr_id = o.mbr_id
AND x.ord_dtm IS NOT NULL
AND (
x.ord_dtm > o.ord_dtm
OR (x.ord_dtm = o.ord_dtm AND x.ord_no > o.ord_no)
)
);
SQL
복사
예상 실행계획
ROW_NUMBER (가능한 형태)
Subquery Scan
-> WindowAgg
-> Sort (mbr_id, ord_dtm DESC, ord_no DESC)
-> tr_ord_big 스캔
NOT EXISTS (가능한 형태)
Nested Loop Anti Join / Hash Anti Join
-> tr_ord_big 외부 후보 행들
-> 같은 회원의 더 나중 주문 존재 여부 검사
Index Cond 또는 Join Filter: mbr_id, ord_dtm, ord_no
=> 후보 행마다 더 나중 행 여부를 확인해야 할 수 있음
Plain Text
복사
핵심: 최신 행은 '같은 회원의 더 나중 행이 존재하지 않는 주문'으로 표현할 수 있다. 시각이 같을 때 주문번호 큰 행을 우선하도록 비교 조건을 추가해 한 건으로 확정했다.
주의: NOT EXISTS가 ROW_NUMBER보다 항상 빠르지는 않다. 후보 행이 매우 많고 인덱스가 부적절하면 반복 탐색으로 더 나빠질 수 있다. NULL 주문일시는 양쪽 SQL에서 명시적으로 제외했다. 이 논리를 제외하지 않으면 NULL 비교의 3값 논리 때문에 결과가 달라진다.
사례 근거: 204건 튜닝 요약의 0706_13, 0710_18, 0820_21에는 마지막 행 검색의 EXISTS/NOT EXISTS 기법이 분류돼 있다. 위 SQL은 BIG 모델로 새로 구성했다.
REMINDER 09-09. 월별 마지막 일자의 데이터는 '날짜'를 먼저 구할 수 있다
CHECK · 상황: 2023년 각 월에서 주문이 실제로 발생한 마지막 날짜를 찾고, 그 날짜 전체 주문의 금액을 합산한다.
BAD 후보 — 모든 일자의 주문을 먼저 집계한 뒤 월별 마지막 날짜를 선택
WITH daily AS (
SELECT DATE_TRUNC('month', o.ord_dtm)::date AS ord_month,
o.ord_dtm::date AS ord_day,
SUM(o.ord_amt) AS ord_amt
FROM tr_ord_big AS o
WHERE o.ord_dtm >= TIMESTAMP '2023-01-01'
AND o.ord_dtm < TIMESTAMP '2024-01-01'
GROUP BY DATE_TRUNC('month', o.ord_dtm)::date,
o.ord_dtm::date
),
ranked AS (
SELECT d.*,
ROW_NUMBER() OVER (
PARTITION BY d.ord_month ORDER BY d.ord_day DESC
) AS rn
FROM daily AS d
)
SELECT TO_CHAR(ord_month, 'YYYYMM') AS ord_ym,
ord_day AS last_ord_day, ord_amt
FROM ranked
WHERE rn = 1
ORDER BY ord_month;
SQL
복사
GOOD 후보 — 월별 마지막 주문일을 먼저 찾고 해당 날짜의 주문만 집계
WITH last_day AS (
SELECT DATE_TRUNC('month', o.ord_dtm)::date AS ord_month,
MAX(o.ord_dtm::date) AS last_ord_day
FROM tr_ord_big AS o
WHERE o.ord_dtm >= TIMESTAMP '2023-01-01'
AND o.ord_dtm < TIMESTAMP '2024-01-01'
GROUP BY DATE_TRUNC('month', o.ord_dtm)::date
)
SELECT TO_CHAR(d.ord_month, 'YYYYMM') AS ord_ym,
d.last_ord_day,
SUM(o.ord_amt) AS ord_amt
FROM last_day AS d
JOIN tr_ord_big AS o
ON o.ord_dtm >= d.last_ord_day::timestamp
AND o.ord_dtm < (d.last_ord_day + 1)::timestamp
GROUP BY d.ord_month, d.last_ord_day
ORDER BY d.ord_month;
SQL
복사
예상 실행계획
BAD (가능한 형태)
Subquery Scan / WindowAgg (월별 ord_day DESC 순위)
-> Sort
-> HashAggregate / GroupAggregate (월·일)
-> 2023년 주문 검색
=> 모든 일자의 집계를 생성하고 마지막 일자 선택
GOOD (가능한 형태)
HashAggregate / GroupAggregate (월별 MAX(ord_day))
-> 2023년 주문 검색
Nested Loop / Hash Join
-> 월별 마지막 날짜 (최대 12개 그룹)
-> Index Scan / Bitmap Heap Scan (해당 날짜 주문 범위)
HashAggregate / GroupAggregate (월별 SUM)
=> 최종 집계는 각 월의 마지막 날짜 주문만 대상
Plain Text
복사
핵심: 월별 마지막 '한 주문'을 찾는 문제가 아니다. 마지막 영업일 또는 마지막 거래일처럼 실제 주문이 있었던 마지막 날짜의 모든 주문을 집계하는 요구사항이다. 이때 날짜 결정과 최종 주문 집계를 나누어 생각할 수 있다.
주의: 월별 일수 전체를 먼저 집계하는 방식보다 GOOD이 언제나 빠르다는 뜻은 아니다. 마지막 날짜를 찾기 위한 첫 스캔이 여전히 필요하고, 해당 날짜를 다시 검색하므로 인덱스·날짜 분포에 따라 결정된다. ord_dtm은 TIMESTAMP(타임존 없는 시각)라고 가정했으며, 실제 timestamptz라면 업무 기준 타임존을 먼저 확정해야 한다.
실제 교재 근거: PG PDF p.277~278, SQL-맛튜닝-날짜6-1/6-2. 원본 실습에서도 일별 집계 후 ROW_NUMBER와 월별 마지막 날짜 결정 후 조인을 비교했다. 위 SQL은 PostgreSQL 날짜 표현으로 재구성했다.
REMINDER 09-10. MAX(번호) + 1을 신규 PK 채번에 사용하지 말자
MUST · 상황: 주문번호를 가장 큰 기존 번호보다 1 증가시켜 신규 주문을 입력하려 한다.
BAD — MAX + 1 채번
-- 실제 주문 INSERT 전에 새로운 번호를 직접 계산
SELECT MAX(o.ord_no) + 1 AS new_ord_no
FROM tr_ord_big AS o;
SQL
복사
GOOD — 신규 테이블 설계 시 IDENTITY와 INSERT RETURNING 활용
-- 구조 설명용 새 테이블. 실제 tr_ord_big을 변경하지 않는다.
CREATE TABLE sql_reminder_order_id_demo (
ord_no BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
mbr_id VARCHAR(20) NOT NULL
);
INSERT INTO sql_reminder_order_id_demo (mbr_id)
VALUES ('M00100')
RETURNING ord_no;
SQL
복사
예상 실행계획 및 동작
BAD (ord_no PK 인덱스가 있고 최댓값 최적화가 적용되면)
Result
InitPlan
-> Limit
-> Index Only Scan Backward on tr_ord_big
=> MAX 조회 자체는 빠를 수 있음
=> 그러나 별도 세션도 같은 MAX+1을 계산할 수 있음
GOOD (일반적인 INSERT 계획의 개념)
Insert on sql_reminder_order_id_demo
-> Result
RETURNING ord_no
=> IDENTITY/내부 시퀀스가 새 값을 할당하고 INSERT 결과를 반환
Plain Text
복사
핵심: MAX(ord_no) + 1이 인덱스 역방향 스캔으로 단 한 건을 읽더라도, 채번과 INSERT 사이에 동시성이 개입한다. 두 트랜잭션이 같은 번호를 계산하면 PK 충돌이 생길 수 있다.
주의: 예시 GOOD은 실제 tr_ord_big DDL을 변경하는 스크립트가 아니라 신규 테이블의 키 발급 설계 예시다. 기존 운영 테이블의 IDENTITY·시퀀스 도입에는 자료형, 기존 번호와 시작값, 연동 프로그램, 중복 처리 절차 검토가 필요하다. 시퀀스는 번호의 무결성과 동시성을 위한 도구이며 연속·무결번(빈 번호 없음)을 보장하지 않는다.
실제 교재 근거: PG PDF p.249, SQL-맛튜닝-LAST4. 원본에서도 성능 문제가 없어 보이더라도 MAX+1 채번은 동시성 위험이 있다고 설명한다.
어떤 최신 데이터 검색 패턴을 선택할까?
업무 목적 | 우선 검토할 SQL | 중요한 조건 |
한 회원의 마지막 주문 시각 | MAX(ord_dtm) | 일치 행 없음은 NULL |
한 회원의 최신 주문 한 건 | ORDER BY ... LIMIT 1 | ord_no 등 동률 기준 |
모든 회원별 한 건 | ROW_NUMBER / DISTINCT ON | 그룹별 정렬·중복 제거 비용 |
일부 회원별 한 건 | LATERAL ... LIMIT 1 | 외부 회원 수·내부 인덱스 |
더 나중 행이 없는 주문 | NOT EXISTS | 동률·NULL 처리·반복 탐색 |
월별 마지막 거래일의 주문 전체 | 날짜 MAX + 일자 범위 JOIN | 한 행이 아닌 마지막 일자의 모든 행 |
신규 주문번호 발급 | IDENTITY / SEQUENCE | MAX+1 동시성 위험 |
인덱스를 점검할 때
기존 실습의 대표 인덱스
-- 실제 실습 SQL에 등장하는 인덱스 구성을 설명
-- tr_ord_big_x12 : (mbr_id, ord_dtm)
-- tr_ord_big PK : (ord_no)
-- 실제 DB의 인덱스 확인용 SQL
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = current_schema()
AND tablename = 'tr_ord_big'
ORDER BY indexname;
SQL
복사
회원별 최신 주문 조회에 고려할 수 있는 별도 후보
-- 교육용 후보 DDL이며 실제 DB에는 생성하지 않았음.
-- NULLS LAST와 동률 기준까지 요구된 정렬을 명시한다.
CREATE INDEX tr_ord_big_latest_review
ON tr_ord_big (
mbr_id,
ord_dtm DESC NULLS LAST,
ord_no DESC
)
INCLUDE (ord_amt);
SQL
복사
이 후보 인덱스는 특정 회원에 대한 최신 주문 한 건 조회가 자주 발생할 때 검토할 수 있다. 기존 x12(mbr_id, ord_dtm)가 이미 존재한다면 인덱스 추가에 따른 중복·유지 비용도 함께 평가한다. INCLUDE로 Index Only Scan이 가능하더라도 visibility map 상태에 따라 Heap Fetches가 발생할 수 있다.
실행계획에서 확인할 신호
-- SQL을 실행하지 않는 계획 확인
EXPLAIN (COSTS OFF)
SELECT o.ord_no, o.ord_dtm, o.ord_amt
FROM tr_ord_big AS o
WHERE o.mbr_id = 'M00100'
ORDER BY o.ord_dtm DESC NULLS LAST, o.ord_no DESC
LIMIT 1;
-- 실습 DB가 준비되면 SELECT에 한해 실제 실행·측정
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT o.ord_no, o.ord_dtm, o.ord_amt
FROM tr_ord_big AS o
WHERE o.mbr_id = 'M00100'
ORDER BY o.ord_dtm DESC NULLS LAST, o.ord_no DESC
LIMIT 1;
SQL
복사
•
Index Scan Backward / Index Only Scan Backward: 정렬된 B-tree를 역방향으로 읽을 수 있는지 확인한다. 인덱스 키 구성·NULL 정렬에 따라 Backward가 아닌 정방향 Index Scan이 더 적합할 수도 있다.
•
Limit 위치: 필요한 1건을 찾기 전에 대량 Sort·Aggregate가 발생하는지 확인한다.
•
Sort / Incremental Sort: ROW_NUMBER, DISTINCT ON, 정렬 LIMIT에서 별도 정렬이 필요한지 살핀다.
•
WindowAgg / Unique: 그룹별 한 건 선택을 위해 몇 건을 처리하는지 확인한다.
•
SubPlan / loops: 상관 MAX가 주문 전체 행에 대해 지나치게 반복 평가되는지 확인한다.
•
Anti Join: NOT EXISTS가 Nested Loop Anti Join·Hash Anti Join 등 어떤 방식으로 실행되는지 확인한다.
•
Index Cond / Filter / Buffers: 그룹 키 검색 범위와 실제 읽기·필터 건수를 함께 평가한다.
EXPLAIN ANALYZE는 SQL을 실제로 실행한다. 여기 기재된 실행계획은 측정되지 않은 예상 형태다.
개발 전 체크리스트
'마지막' 기준이 시각 / 주문번호 / 날짜 중 무엇인가?
전체 최신 1건인가, 회원별 최신 1건인가?
마지막 시각만 필요한가, 주문 행 전체가 필요한가?
동률인 행은 모두 반환할 것인가, 한 건으로 확정할 것인가?
ORDER BY 마지막에 PK 등 유일한 기준을 넣었는가?
NULL 시각의 정렬 및 제외 규칙이 명확한가?
MAX 안에 TO_CHAR 같은 컬럼 가공이 들어 있지 않은가?
ROW_NUMBER를 적용하기 전에 일자·회원 조건을 충분히 제한했는가?
DISTINCT ON의 키가 ORDER BY의 왼쪽 시작 키와 일치하는가?
LATERAL 반복 횟수와 내부 검색 인덱스가 적절한가?
NOT EXISTS의 동률·NULL·반복 조회 조건이 정확한가?
월별 마지막 일자 전체 집계와 마지막 한 건 선택을 혼동하지 않았는가?
신규 번호 생성에 MAX+1을 사용하지 않았는가?
변경 전후 결과 건수·NULL·동률·정렬 순서가 업무 의도에 맞는가?
참고 자료와 작성 범위
첨부 PostgreSQL 교재와 실습 SQL에서 확인한 직접 근거
•
StartUPTuning_PG_20250616.pdf p.245~247 — 마지막 값, 인덱스 Backward, MAX(ord_dtm), ORDER BY LIMIT.
•
같은 PDF p.248 — MAX(TO_CHAR(ord_dtm,...))에서 컬럼 가공과 최댓값 검색 차이.
•
같은 PDF p.249 — MAX(ord_no)+1 채번의 동시성 오류 위험 및 IDENTITY/SEQUENCE 권장.
•
같은 PDF p.268~269 — 회원별 1월 5일 MAX(ord_no) 상관 서브쿼리, 회원별 선집계 후 JOIN, ROW_NUMBER 대안.
•
같은 PDF p.277~278 — 월별 마지막 주문일의 전체 주문금액을 일별 집계 후 ROW_NUMBER로 선택하거나 마지막 날짜를 먼저 결정한 뒤 집계하는 비교.
•
StartUPTuning_PG_SQL_Script.sql — SQL-맛튜닝-LAST1~4, SQL-맛튜닝-WH서브쿼리2-1~4, SQL-맛튜닝-날짜6-1/2.
•
— 0706_13, 0710_18, 0820_21 등 최신 데이터와 EXISTS/NOT EXISTS 관련 분류.
별도 공식 문서 기반 보강
•
PostgreSQL 17 — SELECT / DISTINCT ON: DISTINCT ON의 선택 행·ORDER BY 제약.
•
PostgreSQL 17 — Indexes and ORDER BY: B-tree 정렬·Backward 조회·LIMIT에 적합한 접근.
•
PostgreSQL 17 — Window Functions: ROW_NUMBER와 PARTITION BY.
교육용으로 별도 재구성한 예제
•
BIG 계열 BAD/GOOD SQL은 교재의 문제 유형과 요구사항을 설명하기 위해 새로 구성한 예제이며, 원본 튜닝 보고서에서 복사한 실측 SQL이 아니다.
•
DISTINCT ON, NOT EXISTS의 회원별 최신 주문, LATERAL과 인덱스 후보, IDENTITY 데모 등은 추가한 PostgreSQL 작성 패턴이다. 성능 우열은 이 자료에서 실측하지 않았다.
•
ord_no는 주문번호로서 고유하다는 논리 PK 전제를 사용한다. 실제 타입과 제약조건은 SECTION 01의 ERD만으로 확정하지 않는다.
추후 그림 제작 메모 — 이미지 미삽입
•
그림 A · MAX vs LIMIT: (mbr_id,ord_dtm) 인덱스 리프를 Backward로 읽어 첫 값을 선택하는 과정을 확대
•
그림 B · MAX(TO_CHAR) 위험: 모든 후보를 가공 후 MAX와 원본 날짜 MAX 후 출력만 변환하는 접근의 차이
•
그림 C · 그룹별 최신 1건 비교: 같은 회원별 주문 데이터에 ROW_NUMBER, DISTINCT ON, LATERAL, NOT EXISTS가 어떤 방식으로 한 행을 고르는지 나란히 표시
•
그림 D · 주문 동률 처리: 같은 ord_dtm 2건 중 ord_no까지 고려하면 어떤 행이 남는지, MAX+JOIN이 모두 반환할 수 있음을 시각화
•
그림 E · 월별 마지막 일자: 3월 30일·31일 주문 중 마지막 일자 31일의 모든 주문을 합산하는 개념도
•
그림 F · MAX+1 동시성: 세션 A/B가 같은 MAX를 읽고 같은 번호로 INSERT하다 PK 충돌이 나는 타임라인
SECTION 09 한 줄 요약: 마지막의 정의를 먼저 정하고, 동률·NULL을 명시한 뒤, 업무 범위를 최소화하는 패턴과 인덱스를 선택하자.




