Search

SECTION 09. 마지막 데이터 검색

상세 페이지

마지막 값을 가져오는 것과 마지막 행 한 건을 가져오는 것은 다르다. 최신의 기준, 동률, 그룹별 한 건 여부를 먼저 결정하자.
난이도: 초급 후반~중급 초반 · 설명 방식: BAD SQL → GOOD SQL → 핵심 규칙. 실행계획 전문 용어는 후반 SECTION 11에서 다룬다.
예외와 상세 예상 계획은 SECTION 09 원문 백업에 보존돼 있다.
학습 항목 이동: 기존 09-01 「GROUP BY에 불필요한 컬럼을 추가하지 말자」는 SECTION 07. GROUP BY와 집계 작성 규칙의 07-01로 이동했다. SECTION 09는 이제 최신 데이터 검색에 집중한다.

REMINDER 09-02. MAX 내부의 컬럼 가공을 피하자

상황: 회원별 최근 주문일자를 YYYYMMDD 문자열로 출력한다. 실제 일반 테이블 startdbpg.tr_ord에서 주문이 2,150건 있는 회원 M0035를 선택했다. 기존의 M00100은 주문이 0건이어서 인덱스 비교 실습에 적합하지 않다.
BAD — 모든 주문일시를 문자열로 변환한 후 MAX
SELECT MAX(TO_CHAR(o.ord_dtm, 'YYYYMMDD')) AS last_ord_ymd FROM startdbpg.tr_ord o WHERE o.mbr_id = 'M0035';
SQL
복사
MAX의 대상이 원본 TIMESTAMP가 아니라 TO_CHAR의 결과 문자열이다. 따라서 원본 ord_dtm의 B-tree 정렬 순서를 이용해 최신 1건만 읽는 최댓값 최적화를 적용하기 어려워진다.
GOOD — 원본 컬럼으로 MAX를 먼저 수행하고 최종 결과만 변환
SELECT TO_CHAR(MAX(o.ord_dtm), 'YYYYMMDD') AS last_ord_ymd FROM startdbpg.tr_ord o WHERE o.mbr_id = 'M0035';
SQL
복사
MAX(ord_dtm)는 원본 날짜·시각 컬럼의 최댓값을 찾는다. 조건과 정렬을 지원하는 인덱스가 있으면 PostgreSQL이 LIMIT 1 기반의 최댓값 검색으로 최적화할 수 있다.
실제 인덱스 구성 및 생성
기존 인덱스에는 tr_ord_fk01(mbr_id)와 tr_ord_x51(ord_dtm)가 각각 존재하지만, (mbr_id, ord_dtm) 복합 인덱스는 없었다. 실제 스키마에서 50번대 x51, x52, x53 이름 사용을 확인한 후 다음 인덱스를 실제로 생성했다.
CREATE INDEX IF NOT EXISTS tr_ord_x54 ON startdbpg.tr_ord (mbr_id, ord_dtm);
SQL
복사
•
생성 결과: startdbpg.tr_ord_x54 생성 성공
•
실제 정의: CREATE INDEX tr_ord_x54 ON startdbpg.tr_ord USING btree (mbr_id, ord_dtm)
•
인덱스 크기: 22MB (조회 시점)
•
데이터 입력·수정 시 인덱스 유지 비용과 저장 공간이 추가되므로 실습 목적과 운영 목적의 인덱스 추가는 구분해야 한다.
실제 데이터 / 결과 동등성
•
회원 M0035: 총 2,150건; 마지막 주문일시 2025-01-25 07:16:00
•
BAD 결과: 20250125
•
GOOD 결과: 20250125
•
추가 검증 M0039: 138건, BAD·GOOD 모두 20250119
•
주문이 없는 M00100: BAD·GOOD 모두 NULL
인덱스 생성 전 실행계획 — 실제 실측
항목
BAD
GOOD
스캔 방식
tr_ord_fk01(mbr_id) Index Scan
tr_ord_x51(ord_dtm) Index Scan Backward + 회원 Filter
스캔에서 읽은 행 수
2,150
1 (필터 제거 75행)
Shared Buffer Hit
2,060
7
실행시간 (각 1회)
12.570ms
2.357ms
기존 날짜 단일 인덱스에서도 GOOD은 MAX(ord_dtm) 최적화가 일어나 날짜 인덱스를 역방향으로 읽고 회원 조건에 일치하는 최신 주문 1건을 찾았다. 다만 회원 조건이 인덱스 선두 키에 없어서 다른 회원 주문 75행을 필터링했다.
인덱스 생성 후 실행계획 — 실제 실측 (EXPLAIN (ANALYZE, BUFFERS, COSTS OFF))
항목
BAD: MAX(TO_CHAR(...))
GOOD: TO_CHAR(MAX(...))
사용 인덱스
tr_ord_x54
tr_ord_x54
스캔 방식
Index Only Scan
Index Only Scan Backward
인덱스 스캔 반환 행 수
2,150
1
Heap Fetches
0
0
Buffers: shared hit
12
4
실행시간 (재실행 각 1회)
2.387ms
0.447ms
생성 직후의 캐시 워밍·동시 부하 영향을 줄이기 위해 인덱스 접근 후 재실행한 측정값이다. 다른 반복 실행에서는 BAD 2.771ms, GOOD 0.655ms도 관찰됐다. 실제 시간은 변동하므로 배율은 고정되지 않는다.
BAD — 인덱스 생성 후 실제 실행계획
Aggregate (actual time=2.244..2.245 rows=1 loops=1) Buffers: shared hit=12 -> Index Only Scan using tr_ord_x54 on tr_ord o (actual time=0.072..0.538 rows=2150 loops=1) Index Cond: (mbr_id = 'M0035'::text) Heap Fetches: 0 Buffers: shared hit=12 Planning Time: 0.746 ms Execution Time: 2.387 ms
Plain Text
복사
GOOD — 인덱스 생성 후 실제 실행계획
Result (actual time=0.207..0.208 rows=1 loops=1) Buffers: shared hit=4 InitPlan 1 -> Limit (actual time=0.106..0.107 rows=1 loops=1) Buffers: shared hit=4 -> Index Only Scan Backward using tr_ord_x54 on tr_ord o (actual time=0.105..0.106 rows=1 loops=1) Index Cond: ((mbr_id = 'M0035'::text) AND (ord_dtm IS NOT NULL)) Heap Fetches: 0 Buffers: shared hit=4 Planning Time: 0.995 ms Execution Time: 0.447 ms
Plain Text
복사
실행계획 읽기: BAD는 복합 인덱스를 사용하더라도 해당 회원의 2,150행을 읽고 각 ord_dtm을 TO_CHAR로 변환해 최대 문자열을 집계했다. GOOD은 mbr_id 동등 조건으로 인덱스 구간을 지정하고 ord_dtm을 역방향으로 읽어 첫 1건에서 멈췄다. 중요한 차이는 인덱스 사용 여부 자체가 아니라 읽어야 하는 인덱스 행 수다.
핵심: MAX/MIN의 대상 컬럼을 불필요하게 가공하지 말자. 최댓값을 찾을 때는 원본 컬럼을 사용하고, 문자 형식 등 출력용 변환은 집계가 끝난 후 적용하자. 해당 검색 조건과 집계 컬럼을 지원하는 인덱스가 있으면 최소 접근으로 끝날 수 있다.
주의할 예외
•
이 사례에서는 TIMESTAMP를 YYYYMMDD 문자열로 표현하는 최신 일자가 목적이므로 두 쿼리의 결과가 동일했다. 다른 문자열 형식이나 비단조 함수·타임존 변환·자료형 처리에서는 MAX(f(x)) = f(MAX(x))가 성립한다고 단정하면 안 된다.
•
ord_dtm IS NULL 데이터가 없는 실제 표본에서 검증했다. 주문 0건이면 두 방식 모두 집계 결과 NULL이다.
•
Index Only Scan에서 Heap Fetches = 0인 것은 현재 visibility map 등 접근 조건에 따른 결과이며 언제나 보장되는 것은 아니다.
•
복합 인덱스가 없어도 다른 인덱스를 통해 최댓값 최적화가 가능할 수 있다. 인덱스는 추가 공간·쓰기 비용이 있으므로 필요성을 실제 SQL 빈도·실행계획으로 판단한다.

