남기다
홈 › 쿼리와 SQL › LAST_VALUE가 마지막 값을 안 주는 이유: 윈도 프레임 기본값

LAST_VALUE가 마지막 값을 안 주는 이유: 윈도 프레임 기본값

한서윤 · · 쿼리와 SQL

포인트 적립 내역 화면을 새로 만들던 봄이었다. 회원이 내역을 열면 거래마다 그 시점의 잔액이 옆에 찍혀야 했다. 잔액을 컬럼으로 따로 저장하지 않고 조회할 때 계산하기로 했다. SUM(amount) OVER (PARTITION BY user_id ORDER BY created_at) 한 줄이면 끝날 일이라고 생각했다. 테스트 계정으로 적립과 사용을 몇 번 해 보니 숫자가 차곡차곡 잘 쌓였다.

출시 사흘째에 문의가 들어왔다. 이벤트 보상 두 건이 같은 초에 들어온 회원이었는데, 내역에서 그 두 줄의 잔액이 똑같이 찍혀 있었다. 첫 줄부터 두 건을 다 더한 금액이 나오니 회원 눈에는 포인트가 한 번에 들어왔다가 다음 줄에서 아무 변화가 없는 것처럼 보였다. 쿼리 어디에도 틀린 데가 보이지 않아 한참을 들여다봤다.

원인은 내가 적지도 않은 프레임 절이었다. 윈도 함수에 ORDER BY를 붙이면 프레임 기본값이 따라오는데, 이 기본값은 동점인 행을 한 덩어리로 묶는다. 같은 규칙 때문에 LAST_VALUE는 마지막 값이 아니라 자기 자신을 돌려준다. 그때 정리해 둔 내용을 여기 남긴다.

적지 않은 프레임이 정해 주는 것

윈도 함수는 파티션 안에서 행마다 프레임이라는 범위를 잡고, 그 범위에 든 행만 계산에 쓴다. OVER 절에 프레임을 적지 않으면 데이터베이스가 기본값을 넣는다. PostgreSQL 문서에 따르면 기본값은 RANGE UNBOUNDED PRECEDING이고, 이는 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW와 같다. ORDER BY가 있으면 파티션 첫 행부터 현재 행의 마지막 동료(peer) 행까지가 프레임이 된다.

동료 행이란 ORDER BY 기준으로 값이 같은 행이다. created_at으로 정렬했다면 created_at이 같은 행끼리 동료다. 여기서 헷갈리는 건 CURRENT ROW라는 이름이다. RANGE 모드에서 프레임 끝의 CURRENT ROW는 지금 이 행이 아니라 현재 행과 동점인 마지막 행까지를 가리킨다. 나는 이 단어를 글자 그대로 읽었고, 그래서 사흘 동안 아무것도 의심하지 않았다.

ORDER BY가 있느냐 없느냐에 따라 기본값이 바뀐다는 점도 함정이다. ORDER BY가 없으면 모든 행이 동료가 되어 파티션 전체가 프레임에 들어간다. MySQL 8.0 문서는 ORDER BY를 더하면 프레임이 바뀌어 결과가 달라질 수 있으니 프레임을 명시하라고 권한다. 파티션 합계를 내던 쿼리에 보기 좋게 정렬 하나만 붙였는데 합계가 누적합으로 바뀌는 일은 이렇게 생긴다.

동점 행에서 RANGE와 ROWS가 갈라진다

문제를 그대로 재현하는 테이블을 만들었다. PostgreSQL, MySQL 8.0, SQLite에서 모두 그대로 돈다.

CREATE TABLE point_log (
  id         INTEGER PRIMARY KEY,
  user_id    INTEGER NOT NULL,
  created_at TIMESTAMP NOT NULL,
  amount     INTEGER NOT NULL
);

INSERT INTO point_log (id, user_id, created_at, amount) VALUES
  (1, 1, '2026-03-02 10:00:00', 100),
  (2, 1, '2026-03-02 10:00:05', 50),
  (3, 1, '2026-03-02 10:00:05', 30),
  (4, 1, '2026-03-02 11:20:00', -40);

