이 글에서 다루는 내용과 도달 지점
복잡한 분석 쿼리를 작성하다 보면 SELECT 절 안에 또 다른 SELECT가 중첩되고, 동일한 서브쿼리가 두세 번 반복되며, FROM 절에 인라인 뷰가 잔뜩 들어가는 상황을 자주 만나게 됩니다. ABAP 7.50부터 도입된 Open SQL의 WITH 절(Common Table Expression, CTE)은 이런 중첩 구조를 이름 있는 임시 결과셋으로 분리해, 사람이 읽기 쉽고 옵티마이저도 이해하기 좋은 형태로 재구성합니다. 이 글은 SalesOrder/CustomerMaster/Material 같은 실무 모델을 가지고 CTE의 동작 원리, 단계별 작성법, 운영 환경에서의 주의점까지 다룹니다.
- WITH 절의 문법과 ABAP SQL 파서가 CTE를 처리하는 흐름 이해
- 중첩 서브쿼리를 이름 있는 CTE 여러 개로 분해하는 패턴 습득
- 참조 순서, 열 이름 명시, 결과 셋 메모리 관점에서의 트레이드오프 파악
- 실전에서 발생하는 단축 평가, 인덱스 미사용, 단위 테스트 이슈 대응
이 글을 따라가기 전 갖춰두면 좋은 배경
Open SQL의 SELECT, JOIN, 서브쿼리(IN/EXISTS) 구문을 사용해본 경험이 필요합니다. ABAP 7.50 이상의 새 구문(@DATA, 인라인 선언, 호스트 변수 표기 @var)에 익숙해야 하며, 내부 테이블과 구조 선언, ABAP CDS View와 Open SQL의 차이에 대한 기본 감각이 있으면 흐름을 빠르게 따라잡을 수 있습니다.
실행 환경과 버전 전제
WITH 절은 ABAP 7.50 SP02부터 Open SQL에 도입되었고, 7.51 이후로 재귀 CTE는 지원하지 않는다는 점, 데이터 정의(INSERT/UPDATE/DELETE) 문에서는 직접 사용할 수 없다는 제약이 함께 갱신되었습니다. 이 글의 예제는 ABAP 7.54 (S/4HANA 2020) 기준으로 동작을 검증한다고 가정합니다. 사용 도구는 ADT(ABAP Development Tools) 또는 SE80, 데이터베이스는 SAP HANA를 전제로 합니다. AnyDB(예: Oracle, DB2)에서도 구문은 동일하게 동작하지만, 옵티마이저가 CTE를 인라인 처리하는 방식이 다르므로 실행 계획은 ST05/SAT로 별도 확인하는 것이 좋습니다.
버전이 7.50 미만이라면 WITH 절 대신 임시 내부 테이블 + FOR ALL ENTRIES 또는 ABAP CDS View로 전환하는 접근을 권장합니다.
CTE의 본질과 동작 원리
Common Table Expression은 SQL 문 안에서 "이 쿼리 결과에 임시 이름을 부여한다"는 선언입니다. ABAP Open SQL에서 WITH +cte_name AS ( SELECT ... ) 형태로 정의하고, 같은 문장의 메인 쿼리에서 FROM +cte_name으로 참조합니다. CTE 이름 앞에 붙는 + 기호는 ABAP SQL 파서가 "이건 DB 테이블이 아니라 같은 문장 내의 임시 결과셋"임을 구분하기 위한 표식입니다.
비유하자면 CTE는 한 번 쓰고 버리는 "이름표 붙은 메모지"입니다. 메모지에 한 번 적어두면 본문 어디서나 메모지 이름으로 호출할 수 있고, 문장이 끝나면 메모지는 사라집니다. View와의 결정적 차이는 영속성에 있습니다. CDS View나 DDIC View는 데이터 사전에 정의되어 재사용 가능하지만, CTE는 단일 SELECT 문의 수명만 갖습니다.
동작 흐름은 대략 다음과 같습니다. 첫째, 파서가 모든 CTE를 수집합니다. 둘째, CTE 간 의존성 그래프를 구성합니다(A가 B를 참조하면 B가 먼저 정의되어야 함). 셋째, 옵티마이저가 CTE를 인라인 확장할지, materialize(중간 결과 물리화)할지 결정합니다. HANA의 경우 대부분 인라인 확장으로 처리되는 경향이 있으며, 이는 CTE를 만들었다고 해서 자동으로 "캐시"되는 것은 아니라는 의미입니다. 같은 CTE를 메인 쿼리에서 두 번 참조하면 옵티마이저 판단에 따라 두 번 실행될 수도 있습니다.
구조적으로 WITH 절은 다음 골격을 따릅니다.
WITH
+cte1 AS ( SELECT ... FROM dbtab WHERE ... ),
+cte2 AS ( SELECT ... FROM +cte1 INNER JOIN dbtab2 ... ),
+cte3 AS ( SELECT ... FROM +cte2 ... )
SELECT * FROM +cte3 INTO TABLE @DATA(lt_result).
한 가지 핵심 규칙은 마지막에 반드시 메인 SELECT가 한 번 등장해야 한다는 것이며, 메인 SELECT는 정의된 CTE들 중 적어도 하나를 참조해야 한다는 점입니다. CTE 정의 안에서 또 다른 CTE를 정의하는 중첩은 허용되지 않습니다.
1단계: 기본 형태로 서브쿼리 한 개를 분리
특정 기간에 매출이 발생한 고객 목록을 뽑는 시나리오로 시작합니다. 기존 방식은 IN 서브쿼리로 처리하는 것이 일반적입니다.
" 기존: 인라인 서브쿼리
SELECT customer_id, customer_name
FROM zcustomer_master
WHERE customer_id IN ( SELECT DISTINCT customer_id
FROM zsales_order
WHERE order_date BETWEEN @lv_from AND @lv_to )
INTO TABLE @DATA(lt_active_customers).
이를 CTE로 분리하면 "활성 고객 ID 집합"이라는 의미가 이름으로 드러납니다.
WITH
+active_ids AS (
SELECT DISTINCT customer_id
FROM zsales_order
WHERE order_date BETWEEN @lv_from AND @lv_to )
SELECT cm~customer_id,
cm~customer_name,
cm~country_code
FROM zcustomer_master AS cm
INNER JOIN +active_ids AS ai
ON cm~customer_id = ai~customer_id
INTO TABLE @DATA(lt_active_customers).
주목할 점은 CTE의 SELECT 안에서 호스트 변수 @lv_from, @lv_to를 그대로 쓸 수 있다는 것, 그리고 메인 쿼리에서 일반 DB 테이블처럼 INNER JOIN 대상으로 사용할 수 있다는 점입니다. 결과는 같지만 "왜 이 조건을 거는지"가 코드 구조 자체에 드러납니다.
2단계: 다중 CTE로 분석 쿼리 재구성
이제 좀 더 실무적인 시나리오입니다. 지난 분기 기준 상위 매출 고객 50명을 뽑고, 그 고객들이 가장 많이 산 자재 카테고리별 합계를 함께 보여주는 리포트를 만든다고 가정합니다. 인라인으로 작성하면 FROM 절에 두세 단계 서브쿼리가 들어가며 가독성이 급격히 떨어집니다. CTE로 단계별로 분리합니다.
DATA: lv_from TYPE dats,
lv_to TYPE dats.
lv_from = '20260101'.
lv_to = '20260331'.
TRY.
WITH
+order_in_period AS (
SELECT order_id, customer_id, total_amount, currency
FROM zsales_order
WHERE order_date BETWEEN @lv_from AND @lv_to
AND status_code = 'CMP' ),
+customer_total AS (
SELECT customer_id,
SUM( total_amount ) AS revenue,
currency
FROM +order_in_period
GROUP BY customer_id, currency ),
+top_customer AS (
SELECT customer_id, revenue, currency
FROM +customer_total
ORDER BY revenue DESCENDING
UP TO 50 ROWS ),
+line_with_category AS (
SELECT oi~order_id,
oi~customer_id,
m~material_group,
oi~net_value
FROM zsales_order_item AS oi
INNER JOIN zmaterial AS m
ON oi~material_id = m~material_id
INNER JOIN +top_customer AS tc
ON oi~customer_id = tc~customer_id )
SELECT lwc~customer_id,
lwc~material_group,
SUM( lwc~net_value ) AS group_revenue
FROM +line_with_category AS lwc
GROUP BY lwc~customer_id, lwc~material_group
ORDER BY lwc~customer_id, group_revenue DESCENDING
INTO TABLE @DATA(lt_report).
CATCH cx_sy_open_sql_db INTO DATA(lx_sql).
cl_demo_output=>display( |Open SQL 오류: { lx_sql->get_text( ) }| ).
RETURN.
ENDTRY.
이 코드는 다음과 같은 흐름을 명시적으로 드러냅니다. 기간 내 완료 주문 추출 → 고객별 매출 합계 → 상위 50명 추림 → 해당 고객의 라인 아이템에 자재 카테고리 결합 → 카테고리별 집계. 각 단계가 이름을 가지므로 디버깅 시 "어느 단계에서 행 수가 폭증했는지" 추적하기도 쉽습니다. 임시로 메인 SELECT를 SELECT * FROM +customer_total로 바꿔 중간 결과를 확인할 수 있다는 점도 유지보수 측면에서 큰 장점입니다.
참고로 UP TO n ROWS를 CTE 안에서 쓰려면 ORDER BY가 함께 있어야 결정적 결과가 보장됩니다. CATCH 블록은 단순 디스플레이가 아니라 운영에서는 BAL(Application Log) 또는 ELK로 송출되는 로깅 인프라로 연결하는 것이 일반적입니다.
3단계: 운영 환경을 고려한 견고화
같은 분석 쿼리를 RAP 기반 서비스나 백그라운드 잡에서 사용한다면 성능, 권한, 테스트 가능성을 함께 고려해야 합니다. 다음 예제는 CTE 쿼리를 클래스 메서드로 감싸고, ABAP Unit으로 테스트 가능하도록 의존성을 분리한 형태입니다.
CLASS zcl_sales_revenue_report DEFINITION PUBLIC FINAL CREATE PUBLIC.
PUBLIC SECTION.
TYPES: BEGIN OF ty_row,
customer_id TYPE zcustomer_master-customer_id,
material_group TYPE zmaterial-material_group,
group_revenue TYPE p LENGTH 15 DECIMALS 2,
END OF ty_row,
ty_rows TYPE STANDARD TABLE OF ty_row WITH EMPTY KEY.
METHODS get_top_customer_breakdown
IMPORTING iv_from TYPE dats
iv_to TYPE dats
iv_top_n TYPE i DEFAULT 50
RETURNING VALUE(rt_rows) TYPE ty_rows
RAISING cx_sy_open_sql_db.
ENDCLASS.
CLASS zcl_sales_revenue_report IMPLEMENTATION.
METHOD get_top_customer_breakdown.
IF iv_from IS INITIAL OR iv_to IS INITIAL OR iv_from > iv_to.
RAISE EXCEPTION TYPE cx_sy_open_sql_db.
ENDIF.
DATA(lv_top) = COND i( WHEN iv_top_n BETWEEN 1 AND 500 THEN iv_top_n
ELSE 50 ).
WITH
+period_orders AS (
SELECT order_id, customer_id, total_amount
FROM zsales_order
WHERE order_date BETWEEN @iv_from AND @iv_to
AND status_code = 'CMP' ),
+customer_rev AS (
SELECT customer_id, SUM( total_amount ) AS revenue
FROM +period_orders
GROUP BY customer_id ),
+top_n AS (
SELECT customer_id, revenue
FROM +customer_rev
ORDER BY revenue DESCENDING
UP TO @lv_top ROWS )
SELECT oi~customer_id,
m~material_group,
SUM( oi~net_value ) AS group_revenue
FROM zsales_order_item AS oi
INNER JOIN zmaterial AS m
ON oi~material_id = m~material_id
INNER JOIN +top_n AS tn
ON oi~customer_id = tn~customer_id
GROUP BY oi~customer_id, m~material_group
ORDER BY oi~customer_id, group_revenue DESCENDING
INTO TABLE @rt_rows.
ENDMETHOD.
ENDCLASS.
운영 측면에서 추가로 점검해야 할 항목은 다음과 같습니다. 첫째, 권한 검사는 CDS View의 WHERE 절이나 AUTHORITY-CHECK로 별도 처리합니다. CTE 자체는 권한 객체를 모르기 때문에, 입력 파라미터 범위가 사용자에게 허용된 회사 코드(BUKRS)인지 메서드 진입부에서 확인해야 합니다. 둘째, 성능 측정은 SAT 또는 ST05의 SQL Trace를 켜고 동일 시나리오를 인라인 서브쿼리 버전과 비교합니다. HANA에서는 보통 차이가 미미하지만, AnyDB에서는 옵티마이저가 CTE를 임시 테이블로 물리화하면서 인덱스를 못 쓸 수 있어 실행 계획 확인이 필수입니다. 셋째, ABAP Unit 테스트에서는 cl_osql_test_environment를 활용해 zsales_order, zsales_order_item, zmaterial을 모의 데이터로 채워두고, get_top_customer_breakdown이 예상 행을 돌려주는지 검증하면 회귀 방지에 효과적입니다.
자주 마주치는 함정과 대응
Q1. "WITH 절은 임시 테이블이니 같은 CTE를 여러 번 참조해도 한 번만 실행된다"는 말은 정확한가요? 일반적으로 그렇게 가정하지 않는 편이 안전합니다. SAP HANA를 비롯한 다수 DB는 CTE를 인라인 확장해 메인 쿼리에 펼친 뒤 최적화합니다. 두 번 참조하면 두 번 실행될 수 있으므로, 무거운 집계가 들어가는 CTE를 여러 곳에서 참조하려면 결과를 내부 테이블로 한 번 받아두는 방식이 더 예측 가능합니다.
Q2. CTE 안에서 ORDER BY를 썼는데 메인 쿼리 결과 순서가 보장되지 않습니다. CTE의 ORDER BY는 UP TO n ROWS와 결합되어 "어떤 n개를 뽑을지" 결정하는 용도일 뿐, 최종 결과 순서는 메인 SELECT의 ORDER BY가 결정합니다. 정렬이 필요하면 메인 쿼리 끝에 명시적으로 ORDER BY를 다시 적어야 합니다.
Q3. FOR ALL ENTRIES와 CTE를 같이 쓰고 싶은데 막힙니다. Open SQL의 WITH 절은 FOR ALL ENTRIES와 결합할 수 없습니다. 드라이버 테이블이 필요한 경우 드라이버 데이터를 임시 테이블로 받고, CTE 안에서 일반 JOIN으로 풀거나, 드라이버 자체를 CTE의 첫 단계로 만들어 키 집합을 만든 뒤 메인 쿼리에서 JOIN하는 방식으로 재설계하는 것이 일반적입니다.
그 외 흔한 실수로는 CTE 이름 앞 + 누락(파서가 일반 DB 테이블로 해석해 RUNTIME_ERROR), CTE 정의 안에서 또 다른 CTE를 정의하려는 시도(허용되지 않음), DML(INSERT/UPDATE/DELETE)의 직접 대상으로 CTE를 사용하려는 시도 등이 있습니다.
이어서 살펴볼 주제
WITH 절에 익숙해졌다면, 같은 분석 로직을 ABAP CDS View로 영속화해 재사용성과 권한 통제를 강화하는 방향을 검토할 만합니다. 특히 CDS의 VIEW ENTITY와 association, 그리고 Analytical CDS의 @Analytics.dataCategory는 CTE로 만들었던 단계별 의미 분리를 더 체계적으로 표현해줍니다. 또한 AMDP(ABAP Managed Database Procedure)로 내려보내 HANA SQLScript에서 임시 테이블 변수와 결합하는 방식도 대용량 분석에서 자주 쓰입니다. 단위 테스트 측면에서는 cl_osql_test_environment와 cl_cds_test_environment의 차이를 함께 살펴보는 것을 권장합니다.
더 깊이 파고들 때 도움이 되는 자료
댓글 0
아직 댓글이 없습니다.