BTP

RANK vs DENSE_RANK 동점 처리 비교 #shorts #SAP #HANA

1. Window 함수가 필요한 순간 — 서브쿼리 지옥에서 벗어나기

SAP HANA에서 "카테고리별 매출 상위 3개 제품"을 뽑아야 할 때, Window 함수를 모르면 보통 상관 서브쿼리(correlated subquery)나 셀프 조인으로 해결하게 됩니다. 문제는 이 방식이 행마다 서브쿼리를 반복 실행한다는 점입니다. 수백만 건 규모의 컬럼 스토어 테이블에서는 실행 계획이 급격히 나빠지고, SQL 자체도 읽기 어려워집니다.

Window 함수는 "결과 집합을 유지한 채, 특정 창(window) 안에서만 집계·순위 연산을 수행"하는 도구입니다. GROUP BY가 행을 그룹당 1건으로 압축해 버리는 것과 달리, Window 함수는 원본 행을 그대로 두고 각 행 옆에 계산 결과를 붙여 줍니다. 이 글에서는 HANA 2.0 SPS 05 이상과 SAP HANA Cloud에서 동일하게 동작하는 순위 계열 3형제 — RANK(), DENSE_RANK(), ROW_NUMBER() — 를 실무 시나리오로 비교합니다.

  • 세 함수가 동점(tie)을 처리하는 방식의 차이를 설명할 수 있다
  • PARTITION BY / ORDER BY / FRAME 절의 역할을 구분할 수 있다
  • Top-N, 중복 제거, 페이징 요건에 맞는 함수를 즉시 고를 수 있다

2. RANK vs DENSE_RANK vs ROW_NUMBER — 세 함수의 본질적 차이

세 함수 모두 OVER (ORDER BY ...)로 정렬된 창 안에서 순번을 매기지만, 동점을 만났을 때의 태도가 다릅니다. 마라톤 시상식에 비유하면 이렇습니다.

함수동점 처리결과 예 (점수 100, 90, 90, 80)비유
RANK()같은 순위 부여 후 다음 순위를 건너뜀1, 2, 2, 4올림픽 시상 — 은메달 2명이면 동메달 없음
DENSE_RANK()같은 순위 부여 후 건너뛰지 않음1, 2, 2, 3등급표 — 2등급 다음은 무조건 3등급
ROW_NUMBER()동점이어도 무조건 고유 번호1, 2, 3, 4대기표 발급기 — 동시에 와도 번호는 하나씩

핵심 판단 기준은 두 가지입니다. 첫째, 동점자에게 같은 값을 줘야 하는가? 그렇다면 RANK 또는 DENSE_RANK입니다. 둘째, 순위 숫자가 연속적이어야 하는가? "상위 3개 등급"처럼 갭이 없어야 하면 DENSE_RANK, "전체 중 몇 등인가"라는 상대 위치가 중요하면 RANK를 씁니다. ROW_NUMBER는 순위라기보다 결정적(deterministic) 행 식별자에 가깝고, 중복 제거나 페이징에서 진가를 발휘합니다.

3. OVER() 절 완전 이해 — PARTITION BY와 ORDER BY 해부

Window 함수의 문법 골격은 다음과 같습니다.

함수명() OVER (
  [PARTITION BY 그룹기준컬럼, ...]
  [ORDER BY 정렬컬럼 [ASC|DESC] [NULLS FIRST|LAST], ...]
  [프레임 절]  -- ROWS/RANGE BETWEEN ... (순위 함수에는 미적용)
)

각 요소의 역할을 나누면 이렇습니다.

  • PARTITION BY: 창을 쪼개는 기준. GROUP BY와 비슷하지만 행을 압축하지 않습니다. 생략하면 결과 집합 전체가 하나의 창이 됩니다.
  • ORDER BY: 창 안에서의 정렬. 순위 계열 함수에는 사실상 필수입니다. HANA에서는 NULLS FIRST/LAST를 명시해 NULL 매출 같은 값의 위치를 통제하는 것이 안전합니다.
  • 프레임 절: SUM, AVG 같은 집계형 Window 함수에서 "현재 행 기준 앞뒤 몇 행"을 지정합니다. RANK/DENSE_RANK/ROW_NUMBER는 파티션 전체를 논리적 대상으로 삼으므로 프레임 절이 적용되지 않습니다.

주의할 점 하나 — Window 함수는 WHERE, GROUP BY, HAVING이 모두 처리된 후 평가됩니다. 따라서 WHERE 절에서 RANK() <= 3처럼 직접 필터링할 수 없고, 서브쿼리나 CTE(공통 테이블 표현식)로 감싸야 합니다. 이 실행 순서를 모르면 "invalid use of window function" 계열 오류를 만나게 됩니다.

4. 실전 예제 1단계: 제품 카테고리별 판매 순위 (RANK 활용)

온라인 쇼핑몰의 월별 판매 집계 테이블 SALES_SUMMARY를 가정합니다. 카테고리 안에서 매출액 순위를 매기는 가장 기본적인 패턴입니다.

