BTP

LAG vs LEAD 핵심비교 #shorts #SAP #HANA

1. 왜 LAG/LEAD가 필요한가 — 시계열 비교의 도전

매출, 재고, 센서 측정값, 로그 이벤트처럼 시간 축을 따라 흐르는 데이터를 분석할 때 가장 자주 등장하는 요구사항은 "이전 값 대비 얼마나 변했는가" 혹은 "다음 값과 비교했을 때 어떤 추세인가"입니다. 예를 들어 어제 대비 오늘 매출 증감률, 직전 로그인 시각과 현재 로그인 시각의 간격, 다음 주문이 발생하기까지의 예상 대기 시간 같은 지표는 모두 "현재 행"과 "인접한 행"을 동시에 참조해야 계산할 수 있습니다.

전통적인 SQL 문법만 사용하면 이러한 분석은 다소 번거롭습니다. 셀프 조인(SELF JOIN)으로 같은 테이블을 두 번 참조하여 행 번호나 날짜 오프셋으로 연결하거나, 서브쿼리에서 MAX(date) WHERE date < current_date와 같은 상관 서브쿼리를 반복적으로 계산해야 합니다. 이는 코드가 길어질 뿐 아니라 대량 데이터에서는 심각한 성능 저하를 일으킵니다. SAP HANA는 이러한 문제를 우아하게 해결하기 위해 SQL:2003 표준의 윈도우 함수 계열인 LAGLEAD를 제공합니다. 이 글에서는 HANA Cloud 및 HANA 2.0 SPS 이상 환경을 기준으로 두 함수의 동작 원리, 문법 차이, 실전 활용 패턴, NULL 처리 전략, 그리고 성능 최적화 관점까지 다룹니다.

2. 이 글을 이해하기 위해 갖추면 좋은 배경

이 글은 중급 수준의 HANA SQL 사용자를 대상으로 합니다. GROUP BY와 집계 함수(SUM, AVG, COUNT)의 차이를 이해하고 있고, ROW_NUMBER나 RANK 같은 기본 윈도우 함수를 한 번쯤 사용해 본 경험이 있다면 무리 없이 따라올 수 있습니다. 추가로 HANA Cloud의 SQL 콘솔(SAP HANA Database Explorer) 또는 HANA Studio 환경에서 테이블을 생성하고 데이터를 삽입해 본 경험이 있으면 실습 예제를 즉시 재현할 수 있습니다. OLAP 개념과 시계열 데이터의 개념적 이해도 도움이 됩니다.

3. 실습 환경과 준비물

이 글의 모든 예제는 다음 환경에서 동작을 확인할 수 있습니다.

  • 데이터베이스: SAP HANA Cloud (QRC 2026 기준) 또는 SAP HANA 2.0 SPS05 이상 (LAG/LEAD는 SPS09 이후 안정적으로 지원)
  • 클라이언트 도구: SAP HANA Database Explorer, SAP HANA Studio, 또는 hdbsql CLI
  • 권한: 스키마 내 CREATE TABLE, INSERT, SELECT 권한을 가진 사용자
  • 샘플 스키마: 아래 예제에서는 SALES_ANALYTICS 스키마를 가정합니다

실습용 테이블은 다음과 같이 준비합니다. 일일 매출 데이터를 저장하는 간단한 팩트 테이블입니다.

CREATE COLUMN TABLE SALES_ANALYTICS.DAILY_REVENUE (
    STORE_ID     NVARCHAR(10),
    SALE_DATE    DATE,
    REVENUE      DECIMAL(15, 2),
    ORDER_COUNT  INTEGER,
    PRIMARY KEY (STORE_ID, SALE_DATE)
);

INSERT INTO SALES_ANALYTICS.DAILY_REVENUE VALUES ('ST001', '2026-07-01', 1250000.00, 42);
INSERT INTO SALES_ANALYTICS.DAILY_REVENUE VALUES ('ST001', '2026-07-02', 1380000.00, 47);
INSERT INTO SALES_ANALYTICS.DAILY_REVENUE VALUES ('ST001', '2026-07-03', 1195000.00, 39);
INSERT INTO SALES_ANALYTICS.DAILY_REVENUE VALUES ('ST001', '2026-07-04', 1620000.00, 55);
INSERT INTO SALES_ANALYTICS.DAILY_REVENUE VALUES ('ST002', '2026-07-01',  980000.00, 31);
INSERT INTO SALES_ANALYTICS.DAILY_REVENUE VALUES ('ST002', '2026-07-02', 1120000.00, 38);
INSERT INTO SALES_ANALYTICS.DAILY_REVENUE VALUES ('ST002', '2026-07-03', 1050000.00, 35);
INSERT INTO SALES_ANALYTICS.DAILY_REVENUE VALUES ('ST002', '2026-07-04', 1240000.00, 41);

