[CAP for Java] OData Actions/Functions 완전 정복 — Unbound/Bound 구현부터 타입 안전 EventContext까지

Moderator · 조회 3

1. 개요 및 학습 목표

SAP CAP(Cloud Application Programming Model) for Java에서 OData Actions와 Functions는 표준 CRUD 외의 비즈니스 로직을 서비스로 노출하는 핵심 메커니즘입니다. 주문 취소, 평점 등록, 통계 조회처럼 엔티티 단순 조작을 넘어서는 작업을 깔끔하게 구현할 수 있습니다. 이 튜토리얼에서는 CDS 모델 정의부터 Maven Plugin 기반 타입 안전 EventContext, 실전 핸들러 구현까지 전 과정을 다룹니다.

2. 선수 지식

3. 환경 / 버전 / 준비물

항목권장 버전
CAP Java SDK3.x (2025 이후 릴리스)
CDS Compiler (cds-dk)8.x 이상
Java17 이상 (LTS 권장)
Maven3.9 이상
IDEVS 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.xmlcds-maven-plugin이 포함되어 있어야 CDS 모델에서 Java 인터페이스(EventContext 등)가 자동 생성됩니다. 일반적으로 cds init --add java로 생성하면 기본 설정됩니다.

4. 핵심 개념 - OData Actions vs Functions, Bound vs Unbound

Action과 Function의 차이

OData V4 스펙에서 Action과 Function은 모두 "커스텀 오퍼레이션"이지만 의미론적으로 다릅니다.

비유하자면, Action은 은행 창구에서 "송금 실행"을 요청하는 것이고, Function은 "잔액 조회"를 요청하는 것입니다. 둘 다 서비스 호출이지만 데이터 변경 여부가 결정적 차이입니다.

Bound와 Unbound의 차이

Bound Action은 Java 핸들러에서 context.getCqn()을 통해 바인딩된 엔티티 인스턴스의 CQN Select를 얻을 수 있습니다. 이것이 "어떤 책에 대해 addRating을 호출했는가"를 알 수 있게 해주는 메커니즘입니다.

CAP Java의 이벤트 처리 흐름

CAP Java에서 Action/Function 호출은 다음과 같은 이벤트 처리 파이프라인을 거칩니다.

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 인터페이스를 자동 생성합니다.

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. 다음 단계 / 관련 주제

8. 참고 자료