REMINDER 09-03. 마지막 한 행은 동률까지 고려해 선택하자

상황: 회원의 가장 최근 주문 한 건에 대한 주문번호·주문일시·주문금액을 조회한다. 실제 일반 테이블 startdbpg.tr_ord의 회원 M0045를 선정했다. 이 회원은 주문 140건이 있고, 가장 최근 주문일시 2025-01-17 07:02:00이 같은 주문이 2건 존재한다.
BAD — MAX 주문일시와 같은 행을 모두 조회
SELECT o.ord_no, o.ord_dtm, o.ord_amt FROM startdbpg.tr_ord o WHERE o.mbr_id = 'M0045' AND o.ord_dtm = ( SELECT MAX(x.ord_dtm) FROM startdbpg.tr_ord x WHERE x.mbr_id = o.mbr_id ) ORDER BY o.ord_no DESC;
SQL
복사
MAX(ord_dtm)는 가장 최근 시각만 결정한다. 그 시각에 주문이 2건이면 2행 모두 반환하므로, 한 건만 필요한 업무 요건을 충족하지 못한다.
GOOD — 최신 일시로 정렬한 후 주문번호로 동률을 해소
SELECT o.ord_no, o.ord_dtm, o.ord_amt FROM startdbpg.tr_ord o WHERE o.mbr_id = 'M0045' ORDER BY o.ord_dtm DESC, o.ord_no DESC LIMIT 1;
SQL
복사
ord_dtm DESC로 최신 시각부터 조회하고, 같은 시각의 주문 중 ord_no가 큰 주문 1건을 선택한다. ord_no는 PK라서 정렬 순서가 유일하게 결정된다. 요청에 따라 NULLS LAST는 사용하지 않았다. 현재 실습 데이터에는 ord_dtm IS NULL인 행이 0건이다.
실제 결과 비교
구분
주문번호(ord_no)
주문일시(ord_dtm)
주문금액(ord_amt)
BAD 첫 행
725809
2025-01-17 07:02:00
11,300
BAD 두 번째 행
725781
2025-01-17 07:02:00
4,500
GOOD 한 행
725809
2025-01-17 07:02:00
11,300
실제 실행계획 검증 — EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
항목
BAD
GOOD
결과 행 수
2
1
주요 방식
상관 MAX SubPlan 반복 + Index Scan
Index Scan Backward → Incremental Sort → Limit
SubPlan 실행 횟수
140회
없음
Buffers: shared hit
518
14
실행시간 (각 1회)
2.517ms
0.223ms
BAD 실제 실행계획 (핵심 발췌)
Sort (actual time=2.385..2.386 rows=2 loops=1) Sort Key: o.ord_no DESC Buffers: shared hit=518 -> Index Scan using tr_ord_fk01 on tr_ord o (actual time=2.340..2.353 rows=2 loops=1) Index Cond: ((mbr_id)::text = 'M0045'::text) Filter: (ord_dtm = (SubPlan 2)) Rows Removed by Filter: 138 SubPlan 2 -> Result (actual time=0.011..0.011 rows=1 loops=140) InitPlan 1 -> Limit (actual time=0.010..0.011 rows=1 loops=140) -> Index Only Scan Backward using tr_ord_x54 on tr_ord x (actual time=0.010..0.010 rows=1 loops=140) Heap Fetches: 0 Planning Time: 1.039 ms Execution Time: 2.517 ms
Plain Text
복사
GOOD 실제 실행계획 (핵심 발췌)
Limit (actual time=0.135..0.136 rows=1 loops=1) Buffers: shared hit=14 -> Incremental Sort (actual time=0.134..0.135 rows=1 loops=1) Sort Key: ord_dtm DESC, ord_no DESC Presorted Key: ord_dtm Full-sort Groups: 1 Sort Method: quicksort Peak Memory: 25kB -> Index Scan Backward using tr_ord_x54 on tr_ord o (actual time=0.063..0.076 rows=3 loops=1) Index Cond: ((mbr_id)::text = 'M0045'::text) Planning Time: 0.850 ms Execution Time: 0.223 ms
Plain Text
복사
실행계획 해석: 09-02에서 실제 생성한 복합 인덱스 tr_ord_x54(mbr_id, ord_dtm)를 GOOD에서 사용했다. 인덱스를 역방향으로 조회하되, ord_no가 인덱스 키에 없으므로 같은 주문일시 안에서는 Incremental Sort로 동률을 정렬한다. 실제 스캔은 3행, 결과는 1행이었다. BAD에서는 상관 MAX가 140회 반복됐다. 실행시간은 각각 1회 실측값이며 데이터·캐시·계획에 따라 달라진다.
핵심: 가장 최근 시각의 MAX만으로 가장 최근 주문 한 건이 결정되는 것은 아니다. 한 건이 필요하면 업무상 동률 해소 기준(예: 주문번호)을 포함한 ORDER BY ... LIMIT 1을 사용하자.
주의할 예외
•
최신 주문일시의 동률 주문을 모두 반환해야 하는 업무라면 BAD의 여러 행은 오류가 아니며, LIMIT 1로 줄이면 결과가 달라진다.
•
주문번호 최댓값과 주문일시 최댓값은 같은 의미가 아니다. 먼저 업무상 최신의 기준을 정의한다.
•
이 예제의 ord_dtm은 실제 데이터에서 NULL이 없어서 NULLS LAST를 생략했다. PostgreSQL에서는 DESC 정렬의 기본 NULL 위치가 FIRST이므로, 향후 NULL이 허용·유입되면 별도 처리 여부를 결정해야 한다.
•
적절한 인덱스가 없을 때는 Sort 비용이 증가할 수 있다. 위 성능은 기존 tr_ord_x54 인덱스가 있는 현재 환경에서의 실측이다.

