[CAP for Java] OData Actions/Functions 완전 정복 — Unbound/Bound 구현부터 타입 안전 EventContext까지
1. 개요 및 학습 목표
SAP CAP(Cloud Application Programming Model) for Java에서 OData Actions와 Functions는 표준 CRUD 외의 비즈니스 로직을 서비스로 노출하는 핵심 메커니즘입니다. 주문 취소, 평점 등록, 통계 조회처럼 엔티티 단순 조작을 넘어서는 작업을 깔끔하게 구현할 수 있습니다. 이 튜토리얼에서는 CDS 모델 정의부터 Maven Plugin 기반 타입 안전 EventContext, 실전 핸들러 구현까지 전 과정을 다룹니다.
- OData Action과 Function의 의미론적 차이를 설명할 수 있다
- CDS에서 Unbound/Bound Action과 Function을 정의할 수 있다
- Maven Plugin으로 생성된 타입 안전 EventContext를 활용할 수 있다
- Unbound Action, Bound Action, Function 각각의 Java 핸들러를 구현할 수 있다
- 서비스 내부에서 Typed API로 Action을 직접 호출할 수 있다
2. 선수 지식
- Java 17 이상 기본 문법 및 Spring Boot 개념
- CDS(Core Data Services) 모델링 기초 (entity, service 정의)
- OData V4 프로토콜 기본 이해 (GET, POST, 엔티티셋)
- CAP Java 프로젝트 구조에 대한 기본 이해 (
srv/,db/폴더 구성) - Maven 빌드 시스템 기초
3. 환경 / 버전 / 준비물
| 항목 | 권장 버전 |
|---|---|
| CAP Java SDK | 3.x (2025 이후 릴리스) |
| CDS Compiler (cds-dk) | 8.x 이상 |
| Java | 17 이상 (LTS 권장) |
| Maven | 3.9 이상 |
| IDE | VS Code + SAP CDS Extension 또는 IntelliJ |
| SAP BTP 에디션 | Free Tier / Trial 가능 |
프로젝트 초기화는 아래 명령으로 수행합니다.
# CAP Java 프로젝트 생성
cds init my-bookshop --add java
cd my-bookshop
mvn clean install
pom.xml에 cds-maven-plugin이 포함되어 있어야 CDS 모델에서 Java 인터페이스(EventContext 등)가 자동 생성됩니다. 일반적으로 cds init --add java로 생성하면 기본 설정됩니다.
4. 핵심 개념 - OData Actions vs Functions, Bound vs Unbound
Action과 Function의 차이
OData V4 스펙에서 Action과 Function은 모두 "커스텀 오퍼레이션"이지만 의미론적으로 다릅니다.
- Action: 부수 효과(side effect)가 있는 연산. 데이터를 변경하거나, 메일을 보내거나, 주문을 취소하는 등의 작업. HTTP POST로 호출됩니다.
- Function: 부수 효과가 없는 읽기 전용 연산. 통계 조회, 계산 결과 반환 등. HTTP GET으로 호출됩니다.
비유하자면, Action은 은행 창구에서 "송금 실행"을 요청하는 것이고, Function은 "잔액 조회"를 요청하는 것입니다. 둘 다 서비스 호출이지만 데이터 변경 여부가 결정적 차이입니다.
Bound와 Unbound의 차이
- Unbound: 서비스 레벨에 직접 선언. 특정 엔티티 인스턴스 없이 호출 가능. URL 패턴:
/odata/v4/OrderService/cancelOrder - Bound: 엔티티의
actions블록 안에 선언. 특정 엔티티 인스턴스에 바인딩되어 호출. URL 패턴:/odata/v4/CatalogService/Books(42)/CatalogService.addRating
Bound Action은 Java 핸들러에서 context.getCqn()을 통해 바인딩된 엔티티 인스턴스의 CQN Select를 얻을 수 있습니다. 이것이 "어떤 책에 대해 addRating을 호출했는가"를 알 수 있게 해주는 메커니즘입니다.
CAP Java의 이벤트 처리 흐름
CAP Java에서 Action/Function 호출은 다음과 같은 이벤트 처리 파이프라인을 거칩니다.
- @Before - 입력값 검증, 전처리
- @On - 핵심 비즈니스 로직 실행 (Action/Function은 주로 여기서 구현)
- @After - 결과 후처리, 로깅
5. 실전 코드 3단계
1단계: CDS 모델 정의와 기본 Unbound Action
주문 관리 시나리오를 기반으로 CDS 모델을 정의합니다. Unbound Action cancelOrder와 Unbound Function countOpenOrders, 그리고 Books 엔티티에 Bound Action addRating을 선언합니다.
// srv/order-service.cds
using { managed, cuid } from '@sap/cds/common';
namespace my.bookshop;
entity Books : cuid, managed {
title : String(200);
author : String(100);
stock : Integer;
rating : Decimal(2,1);
} actions {
// Bound Action: 특정 책에 평점 등록
action addRating (stars : Integer) returns Books;
// Bound Function: 특정 책의 조회수 반환
function getViewsCount() returns Integer;
};
entity Orders : cuid, managed {
book : Association to Books;
quantity : Integer;
status : String enum { open; confirmed; cancelled; };
};
// Unbound Action/Function: 서비스 레벨 선언
service OrderService {
entity ListedBooks as projection on Books;
entity ListedOrders as projection on Orders;
// Unbound Action: 주문 취소
action cancelOrder (orderID : UUID, reason : String) returns {
success : Boolean;
message : String;
};
// Unbound Function: 열린 주문 수 조회
function countOpenOrders() returns Integer;
}
mvn clean compile을 실행하면 cds-maven-plugin이 CDS 모델을 분석하여 다음과 같은 Java 인터페이스를 자동 생성합니다.
CancelOrderEventContext- cancelOrder 액션용 타입 안전 컨텍스트CountOpenOrdersEventContext- countOpenOrders 함수용 컨텍스트AddRatingEventContext- addRating 바운드 액션용 컨텍스트OrderService_- 서비스 상수 및 Typed 서비스 인터페이스
2단계: 실무 시나리오 - Unbound/Bound Action 핸들러 구현
생성된 EventContext를 활용하여 실제 비즈니스 로직을 구현합니다. 에러 처리와 로깅을 포함한 실무 수준의 코드입니다.
package my.bookshop.handlers;
import cds.gen.orderservice.*;
import cds.gen.my.bookshop.Books;
import cds.gen.my.bookshop.Books_;
import cds.gen.my.bookshop.Orders;
import cds.gen.my.bookshop.Orders_;
import com.sap.cds.services.handler.EventHandler;
import com.sap.cds.services.handler.annotations.*;
import com.sap.cds.services.cds.CqnService;
import com.sap.cds.ql.Select;
import com.sap.cds.ql.Update;
import com.sap.cds.services.ErrorStatuses;
import com.sap.cds.services.ServiceException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Component
@ServiceName(OrderService_.CDS_NAME)
public class OrderServiceHandler implements EventHandler {
private static final Logger log =
LoggerFactory.getLogger(OrderServiceHandler.class);
private final CqnService db;
public OrderServiceHandler(CqnService db) {
this.db = db;
}
// ── Unbound Action: cancelOrder ──
@On(event = CancelOrderEventContext.CDS_NAME)
public void onCancelOrder(CancelOrderEventContext context) {
String orderID = context.getOrderID();
String reason = context.getReason();
log.info("주문 취소 요청: orderID={}, reason={}", orderID, reason);
// 1. 주문 조회
var order = db.run(Select.from(Orders_.class)
.where(o -> o.ID().eq(orderID)))
.first(Orders.class)
.orElseThrow(() -> new ServiceException(
ErrorStatuses.NOT_FOUND,
"주문 ID '{0}'을 찾을 수 없습니다.", orderID));
// 2. 상태 검증
if ("cancelled".equals(order.getStatus())) {
throw new ServiceException(
ErrorStatuses.CONFLICT,
"이미 취소된 주문입니다.");
}
// 3. 주문 상태 업데이트
order.setStatus("cancelled");
db.run(Update.entity(Orders_.class)
.data(order)
.where(o -> o.ID().eq(orderID)));
// 4. 재고 복원 로직 (실무 시나리오)
if (order.getBookId() != null) {
db.run(Update.entity(Books_.class)
.where(b -> b.ID().eq(order.getBookId()))
.set(b -> b.stock(),
old -> old.get("stock").as(Integer.class)
+ order.getQuantity()));
log.info("재고 복원 완료: bookId={}, qty={}",
order.getBookId(), order.getQuantity());
}
// 5. 결과 반환 (구조체)
var result = CancelOrderEventContext.ReturnType.create();
result.setSuccess(true);
result.setMessage("주문이 취소되었습니다. 사유: " + reason);
context.setResult(result);
}
// ── Bound Action: addRating (Books 엔티티에 바운드) ──
@On(event = AddRatingEventContext.CDS_NAME,
entity = ListedBooks_.CDS_NAME)
public void onAddRating(AddRatingEventContext context) {
Integer stars = context.getStars();
// 입력값 검증
if (stars == null || stars < 1 || stars > 5) {
throw new ServiceException(
ErrorStatuses.BAD_REQUEST,
"평점은 1~5 사이의 정수여야 합니다.");
}
// getCqn()으로 바인딩된 엔티티 인스턴스 조회
var book = db.run(context.getCqn())
.first(Books.class)
.orElseThrow(() -> new ServiceException(
ErrorStatuses.NOT_FOUND, "도서를 찾을 수 없습니다."));
log.info("평점 등록: bookId={}, title={}, stars={}",
book.getId(), book.getTitle(), stars);
// 평점 업데이트 (단순화: 직접 덮어쓰기)
book.setRating(new java.math.BigDecimal(stars));
db.run(Update.entity(Books_.class)
.data(book)
.where(b -> b.ID().eq(book.getId())));
context.setResult(book);
}
// ── Unbound Function: countOpenOrders ──
@On(event = CountOpenOrdersEventContext.CDS_NAME)
public void onCountOpenOrders(CountOpenOrdersEventContext context) {
long count = db.run(Select.from(Orders_.class)
.where(o -> o.status().eq("open")))
.rowCount();
context.setResult((int) count);
log.info("열린 주문 수 조회 결과: {}", count);
}
}
3단계: 프로덕션 수준 - Typed API 호출 및 @Before 검증
다른 서비스 핸들러나 이벤트 내부에서 Action을 프로그래밍 방식으로 호출해야 하는 경우, CAP Java의 Typed Service API를 사용할 수 있습니다. 또한 @Before 핸들러로 입력값 검증을 분리하면 코드 유지보수성이 높아집니다.
package my.bookshop.handlers;
import cds.gen.orderservice.*;
import com.sap.cds.services.handler.EventHandler;
import com.sap.cds.services.handler.annotations.*;
import com.sap.cds.services.runtime.CdsRuntime;
import org.springframework.stereotype.Component;
@Component
@ServiceName(OrderService_.CDS_NAME)
public class OrderValidationHandler implements EventHandler {
// ── @Before로 입력값 검증 분리 ──
@Before(event = CancelOrderEventContext.CDS_NAME)
public void validateCancelOrder(CancelOrderEventContext context) {
if (context.getOrderID() == null) {
throw new com.sap.cds.services.ServiceException(
com.sap.cds.services.ErrorStatuses.BAD_REQUEST,
"orderID는 필수 파라미터입니다.");
}
if (context.getReason() == null
|| context.getReason().isBlank()) {
throw new com.sap.cds.services.ServiceException(
com.sap.cds.services.ErrorStatuses.BAD_REQUEST,
"취소 사유(reason)를 반드시 입력해야 합니다.");
}
}
}
// ── 별도 서비스에서 Typed API로 Action 호출 ──
// 예: 배치 작업이나 다른 이벤트 핸들러에서 cancelOrder 호출
@Component
class BatchCleanupService {
private final CdsRuntime runtime;
BatchCleanupService(CdsRuntime runtime) {
this.runtime = runtime;
}
public void cancelExpiredOrders(java.util.List<String> orderIds) {
// Typed 서비스 인터페이스를 통해 Action 호출
var service = runtime.getServiceCatalog()
.getService(OrderService.class, OrderService_.CDS_NAME);
for (String orderId : orderIds) {
// EventContext를 직접 생성하여 Action 실행
CancelOrderEventContext ctx =
CancelOrderEventContext.create();
ctx.setOrderID(orderId);
ctx.setReason("자동 만료 처리 - 30일 경과");
service.emit(ctx);
// 결과 확인
var result = ctx.getResult();
if (result != null && result.getSuccess()) {
// 성공 처리 로직
}
}
}
}
6. 흔한 실수 / 트러블슈팅
FAQ 1: EventContext 클래스를 찾을 수 없다는 컴파일 에러
원인: mvn clean compile을 실행하지 않아 cds-maven-plugin이 Java 인터페이스를 생성하지 못한 경우입니다. CDS 모델을 변경할 때마다 Maven 빌드를 다시 실행해야 합니다.
해결: mvn clean compile 실행 후, IDE에서 프로젝트를 리프레시합니다. 생성된 클래스는 일반적으로 srv/target/generated-sources/ 하위에 위치합니다.
FAQ 2: Bound Action 호출 시 404 또는 405 에러
원인: URL 패턴이 잘못된 경우가 많습니다. Bound Action은 반드시 엔티티 인스턴스를 특정한 후 호출해야 합니다.
해결: POST /odata/v4/OrderService/ListedBooks(<key>)/OrderService.addRating 형태로 호출합니다. 서비스 네임스페이스를 Action 이름 앞에 붙여야 하는 점에 유의하세요.
FAQ 3: context.setResult()를 누락하면 어떻게 되나?
증상: Action은 HTTP 200이 반환되지만 응답 본문이 비어 있거나 null입니다. Function의 경우 에러가 발생할 수 있습니다.
해결: returns 절이 있는 Action/Function의 @On 핸들러에서는 반드시 context.setResult()를 호출해야 합니다. 반환값이 없는 Action이라면 CDS 정의에서 returns 절을 생략합니다.
FAQ 4: @On 핸들러가 두 번 실행된다
원인: 동일한 이벤트에 대해 @On 핸들러를 두 개 등록한 경우, CAP의 이벤트 처리 체인에서 둘 다 실행될 수 있습니다.
해결: 하나의 이벤트에는 하나의 @On 핸들러만 구현하는 것이 권장됩니다. 검증 로직은 @Before, 후처리는 @After로 분리하세요.
7. 다음 단계 / 관련 주제
- Draft 기반 Action: Fiori Elements UI에서 Draft와 함께 동작하는 Action 구현.
@cds.odata.bindingparameter.collection어노테이션 활용. - Remote Service Action 호출: CAP에서 외부 OData 서비스의 Action을 프록시로 호출하는 방법.
- Custom Error Messages:
ServiceException의 메시지 번들(i18n) 연동으로 다국어 에러 메시지 처리. - Unit Testing:
cds.test라이브러리를 활용한 Action/Function 핸들러 단위 테스트 작성. - Authorization:
@requires,@restrict어노테이션으로 Action별 권한 제어 구현.
8. 참고 자료
- CAP Java - Event Handlers - 핸들러 등록, @On/@Before/@After 전체 레퍼런스
- CDS Definition Language - Actions and Functions - CDS에서 Action/Function 정의 문법 공식 가이드
- CAP Java - Application Services: Actions and Functions - Typed EventContext, Typed Service API 상세 설명
- SAP Help - Developing with CAP Java (SAP BTP) - BTP 환경에서의 CAP Java 개발 종합 가이드
- SAP Help - OData V4 on SAP BTP - OData V4 프로토콜 및 SAP 구현 상세
- SAP Help - CAP Java SDK Reference - CAP Java SDK API 레퍼런스
- GitHub - SAP CAP Java Samples - 공식 샘플 코드 저장소 (AdminServiceHandler, 커스텀 액션 예제 포함)