CAP for Java

CDS 애노테이션 vs 핸들러 — 실수 3가지 #shorts #SAP #CAPforJava

📖 개요: 이 글에서 다루는 것

CAP Java 프로젝트에서 CDS 어노테이션은 단순한 메타데이터가 아니라, 런타임 동작(검증·권한·CRUD 제한)과 UI 렌더링을 실제로 바꾸는 선언적 계약입니다. 문제는 "어떤 어노테이션을 CDS에 걸고, 어떤 로직을 Java 핸들러로 내려야 하는가"라는 판단이 실무에서 자주 갈린다는 점입니다. 이 글은 SalesOrder(판매 오더) 시나리오를 기준으로 그 판단 기준을 정리합니다.

  • @readonly / @insertonly / @mandatory의 런타임 동작 원리 이해
  • @assert.range 등 입력 검증 어노테이션과 Java 핸들러 검증의 역할 분담
  • @cds.persistence.exists로 기존 DB 아티팩트 재사용하기
  • @Common.Label, @UI.LineItem으로 Fiori Elements UI 구성하기
  • 프로덕션 관점의 보안(@restrict)·성능·테스트 전략

📚 미리 알아두면 좋은 것

CDS 엔티티/서비스 정의 문법, Spring Boot 기본 구조(빈, 컴포넌트 스캔), Maven 빌드 경험이 있으면 수월합니다. CAP Node.js 경험자라면 어노테이션 자체는 동일하고 핸들러 작성 방식만 다르다는 점을 기억하면 됩니다. OData V4 기본 개념(엔티티 셋, CRUD 요청)도 알고 있으면 좋습니다.

🔧 환경 구성과 준비물

이 글의 예제는 다음 환경을 기준으로 작성했습니다.

  • CAP Java SDK 3.x (com.sap.cds:cds-services-*, Spring Boot 3.x 기반) — 2.x에서도 어노테이션 동작은 대부분 동일합니다
  • Java 17 이상 (21 권장), Maven 3.9+
  • @sap/cds-dk 8.x (CDS 컴파일러/빌드 도구, Node.js 20+)
  • 로컬 개발은 H2/SQLite, 배포 대상은 SAP BTP Cloud Foundry 환경 + SAP HANA Cloud 기준

프로젝트 생성은 cds init sales-mgmt --add java 또는 mvn archetype:generate(cds-services-archetype)로 시작합니다. 실행은 mvn spring-boot:run, CDS 모델 변경 후에는 mvn compile로 생성 인터페이스(typed accessor)를 갱신합니다.

💡 핵심 개념: 어노테이션은 "계약", 핸들러는 "재량"

CDS 어노테이션을 건물의 도면에, Java 핸들러를 현장 시공 판단에 비유할 수 있습니다. 도면에 "이 벽은 내력벽(readonly)"이라고 적으면 누구도 임의로 허물 수 없습니다. 반면 "채광에 따라 창 크기 조정" 같은 조건부 판단은 현장(핸들러)의 몫입니다. 이 구분이 어노테이션 선택의 제1 기준입니다.

동작 원리를 보면, CDS 컴파일러가 어노테이션을 CSN(모델 JSON)에 포함시키고, CAP Java 런타임의 제네릭 핸들러가 요청 처리 파이프라인(Before → On → After)에서 이를 해석합니다. 예를 들어 @readonly 엔티티에 POST가 들어오면 커스텀 코드에 도달하기 전에 405 계열 오류로 거절됩니다. 즉 어노테이션 기반 제약은 모든 프로토콜 어댑터(OData, REST)에 일관되게, 커스텀 코드보다 먼저 적용됩니다.

실무 판단 기준을 표로 정리하면 다음과 같습니다.

상황선택이유
무조건적·정적 규칙 (항상 읽기 전용, 항상 필수)CDS 어노테이션선언만으로 전 채널 일관 적용, 문서화 효과
조건부 규칙 (상태가 'Open'일 때만 수정 가능)Java @Before 핸들러런타임 데이터 조회가 필요한 판단
단순 값 범위·형식 검증@assert.range / @assert.format검증 코드 중복 제거
교차 필드 검증 (납기일 > 주문일)Java 핸들러어노테이션으로 표현 불가
UI 라벨·목록 컬럼@Common.Label / @UI.LineItemFiori Elements가 메타데이터로 소비

또 하나 중요한 축은 런타임 어노테이션 vs UI 어노테이션의 구분입니다. @readonly, @mandatory, @assert.*는 서버가 강제하는 규칙이고, @Common.*, @UI.*는 OData $metadata에 노출되어 클라이언트(Fiori Elements)가 해석하는 힌트입니다. UI 어노테이션만으로는 서버 측 보호가 되지 않는다는 점이 일반적으로 가장 많이 놓치는 부분입니다.

