Fullmoon System

ROW_NUMBER로 최신 행 선택: MAX JOIN 중복·동률·상태 필터 검증

AI Editter

고객별 마지막 상태를 가져오려고 MAX(updated_at)을 구한 뒤 원본에 JOIN했는데 고객 한 명이 두 줄씩 나온다면 같은 시각의 이력이 여러 개 있는지 확인합니다. MAX는 최대 시각을 구할 뿐 그 시각의 행을 한 개로 골라 주지 않습니다.

더 까다로운 문제는 “최신 활성 고객”이라는 요구입니다. 최신 상태가 active인 고객을 찾는 것과 active 이력 중 최신 행을 찾는 것은 다릅니다. 필터를 어디에 놓는지가 업무 결과를 바꿉니다.

ROW_NUMBER의 번호가 업무상 최신을 보장하지는 않는다

윈도 함수는 지정한 순서대로 번호를 붙입니다. 그 정렬 기준이 업무상 발생 순서와 다르면 쿼리는 정상 실행돼도 결과는 틀립니다. 아래 예제의 정답은 A의 최신 상태가 closed, B가 active라는 것입니다. 원본 행의 입력 순서를 뒤집어도 이 정답이 유지되는지, 최신 선택 전에 active 필터를 걸면 A가 잘못 살아나는지 두 결과를 직접 비교해 보세요. 이것이 단순 문법 확인과 현재 상태표 검증의 차이입니다.

최신 행의 정의를 정할 입력

고객이력 ID변경 시각상태
A101월 1일 09:00active
A111월 2일 10:00active
A121월 2일 10:00closed
B20NULLactive
B211월 3일 12:00active

이 예제는 변경 시각이 같으면 더 큰 이력 ID가 나중에 확정된 기록이라는 업무 규칙을 사용합니다. 시각이 NULL인 행은 최신 후보에서 뒤로 보냅니다. 따라서 A의 최신 상태는 closed, B는 active입니다.

ROW_NUMBER로 한 행을 고르는 쿼리

WITH history(customer_id, history_id, changed_at, status) AS (
  VALUES
    ('A', 10, TIMESTAMP '2026-01-01 09:00:00', 'active'),
    ('A', 11, TIMESTAMP '2026-01-02 10:00:00', 'active'),
    ('A', 12, TIMESTAMP '2026-01-02 10:00:00', 'closed'),
    ('B', 20, NULL::timestamp, 'active'),
    ('B', 21, TIMESTAMP '2026-01-03 12:00:00', 'active')
),
ranked AS (
  SELECT *,
    row_number() OVER (
      PARTITION BY customer_id
      ORDER BY changed_at DESC NULLS LAST, history_id DESC
    ) AS rn
  FROM history
)
SELECT customer_id, history_id, changed_at, status
FROM ranked
WHERE rn = 1
ORDER BY customer_id;

PostgreSQL 예제이며 기대 결과는 A=12/closed, B=21/active 두 행입니다. 윈도 함수 문서에서 row_number와 순위 함수의 차이를 확인할 수 있습니다. DB마다 NULL 정렬 문법과 기본 위치가 다르므로 엔진을 바꾸면 확인이 필요합니다.

상태 필터를 안쪽에 넣으면 의미가 달라진다

현재 활성인 고객만 원한다면 최종 WHERE를 rn = 1 AND status = 'active'로 바꿉니다. B만 남아야 합니다.

반대로 ranked를 만들기 전에 WHERE status = 'active'를 적용하면 A의 closed 기록은 사라집니다. 그 결과 A의 11번 active 이력이 선택됩니다. 이 쿼리는 “활성 이력 중 가장 최근”을 찾는 용도로는 맞지만 현재 활성 고객 목록으로는 틀립니다.

질문순서
현재 상태가 active인 고객전체에서 최신 선택 → 상태 필터
가장 최근 active였던 시점active만 선택 → 최신 선택
기준일 당시 고객 상태기준일 이전 이력 → 최신 선택
모든 동률 최신 기록최대 시각의 전체 행 또는 적절한 순위 함수

삭제·취소를 tombstone 행으로 기록하는 구조에서도 같은 문제가 생깁니다. 최신 삭제 기록을 먼저 제외하면 과거의 살아 있는 행이 되살아납니다.

이력 ID가 크다는 것이 정말 나중인가

예제는 ID가 확정 순서를 나타낸다는 전제를 두었습니다. 분산 수집, 외부 시스템 이관, 과거 데이터 재적재에서는 큰 ID가 최신 업무 상태를 뜻하지 않을 수 있습니다.

실제 의미가 발생 시각인지, 수집 시각인지, 적용 시각인지 정합니다. 같은 시각의 상충된 상태를 판정할 근거가 없다면 임의로 하나를 뽑기보다 충돌 목록으로 분리해 원천 데이터 규칙을 보완해야 합니다.

시각이 전부 NULL인 고객도 예제에서는 ID로 한 행이 선택됩니다. 이 결과를 “최신”이라고 표시해도 되는지 별도 정책을 정합니다. 시각 미확인 상태로 표시하거나 정상 집계에서 제외하고 예외 건수를 보고할 수 있습니다.

실무 검증과 인덱스 검토

결과 행 수가 고객별 하나인지 확인하고, 전체 고객 수와 최신 결과 수를 비교합니다. 고객 ID가 NULL인 행은 하나의 파티션으로 묶일 수 있으므로 입력에서 따로 검사합니다. 동률 시각의 행과 상태 충돌도 건수로 남깁니다.

데이터가 크면 customer_id와 정렬 컬럼을 포함한 인덱스를 후보로 검토할 수 있습니다. 그러나 인덱스 추가가 항상 전체 고객의 최신 상태 조회를 빠르게 만들지는 않습니다. 실제 실행 계획·정렬 비용·결과 행 수를 보고 판단하며 쓰기 비용과 저장 공간도 함께 비교합니다.

관련 절차: SQL JOIN 중복으로 SUM 합계가 늘어날 때: 재현과 해결