-- 테스트 데이터 준비 (HANA 2.0 / HANA Cloud 공통)
CREATE COLUMN TABLE SALES_SUMMARY (
  PRODUCT_ID   NVARCHAR(10) PRIMARY KEY,
  CATEGORY     NVARCHAR(20),
  NET_AMOUNT   DECIMAL(15,2)
);

INSERT INTO SALES_SUMMARY VALUES ('P001', 'LAPTOP',  5200.00);
INSERT INTO SALES_SUMMARY VALUES ('P002', 'LAPTOP',  4800.00);
INSERT INTO SALES_SUMMARY VALUES ('P003', 'LAPTOP',  4800.00);
INSERT INTO SALES_SUMMARY VALUES ('P004', 'LAPTOP',  3100.00);
INSERT INTO SALES_SUMMARY VALUES ('P005', 'MONITOR', 2700.00);
INSERT INTO SALES_SUMMARY VALUES ('P006', 'MONITOR', 2700.00);
INSERT INTO SALES_SUMMARY VALUES ('P007', 'MONITOR', 1900.00);

-- 카테고리별 매출 순위
SELECT CATEGORY,
       PRODUCT_ID,
       NET_AMOUNT,
       RANK() OVER (PARTITION BY CATEGORY
                    ORDER BY NET_AMOUNT DESC) AS SALES_RANK
FROM   SALES_SUMMARY
ORDER  BY CATEGORY, SALES_RANK;

LAPTOP 파티션의 결과는 1, 2, 2, 4가 됩니다. P002와 P003이 4,800으로 동점이라 둘 다 2위이고, P004는 3위가 아닌 4위입니다. "너보다 잘 판 제품이 3개 있다"는 상대 위치 정보가 그대로 보존되는 것이 RANK의 특징이며, 영업 성과 평가처럼 경쟁 순위가 중요한 리포트에 적합합니다.

5. 실전 예제 2단계: 동점(Tie) 처리 전략 — DENSE_RANK와 방어적 쿼리

"카테고리별 매출 상위 2개 가격대의 제품을 모두 보여 달라"는 요건이라면 RANK의 갭이 문제가 됩니다. 이때는 DENSE_RANK로 갭 없는 등급을 만들고, 실무에서 자주 빠뜨리는 NULL 처리와 결과 검증 로깅까지 포함해 보겠습니다.

-- 상위 2개 가격 등급 제품 추출 + NULL 매출 방어
WITH RANKED AS (
  SELECT CATEGORY,
         PRODUCT_ID,
         NET_AMOUNT,
         DENSE_RANK() OVER (
           PARTITION BY CATEGORY
           ORDER BY NET_AMOUNT DESC NULLS LAST   -- NULL을 꼴찌로 밀어냄
         ) AS AMT_GRADE
  FROM   SALES_SUMMARY
  WHERE  NET_AMOUNT IS NOT NULL                  -- 집계 오류 데이터 사전 차단
)
SELECT *
FROM   RANKED
WHERE  AMT_GRADE <= 2
ORDER  BY CATEGORY, AMT_GRADE, PRODUCT_ID;

LAPTOP에서는 5,200(1등급)과 4,800(2등급, 2건)이 모두 반환되어 총 3행이 나옵니다. RANK를 썼다면 동일하지만, "상위 3개 등급"으로 조건을 바꾸는 순간 결과가 달라지므로 요건이 '등급'인지 '순위'인지 먼저 확정해야 합니다. SQLScript 프로시저 안에서 쓸 때는 다음처럼 건수를 검증해 예외 상황을 로깅하는 패턴이 일반적으로 권장됩니다.

-- SQLScript 내 검증 예시 (발췌)
DECLARE lv_cnt INT;
SELECT COUNT(*) INTO lv_cnt FROM :RANKED_RESULT WHERE AMT_GRADE = 1;
IF :lv_cnt = 0 THEN
  SIGNAL SQL_ERROR_CODE 10001
    SET MESSAGE_TEXT = 'RANKING_EMPTY: check source data load';
END IF;

6. 실전 예제 3단계: ROW_NUMBER 프로덕션 패턴 3가지

ROW_NUMBER는 동점을 허용하지 않는 성질 덕분에 프로덕션 배치·인터페이스 코드에서 가장 자주 등장합니다. 대표 패턴 세 가지입니다.

패턴 1 — 중복 제거(최신 1건만 유지): 인터페이스 스테이징 테이블에 같은 주문이 여러 번 적재됐을 때, 파티션별 최신 행만 남깁니다.

DELETE FROM ORDER_STAGING
WHERE (ORDER_ID, RECEIVED_AT) IN (
  SELECT ORDER_ID, RECEIVED_AT
  FROM (
    SELECT ORDER_ID, RECEIVED_AT,
           ROW_NUMBER() OVER (PARTITION BY ORDER_ID
                              ORDER BY RECEIVED_AT DESC) AS RN
    FROM ORDER_STAGING
  )
  WHERE RN > 1          -- 최신 1건(RN=1) 외 전부 삭제
);