REMINDER 09-04. 상관 MAX를 반복하지 말고 먼저 집계하자

상황: 2023년 1월 5일 주문에서 회원별 가장 큰 주문번호를 가진 주문 한 건과 회원 닉네임을 구한다. 실제 일반 테이블 startdbpg.tr_ord, startdbpg.ms_mbr를 사용했다. 해당 날짜 주문은 590건, 회원은 380명이고, 정상 결과는 회원별 1행씩 380건이다.
BAD — 날짜를 먼저 제한해도 주문마다 상관 MAX를 반복
SELECT o.mbr_id, m.nick_nm, o.ord_no, o.ord_dtm, o.ord_amt FROM startdbpg.tr_ord o JOIN startdbpg.ms_mbr m ON m.mbr_id = o.mbr_id WHERE o.ord_dtm >= TIMESTAMP '2023-01-05' AND o.ord_dtm < TIMESTAMP '2023-01-06' AND o.ord_no = ( SELECT MAX(x.ord_no) FROM startdbpg.tr_ord 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
복사
바깥쪽 주문을 하루로 제한했는데도, WHERE의 상관 서브쿼리가 후보 주문 590행 각각에 대해 MAX(ord_no)를 재계산했다. 같은 회원의 주문이 여러 건이면 그 회원의 최대 주문번호를 중복 계산하게 된다.
GOOD — 해당 날짜 주문을 회원별로 한 번 집계한 후 필요한 행과 JOIN
WITH last_no AS ( SELECT x.mbr_id, MAX(x.ord_no) AS ord_no FROM startdbpg.tr_ord 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 l JOIN startdbpg.tr_ord o ON o.ord_no = l.ord_no JOIN startdbpg.ms_mbr m ON m.mbr_id = o.mbr_id ORDER BY o.mbr_id;
SQL
복사
날짜 범위의 590건을 한 번 읽고 회원별 MAX(ord_no)를 380건으로 선집계한다. 이후 주문 PK(ord_no)와 회원 PK(mbr_id)로 상세 정보에 접근한다.
실제 데이터 / 결과 검증
•
2023-01-05 주문 590건, 서로 다른 회원 380명.
•
BAD와 GOOD 모두 380건을 반환했다.
•
출력 컬럼 전체에 대해 EXCEPT ALL을 양방향으로 실행한 결과 차이 0건이다.
•
원문 BAD에는 바깥쪽 날짜 조건이 없었다. 이번에는 BAD에도 같은 날짜 조건을 추가해 후보 범위를 같게 만들고, 상관 집계 반복과 선집계의 차이에 집중했다. 원래 BAD가 더 넓은 범위를 검사할 수 있다는 사실과 별개의 비교다.
실제 실행계획·성능 비교 (EXPLAIN (ANALYZE, BUFFERS, COSTS OFF))
항목
BAD: 행별 상관 MAX
GOOD: 회원별 선집계
대상 날짜 주문
590건
590건
MAX(ord_no) 처리
SubPlan 590회 실행
HashAggregate 1회
집계 결과 회원
개별 주문의 조건 검사 후 380건
380개 그룹
최종 반환 행
380건
380건
Buffers: shared hit
3,856
2,676
실행시간 (재측정 각 1회)
9.703ms
5.578ms
BAD — 실제 실행계획 (핵심 노드 발췌)
Sort (actual time=9.467..9.503 rows=380 loops=1) Sort Key: o.mbr_id Buffers: shared hit=3856 -> Nested Loop (actual time=0.268..8.845 rows=380 loops=1) -> Index Scan using tr_ord_x51 on tr_ord o (actual time=0.078..6.652 rows=380 loops=1) Index Cond: (ord_dtm >= '2023-01-05' AND ord_dtm < '2023-01-06') Filter: (ord_no = (SubPlan 1)) Rows Removed by Filter: 210 SubPlan 1 -> Aggregate (actual time=0.010..0.010 rows=1 loops=590) -> Index Scan using tr_ord_x54 on tr_ord x Index Cond: (mbr_id = o.mbr_id AND ord_dtm 범위) -> Index Scan using ms_mbr_pk on ms_mbr m Planning Time: 1.918 ms Execution Time: 9.703 ms
Plain Text
복사
위 Index Cond는 비교 기간을 읽기 쉽게 줄여 쓴 발췌 표현이다. 원본 계획에는 PostgreSQL 자료형 변환·날짜 상수 등이 상세하게 출력된다.
GOOD — 실제 실행계획 (핵심 노드 발췌)
Sort (actual time=5.280..5.337 rows=380 loops=1) Sort Key: o.mbr_id Buffers: shared hit=2676 -> Nested Loop (actual time=0.696..4.081 rows=380 loops=1) -> Nested Loop (actual time=0.674..1.966 rows=380 loops=1) -> HashAggregate (actual time=0.634..0.763 rows=380 loops=1) Group Key: x.mbr_id Batches: 1 Memory Usage: 73kB -> Index Scan using tr_ord_x51 on tr_ord x (actual time=0.038..0.268 rows=590 loops=1) -> Index Scan using tr_ord_pk on tr_ord o Index Cond: (ord_no = max(x.ord_no)) -> Index Scan using ms_mbr_pk on ms_mbr m Planning Time: 2.841 ms Execution Time: 5.578 ms
Plain Text
복사
실행계획 읽기: BAD는 tr_ord_x54(mbr_id, ord_dtm) 복합 인덱스가 있어도 SubPlan loops=590으로 같은 목적의 MAX 조회를 반복했다. GOOD은 tr_ord_x51(ord_dtm)로 기간 주문 590건을 읽은 다음 HashAggregate로 380개 그룹을 한 번에 만들었다. 이 사례는 인덱스 추가가 아니라 SQL 작성 방식의 변화에 의한 반복 작업 감소를 보여준다. 실행시간은 캐시·부하에 따라 달라지며 다른 재측정에서도 BAD 9.910ms, GOOD 4.870ms가 관찰됐다.
핵심: 여러 행을 처리하면서 같은 그룹의 MAX를 상관 서브쿼리로 반복 계산한다면, 필요한 범위를 먼저 읽고 그룹별로 한 번 집계한 후 JOIN하자.
주의할 예외
•
여기서 '마지막'은 그날 회원별 ord_no 최댓값이다. 가장 최근 주문일시와 같은 뜻이라는 보장은 없다.
•
ord_no가 PK이므로 최대 주문번호에 해당하는 주문 행은 하나뿐이다. 집계에 여러 값을 함께 출력하거나 1:N 관계에 JOIN할 때는 반환 행 수를 다시 확인해야 한다.
•
PostgreSQL 옵티마이저가 상관 서브쿼리를 효율적으로 처리하거나, 외부 후보가 아주 적은 경우에는 선집계가 더 빠르다고 단정할 수 없다.
•
09-05는 같은 업무를 ROW_NUMBER()로 해결하는 다른 패턴이다. 어느 방식이 빠른지는 별도 실행계획 비교가 필요하다.

REMINDER 09-05. ROW_NUMBER로 그룹별 마지막 한 건을 선택할 수 있다

검토 상태: 추가 테스트 필요 (보류)
•
후속 검증: 일반 테이블 startdbpg.tr_ord에서 NOT EXISTS·ROW_NUMBER()·GROUP BY + MAX를 업무 조건별로 추가 비교한다. 회원별/매장별, 날짜 범위, Hash Anti Join·Merge Anti Join·Nested Loop Anti Join 선택에 따른 성능 차이를 확인한다.
•
현재까지 관찰: 매장별 주간 조회에서는 NOT EXISTS가 경쟁력 있었으나, 회원별 조회와 단일 매장 장기간 조회에서는 ROW_NUMBER()가 유리했다. 새로 만든 tr_ord_x55(shop_id, ord_dtm, ord_no)가 실제 계획에 쓰이는지 및 쓰기 비용 대비 실효성도 검토해야 한다.
•
주의: 아래 tr_ord_big의 기존 BAD/GOOD은 미확정 초안으로 유지한다. ROW_NUMBER()가 항상 빠르다는 의미는 아니며, NOT EXISTS로 대체할지는 추가 테스트 후 결정한다.
상황: 09-04와 같은 날짜의 회원별 최대 주문번호 한 건이 필요하다.
BAD — 기존 또는 비교 대상
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
복사
핵심: 날짜 조건을 먼저 적용한 후 PARTITION BY mbr_id로 묶고, 회원별 ord_no DESC 첫 행을 선택한다.
주의할 예외: ROW_NUMBER는 SQL상 회원별 한 건을 만드는 방법일 뿐, 적합한 정렬 인덱스가 없다면 많은 후보 행의 Sort가 발생할 수 있다. MAX+선집계 방식과 성능 비교가 필요하다. 반환 그룹에 여러 건을 허용한다면 RANK/동률 처리 등 다른 요구사항이다.

SQL 작성 후 체크리스트

MAX로 값 하나가 필요한가, 최신 주문 한 행이 필요한가?
주문번호의 최댓값을 최신 시각과 혼동하지 않는가?
동률 시 PK 등 유일한 기준을 지정했는가?
그룹별 마지막 한 건 조회에서 그룹 기준과 정렬 기준을 명확히 했는가?
한 줄 요약: 마지막 값을 가져오는 것과 마지막 행 한 건을 가져오는 것은 다르다. 최신의 기준, 동률, 그룹별 한 건 여부를 먼저 결정하자.