4. 두 함수의 핵심 개념 — 프레임 없는 오프셋 접근

LAG와 LEAD는 윈도우 함수 계열 중에서도 "값 접근(value access)" 함수로 분류됩니다. FIRST_VALUE, LAST_VALUE, NTH_VALUE와 같은 계열입니다. 다른 윈도우 함수처럼 OVER 절이 필수이지만, 특이하게도 ROWS BETWEEN ... AND ... 같은 프레임 정의는 사용할 수 없습니다(사용해도 무시되거나 오류가 발생합니다). 왜냐하면 두 함수의 의미 자체가 "정렬된 파티션 내에서 현재 행으로부터 N개 앞/뒤 행"이라는 명확한 위치를 지칭하기 때문입니다.

개념적으로 비유하자면 다음과 같습니다. 정렬된 데이터를 일렬로 세워둔 기차라고 생각해 봅시다. 여러분이 특정 객차에 서 있을 때, LAG는 뒤쪽 객차를 돌아보는 함수이고 LEAD는 앞쪽 객차를 내다보는 함수입니다. 몇 칸 떨어진 객차를 볼지는 offset 파라미터로 지정할 수 있습니다. 만약 첫 번째 또는 마지막 객차 근처여서 볼 수 있는 객차가 없다면 NULL이 반환되거나, 미리 지정한 기본값이 대신 반환됩니다.

다음은 도식화한 동작 개요입니다.

-- 파티션(PARTITION BY STORE_ID) + 정렬(ORDER BY SALE_DATE)
-- STORE_ID = 'ST001'
-- ┌────────────┬───────────┬─────────────┬──────────────┐
-- │ SALE_DATE  │ REVENUE   │ LAG(REV, 1) │ LEAD(REV, 1) │
-- ├────────────┼───────────┼─────────────┼──────────────┤
-- │ 2026-07-01 │ 1,250,000 │ NULL        │ 1,380,000    │
-- │ 2026-07-02 │ 1,380,000 │ 1,250,000   │ 1,195,000    │
-- │ 2026-07-03 │ 1,195,000 │ 1,380,000   │ 1,620,000    │
-- │ 2026-07-04 │ 1,620,000 │ 1,195,000   │ NULL         │
-- └────────────┴───────────┴─────────────┴──────────────┘

공통 문법은 다음과 같습니다.

LAG (<expression> [, <offset> [, <default>]])
    OVER ([PARTITION BY <cols>] ORDER BY <cols>)

LEAD(<expression> [, <offset> [, <default>]])
    OVER ([PARTITION BY <cols>] ORDER BY <cols>)
  • expression: 반환하려는 컬럼이나 표현식
  • offset: 몇 행 떨어진 값을 가져올지 (생략 시 1)
  • default: 참조할 행이 없을 때 반환할 값 (생략 시 NULL)
  • ORDER BY: 필수. 정렬 순서가 앞/뒤 개념을 결정
  • PARTITION BY: 선택. 파티션 경계를 넘어서는 참조는 발생하지 않음

5. 단계별 실전 예제 — 기본부터 프로덕션까지

단계 1 — 기본 예제: 매장별 전일 매출 조회

가장 단순한 형태로, 각 매장에서 하루 전 매출을 함께 조회하는 쿼리입니다.

SELECT
    STORE_ID,
    SALE_DATE,
    REVENUE                                         AS TODAY_REVENUE,
    LAG(REVENUE, 1) OVER (
        PARTITION BY STORE_ID
        ORDER BY SALE_DATE
    )                                               AS YESTERDAY_REVENUE,
    LEAD(REVENUE, 1) OVER (
        PARTITION BY STORE_ID
        ORDER BY SALE_DATE
    )                                               AS TOMORROW_REVENUE
FROM SALES_ANALYTICS.DAILY_REVENUE
ORDER BY STORE_ID, SALE_DATE;

이 쿼리를 실행하면 각 매장의 첫 행에서는 YESTERDAY_REVENUE가 NULL, 마지막 행에서는 TOMORROW_REVENUE가 NULL로 나옵니다. PARTITION BY STORE_ID 덕분에 ST001의 마지막 행이 ST002의 첫 행을 참조하는 사고는 발생하지 않습니다.

단계 2 — 실무 시나리오: 전일 대비 증감률과 이상 탐지

실무에서는 단순 조회를 넘어 파생 지표를 계산하고, 이상치를 자동으로 표시해야 합니다. 다음은 전일 대비 증감률을 %로 계산하고, 급락(-15% 이하) 또는 급등(+20% 이상) 케이스에 플래그를 붙이는 예제입니다.