패턴 2 — 파티션별 Top-1 조회: 카테고리마다 매출 1위 제품 딱 한 건씩 필요할 때, RANK는 동점 시 2건 이상을 반환할 수 있으므로 ROW_NUMBER에 타이브레이커 정렬 키를 추가해 결정성을 확보합니다.

SELECT CATEGORY, PRODUCT_ID, NET_AMOUNT
FROM (
  SELECT S.*,
         ROW_NUMBER() OVER (
           PARTITION BY CATEGORY
           ORDER BY NET_AMOUNT DESC, PRODUCT_ID ASC  -- 동점 시 ID로 확정
         ) AS RN
  FROM SALES_SUMMARY S
)
WHERE RN = 1;

패턴 3 — 키셋 페이징: UI 페이징에서 LIMIT/OFFSET 대신 ROW_NUMBER 범위를 쓰면 정렬 기준이 복잡한 화면에서도 안정적인 페이지 경계를 만들 수 있습니다. 다만 대용량 테이블에서는 매 요청마다 전체 정렬이 일어나지 않도록 WHERE 조건으로 파티션 범위를 먼저 좁히는 것이 성능상 유리합니다. 또한 ORDER BY 키가 유일하지 않으면 실행 때마다 행 배정이 달라질 수 있으므로, ROW_NUMBER의 ORDER BY에는 항상 유일 키를 마지막에 붙이는 것이 보안·감사 로그처럼 재현성이 필요한 영역에서 특히 중요합니다.

7. 한 걸음 더 — 집계형 Window 함수와 FRAME 절 조합

순위 함수는 종종 누적 합계·이동 평균과 함께 쓰입니다. 이때 등장하는 것이 프레임 절입니다.

SELECT CATEGORY, PRODUCT_ID, NET_AMOUNT,
       DENSE_RANK() OVER (PARTITION BY CATEGORY
                          ORDER BY NET_AMOUNT DESC) AS GRADE,
       SUM(NET_AMOUNT) OVER (
         PARTITION BY CATEGORY
         ORDER BY NET_AMOUNT DESC
         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) AS RUNNING_TOTAL,               -- 누적 매출
       SUM(NET_AMOUNT) OVER (PARTITION BY CATEGORY) AS CAT_TOTAL
FROM SALES_SUMMARY;

RUNNING_TOTAL / CAT_TOTAL을 계산하면 "상위 몇 개 제품이 카테고리 매출의 80%를 차지하는가" 같은 파레토 분석이 쿼리 한 번으로 끝납니다. 기억할 규칙: ORDER BY가 있는 집계형 Window 함수는 프레임을 생략하면 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW가 기본이며, RANGE는 동점 행을 한 덩어리로 취급해 ROWS와 결과가 달라질 수 있습니다. 누적 합계를 행 단위로 정확히 쌓으려면 ROWS를 명시하는 편이 안전합니다.

자주 만나는 실수 FAQ도 정리합니다.

  • Q1. WHERE 절에 RANK() 조건을 넣었더니 오류가 납니다. Window 함수는 WHERE보다 늦게 평가됩니다. CTE나 인라인 뷰로 감싼 뒤 바깥에서 필터링하세요.
  • Q2. 같은 쿼리인데 실행할 때마다 ROW_NUMBER 결과가 바뀝니다. ORDER BY 키가 유일하지 않기 때문입니다. 기본 키 등 유일 컬럼을 정렬 마지막에 추가해 결정성을 확보하세요.
  • Q3. 상위 3위를 요청했는데 4~5건이 나옵니다. RANK/DENSE_RANK는 동점을 모두 반환합니다. "정확히 N건"이 요건이면 ROW_NUMBER로 바꿔야 합니다.
  • Q4. NULL 매출이 1위로 올라옵니다. 정렬 방향에 따라 NULL 위치가 달라질 수 있으므로 NULLS LAST를 명시하는 습관이 일반적으로 권장됩니다.

8. 비즈니스 요건별 함수 선택 기준과 더 읽어볼 문서

마지막으로 요건 문장을 함수로 번역하는 의사결정 기준입니다.

  • "전체 중 몇 등인지, 경쟁 관점 순위" → RANK() (갭 허용)
  • "상위 N개 등급/가격대를 빠짐없이" → DENSE_RANK() (갭 없음)
  • "정확히 N건, 중복 제거, 페이징, 최신 1건" → ROW_NUMBER() + 유일 타이브레이커
  • "누적·비중·이동 평균" → 집계형 Window 함수 + ROWS 프레임 명시

이 조합에 익숙해졌다면 NTILE(균등 분위), LAG/LEAD(전월 대비 증감), PERCENT_RANK(백분위)로 확장해 보세요. CDS 뷰와 CAP(CDS QL)에서도 동일한 개념이 이어지므로, HANA SQL에서 다진 감각이 그대로 재사용됩니다. 아래 문서로 학습을 이어가시길 권장합니다.

댓글 0

아직 댓글이 없습니다.