
고객마다 가장 최근 주문 한 건을 조회하려면 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 윈도 함수 문서에서 확인할 수 있다.
'데이터베이스(SQL) > 데이터베이스 개념' 카테고리의 다른 글
| SQL UNION과 UNION ALL 차이: 중복 제거·정렬·성능 선택 기준 (0) | 2026.09.22 |
|---|---|
| SQL COUNT(*)와 COUNT(컬럼)은 NULL에서 왜 다를까? (1) | 2026.09.19 |
| SQL NOT IN과 NOT EXISTS 차이: NULL 때문에 사라지는 행 (0) | 2026.09.09 |
| [DB] Django의 Filter 및 ORM과 참조 개념 정리 (0) | 2023.04.16 |
| [DB] 데이터베이스 정규화 & 참조 무결성 정리 (0) | 2023.04.16 |
댓글