WITH DAILY_WITH_LAG AS (
    SELECT
        STORE_ID,
        SALE_DATE,
        REVENUE,
        LAG(REVENUE, 1, 0) OVER (
            PARTITION BY STORE_ID
            ORDER BY SALE_DATE
        ) AS PREV_REVENUE
    FROM SALES_ANALYTICS.DAILY_REVENUE
)
SELECT
    STORE_ID,
    SALE_DATE,
    REVENUE,
    PREV_REVENUE,
    CASE
        WHEN PREV_REVENUE = 0 THEN NULL
        ELSE ROUND(((REVENUE - PREV_REVENUE) / PREV_REVENUE) * 100, 2)
    END AS GROWTH_PCT,
    CASE
        WHEN PREV_REVENUE = 0 THEN 'NEW'
        WHEN (REVENUE - PREV_REVENUE) / PREV_REVENUE <= -0.15 THEN 'SHARP_DROP'
        WHEN (REVENUE - PREV_REVENUE) / PREV_REVENUE >=  0.20 THEN 'SPIKE'
        ELSE 'NORMAL'
    END AS ANOMALY_FLAG
FROM DAILY_WITH_LAG
ORDER BY STORE_ID, SALE_DATE;

여기서 두 가지 주목할 포인트가 있습니다. 첫째, LAG(REVENUE, 1, 0)처럼 세 번째 인자로 기본값 0을 주면 파티션 첫 행에서도 NULL 대신 0이 반환됩니다. 다만 이후 계산에서 0으로 나누기 오류가 발생하지 않도록 CASE WHEN PREV_REVENUE = 0 방어 로직을 반드시 추가해야 합니다. 둘째, 이상 플래그는 비즈니스 룰에 따라 임계값을 조정할 수 있는 지점입니다.

단계 3 — 프로덕션 관점: 이동 간격 분석과 뷰 캡슐화

실무 대시보드에서는 하루 전만이 아니라 3일 전, 7일 전 등 여러 시점을 동시에 비교해야 하는 경우가 많습니다. 또한 이러한 계산 로직은 재사용 가능한 뷰로 캡슐화하는 것이 유지보수 측면에서 유리합니다.

CREATE OR REPLACE VIEW SALES_ANALYTICS.V_REVENUE_TREND AS
SELECT
    STORE_ID,
    SALE_DATE,
    REVENUE,
    LAG(REVENUE, 1) OVER W AS REV_D1,
    LAG(REVENUE, 3) OVER W AS REV_D3,
    LAG(REVENUE, 7) OVER W AS REV_D7,
    LEAD(SALE_DATE, 1) OVER W AS NEXT_SALE_DATE,
    DAYS_BETWEEN(
        SALE_DATE,
        LEAD(SALE_DATE, 1) OVER W
    ) AS DAYS_TO_NEXT_SALE
FROM SALES_ANALYTICS.DAILY_REVENUE
WINDOW W AS (PARTITION BY STORE_ID ORDER BY SALE_DATE);

HANA는 WINDOW 절을 지원하므로 동일한 OVER 정의가 반복될 때 이름을 붙여 재사용할 수 있습니다. 코드 가독성이 크게 개선되고, 옵티마이저가 동일한 윈도우를 한 번만 계산하도록 유도하는 데도 도움이 됩니다. 또한 DAYS_BETWEEN 함수와 조합하면 다음 판매까지 며칠이 걸렸는지도 손쉽게 얻을 수 있습니다.

6. 자주 저지르는 실수와 트러블슈팅

LAG/LEAD를 처음 다룰 때 흔히 발생하는 실수 세 가지를 문답 형식으로 정리합니다.

Q1. ORDER BY를 빠뜨렸는데 오류가 발생합니다.
LAG/LEAD는 정렬 순서가 정의되지 않으면 "앞/뒤"라는 개념 자체가 성립하지 않으므로 ORDER BY가 필수입니다. HANA는 이 경우 SQL 오류를 반환합니다. 반드시 시간축 컬럼(예: SALE_DATE, EVENT_TS)이나 순번 컬럼을 ORDER BY에 포함시켜야 합니다.

Q2. PARTITION BY를 빠뜨렸더니 매장이 뒤섞여 잘못된 값이 나옵니다.
PARTITION BY 없이 사용하면 테이블 전체가 하나의 파티션으로 취급됩니다. ST001의 마지막 행에서 LAG를 호출하면 ST002 데이터가 반환될 수 있습니다. 논리적 그룹이 있다면 반드시 PARTITION BY를 지정하는 것이 일반적으로 권장됩니다.

