LAG 함수가 조용히 틀리는 이유

2026년 7월 14일 · 개발 로그

시장 breadth를 만들고 있었습니다. 오늘 오른 종목이 몇 개고 내린 종목이 몇 개인지 세는, 그거 하나입니다.

기능 명세는 세 줄이면 끝납니다. 각 종목의 오늘 종가를 전일 종가와 비교한다. 오르면 상승, 내리면 하락, 같으면 보합. 세어서 더한다.

SQL로도 세 줄입니다.

SELECT
  ticker,
  date,
  close,
  LAG(close) OVER (PARTITION BY ticker ORDER BY date) AS prev_close
FROM price_daily;

LAG()는 흔한 함수입니다. 파티션을 종목별로 나누고 날짜순으로 정렬한 다음, 직전 값을 가져옵니다. 시계열 등락 계산의 교과서적 패턴이고, 검색하면 어디에나 나옵니다.

그리고 이 쿼리는 2026년 7월 10일 NASDAQ에서 70개 종목을 잘못 분류하고 있었습니다.

LAG는 '어제'를 가져오지 않습니다

LAG()가 가져오는 것은 이전 행입니다. 어제가 아닙니다.

이 둘은 데이터가 완벽할 때만 같은 말입니다. 종목별 시계열에 거래일이 하나도 빠짐없이 들어 있다면 "정렬했을 때 직전 행"과 "직전 거래일"은 일치합니다. 개발할 때 머릿속에 그리는 테이블은 항상 그렇게 생겼습니다.

실제 데이터는 그렇게 생기지 않았습니다.

이런 종목에 LAG()를 걸면 어떻게 될까요. 함수는 에러를 내지 않습니다. NULL도 반환하지 않습니다. 가장 최근에 저장돼 있는 행을 성실하게 가져옵니다. 그게 사흘 전이든 두 달 전이든 상관없습니다.

그래서 이 종목의 "전일 대비 등락률"은 사실 "지난번에 데이터가 있었던 날 대비 등락률"이 됩니다. 그리고 그렇게 계산된 숫자는 완벽하게 정상적으로 생겼습니다.

-2.3%라고 찍혀 있으면 아무도 의심하지 않습니다. 그게 하루 치인지 두 달 치인지 숫자만 봐서는 알 수 없기 때문입니다.

실측: 2026년 7월 10일 NASDAQ

같은 날, 같은 데이터를 두 가지 방식으로 집계했습니다.

2026-07-10 NASDAQ 하락 종목 집계
방식하락보합제외
LAG() 윈도우 함수1,9761680
명시적 D-1 JOIN1,94371

D-1 JOIN 방식은 71개 종목을 제외했습니다. 직전 거래일 종가 행이 존재하지 않는 종목들입니다. 이 중 70개는 LAG 방식에서 잘못된 기준가와 비교돼 상승·하락·보합 어딘가로 이미 집계되고 있었습니다.

중요한 건 이 오류의 성질입니다. 크래시가 아닙니다. NULL도 아닙니다. 이상치도 아닙니다. 그냥 조금 다른 숫자입니다. 1,976과 1,943은 둘 다 "오늘 시장 많이 빠졌네"로 읽힙니다. 대시보드에 어느 쪽을 띄워도 아무도 이의를 제기하지 않습니다.

그리고 이 서비스에서 그 숫자는 사용자에게 그대로 노출될 예정이었습니다.

KOSPI가 진단이었습니다

같은 날 KOSPI로 두 방식을 돌리면 결과가 완전히 일치합니다. 단 한 종목도 차이가 없습니다.

그날 KOSPI 데이터에는 갭이 없었기 때문입니다. 모든 종목에 직전 거래일 행이 있었고, 그러면 "이전 행"과 "어제"가 같은 말이 됩니다. LAG는 정확히 옳게 동작했습니다.

이게 이 버그의 가장 고약한 부분입니다.

버그가 가끔만 나타납니다. 시장에 따라, 날짜에 따라, 그날 데이터가 얼마나 깨끗했는지에 따라. 어떤 날은 정답이고 어떤 날은 오답인데, 오답인 날에도 결과는 그럴듯합니다.

KOSPI로만 개발하고 KOSPI로만 확인했다면 이 코드는 영원히 정상으로 보였을 겁니다. 두 시장을 같은 코드로 돌리고, 한쪽이 일치하고 한쪽이 안 맞는다는 사실 자체가 진단이 됐습니다. 일치하는 대조군이 없었으면 불일치를 발견해도 어느 쪽이 틀렸는지 알 수 없었을 것입니다.

