본문 바로가기
데이터베이스(SQL)/데이터베이스 개념

SQL ROW_NUMBER: 고객별 최신 주문과 동점 처리 기준

by char_lie 2026. 9. 11.
반응형

SQL ROW_NUMBER: 고객별 최신 주문과 동점 처리 기준 썸네일

고객마다 가장 최근 주문 한 건을 조회하려면 MAX(ordered_at)만으로는 주문 번호와 금액까지 한 행으로 선택하기 어렵다. 고객별로 행에 번호를 붙인 뒤 첫 행을 고르면 원래 주문의 다른 열도 함께 가져올 수 있다.

ROW_NUMBER()에 PARTITION BY customer_id와 정렬 조건을 주고, 바깥 쿼리에서 rn = 1을 선택한다. 같은 시각의 주문이 두 건 있다면 무엇을 남길지도 정렬 조건에 적어야 한다. 아래 실습은 SQLite 3.53.4의 별도 메모리 데이터베이스에서 실행했다.

동점이 있는 데이터부터 만들기

아래 두 문장을 새 실습 데이터베이스에서 순서대로 실행한다. ordered_at은 같은 시간대의 고정 길이 YYYY-MM-DD HH:MM:SS 문자열이고 NULL은 허용하지 않는다. 이 조건에서 문자열 순서와 시각 순서가 일치한다. 실제 서비스에서는 사용하는 DB의 날짜·시각 자료형과 시간대 정책에 맞춘다.

CREATE TABLE order_demo (
    order_id INTEGER PRIMARY KEY,
    customer_id TEXT NOT NULL,
    ordered_at TEXT NOT NULL,
    amount INTEGER NOT NULL
);

INSERT INTO order_demo VALUES
    (101, 'A', '2026-09-01 09:00:00', 12000),
    (102, 'A', '2026-09-02 10:00:00', 18000),
    (103, 'A', '2026-09-02 10:00:00', 16000),
    (201, 'B', '2026-09-01 11:00:00', 7000),
    (202, 'B', '2026-09-03 08:00:00', 9000),
    (301, 'C', '2026-09-01 12:00:00', 5000);

A 고객은 가장 최근 시각이 같은 주문 두 건을 가진다. 이 글에서는 시각이 같으면 주문 번호가 큰 행을 남기기로 정한다. 주문 번호가 실제 생성 순서를 항상 뜻한다는 가정은 하지 않으며, 동점 해소를 위해 택한 규칙이다.

고객 안에서 번호를 붙이고 첫 행 선택하기

WITH numbered AS (
    SELECT order_id, customer_id, ordered_at, amount,
           ROW_NUMBER() OVER (
               PARTITION BY customer_id
               ORDER BY ordered_at DESC, order_id DESC
           ) AS rn
    FROM order_demo
)
SELECT customer_id, order_id, ordered_at, amount
FROM numbered
WHERE rn = 1
ORDER BY customer_id;

실행 결과는 다음과 같다.

customer_id order_id ordered_at amount
A 103 2026-09-02 10:00:00 16000
B 202 2026-09-03 08:00:00 9000
C 301 2026-09-01 12:00:00 5000

PARTITION BY는 번호를 다시 1부터 매길 범위를 정한다. OVER 안의 ORDER BY는 각 고객 안에서 번호를 매길 순서다. 마지막 ORDER BY customer_id는 조회 결과 자체의 표시 순서를 정하므로 두 정렬의 역할이 다르다.

주문 번호가 기본키이므로 시각이 같아도 두 번째 정렬 조건까지 같아지는 행은 없다. 시각만으로 정렬하면 동점 행 중 어느 것이 1번인지 확정할 수 없다. 윈도 함수로 만든 번호는 같은 SELECT의 WHERE에서 바로 걸러내지 않고, 예제처럼 공통 테이블 식(CTE)이나 서브쿼리 바깥에서 필터링한다.

MAX로 다시 연결하면 왜 두 행이 나올까?

MAX는 고객별 최신 시각을 구하는 데 맞는 함수다. 다만 그 시각과 같은 원본 주문을 모두 다시 연결하면 동점이 그대로 남는다.

WITH latest_time AS (
    SELECT customer_id, MAX(ordered_at) AS ordered_at
    FROM order_demo
    GROUP BY customer_id
)
SELECT o.customer_id, o.order_id, o.amount
FROM order_demo AS o
JOIN latest_time AS t
  ON t.customer_id = o.customer_id
 AND t.ordered_at = o.ordered_at
ORDER BY o.customer_id, o.order_id;

이 쿼리는 A의 102·103번, B의 202번, C의 301번으로 총 네 행을 반환한다. 잘못된 조회라기보다 ‘최신 시각에 해당하는 주문을 모두 조회한다’는 다른 요구에 맞는 결과다.

MAX(ordered_at)과 MAX(amount)를 각각 구해도 두 최댓값이 같은 주문에서 나온다는 보장은 없다. A의 최신 선택 행은 103번이고 금액은 16000이지만, 최대 금액 18000은 102번 주문에 있다. 한 주문의 열을 함께 가져오려면 선택한 행을 기준으로 읽는다.

동점 전체가 필요하면 RANK로

최신 시각의 주문을 모두 남기려면 RANK()를 시각만으로 정렬하고 1위를 고를 수 있다.

WITH ranked AS (
    SELECT order_id, customer_id, ordered_at, amount,
           RANK() OVER (
               PARTITION BY customer_id
               ORDER BY ordered_at DESC
           ) AS recent_rank
    FROM order_demo
)
SELECT customer_id, order_id, amount
FROM ranked
WHERE recent_rank = 1
ORDER BY customer_id, order_id;

이번 데이터에서는 앞의 MAX 연결과 같은 네 행을 반환한다. 여기에 고유한 order_id까지 윈도 정렬 조건으로 넣으면 동점 그룹이 나뉘므로 최신 시각의 모든 행을 보존한다는 목적과 달라진다.

필요한 결과 번호와 조건
고객별 정확히 한 행 고유한 동점 기준을 포함한 ROW_NUMBER, 1번 선택
고객별 최대 N행 같은 기준의 ROW_NUMBER, 번호가 N 이하
최신 시각에 해당하는 모든 행 시각만 정렬한 RANK, 1위 선택

주문이 없는 고객은 이 테이블에서 시작한 조회에 나타나지 않는다. 그런 고객도 필요하면 고객 목록을 기준으로 결과를 LEFT JOIN하는 구성이 추가로 필요하다. INNER JOIN과 LEFT JOIN 예제에서 행 보존 범위를 함께 확인할 수 있다.

실습에서는 고객별 한 행이 3건, 최신 시각 동점 포함이 4건인 것을 확인했다. 빈 테이블에서는 결과가 없고, 한 고객의 모든 주문 시각이 같아도 주문 번호를 포함한 ROW_NUMBER는 정한 규칙대로 한 행을 골랐다. 대용량 데이터의 성능은 측정하지 않았으며, 인덱스 효과는 실제 DB의 실행 계획과 데이터 분포를 보고 판단해야 한다.

쿼리를 적용하기 전에 ‘최신 한 행’인지 ‘최신 시각의 모든 행’인지부터 정하고, NULL 허용 여부·시간대·동점 기준을 맞춘다. 관련 문법은 SQLite 윈도 함수 문서에서 확인할 수 있다.

반응형

댓글