개요: 왜 CDS 모델 확장을 알아야 하는가
CAP(Cloud Application Programming Model) 프로젝트가 커지면 하나의 CDS 모델을 여러 팀, 여러 앱이 공유하게 됩니다. 이때 원본 모델 파일을 직접 고치지 않고 필드·연관·어노테이션을 덧붙이는 기법이 바로 CDS 모델 확장(extend / annotate)입니다. 이 글에서는 CAP Java(SAP BTP, Cloud Foundry/Kyma 환경 기준) 실무에서 자주 쓰는 확장 패턴을 물류 장비 임대 도메인 예제로 정리합니다.
- 재사용 패키지의 엔티티에 필드를 추가하는
extend패턴 - 모델과 메타데이터를 분리하는
annotate패턴 - 공통 필드를 묶는 사용자 정의 aspect 패턴
- 확장된 필드를 CAP Java 이벤트 핸들러에서 안전하게 읽고 쓰는 방법
미리 알아두면 좋은 배경
CDS 문법 기초(entity, service, projection)와 Java 17 이상 문법, Maven 빌드 경험이 있으면 수월합니다. CAP Java의 @On/@Before/@After 핸들러 개념을 처음 접해도 예제 코드에 주석을 달아두었으니 따라올 수 있습니다.
환경과 준비물
이 글의 코드는 아래 환경에서 검증하는 것을 전제로 합니다.
- CAP Java SDK 3.x (com.sap.cds:cds-services-bom 기준), Spring Boot 3 기반
- @sap/cds-dk 8.x 이상 (cds compile, cds build 사용)
- Java 17+, Maven 3.9+, Node.js 20+ (CDS 툴체인용)
- 로컬 개발은 H2/SQLite, 배포는 SAP HANA Cloud(HDI 컨테이너)를 일반적으로 사용
프로젝트 구조는 db/(모델), srv/(서비스 정의 + Java 핸들러), app/(UI)로 나뉘며, 확장 대상 기본 모델은 npm 패키지 형태의 재사용 모듈로 가져온다고 가정합니다.
핵심 개념: extend, annotate, aspect의 역할 분담
CDS 확장은 "원본을 수정하지 않고 겹쳐 쓰는" 레이어링 개념입니다. 비유하자면 원본 모델은 인쇄된 지도이고, 확장 파일은 그 위에 올리는 투명 필름입니다. 컴파일 시점에 cds 컴파일러가 필름을 모두 겹쳐 최종 모델(CSN)을 만들고, CAP Java는 그 결과물만 바라봅니다. 즉 런타임 입장에서는 확장 필드와 원본 필드의 구분이 없습니다.
- extend entity ... with — 구조 자체를 바꿉니다. 필드, 연관(association), 복합 타입을 추가할 때 사용합니다. DB 스키마가 변경되므로 재배포가 필요합니다.
- annotate — 구조는 그대로 두고 메타데이터(@title, @readonly, @UI 등)만 덧붙입니다. 모델 정의와 UI/권한 어노테이션을 파일 단위로 분리하는 것이 일반적으로 권장됩니다.
- aspect — 여러 엔티티에 반복되는 필드 묶음을 정의하고
extend ... with AspectName또는 엔티티 선언 시 include로 재사용합니다.cuid,managed가 대표적인 내장 aspect입니다.
확장이 적용되는 순서도 중요합니다. cds 컴파일러는 using으로 연결된 파일을 모두 로드한 뒤 확장을 병합하므로, 같은 필드를 두 파일에서 추가하면 충돌 에러가 납니다. 팀 간에는 "확장 전용 네임스페이스 접두어"를 정하는 것이 일반적으로 안전합니다.
실전 코드 1단계: 기본 extend와 annotate
재사용 패키지 gearhub-core가 제공하는 장비 엔티티를 우리 앱에서 확장하는 상황입니다. 먼저 기본 모델(패키지 내부, 수정 불가)입니다.
// node_modules/gearhub-core/db/schema.cds
namespace gearhub.core;
using { cuid, managed } from '@sap/cds/common';
entity RentalGears : cuid, managed {
gearCode : String(20);
gearName : localized String(100);
dailyFee : Decimal(9,2);
status : String(10); // AVAILABLE / RENTED / REPAIR
}
우리 프로젝트의 db/extensions.cds에서 필드와 연관을 추가하고, 어노테이션은 별도 파일로 분리합니다.
// db/extensions.cds
using { gearhub.core as core } from 'gearhub-core';
namespace gearhub.app;
entity Depots : cuid {
depotName : String(60);
region : String(30);
}
extend entity core.RentalGears with {
zz_insuredValue : Decimal(11,2); // 확장 필드는 접두어로 구분
zz_depot : Association to Depots;
}
// srv/annotations.cds — 구조 변경 없이 메타데이터만 부여
using { gearhub.core as core } from 'gearhub-core';
annotate core.RentalGears with {
gearCode @title : '장비 코드' @readonly;
zz_insuredValue @title : '보험 평가액' @assert.range : [0, 999999999];
};
cds compile db srv --to sql로 확인하면 확장 필드가 원본 테이블 컬럼으로 합쳐진 것을 볼 수 있습니다.
실전 코드 2단계: 확장 필드를 다루는 Java 핸들러 (검증·로깅)
CAP Java는 빌드 시 최종 모델 기준으로 접근자 인터페이스를 생성하므로, 확장 필드도 타입 안전하게 다룰 수 있습니다. 임대 서비스에서 보험 평가액이 없는 장비의 대여를 막는 검증 핸들러입니다.
// srv/rental-service.cds
using { gearhub.core as core } from 'gearhub-core';
service RentalService {
entity Gears as projection on core.RentalGears;
action checkoutGear(gearId : UUID, days : Integer) returns String;
}
@Component
@ServiceName("RentalService")
public class GearCheckoutHandler implements EventHandler {
private static final Logger log =
LoggerFactory.getLogger(GearCheckoutHandler.class);
private final PersistenceService db;
public GearCheckoutHandler(PersistenceService db) {
this.db = db;
}
@Before(event = "checkoutGear")
public void validateInsurance(CheckoutGearContext ctx) {
CqnSelect query = Select.from(Gears_.class)
.where(g -> g.ID().eq(ctx.getGearId()));
Gears gear = db.run(query)
.first(Gears.class)
.orElseThrow(() -> new ServiceException(
ErrorStatuses.NOT_FOUND, "장비를 찾을 수 없습니다."));
// 생성된 접근자로 확장 필드 접근 (getZzInsuredValue)
BigDecimal insured = gear.getZzInsuredValue();
if (insured == null || insured.signum() <= 0) {
log.warn("보험 미등록 장비 대여 시도: {}", gear.getGearCode());
throw new ServiceException(ErrorStatuses.BAD_REQUEST,
"보험 평가액이 등록되지 않은 장비는 대여할 수 없습니다.");
}
}
}
접근자 인터페이스가 아직 재생성되지 않은 전환기에는 gear.get("zz_insuredValue")처럼 CdsData의 맵 접근도 가능하지만, 오타를 컴파일러가 못 잡으므로 임시 용도로만 쓰는 것이 좋습니다.
실전 코드 3단계: aspect 기반 공통 확장과 프로덕션 고려사항
여러 엔티티에 "감사 승인" 필드 묶음이 반복된다면 aspect로 추출합니다.
// db/aspects.cds
namespace gearhub.app;
aspect ApprovalTrail {
zz_approvedBy : String(255);
zz_approvedAt : Timestamp;
zz_approvalNote : String(500);
}
using { gearhub.core as core } from 'gearhub-core';
extend entity core.RentalGears with ApprovalTrail;
승인 시각을 자동 기록하는 공통 핸들러는 이렇게 작성합니다.
@Component
@ServiceName("RentalService")
public class ApprovalStampHandler implements EventHandler {
@Before(event = CqnService.EVENT_UPDATE, entity = "RentalService.Gears")
public void stampApproval(CdsUpdateEventContext ctx, List<Gears> gears) {
String user = ctx.getUserInfo().getName();
Instant now = Instant.now();
for (Gears g : gears) {
if (g.containsKey("zz_approvedBy")) { // 클라이언트가 승인 필드를 보낸 경우만
g.put("zz_approvedBy", user);
g.put("zz_approvedAt", now);
}
}
}
}
프로덕션에서 함께 챙길 사항입니다.
- 성능: 확장 연관(zz_depot)을 목록 조회에서 항상 expand하면 조인 비용이 커집니다. 필요한 화면에서만
$expand하도록 UI 어노테이션을 조정하세요. - 보안: 확장 필드에도
@restrict/필드 수준 권한이 자동으로 상속되지 않는 경우가 있으니 annotate로 명시하는 것이 일반적으로 권장됩니다. - 테스트:
@SpringBootTest와MockMvc로 확장 필드 포함 payload를 검증하고,cds build산출물의 CSN에 확장이 반영됐는지 CI에서 확인하면 배포 사고를 줄일 수 있습니다.
흔한 실수와 트러블슈팅 FAQ
- Q1. extend 했는데 Java에서 getter가 없다고 컴파일 에러가 납니다. — cds-maven-plugin의 generate 골이 실행되지 않은 상태입니다.
mvn clean install로 접근자 인터페이스를 재생성하세요. IDE 캐시가 남았다면 프로젝트 리로드가 필요합니다. - Q2. "Duplicate definition of element" 오류가 발생합니다. — 두 확장 파일이 같은 필드명을 추가한 경우입니다. 팀별 접두어(zz_, x_ 등) 규칙을 정하고, 공통 필드는 aspect 하나로 통합하세요.
- Q3. annotate가 화면에 반영되지 않습니다. — annotate 파일이 어떤 using 경로에도 연결되지 않아 컴파일 대상에서 빠진 경우가 대부분입니다.
cds compile srv -to edmx로 어노테이션 포함 여부를 직접 확인하세요. - Q4. HANA 배포 시 기존 데이터가 있는 테이블에 NOT NULL 확장 필드를 추가해 실패합니다. — 확장 필드는 널 허용 + default로 추가한 뒤, 데이터 마이그레이션 후 제약을 조이는 순서가 일반적으로 안전합니다.
더 깊이 들어가기
이 글의 패턴을 익혔다면 다음 주제로 확장해 보세요. SaaS 멀티테넌트 환경에서 구독자가 런타임에 필드를 추가하는 MTX 기반 테넌트 확장, 확장 필드까지 감사 로그로 남기는 Audit Logging 연동, 그리고 확장 모델을 OData V4 어노테이션으로 노출해 SAP Fiori elements 화면을 자동 구성하는 흐름이 자연스러운 다음 순서입니다. Side-by-Side 확장이 필요하다면 SAP Build Code와의 조합도 검토할 만합니다.
함께 보면 좋은 문서
댓글 0
아직 댓글이 없습니다.