정답 대조

두 방식이 다르다는 것만으로는 어느 쪽이 옳은지 알 수 없습니다. 외부 정답이 필요합니다.

KOSPI는 KRX가 매일 상승·하락·보합 종목 수를 공식 집계해 발표합니다. 대조한 날의 공식 수치는 상승 802 / 하락 92 / 보합 51이었고, 우리 계산과 일치했습니다.

즉 KOSPI에서는 두 방식이 모두 정답이었고, NASDAQ에서는 JOIN 방식만 정답이었습니다. LAG 방식이 틀린 게 확정됩니다.

NASDAQ에는 KRX 같은 단일 공식 집계가 없어 같은 강도로 검증할 수 없습니다. 그래서 검증 가능한 시장에서 계산 로직을 확정하고, 그 로직을 그대로 다른 시장에 적용하는 순서를 택했습니다. 검증할 수 없는 쪽에서 로직을 만들면 틀려도 알 방법이 없습니다.

고친 쿼리

윈도우 함수를 버리고 명시적 조인으로 바꿨습니다.

-- d0 = 기준 거래일, d1 = 직전 거래일
-- 캘린더에서 미리 계산해 넣는다. 절대 date - 1 이 아니다.
SELECT
  cur.ticker,
  cur.close                                  AS close_d0,
  prv.close                                  AS close_d1,
  (cur.close - prv.close) / prv.close * 100  AS change_pct
FROM price_daily cur
JOIN price_daily prv
  ON  prv.ticker = cur.ticker
  AND prv.date   = :d1        -- 직전 거래일 '그 날짜'와 정확히 일치
WHERE cur.date = :d0;

핵심은 JOININNER JOIN이라는 점입니다. 직전 거래일 행이 없는 종목은 결과에서 사라집니다. 이게 버그가 아니라 사양입니다.

전일 종가를 모르는 종목의 등락은 계산할 수 없습니다. 계산할 수 없는 것을 억지로 계산하면 오답이 나오고, 오답은 정답과 똑같이 생겼습니다. 그래서 제외합니다. 상승도 하락도 보합도 아닌 네 번째 상태 — 알 수 없음 — 를 인정하는 것이 유일하게 정직한 처리입니다.

그리고 :d1d0 - 1이 아닙니다. 주말과 공휴일이 있습니다. 거래일 캘린더에서 가져와야 합니다. 금융 데이터에서 "어제"는 자명한 개념이 아닙니다.

남는 교훈

1. 함수의 계약을 문장으로 다시 읽으십시오. LAG()의 계약은 "직전 행을 반환한다"입니다. "어제 값을 반환한다"가 아닙니다. 우리가 그렇게 읽었을 뿐입니다. 데이터가 촘촘할 때 두 문장이 같아 보이는 것은 우연입니다.

2. 위험한 버그는 크래시하지 않습니다. 예외를 던지는 코드는 배포 전에 잡힙니다. 그럴듯한 숫자를 반환하는 코드는 몇 달을 살아남습니다. 우리 서비스는 데이터를 보여주는 게 전부인데, 그 데이터가 조용히 틀리면 서비스가 존재할 이유가 없습니다.

3. 검증 게이트는 배포 전에 놓아야 합니다. "KRX 공식 집계와 대조해서 두 자릿수 이상 차이나면 배포 중단"이라는 규칙을 코드보다 먼저 정해 뒀습니다. 그 규칙이 없었다면 1,976이라는 숫자를 그대로 띄웠을 겁니다. 눈으로 봐서는 멀쩡하니까요.

4. 결측을 채우지 말고 드러내십시오. 없는 데이터를 0으로 채우거나, 가장 가까운 값으로 대체하거나, 어떻게든 한 줄을 만들어내려는 충동이 문제의 시작입니다. 모르면 모른다고 표시하는 편이 언제나 낫습니다. 사용자가 볼 화면에 "70종목 제외"라고 적는 게 창피할 것 같지만, 틀린 숫자를 자신 있게 띄우는 것보다는 덜 창피합니다.

데이터 기준 — 2026-07-10 종가. NASDAQ 집계는 FomoBot 내부 유니버스 기준이며, 벤더·거래소별 상장 종목 정의가 달라 다른 서비스의 breadth 수치와 정확히 일치하지 않을 수 있습니다. KOSPI 공식 집계 수치는 KRX 발표 기준입니다.

투자 판단에 대하여 — FomoBot은 이미 지나간 가격을 정리해 보여주는 도구입니다. 이 글은 개발 기록이며, 어떤 내용도 투자 자문이 아닙니다.