💻 실전 코드: 3단계로 완성하는 SalesOrder 서비스

1단계 — 기본: 도메인 모델과 런타임 어노테이션. 판매 오더에서 오더 번호는 시스템 생성 값(읽기 전용), 고객명은 필수, 할인율은 0~30% 범위로 제한합니다.

// db/schema.cds
namespace sales.mgmt;
using { cuid, managed } from '@sap/cds/common';

entity SalesOrders : cuid, managed {
  @readonly orderNo   : String(10);          // 시스템 채번, 클라이언트 수정 차단
  @mandatory customerName : String(80);      // null/빈값이면 400 오류
  @assert.range: [0, 30]
  discountPct : Decimal(5,2) default 0;      // 범위 밖이면 제네릭 검증이 거절
  status      : String(10) enum { Open; Approved; Closed } default 'Open';
  items       : Composition of many OrderItems on items.parent = $self;
}

entity OrderItems : cuid {
  parent   : Association to SalesOrders;
  material : String(40) @mandatory;
  quantity : Integer @assert.range: [1, 9999];
}

여기서 orderNo 채번처럼 "값을 만들어 주는" 일은 어노테이션이 못 하므로 Java 핸들러가 맡습니다.

@Component
@ServiceName(OrderService_.CDS_NAME)
public class OrderServiceHandler implements EventHandler {

  @Before(event = CqnService.EVENT_CREATE, entity = SalesOrders_.CDS_NAME)
  public void assignOrderNo(List orders) {
    for (SalesOrders order : orders) {
      order.setOrderNo("SO-" + System.currentTimeMillis() % 1_000_000);
    }
  }
}

@readonly 필드라도 서버 측 핸들러에서는 자유롭게 값을 설정할 수 있습니다. 차단 대상은 어디까지나 외부 요청입니다.

2단계 — 실무: 조건부 검증, 오류 처리, 감사 로그. "승인된 오더는 수정 불가" 같은 조건부 규칙은 핸들러로, "감사 로그는 생성만 가능"은 @insertonly로 해결합니다.

// srv/order-service.cds
using sales.mgmt as db from '../db/schema';

service OrderService {
  entity SalesOrders as projection on db.SalesOrders;
  @insertonly entity AuditEntries as projection on db.AuditEntries;
  // READ/UPDATE/DELETE 요청은 런타임이 자동 거절 → 로그 위·변조 방지
}
@Component
@ServiceName(OrderService_.CDS_NAME)
public class OrderGuardHandler implements EventHandler {

  private static final Logger log =
      LoggerFactory.getLogger(OrderGuardHandler.class);

  @Autowired PersistenceService db;

  @Before(event = { CqnService.EVENT_UPDATE, CqnService.EVENT_DELETE },
          entity = SalesOrders_.CDS_NAME)
  public void rejectIfApproved(CdsUpdateEventContext ctx, List orders) {
    for (SalesOrders incoming : orders) {
      SalesOrders current = db.run(
          Select.from(SalesOrders_.class).byId(incoming.getId()))
          .single(SalesOrders.class);
      if (!"Open".equals(current.getStatus())) {
        log.warn("Blocked modification of order {} in status {}",
                 current.getOrderNo(), current.getStatus());
        throw new ServiceException(ErrorStatuses.CONFLICT,
            "승인 또는 마감된 오더는 수정할 수 없습니다: " + current.getOrderNo());
      }
    }
  }

  @After(event = CqnService.EVENT_UPDATE, entity = SalesOrders_.CDS_NAME)
  public void writeAudit(List orders) {
    orders.forEach(o -> log.info("Order {} updated", o.getOrderNo()));
    // 실제 감사 기록은 AuditEntries에 INSERT (insertonly라 외부 조회·수정 불가)
  }
}

ServiceException에 ErrorStatuses를 지정하면 OData 오류 응답 코드가 함께 정리되어, 프런트엔드가 409/400을 구분해 처리할 수 있습니다.

3단계 — 프로덕션: 기존 테이블 재사용, 권한, UI 어노테이션, 테스트. 이미 HANA에 존재하는 레거시 테이블(예: 환율 테이블)을 CAP이 새로 생성하지 않고 참조만 하려면 @cds.persistence.exists를 씁니다.

// db/external.cds — 배포 시 CREATE TABLE 생성이 생략됨
@cds.persistence.exists
entity ExchangeRates {
  key currency : String(3);
  rate         : Decimal(15,5);
}