Q3. 결과에서 NULL이 자꾸 나옵니다. 어떻게 처리해야 하나요?
파티션의 첫 행에서 LAG는 참조할 이전 행이 없으므로 NULL을 반환합니다. LEAD 역시 마지막 행에서 NULL입니다. 처리 방법은 세 가지입니다. 첫째, LAG(col, 1, 0)처럼 세 번째 인자에 기본값을 지정. 둘째, COALESCE(LAG(...), <대체값>)으로 감싸기. 셋째, 후속 CASE 문에서 명시적으로 NULL을 다루기. 세 방식 중 어느 것이 적절한지는 비즈니스 의미에 따라 다릅니다. 예를 들어 증감률 계산 시에는 "비교 대상 없음"과 "0에서 증가"는 의미가 다르므로 NULL을 유지하는 편이 안전한 경우가 많습니다.

Q4. 같은 날짜에 여러 행이 있으면 결과가 매번 달라집니다.
ORDER BY 컬럼에 동률(tie)이 있으면 정렬 순서가 비결정적일 수 있습니다. 이 경우 보조 정렬 컬럼(예: 주문 ID, 타임스탬프의 밀리초)을 추가해 완전히 결정적인 순서를 만들어야 재현 가능한 결과를 얻을 수 있습니다.

7. 함께 익히면 좋은 관련 주제

LAG/LEAD를 익혔다면 자연스럽게 확장할 수 있는 주제들이 있습니다. 첫째, FIRST_VALUELAST_VALUE는 파티션의 시작/끝 값을 반환하는 함수로, "월 첫날 매출과의 비교" 같은 분석에 유용합니다. 둘째, NTH_VALUE는 N번째 특정 위치의 값을 가져오는 함수입니다. 셋째, ROWS BETWEEN 기반 이동 평균(SMA, EMA)은 시계열 스무딩에 필수적입니다. 넷째, HANA의 시계열 특화 기능인 SERIES_GENERATE_TIMESTAMPSERIES_ROUND를 결합하면 결측일을 채우고 LAG/LEAD를 적용하는 고도화된 분석이 가능합니다. 마지막으로 SAP HANA의 Predictive Analysis Library(PAL) 중 시계열 예측 알고리즘(ARIMA, Exponential Smoothing)과 연계하면 LAG로 만든 피처를 머신러닝 입력으로 활용할 수 있습니다.

8. 성능 고려사항과 실무 적용 팁

LAG/LEAD는 HANA의 컬럼 스토어 엔진과 잘 어울리는 함수입니다. 내부적으로는 정렬된 파티션을 한 번 스캔하면서 오프셋 위치를 직접 참조하므로, 셀프 조인보다 I/O가 적고 병렬 실행에도 유리합니다. 그러나 몇 가지 주의사항이 있습니다.

첫째, ORDER BY 컬럼에 인덱스가 없으면 정렬 비용이 발생합니다. HANA 컬럼 테이블의 경우 주 키(PRIMARY KEY) 또는 자동 정렬을 활용하거나, 쿼리 프로파일러로 정렬 연산 비중을 확인하는 것이 좋습니다.

둘째, 동일한 OVER 정의가 여러 번 등장하면 WINDOW 절로 분리하세요. 이름 붙인 윈도우는 옵티마이저가 한 번만 평가하도록 힌트를 줍니다.

-- 비효율: OVER 절 반복
SELECT
    ORDER_ID,
    ORDER_DATE,
    AMOUNT,
    LAG(AMOUNT)  OVER (PARTITION BY CUSTOMER_ID ORDER BY ORDER_DATE) AS PREV_AMT,
    LEAD(AMOUNT) OVER (PARTITION BY CUSTOMER_ID ORDER BY ORDER_DATE) AS NEXT_AMT,
    LAG(ORDER_DATE) OVER (PARTITION BY CUSTOMER_ID ORDER BY ORDER_DATE) AS PREV_DATE
FROM ORDERS;

-- 효율: WINDOW 절 통합
SELECT
    ORDER_ID,
    ORDER_DATE,
    AMOUNT,
    LAG(AMOUNT)     OVER W AS PREV_AMT,
    LEAD(AMOUNT)    OVER W AS NEXT_AMT,
    LAG(ORDER_DATE) OVER W AS PREV_DATE
FROM ORDERS
WINDOW W AS (PARTITION BY CUSTOMER_ID ORDER BY ORDER_DATE);

셋째, 파티션 카디널리티가 너무 낮거나(파티션당 행 수 1~2개) 너무 높으면(수백만 행짜리 단일 파티션) 성능이 저하될 수 있습니다. PARTITION BY 컬럼 선정 시 데이터 분포를 먼저 확인하세요. 넷째, 집계 후 결과에 LAG/LEAD를 적용하려면 서브쿼리나 CTE로 집계 먼저 → 외부 쿼리에서 LAG/LEAD 적용 패턴을 사용하세요. 집계와 LAG를 같은 SELECT 절에 혼용하면 예상치 못한 오류가 발생할 수 있습니다.

댓글 0

아직 댓글이 없습니다.