이 글에서 다루는 내용
CAP for Java 이벤트 핸들러를 작성하다 보면 "지금 들어온 요청이 어떤 엔티티를, 어떤 키로, 어떤 조건으로 조회하는가"를 알아야 하는 순간이 반드시 옵니다. 이때 cqn.toString() 결과를 문자열로 잘라 쓰는 코드는 당장은 동작해도 확장 시 반드시 깨집니다. 이 글은 CQN(Core Query Notation) 요청을 CqnAnalyzer로 안전하게 분석하는 방법을 실전 예제로 정리합니다.
- CQN 트리 구조와 CqnAnalyzer의 동작 원리 이해
targetEntity(),targetKeys(),rootKeys()로 키 값 추출- 내비게이션 경로(
/SalesOrders(...)/items) 분석과 세그먼트 순회 CqnVisitor로 where 필터 조건 추출- 프로덕션 수준의 캐싱, 검증, 단위 테스트 패턴
미리 갖추면 좋은 배경
CAP Java 프로젝트 구조(srv/db 모듈)와 @Before/@On/@After 이벤트 핸들러의 기본 개념을 알고 있다면 수월합니다. CDS로 엔티티를 정의하고 OData V4로 노출하는 흐름, 그리고 Java의 Optional·람다 문법 정도가 전제됩니다. CQL/CQN을 처음 접해도 핵심 개념 섹션에서 필요한 만큼 설명합니다.
실습 환경과 버전
이 글의 코드는 다음 환경을 기준으로 작성했습니다.
- CAP Java SDK 3.x (
com.sap.cds:cds-services-bom3.x) — 핵심 API는 2.x에서도 동일하게 동작합니다 - Java 17 이상 (SapMachine 17/21 권장), Maven 3.9+
@sap/cds-dk8.x (CDS 컴파일용)- SAP BTP Cloud Foundry 런타임 또는 로컬
mvn spring-boot:run
예제 도메인은 판매 오더 서비스입니다. CDS 모델은 다음과 같다고 가정합니다.
service OrdersService {
entity SalesOrders {
key ID : UUID;
orderNo : String(20);
status : String(2); // 'N'=신규, 'A'=승인, 'C'=마감
buyer : String(100);
items : Composition of many SalesOrderItems on items.parent = $self;
}
entity SalesOrderItems {
key ID : UUID;
parent : Association to SalesOrders;
material : String(40);
quantity : Integer;
}
}
CqnAnalyzer를 써야 하는 이유와 동작 원리
OData 요청 GET /SalesOrders(ID=...)/items?$filter=quantity gt 10이 들어오면 CAP 런타임은 이를 문자열이 아닌 CQN 트리로 변환합니다. CqnSelect는 참조 경로(ref) 세그먼트, 세그먼트별 필터, where 절, 컬럼 목록이 중첩된 객체 그래프입니다. 즉 요청은 처음부터 "파싱이 끝난 구조체"로 도착합니다.
그런데 실무 코드에서 종종 이런 안티패턴을 봅니다.
// 안티패턴 — 절대 이렇게 하지 마세요
String cqnStr = context.getCqn().toString();
String orderId = cqnStr.substring(cqnStr.indexOf("\"ID\":\"") + 6, ...);
이 방식이 위험한 이유는 세 가지입니다.
- 직렬화 포맷은 계약이 아닙니다.
toString()이 내놓는 JSON 형태는 디버깅용이며, SDK 마이너 버전 업그레이드에서 필드 순서나 표기가 바뀔 수 있습니다. - 경로 다양성을 감당할 수 없습니다. 같은 데이터라도
/SalesOrderItems(ID=x)로 올 수도,/SalesOrders(ID=y)/items(ID=x)로 올 수도 있습니다. 문자열 파싱은 케이스마다 분기가 늘어나다 결국 무너집니다. - draft, localized, 확장 필드가 끼어들면 구조가 달라집니다. draft 활성화 엔티티는
IsActiveEntity키가 추가되는데, 문자열 오프셋 기반 코드는 여기서 바로 깨집니다.
CqnAnalyzer는 이 문제를 해결하는 공식 경로입니다. 비유하자면, 문자열 파싱이 "지도를 사진 찍어 픽셀 좌표를 재는 것"이라면 CqnAnalyzer는 "내비게이션 API에 목적지를 물어보는 것"입니다. 분석기는 CDS 모델(CdsModel)을 알고 있으므로 ref 경로의 각 세그먼트를 실제 엔티티 정의에 해석(resolve)해서 돌려줍니다.
// 동작 흐름 요약
CqnAnalyzer analyzer = CqnAnalyzer.create(cdsModel); // 모델 기반 분석기 생성
AnalysisResult result = analyzer.analyze(select.ref()); // ref 경로 해석
result.rootEntity(); // 경로의 첫 엔티티 (SalesOrders)
result.targetEntity(); // 경로의 마지막 엔티티 (SalesOrderItems)
result.rootKeys(); // 루트 세그먼트 키 맵
result.targetKeys(); // 타깃 세그먼트 키 맵
핵심은 analyze()가 반환하는 AnalysisResult가 세그먼트별로 "엔티티 + 키 값" 쌍을 정렬된 형태로 제공한다는 점입니다. 경로가 3단 이상이어도 반복자(iterator)로 순회하면 됩니다. UPDATE/DELETE 요청의 CqnUpdate, CqnDelete도 filterable statement이므로 같은 분석기로 처리할 수 있습니다.
단계별 실전 코드
1단계: 기본 — 타깃 엔티티와 키 추출
READ 요청에서 판매 오더의 키를 꺼내는 최소 예제입니다. CdsModel은 Spring 빈으로 주입받아 생성자에서 분석기를 한 번만 만듭니다.
import com.sap.cds.ql.cqn.AnalysisResult;
import com.sap.cds.ql.cqn.CqnAnalyzer;
import com.sap.cds.ql.cqn.CqnSelect;
import com.sap.cds.reflect.CdsModel;
import com.sap.cds.services.cds.CdsReadEventContext;
import com.sap.cds.services.cds.CqnService;
import com.sap.cds.services.handler.EventHandler;
import com.sap.cds.services.handler.annotations.Before;
import com.sap.cds.services.handler.annotations.ServiceName;
import org.springframework.stereotype.Component;
@Component
@ServiceName("OrdersService")
public class SalesOrderReadHandler implements EventHandler {
private final CqnAnalyzer analyzer;
public SalesOrderReadHandler(CdsModel model) {
this.analyzer = CqnAnalyzer.create(model); // 스레드 세이프, 재사용 가능
}
@Before(event = CqnService.EVENT_READ, entity = "OrdersService.SalesOrders")
public void beforeReadOrder(CdsReadEventContext context) {
CqnSelect select = context.getCqn();
AnalysisResult result = analyzer.analyze(select.ref());
String targetName = result.targetEntity().getQualifiedName();
Map keys = result.targetKeys();
Object orderId = keys.get("ID"); // 단건 조회면 값, 컬렉션 조회면 null
if (orderId != null) {
// 단건 조회: 예) 마감된 오더 접근 시 감사 로그 남기기 등
}
}
}
GET /SalesOrders(ID=abc-123)이면 targetKeys()에 ID=abc-123이 담기고, GET /SalesOrders 같은 컬렉션 조회면 키 맵이 비어 있습니다. 문자열을 한 글자도 자르지 않았다는 점에 주목하세요.
2단계: 실무 — 내비게이션 경로 분석과 필터 추출, 에러 처리
GET /SalesOrders(ID=...)/items?$filter=quantity gt 10처럼 경로와 필터가 섞인 요청을 다룹니다. 루트 키(오더 ID)와 where 조건(수량 필터)을 각각 안전하게 꺼내고, 마감된 오더의 품목 조회를 차단합니다.
import com.sap.cds.ql.cqn.CqnComparisonPredicate;
import com.sap.cds.ql.cqn.CqnVisitor;
import com.sap.cds.services.ErrorStatuses;
import com.sap.cds.services.ServiceException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Component
@ServiceName("OrdersService")
public class SalesOrderItemHandler implements EventHandler {
private static final Logger log = LoggerFactory.getLogger(SalesOrderItemHandler.class);
private final CqnAnalyzer analyzer;
private final PersistenceService db;
public SalesOrderItemHandler(CdsModel model, PersistenceService db) {
this.analyzer = CqnAnalyzer.create(model);
this.db = db;
}
@Before(event = CqnService.EVENT_READ, entity = "OrdersService.SalesOrderItems")
public void beforeReadItems(CdsReadEventContext context) {
CqnSelect select = context.getCqn();
AnalysisResult result = analyzer.analyze(select.ref());
// 1) 경로 루트(SalesOrders)의 키 — 어떤 경로로 왔든 모델 기준으로 해석됨
Object parentOrderId = result.rootKeys().get("ID");
if (parentOrderId == null) {
log.debug("직접 컬렉션 조회 — 부모 오더 컨텍스트 없음");
return;
}
// 2) $filter → where 절은 CqnVisitor로 순회
select.where().ifPresent(where -> where.accept(new CqnVisitor() {
@Override
public void visit(CqnComparisonPredicate cmp) {
if (cmp.left().isRef()
&& "quantity".equals(cmp.left().asRef().displayName())
&& cmp.right().isValue()) {
log.info("수량 필터 감지: {} {}", cmp.operator(),
cmp.right().asValue().value());
}
}
}));
// 3) 마감 오더 차단 — 추출한 키로 상태 확인
Row order = db.run(Select.from("OrdersService.SalesOrders")
.columns("status")
.matching(Map.of("ID", parentOrderId)))
.single();
if ("C".equals(order.get("status"))) {
throw new ServiceException(ErrorStatuses.FORBIDDEN,
"마감된 오더의 품목은 조회할 수 없습니다.");
}
}
}
포인트는 두 가지입니다. 첫째, OData 키 조건(괄호 안 키)은 CQN에서 ref 세그먼트의 필터로 변환되므로 rootKeys()/targetKeys()로 잡힙니다. 반면 $filter는 where 절로 들어오므로 CqnVisitor로 순회해야 합니다. 둘째, 예상과 다른 구조가 와도 NPE 대신 Optional·null 체크와 ServiceException으로 의도된 응답을 돌려줍니다.
3단계: 프로덕션 — 깊은 경로 순회, 성능, 테스트
경로가 몇 단이든 대응하는 세그먼트 순회, 그리고 CQN 빌더로 요청을 직접 만들어 검증하는 단위 테스트입니다. UPDATE에도 같은 분석기를 재사용합니다.
import com.sap.cds.ql.cqn.ResolvedSegment;
import java.util.Iterator;
// 깊은 경로도 세그먼트 단위로 안전하게 순회
@Before(event = CqnService.EVENT_UPDATE, entity = "OrdersService.SalesOrderItems")
public void beforeUpdateItem(CdsUpdateEventContext context) {
// CqnUpdate도 filterable statement — 동일한 analyze 사용
AnalysisResult result = analyzer.analyze(context.getCqn());
Iterator segments = result.iterator();
while (segments.hasNext()) {
ResolvedSegment seg = segments.next();
log.debug("세그먼트 {} 키 {}", seg.entity().getQualifiedName(), seg.keys());
}
}
// 단위 테스트 — 런타임 없이 분석 로직만 검증
class CqnAnalysisTest {
@Test
void 내비게이션_경로에서_루트키를_추출한다() {
CdsModel model = CdsModel.read( // 테스트 리소스의 컴파일된 모델
getClass().getResourceAsStream("/csn.json"));
CqnAnalyzer analyzer = CqnAnalyzer.create(model);
CqnSelect select = Select.from("OrdersService.SalesOrders",
o -> o.matching(Map.of("ID", "ord-9001")).to("items"));
AnalysisResult result = analyzer.analyze(select.ref());
assertEquals("OrdersService.SalesOrderItems",
result.targetEntity().getQualifiedName());
assertEquals("ord-9001", result.rootKeys().get("ID"));
}
}
운영 관점 체크리스트입니다.
- 성능:
CqnAnalyzer.create()는 모델당 한 번만 호출해 필드로 캐싱하세요. 분석기 자체는 상태가 없어 스레드 간 공유해도 안전한 것이 일반적입니다. 요청마다 생성하면 불필요한 오버헤드가 생깁니다. - 보안: 추출한 키·필터 값을 로그에 남길 때 구매자명 같은 개인정보는 마스킹하고, 값 검증 없이 네이티브 SQL에 이어붙이지 마세요. CQL 빌더(
Select.from(...).matching(...))를 쓰면 파라미터 바인딩이 유지됩니다. - 테스트: 위처럼 CQL 빌더로 요청을 합성하면 HTTP 계층 없이도 분석 로직을 회귀 테스트할 수 있습니다. draft 엔티티라면
IsActiveEntity키가 포함된 케이스도 추가하세요.
자주 겪는 문제와 해결 방법
Q1. targetKeys()가 계속 비어 있습니다.
컬렉션 조회(키 미지정)라면 정상입니다. 단건인데도 비어 있다면 키 조건이 URL 괄호가 아니라 $filter로 전달된 경우입니다. $filter=ID eq ...는 ref 필터가 아닌 where 절로 들어오므로 select.where()를 CqnVisitor로 순회해서 꺼내야 합니다. 두 경로를 모두 처리하는 유틸 메서드를 만들어 두는 것을 권장합니다.
Q2. CqnAnalyzer.create()에 넘길 CdsModel은 어디서 얻나요?
세 가지 방법이 있습니다. Spring 빈 주입(생성자 파라미터 CdsModel model), 이벤트 컨텍스트의 context.getModel(), 테스트에서는 CdsModel.read(csn.json 스트림). 핸들러라면 생성자 주입 후 분석기를 필드로 캐싱하는 패턴이 일반적입니다. 단, 멀티테넌트에서 테넌트별 모델 확장을 쓴다면 context.getModel() 기반으로 테넌트 모델을 반영해야 합니다.
Q3. draft 활성화 후 키 추출 코드가 깨졌습니다.
draft 엔티티는 IsActiveEntity가 키에 추가됩니다. 문자열 파싱 코드는 여기서 깨지지만, CqnAnalyzer 기반 코드는 targetKeys() 맵에 항목이 하나 늘어날 뿐입니다. keys.get("ID")처럼 이름으로 접근했다면 수정 없이 동작합니다. 이것이 "확장 시 안전성"의 실제 사례입니다.
Q4. UPDATE/DELETE 요청도 분석할 수 있나요?
가능합니다. CqnUpdate, CqnDelete 모두 filterable statement라 analyzer.analyze(context.getCqn())이 그대로 동작합니다. 수정 대상 데이터 자체는 UPDATE의 경우 context.getCqn().data() 계열로 별도 접근합니다.
여기서 더 나아가기
요청을 "읽는" 단계를 익혔다면, 다음은 요청을 "고쳐 쓰는" 단계입니다. CQL.copy()와 Modifier를 쓰면 분석한 CQN에 where 조건을 추가하거나 컬럼을 바꿔 다시 실행할 수 있어, 테넌트 필터 강제 주입 같은 패턴이 가능합니다. 그 외에 @restrict/@requires 어노테이션 기반 인가와 CqnAnalyzer 수동 검증의 역할 분담, Remote Service로 위임 시 CQN 전달 방식도 이어서 살펴볼 만한 주제입니다.
함께 보면 좋은 문서
- CAP Java — Query Introspection (CqnAnalyzer, CqnVisitor)
- CAP Java — Event Handlers (Before/On/After)
- CDS — Core Query Notation(CQN) 명세
- SAP Help Portal — SAP BTP에서 CAP으로 개발하기
- SAP Help Portal — Cloud Application Programming Model 개요
- SAP Help Portal — BTP 프로그래밍 모델 권장 사례
- cds4j-api Javadoc — CqnAnalyzer 인터페이스
댓글 0
아직 댓글이 없습니다.