이 어노테이션이 없으면 cds deploy가 동명의 테이블을 새로 만들려다 충돌합니다. 반대로 아예 DB 아티팩트 자체가 필요 없는 순수 계산용 엔티티라면 @cds.persistence.skip이 적합합니다. 권한과 UI는 다음과 같이 선언합니다.

annotate OrderService.SalesOrders with @(restrict: [
  { grant: 'READ',  to: 'Viewer' },
  { grant: ['CREATE','UPDATE'], to: 'SalesRep' },
  { grant: '*', to: 'SalesManager' }
]);

annotate OrderService.SalesOrders with {
  orderNo      @Common.Label: '오더 번호';
  customerName @Common.Label: '고객명';
  discountPct  @Common.Label: '할인율(%)';
};

annotate OrderService.SalesOrders with @UI: {
  LineItem: [
    { Value: orderNo },
    { Value: customerName },
    { Value: status, Criticality: #Information },
    { Value: discountPct }
  ],
  HeaderInfo: { TypeName: '판매 오더', TypeNamePlural: '판매 오더 목록' }
};

테스트는 MockMvc로 어노테이션 동작까지 함께 검증하는 방식이 권장됩니다.

@SpringBootTest
@AutoConfigureMockMvc
class OrderServiceTest {
  @Autowired MockMvc mvc;

  @Test
  @WithMockUser(authorities = "SalesRep")
  void rejectsOutOfRangeDiscount() throws Exception {
    mvc.perform(post("/odata/v4/OrderService/SalesOrders")
        .contentType(MediaType.APPLICATION_JSON)
        .content("{\"customerName\":\"Acme\",\"discountPct\":55}"))
       .andExpect(status().isBadRequest());   // @assert.range 위반
  }
}

성능 관점에서는 2단계 핸들러처럼 건별 Select를 반복하지 말고, 배치 요청이 가능하면 ID 목록을 모아 Select ... where ID in (...) 한 번으로 조회하는 편이 좋습니다. 또한 @cds.autoexpose는 연관 코드 리스트를 자동 노출해 편리하지만, 의도치 않은 데이터 노출 경로가 될 수 있어 @restrict와 함께 검토하는 것이 일반적입니다.

⚠️ 자주 겪는 문제와 해결 포인트

Q1. @readonly 필드에 값을 보내면 오류가 나나요, 무시되나요? 엔티티 전체에 @readonly를 걸면 쓰기 요청 자체가 거절되지만, 개별 필드 @readonly는 일반적으로 해당 필드 값이 조용히 무시됩니다. "오류를 기대했는데 200이 온다"며 당황하는 경우가 많으니, 명시적 거절이 필요하면 @Before 핸들러에서 검사하세요.

Q2. @mandatory를 걸었는데 공백 문자열이 통과합니다. @mandatory는 null과 빈 문자열을 검사하지만, 공백만 있는 문자열(" ")은 별개입니다. trim 후 검증하는 로직은 핸들러에 추가해야 합니다. 초기값이 있는 필드는 default와 조합할 때 동작을 반드시 테스트하세요.

Q3. @UI.LineItem을 추가했는데 화면에 반영이 안 됩니다. UI 어노테이션은 $metadata에 반영되어야 하므로 mvn compile(또는 cds build) 후 서버 재시작이 필요하고, 브라우저의 메타데이터 캐시도 지워야 합니다. annotate 대상 이름이 서비스 프로젝션(OrderService.SalesOrders)인지 DB 엔티티인지도 확인하세요. DB 엔티티에만 걸면 프로젝션 이름 변경(excluding, 별칭) 시 전파되지 않을 수 있습니다.

Q4. @cds.persistence.exists를 썼는데 로컬 H2에서 테이블이 없다고 합니다. 이 어노테이션은 "이미 존재한다"는 선언일 뿐 만들어 주지는 않습니다. 로컬 개발용으로는 프로파일 분기(예: [development] 프로파일에서는 어노테이션 없이 배포)나 초기 스크립트로 테이블을 준비하는 방식이 일반적입니다.

🚀 이어서 살펴볼 주제

어노테이션 기반 제약을 익혔다면, 다음으로는 @odata.draft.enabled를 통한 드래프트 편집 플로우, @restrict의 where 조건을 활용한 인스턴스 단위 권한(예: 자기 부서 오더만 조회), 그리고 @cds.search로 검색 대상 필드를 제어하는 방법을 살펴보는 것을 권장합니다. CAP Java의 Typed Event Context와 Remote Service 연동(@cds.persistence.skip + 외부 OData 소비)까지 확장하면 실무 아키텍처의 대부분을 어노테이션과 핸들러 조합으로 설계할 수 있습니다.

📚 더 읽어볼 문서

댓글 0

아직 댓글이 없습니다.