📖 개요: 이 글에서 다루는 것
서브쿼리가 3중, 4중으로 중첩된 HANA SQL을 유지보수해 본 적이 있다면, WITH 절(CTE, Common Table Expression)이 왜 "가독성 끝판왕"이라 불리는지 바로 공감할 수 있습니다. 이 글은 SAP HANA(온프레미스 2.0 및 HANA Cloud)에서 CTE를 활용해 복잡한 쿼리를 단계별 블록으로 분리하는 방법을 구매오더(EKKO/EKPO 패턴) 실전 예제로 설명합니다.
- ☑ CTE 기본 문법과 HANA에서의 동작 방식 이해
- ☑ 중첩 서브쿼리를 CTE로 리팩터링하는 3단계 실전 예제
- ☑ 재귀 CTE로 계층 구조(BOM, 조직도) 조회하기
- ☑ Materialization 여부 등 성능 관점의 판단 기준 습득
📚 미리 알고 있으면 좋은 내용
기본적인 SELECT / JOIN / GROUP BY 문법과 서브쿼리 개념을 알고 있다면 충분합니다. SAP ERP의 구매오더 테이블 구조(헤더-아이템 관계)를 알면 예제 이해가 더 빠르지만, 몰라도 따라올 수 있도록 각 테이블의 역할을 함께 설명합니다. ABAP CDS나 Calculation View 경험은 필수가 아닙니다.
🔧 환경 및 준비물
이 글의 예제는 다음 환경에서 검증하는 것을 전제로 작성했습니다.
- SAP HANA Cloud (QRC 기준 최신 버전) — SAP BTP에서 무료 티어로 프로비저닝 가능
- SAP HANA 2.0 SPS 05 이상 (온프레미스) — WITH 절과 재귀 CTE 모두 지원
- 실행 도구: Database Explorer(BTP Cockpit 또는 Business Application Studio 연동), 온프레미스라면 HANA Studio의 SQL Console
HANA Cloud에서는 Database Explorer 좌측에서 인스턴스를 선택한 뒤 SQL Console을 열고 쿼리를 붙여넣어 실행(F8)하면 됩니다. HANA Studio에서는 시스템 접속 후 우클릭 → Open SQL Console 순서로 동일하게 실행할 수 있습니다. 예제 테이블은 아래 코드로 직접 생성해도 되고, 사내 개발 시스템의 유사 테이블로 대체해도 됩니다.
💡 핵심 개념: CTE는 "쿼리에 이름표를 붙이는 것"
CTE를 한 문장으로 요약하면 "쿼리 안에서만 유효한, 이름 붙은 임시 결과 집합"입니다. 요리에 비유하면, 인라인 서브쿼리는 모든 재료 손질을 냄비 안에서 동시에 하는 방식이고, CTE는 손질한 재료를 이름 붙은 볼(bowl)에 하나씩 담아두고 마지막에 조합하는 방식입니다. 결과물은 같지만 중간 과정을 눈으로 확인하고 재사용할 수 있다는 점이 다릅니다.
WITH 이름1 AS ( 쿼리1 ),
이름2 AS ( 이름1을 참조하는 쿼리2 )
SELECT ... FROM 이름2 JOIN 이름1 ON ...;
HANA에서 CTE가 갖는 특성은 다음과 같습니다.
- 단일 문장 스코프: CTE는 해당 SELECT 문이 끝나면 사라집니다. 임시 테이블(Local Temporary Table)과 달리 세션에 남지 않습니다.
- 체이닝 가능: 뒤에 정의한 CTE가 앞의 CTE를 참조할 수 있어, 데이터 가공을 파이프라인처럼 단계화할 수 있습니다.
- 다중 참조: 하나의 CTE를 메인 쿼리에서 여러 번 참조할 수 있습니다. 같은 서브쿼리를 복사-붙여넣기하던 패턴을 제거할 수 있습니다.
- 옵티마이저 인라이닝: HANA 옵티마이저는 일반적으로 CTE를 물리적으로 저장(materialize)하지 않고 인라인 뷰처럼 병합해 최적화합니다. 즉 CTE는 대부분 가독성 도구이지, 그 자체로 성능 부스터나 성능 저하 요인이 아닙니다.
흐름을 도식으로 표현하면 이렇습니다.
-- [원천 테이블] → (CTE 1: 필터링) → (CTE 2: 집계) → (CTE 3: 랭킹) → [최종 SELECT]
-- 각 단계를 따로 실행해 볼 수 있어 디버깅이 쉬워짐
💻 실전 예제: 구매오더 분석 쿼리 3단계 리팩터링
구매오더 헤더(ZPO_HEADER, EKKO 패턴)와 아이템(ZPO_ITEM, EKPO 패턴)으로 "올해 벤더별 발주금액 상위 5개 벤더와 그들의 평균 납기 준수 아이템 비율"을 구하는 시나리오입니다.
1단계 — 기본: 중첩 서브쿼리를 CTE로 전환
먼저 CTE 없이 쓰면 이런 형태가 됩니다. 읽는 순서가 안쪽에서 바깥쪽으로 뒤집혀 있어 의도를 파악하기 어렵습니다.
-- Before: 서브쿼리 중첩 (읽기 어려움)
SELECT vendor_id, total_amt
FROM ( SELECT h.vendor_id, SUM(i.net_amount) AS total_amt
FROM zpo_header h
JOIN zpo_item i ON i.po_number = h.po_number
WHERE h.doc_date >= '2026-01-01' AND h.doc_status = 'REL'
GROUP BY h.vendor_id )
WHERE total_amt > 100000;
CTE로 바꾸면 "릴리즈된 올해 오더 → 벤더별 집계 → 필터"라는 논리 순서 그대로 위에서 아래로 읽힙니다.
-- After: CTE로 단계 분리
WITH released_po AS (
SELECT h.po_number, h.vendor_id
FROM zpo_header h
WHERE h.doc_date >= '2026-01-01'
AND h.doc_status = 'REL'
),
vendor_amount AS (
SELECT r.vendor_id, SUM(i.net_amount) AS total_amt
FROM released_po r
JOIN zpo_item i ON i.po_number = r.po_number
GROUP BY r.vendor_id
)
SELECT vendor_id, total_amt
FROM vendor_amount
WHERE total_amt > 100000
ORDER BY total_amt DESC;
2단계 — 실무: 다중 CTE 체이닝과 검증 로직 추가
실무에서는 집계에 더해 순위, 예외 데이터 검증까지 필요합니다. CTE를 체이닝하면 각 단계를 독립적으로 검증할 수 있습니다. 특정 CTE만 SELECT * FROM 해당CTE로 바꿔 실행하면 중간 결과를 즉시 확인할 수 있어, 사실상 로깅/디버깅 포인트 역할을 합니다.
WITH released_po AS (
SELECT h.po_number, h.vendor_id, h.doc_date
FROM zpo_header h
WHERE h.doc_date >= '2026-01-01' AND h.doc_status = 'REL'
),
item_delivery AS (
SELECT i.po_number, i.po_item, i.net_amount,
CASE WHEN i.goods_receipt_date <= i.delivery_date
THEN 1 ELSE 0 END AS on_time_flag
FROM zpo_item i
WHERE i.delete_flag = ''
),
vendor_kpi AS (
SELECT r.vendor_id,
SUM(d.net_amount) AS total_amt,
AVG(TO_DECIMAL(d.on_time_flag, 5, 4)) AS otd_ratio,
COUNT(*) AS item_cnt
FROM released_po r
JOIN item_delivery d ON d.po_number = r.po_number
GROUP BY r.vendor_id
),
ranked AS (
SELECT v.*, RANK() OVER (ORDER BY v.total_amt DESC) AS amt_rank
FROM vendor_kpi v
)
SELECT vendor_id, total_amt, ROUND(otd_ratio * 100, 1) AS otd_pct, item_cnt
FROM ranked
WHERE amt_rank <= 5;
3단계 — 프로덕션: 재귀 CTE와 성능 고려
HANA는 WITH RECURSIVE 형태의 재귀 CTE도 지원합니다(HANA 2.0 SPS 04+, HANA Cloud). 자재 BOM이나 구매 조직 계층처럼 부모-자식 구조를 풀어낼 때 유용합니다. 무한 루프를 막는 깊이 제한을 반드시 넣는 것이 안전합니다.
WITH RECURSIVE org_tree (org_id, parent_id, org_name, depth) AS (
SELECT org_id, parent_id, org_name, 1
FROM zpurch_org
WHERE parent_id IS NULL
UNION ALL
SELECT c.org_id, c.parent_id, c.org_name, t.depth + 1
FROM zpurch_org c
JOIN org_tree t ON c.parent_id = t.org_id
WHERE t.depth < 10
)
SELECT LPAD(' ', (depth - 1) * 2) || org_name AS tree_view, depth
FROM org_tree
ORDER BY depth, org_id;
프로덕션 관점에서는 EXPLAIN PLAN FOR 또는 PlanViz로 실행 계획을 확인해, CTE가 인라이닝되었는지(대부분 그렇습니다) 점검하는 것이 권장됩니다. SQL을 애플리케이션에 심을 때는 반드시 파라미터 바인딩(? 플레이스홀더)을 사용해 SQL Injection을 차단해야 합니다.
⚠️ 흔한 실수와 트러블슈팅 FAQ
- Q1. "sql syntax error: incorrect syntax near WITH" 오류가 납니다. — WITH 절 앞에 다른 문장이 세미콜론 없이 붙어 있거나, INSERT/UPDATE 문 중간에 WITH를 넣은 경우가 대부분입니다. HANA에서 WITH는 일반적으로 SELECT 문의 최상단에서 시작해야 하며, 이전 문장을 세미콜론으로 확실히 종료했는지 확인하세요.
- Q2. CTE를 여러 번 참조하면 그만큼 여러 번 실행되나요? — 옵티마이저가 상황에 따라 결정합니다. 일반적으로 인라인 병합되지만, 비용이 큰 CTE를 다회 참조할 때 계획이 비효율적이라면 PlanViz로 확인 후 로직을 조정하거나 임시 테이블 분리를 검토하는 것이 좋습니다.
- Q3. 재귀 CTE가 "maximum recursion depth exceeded"로 실패합니다. — 데이터에 순환 참조(A→B→A)가 있을 가능성이 큽니다. depth 제한 조건을 넣고, 방문 경로를 문자열로 누적해 순환 여부를 점검하세요.
- Q4. CTE 이름이 실제 테이블명과 겹치면? — 해당 문장 안에서는 CTE가 우선 해석되어 원본 테이블이 가려집니다. tmp, data 같은 모호한 이름 대신 released_po처럼 역할이 드러나는 이름을 쓰는 것이 안전합니다.
🚀 이어서 살펴보면 좋은 주제
CTE로 쿼리 구조화에 익숙해졌다면, 같은 로직을 ABAP CDS View Entity로 옮겨 재사용 가능한 데이터 모델로 만드는 방법을 추천합니다. 윈도우 함수(RANK, LAG)와 CTE의 조합, HANA Cloud의 Calculation View와 SQL 기반 모델링 비교도 자연스러운 다음 학습 지점입니다.
댓글 0
아직 댓글이 없습니다.