개요: Path Expression 한 줄이 조인 폭탄이 되는 순간
ABAP CDS를 처음 접하면 Association과 Path Expression은 "편리한 조인 문법" 정도로 보입니다. 그런데 실무에서는 이 둘의 구조적 차이를 이해하지 못한 채 Path Expression을 남용하다가, 뷰 하나가 수십 개의 JOIN으로 번역되어 응답 시간이 수 초씩 늘어나는 사례가 자주 발생합니다. 이 글은 물류 도메인의 배송 오더 시나리오를 예로 삼아, Association이 언제 실제 JOIN으로 변환되는지, Path Expression 사용 위치에 따라 조인 타입과 실행 계획이 어떻게 달라지는지를 단계별로 짚어봅니다. 읽고 나면 다음을 스스로 점검할 수 있게 됩니다.
- Association 정의만으로는 JOIN이 실행되지 않는 이유(Join on Demand)를 설명할 수 있다
- Path Expression 사용 위치(SELECT 리스트, WHERE, ABAP SQL FROM)에 따른 기본 조인 타입을 구분할 수 있다
- 카디널리티 선언이 조인 프루닝(Join Pruning)에 미치는 영향을 이해하고 성능 저하를 예방할 수 있다
- to-many Association을 필드 리스트에서 잘못 풀어 행이 폭증하는 실수를 피할 수 있다
시작 전에 갖추면 좋은 배경
이 글은 중급 난이도로, CDS View Entity의 기본 문법(define view entity, 필드 리스트, 어노테이션)과 ABAP SQL의 SELECT ... INTO TABLE 구문을 이미 사용해 본 독자를 가정합니다. SQL의 INNER JOIN과 LEFT OUTER JOIN 차이, 그리고 카디널리티(1:1, 1:N) 개념을 알고 있다면 내부 동작 설명을 훨씬 수월하게 따라올 수 있습니다. ST05(SQL Trace)나 ADT의 SQL 분석 도구를 한 번이라도 열어봤다면 성능 검증 단계에서 도움이 됩니다.
테스트 환경
예제 코드는 다음 환경을 기준으로 작성했습니다.
- SAP S/4HANA 2023(On-Premise, ABAP Platform 2023) 또는 SAP BTP ABAP Environment(ABAP Cloud) — CDS View Entity 문법 기준
- 개발 도구: ABAP Development Tools(ADT) for Eclipse. SE80에서는 CDS 소스를 편집할 수 없으므로 ADT가 사실상 필수입니다
- 데이터베이스: SAP HANA. 조인 프루닝 등 옵티마이저 동작 설명은 HANA 기준이며, AnyDB 환경에서는 결과가 다를 수 있습니다
- 구버전 참고: 구식
define view(DDIC 기반 CDS View)에서도 Association 개념은 동일하지만, 이 글의 코드는 View Entity 문법이므로 NetWeaver 7.5x에서는 일부 구문 조정이 필요합니다
실습용으로 배송 실행(zdlv_run), 배송 스톱(zdlv_stop), 운송사 마스터(zdlv_carrier) 세 개의 커스텀 테이블을 가정합니다. 실제 환경에서는 각자 프로젝트의 네이밍 규칙에 맞게 바꿔 쓰면 됩니다.
핵심 개념: Association은 설계도, Path Expression은 실행 버튼
둘의 관계를 비유하면 이렇습니다. Association은 "이 뷰와 저 뷰는 이런 ON 조건으로 연결될 수 있다"라고 적어둔 배선 설계도입니다. 설계도를 그렸다고 전기가 흐르지는 않습니다. 반면 Path Expression은 그 배선에 실제로 전류를 흘리는 스위치입니다. 스위치를 누르는 순간(경로를 사용하는 순간) 비로소 데이터베이스 레벨에서 JOIN이 생성됩니다. 이 지연 실행 방식을 일반적으로 Join on Demand라고 부릅니다.
내부 동작을 단계별로 정리하면 다음과 같습니다.
- 정의 시점:
association [0..*] to ... on ...은 메타데이터로만 저장됩니다. ON 조건은 이 시점에 평가되지 않고, SQL 뷰 정의에도 JOIN이 포함되지 않습니다. - 사용 시점: 필드 리스트나 WHERE 절에서
_Assoc.Field형태의 Path Expression이 등장하면, 컴파일러가 해당 경로를 JOIN으로 번역합니다. CDS 안에서 경로를 사용하면 일반적으로 LEFT OUTER MANY TO ONE JOIN이 기본이고, ABAP SQL의 FROM 절에서 경로를 쓰면(FROM zi_view\_assoc) INNER JOIN이 기본입니다. 같은 문법처럼 보여도 사용 위치가 조인 타입을 바꾼다는 점이 첫 번째 함정입니다. - 병합 규칙: 동일한 Association을 동일한 필터로 여러 번 사용하면 하나의 JOIN으로 병합됩니다. 그러나 필터 조건이 다르면(
_Stop[Status='A']와_Stop[Status='B']) 각각 별도의 JOIN 인스턴스가 생성됩니다. 필터 변형을 남발하면 뷰 하나가 같은 테이블을 여러 번 조인하게 됩니다. - 조인 프루닝: 소비자가 Association 대상 필드를 SELECT하지 않으면, HANA 옵티마이저가 LEFT OUTER to-one JOIN을 실행 계획에서 제거할 수 있습니다. 단, 이는 카디널리티 선언이 정확할 때의 이야기입니다. 실제로는 1:N 관계인데
[1..1]로 선언하면 중복 행이라는 잘못된 결과가 나오고, 반대로 실제 1:1인데[0..*]로 선언하면 프루닝이 막혀 불필요한 JOIN이 항상 실행됩니다.
정리하면, Path Expression 자체가 나쁜 것이 아니라 "어디서, 몇 번, 어떤 필터로, 어떤 카디널리티 선언 아래에서" 스위치를 누르느냐가 성능을 결정합니다.
실전 예제 1단계: Association 정의와 기본 Path Expression
배송 실행 뷰에 운송사(to-one)와 배송 스톱(to-many) Association을 정의합니다.
@AccessControl.authorizationCheck: #NOT_REQUIRED
@EndUserText.label: '배송 실행 기본 뷰'
define view entity ZI_DeliveryRun
as select from zdlv_run
association [1..1] to ZI_CarrierProfile as _Carrier
on $projection.CarrierId = _Carrier.CarrierId
association [0..*] to ZI_DeliveryStop as _Stop
on $projection.RunId = _Stop.RunId
{
key zdlv_run.run_id as RunId,
zdlv_run.carrier_id as CarrierId,
zdlv_run.planned_date as PlannedDate,
zdlv_run.total_weight as TotalWeight,
// Path Expression: 여기서 처음으로 JOIN이 생성됨 (LEFT OUTER)
_Carrier.CarrierName as CarrierName,
// Association 노출: JOIN 없음, 소비자가 쓸 때만 실행됨
_Carrier,
_Stop
}
주목할 점은 마지막 두 줄입니다. _Carrier와 _Stop을 필드 리스트에 그대로 노출하면 JOIN이 전혀 발생하지 않습니다. 반면 _Carrier.CarrierName은 이 뷰가 실행될 때마다 LEFT OUTER JOIN을 만듭니다. 즉 "노출"과 "사용"은 완전히 다른 비용을 가집니다. 소비자가 운송사 이름을 쓸지 안 쓸지 모른다면, 필드로 풀지 말고 Association만 노출하는 편이 일반적으로 유리합니다.
실전 예제 2단계: 실무 소비 시나리오와 함정 재현
운영 대시보드용 ABAP 클래스에서 이 뷰를 소비한다고 가정합니다. 먼저 흔히 보는 문제 코드입니다.
" 안티패턴: to-many 경로를 필드 리스트에서 직접 사용
SELECT FROM zi_deliveryrun
FIELDS runid,
planneddate,
\_stop-stopstatus " to-many 경로 → 행 폭증!
WHERE planneddate = @sy-datum
INTO TABLE @DATA(lt_board).
스톱이 20개인 배송 실행은 결과에 20행으로 복제됩니다. 이를 감추려고 DISTINCT를 붙이면 중복 제거 비용까지 얹혀 더 느려집니다. 올바른 접근은 경로 필터로 카디널리티를 1로 좁히거나, 집계가 목적이라면 별도 집계 뷰를 두는 것입니다.
CLASS zcl_dlv_board_reader DEFINITION PUBLIC FINAL CREATE PUBLIC.
PUBLIC SECTION.
METHODS read_today_board
RETURNING VALUE(rt_board) TYPE zif_dlv_types=>tt_board
RAISING zcx_dlv_read_error.
ENDCLASS.
CLASS zcl_dlv_board_reader IMPLEMENTATION.
METHOD read_today_board.
TRY.
" to-one 경로만 사용: LEFT OUTER JOIN 1개로 번역됨
SELECT FROM zi_deliveryrun
FIELDS runid,
planneddate,
\_carrier-carriername,
\_carrier-servicegrade
WHERE planneddate = @sy-datum
ORDER BY runid
INTO CORRESPONDING FIELDS OF TABLE @rt_board.
CATCH cx_sy_open_sql_db INTO DATA(lx_db).
" 로깅 후 도메인 예외로 변환 (BAL 로그 등 프로젝트 표준 사용)
zcl_dlv_app_log=>get( )->add_exception( lx_db ).
RAISE EXCEPTION NEW zcx_dlv_read_error( previous = lx_db ).
ENDTRY.
ENDMETHOD.
ENDCLASS.
ABAP SQL의 FROM 절에서 경로를 쓰는 변형도 알아두면 좋습니다. SELECT FROM zi_deliveryrun\_carrier AS c ...처럼 FROM 절 경로는 INNER JOIN이 기본이므로, 운송사가 없는 배송 실행은 결과에서 조용히 사라집니다. "LEFT OUTER인 줄 알았는데 행이 빠졌다"는 문의의 상당수가 이 차이에서 나옵니다.
실전 예제 3단계: 프로덕션 수준의 소비 뷰와 검증
프로덕션에서는 소비 뷰(Consumption View)를 별도로 두고, 경로 필터를 통일해 JOIN 병합을 유도합니다.
@AccessControl.authorizationCheck: #CHECK
@EndUserText.label: '배송 보드 소비 뷰'
define view entity ZC_DeliveryRunBoard
as select from ZI_DeliveryRun
{
key RunId,
PlannedDate,
TotalWeight,
// 동일 Association + 무필터 → 두 필드가 JOIN 1개로 병합
_Carrier.CarrierName,
_Carrier.ServiceGrade,
// [1: ...] 필터: to-many를 to-one으로 좁혀 행 폭증 차단
_Stop[1: StopSeq = 1].RegionCode as FirstStopRegion
}
여기서 _Stop[1: StopSeq = 1]은 경로 필터로 결과 카디널리티를 1로 선언한 것입니다. 만약 같은 뷰에서 _Stop[1: StopSeq = 1]과 _Stop[1: StopStatus = 'DONE']을 함께 쓰면 필터가 다르므로 zdlv_stop에 대한 JOIN이 두 개 생깁니다. 필터 변형이 3~4개로 늘어나면 실행 계획이 눈에 띄게 무거워지므로, 서로 다른 필터가 정말 필요한지 설계 단계에서 검토하는 것이 권장됩니다.
검증은 두 축으로 합니다. 첫째, ADT에서 뷰를 열고 "Show SQL CREATE Statement"로 실제 생성 SQL의 JOIN 개수를 확인하거나 ST05 트레이스로 실행 계획을 봅니다. 둘째, CDS Test Double Framework로 로직을 회귀 테스트합니다.
CLASS ltc_board_view DEFINITION FINAL FOR TESTING
DURATION SHORT RISK LEVEL HARMLESS.
PRIVATE SECTION.
CLASS-DATA go_env TYPE REF TO if_cds_test_environment.
CLASS-METHODS class_setup.
CLASS-METHODS class_teardown.
METHODS first_stop_region_is_flat FOR TESTING.
ENDCLASS.
CLASS ltc_board_view IMPLEMENTATION.
METHOD class_setup.
go_env = cl_cds_test_environment=>create(
i_for_entity = 'ZC_DELIVERYRUNBOARD' ).
ENDMETHOD.
METHOD first_stop_region_is_flat.
go_env->insert_test_data( i_data = VALUE zif_dlv_types=>tt_run(
( runid = 'R001' carrierid = 'C10' planneddate = '20260826' ) ) ).
go_env->insert_test_data( i_data = VALUE zif_dlv_types=>tt_stop(
( runid = 'R001' stopseq = 1 regioncode = 'KR-11' )
( runid = 'R001' stopseq = 2 regioncode = 'KR-26' ) ) ).
SELECT * FROM zc_deliveryrunboard INTO TABLE @DATA(lt_act).
" to-many 필터 덕분에 R001이 1행만 나와야 함
cl_abap_unit_assert=>assert_equals( act = lines( lt_act ) exp = 1 ).
ENDMETHOD.
METHOD class_teardown.
go_env->destroy( ).
ENDMETHOD.
ENDCLASS.
보안 측면에서는 소비 뷰에 @AccessControl.authorizationCheck: #CHECK와 DCL을 반드시 붙이고, Association 대상 뷰의 접근 제어가 경로 사용 시 함께 적용되는지 확인해야 합니다. 성능 측면에서는 카디널리티 선언을 데이터 현실과 일치시키는 것이 단일 최대 레버리지입니다. 잘못된 [1..1] 선언은 옵티마이저에게 거짓 정보를 주는 것과 같습니다.
삽질 노트 — 자주 만나는 함정
Q1. 결과 행이 갑자기 몇 배로 늘었습니다. 필드 리스트나 WHERE에서 to-many Association 경로를 필터 없이 사용했을 가능성이 큽니다. _Stop.Field 대신 _Stop[1: 조건].Field로 카디널리티를 좁히거나, 개수·합계가 필요하면 집계 전용 뷰를 분리하세요. DISTINCT로 덮는 것은 증상만 가리고 비용은 더 키우는 처방입니다.
Q2. 조인 대상 필드를 SELECT하지 않는데도 뷰가 느립니다. 두 가지를 확인하세요. 첫째, 기본 뷰에서 Association을 노출만 한 게 아니라 경로로 사용해 필드로 풀어놓았다면, 소비자가 그 필드를 안 읽어도 뷰 스택 어딘가에서 JOIN이 살아 있을 수 있습니다. 둘째, 실제 1:1 관계인데 [0..*]로 선언했다면 옵티마이저가 조인 프루닝을 포기합니다. 카디널리티를 실측 데이터에 맞게 수정하면 일반적으로 개선됩니다.
Q3. 운송사 미배정 건이 결과에서 사라졌습니다. ABAP SQL FROM 절 경로(FROM zi_deliveryrun\_carrier)는 INNER JOIN이 기본입니다. 미배정 건까지 보려면 FROM 절 경로 대신 필드 리스트 경로(\_carrier-carriername)를 쓰거나 명시적 LEFT OUTER JOIN으로 바꾸세요.
Q4. 같은 Association을 여러 필터로 썼더니 급격히 느려졌습니다. 필터가 다르면 JOIN이 각각 생성됩니다. ST05나 실행 계획에서 같은 테이블이 여러 번 조인되는지 확인하고, 필터를 통일하거나 뷰를 분리해 JOIN 수를 줄이는 방향으로 리팩터링하세요. 마지막으로, LOOP 안에서 경로 포함 SELECT를 반복 호출하는 고전적 실수도 CDS라고 예외가 아닙니다. FOR ALL ENTRIES나 조인 한 방으로 끌어올리는 원칙은 그대로 유효합니다.
더 파볼 주제
이 글의 개념이 잡혔다면 다음 주제를 이어서 살펴보는 것을 권합니다. 첫째, RAP(ABAP RESTful Application Programming Model)에서 Composition과 Association의 차이 — 문법은 비슷하지만 런타임 의미가 다릅니다. 둘째, @ObjectModel.text.association 같은 어노테이션 기반 Association 활용과 Fiori Elements Value Help 연동. 셋째, ADT의 SQL 분석 도구와 SQL Monitor(SQLM)를 이용한 CDS 뷰 스택 성능 프로파일링. 넷째, 뷰 스택이 깊어질 때의 계층 설계 전략(Interface View / Consumption View 분리)입니다. 특히 뷰 스택 계층화는 이번 글의 JOIN 병합·프루닝 개념과 직결되므로 우선순위를 높게 두길 권합니다.
댓글 0
아직 댓글이 없습니다.