SELECT id, created_at, amount,
       SUM(amount) OVER (PARTITION BY user_id ORDER BY created_at) AS balance_range,
       SUM(amount) OVER (PARTITION BY user_id ORDER BY created_at
                         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS balance_rows
FROM point_log
ORDER BY created_at, id;
idcreated_atamountbalance_rangebalance_rows
110:00:00100100100
210:00:0550180150
310:00:0530180180
411:20:00-40140140

RANGE는 id 2와 3을 같은 시각이라는 이유로 한 묶음으로 본다. 그래서 두 행 모두 프레임이 id 3까지 늘어나 180을 받는다. ROWS는 물리적인 행 하나하나를 단위로 세므로 id 2에서는 거기까지만 더한 150이 나온다. SQLite 문서도 동료 행은 언제나 같은 프레임에 든다고 적어 두었다.

같은 시각에 들어온 두 행이 있을 때 RANGE 프레임은 두 행을 함께 포함해 180을 내고 ROWS 프레임은 현재 행까지만 포함해 150을 내는 모습을 나란히 비교한 그림
id 2 행을 기준으로 본 RANGE와 ROWS의 프레임 차이

동점이 없는 데이터에서는 두 결과가 똑같다. 테스트에서 문제가 안 보였던 것도 그래서다. 같은 초에 두 건이 들어오는 일은 배치로 보상을 한꺼번에 넣는 운영 환경에서만 생겼다.

LAST_VALUE가 자기 자신을 돌려준 날

몇 달 뒤 같은 테이블로 관리자 화면을 손봤다. 내역의 모든 줄 옆에 그 회원의 가장 최근 거래 금액을 함께 띄워 달라는 요청이었다. 이번에는 LAST_VALUE를 꺼냈다.

SELECT id, amount,
       LAST_VALUE(amount) OVER (PARTITION BY user_id ORDER BY created_at) AS latest
FROM point_log;

결과를 보고 잠깐 멍했다. latest 열에 행마다 거의 자기 금액이 그대로 찍혀 있었다. 이미 한 번 데인 규칙에 또 걸린 셈이라 허탈했다. 프레임 끝이 현재 행과 그 동료 행이니, 프레임의 마지막 행은 늘 자기 자신이거나 동점 행 가운데 하나다. id 2와 3은 같은 값을 받는데 그게 50일지 30일지는 정렬 기준이 정해 주지 않는다.

PostgreSQL 문서에도 이 문제가 그대로 적혀 있다. first_value, last_value, nth_value는 프레임 안의 행만 보고, 기본 프레임에서는 last_value가 쓸모없는 결과를 주기 쉽다는 설명이다. PostgreSQL 버그 메일링 리스트에도 똑같은 결과를 버그로 신고한 기록이 있었다. Tom Lane은 기본 프레임이 첫 행부터 현재 행까지라고 짧게 답했다. 표준대로 움직이는 것이고, 놀라는 쪽이 문서를 덜 읽은 것이었다.

고치는 방법은 두 가지다.

-- 1) 프레임을 파티션 전체로 넓힌다
SELECT id, amount,
       LAST_VALUE(amount) OVER (
         PARTITION BY user_id ORDER BY created_at, id
         ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
       ) AS latest
FROM point_log;

-- 2) 정렬을 뒤집어 FIRST_VALUE로 읽는다
SELECT id, amount,
       FIRST_VALUE(amount) OVER (
         PARTITION BY user_id ORDER BY created_at DESC, id DESC
       ) AS latest
FROM point_log;

두 번째 방법은 기본 프레임을 그대로 둬도 된다. 프레임이 늘 파티션의 첫 행에서 시작하므로 FIRST_VALUE는 프레임 끝과 상관없이 같은 행을 본다. 나는 의도가 잘 보이는 첫 번째 방법을 더 자주 쓴다.

세 데이터베이스에서 규칙이 같은지 확인했다

사이드 프로젝트는 SQLite로, 회사 일은 PostgreSQL과 MySQL로 하다 보니 셋의 기본값이 정말 같은지 문서로 하나씩 대조했다.

데이터베이스ORDER BY 있을 때 기본 프레임ORDER BY 없을 때참고
PostgreSQLRANGE, 첫 행부터 현재 행의 마지막 동료까지파티션 전체GROUPS 모드 지원
MySQL 8.0RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWRANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWINGORDER BY를 더하면 결과가 바뀔 수 있다고 문서에 명시
SQLiteRANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS모든 행이 동료라 파티션 전체윈도 함수는 3.25.0, GROUPS와 EXCLUDE는 3.28.0부터

셋 다 같은 규칙이라 데이터베이스를 바꿔서 피해 갈 수는 없다. SQLite는 3.25.0(2018-09-15)에서 윈도 함수가 들어왔으니, 오래된 SQLite가 깔린 환경이라면 아예 문법 오류가 날 수도 있다.

ROWS로 바꿔도 끝은 아니다

잔액 쿼리를 ROWS로 고치고 나서 안심했는데, 스테이징에서 다시 돌려 보니 같은 초에 들어온 두 줄의 잔액 순서가 뒤집혀 나왔다. 정렬 기준이 created_at 하나뿐이라 동점 행끼리의 순서는 데이터베이스 마음이었다. 실행 계획이나 읽는 인덱스가 달라지면 150과 180이 다른 행에 붙을 수 있다.

ROWS는 동점 행을 쪼개 주지만 어느 쪽을 먼저 둘지는 정해 주지 않는다. 그래서 ORDER BY에 id처럼 유일한 열을 하나 덧붙여 순서를 고정했다. 바깥 SELECT의 ORDER BY도 같은 기준으로 맞췄다. 화면에 찍히는 순서와 잔액을 계산한 순서가 다르면, 숫자는 맞아도 사람 눈에는 틀려 보인다.

반대로 RANGE가 맞는 경우도 있다. 일자별 잔액처럼 같은 날 거래를 하나로 보고 싶을 때는 동점 묶음을 통째로 더하는 RANGE가 의도에 맞는다. 묶음 단위로 몇 개 앞까지 볼지 세고 싶다면 PostgreSQL과 SQLite의 GROUPS 모드가 있다.

지금은 프레임을 늘 적는다

그 뒤로 내 규칙은 단순해졌다. OVER 절에 ORDER BY를 쓰면 프레임도 반드시 적는다. 기본값과 똑같은 프레임이어도 적는다. 쿼리를 읽는 사람이 CURRENT ROW가 어디까지인지 머릿속으로 계산하지 않아도 되기 때문이다. 정렬 키에는 끝에 유일한 열을 하나 붙여 동점을 없앤다.

테스트 데이터도 바뀌었다. 지금은 시각이 같은 행을 일부러 두세 개 넣어 두고, 누적합과 LAST_VALUE 결과를 손으로 계산한 값과 맞춰 본다. 동점은 운영에서 반드시 생기는데 손으로 만든 데이터에는 좀처럼 생기지 않는다. 사흘 만에 들어온 문의 한 통이 알려 준 건 이것이었다. 기본값은 편하라고 있는 게 아니라 표준이 정해 둔 것이고, 그게 내 의도와 같은지는 직접 적어 봐야 안다.

참고한 자료