CAP for Java

@readonly vs @Core.Computed 뭐가 다를까 #shorts #SAP #CAPforJava

▶ YouTube에서 보기

📖 개요 및 핵심 체크리스트

CAP Java 프로젝트가 커지면 어느 순간 이런 질문이 반복됩니다. "이 필드는 @readonly로 막아야 하나, 아니면 핸들러에서 검사해야 하나?" "권한은 @requires인가 @restrict인가?" 어노테이션은 종류가 많은 데다 이름이 비슷해서, 감으로 붙이다 보면 어떤 건 런타임이 처리하고 어떤 건 조용히 무시되는 상황이 생깁니다. 이 글은 CAP Java 서비스 계층을 설계할 때 상황별로 어떤 CDS 어노테이션을 선택해야 하는지 판단 기준을 세우는 데 초점을 맞춥니다.

  • 어노테이션을 '쓰기 제어 / 값 검증 / 영속성 / 권한'의 4축으로 분류해 선택할 수 있다
  • @readonly, @insertonly, @Core.Computed의 적용 시점 차이를 구분한다
  • @mandatory·@assert.range로 처리할 검증과 Java 핸들러로 넘길 검증의 경계를 정한다
  • @cds.persistence.exists/skip으로 DB 테이블 생성 여부를 통제한다
  • 커스텀 어노테이션을 CdsModel에서 읽어 공통 로직을 만드는 패턴을 익힌다

📚 미리 갖추면 좋은 배경

CDS로 엔티티와 서비스를 한 번이라도 정의해 봤고, CAP Java에서 EventHandler를 구현해 본 경험이 있으면 충분합니다. Spring Boot의 @Component 빈 등록과 OData V4의 기본 요청 형태(POST/PATCH/함수 호출)를 알고 있으면 예제를 따라가기 수월합니다. XSUAA 역할 개념은 권한 파트에서만 필요합니다.

🔧 이 글에서 사용한 환경과 준비물

예제는 2026년 시점에서 널리 쓰이는 다음 조합을 기준으로 작성했습니다. 버전에 따라 지원 여부가 다른 어노테이션이 있으므로, 실제 프로젝트에서는 mvn dependency:treecds -v로 먼저 확인하는 것이 권장됩니다.

  • CAP Java: cds-services 3.x 계열 (Spring Boot 3.x, Java 17 이상)
  • CDS 컴파일러: @sap/cds-dk 8.x 이상 — 계산 요소(calculated element)와 최신 @assert.* 지원 확인 필요
  • DB: 로컬은 H2 또는 SQLite, 배포는 SAP HANA Cloud
  • 프로토콜: OData V4 (@odata.draft.enabled는 OData V4 + Fiori Elements 조합 전제)
  • 도메인: 이 글의 시나리오는 가상의 설비 정비 회사 "노스라인"의 부품·수리 접수 관리입니다

💡 어노테이션 선택의 4가지 축

CDS 어노테이션을 외우려 하면 끝이 없습니다. 대신 "이 제약이 언제, 어디서 평가되는가"를 기준으로 4개 축에 배치해 두면 선택이 단순해집니다. 공항 검색대에 비유하면 이렇습니다.

  • 1축 · 쓰기 제어(탑승 게이트) — 애초에 들어올 수 없는 통로를 막습니다. @readonly, @insertonly, @Core.Computed. 요청이 프로토콜 어댑터를 통과하는 순간 평가됩니다.
  • 2축 · 값 검증(수하물 스캐너) — 들어온 값의 형태를 봅니다. @mandatory, @assert.range, @assert.format, @assert.unique, @assert.target. CAP 런타임이 @Before 단계에서 자동 검사합니다.
  • 3축 · 영속성(창고 배정) — 테이블을 만들지, 남의 테이블을 빌릴지, 아예 안 만들지를 정합니다. @cds.persistence.exists, @cds.persistence.skip, @cds.persistence.table.
  • 4축 · 권한(보안 검색) — 누가 무엇을 어디까지 볼 수 있는지 결정합니다. @requires, @restrict.

여기에 UI/편집 흐름을 담당하는 @odata.draft.enabled가 얹히는 구조입니다. 실무 판단표로 정리하면 다음과 같습니다.

상황선택이유
서버가 채번하는 문서번호@Core.Computed클라이언트 입력을 무시하되 서버 쓰기는 허용
조회 전용 마스터 뷰@readonly서비스 레벨에서 CUD 전체 차단
감사 로그·이력 테이블@insertonly추가만 허용, 수정·삭제 차단
1~5 등급 같은 고정 범위@assert.range선언으로 끝나는 검증은 핸들러로 빼지 않음
재고 수량 대비 출고 가능 여부Java 핸들러다른 레코드를 조회해야 하는 교차 검증
타 팀이 관리하는 기존 테이블@cds.persistence.exists배포 시 DDL 재생성 방지
외부 API 결과를 노출하는 엔티티@cds.persistence.skip테이블 없이 커스텀 @On 핸들러로만 서빙

가장 중요한 원칙 하나만 기억하면 됩니다. 어노테이션은 "선언으로 끝나는 규칙"에만 쓰고, 다른 데이터를 읽어야 판단 가능한 규칙은 Java 핸들러로 보냅니다. 이 경계를 흐리면 CDS 파일에 억지 표현식이 쌓이거나, 반대로 핸들러에 세 줄이면 될 null 체크가 반복됩니다.

💻 실전 예제 3단계

1단계 — 기본: 도메인 모델에 1·2축 어노테이션 배치하기

노스라인의 부품 마스터와 수리 접수 모델입니다. 값 검증은 최대한 선언으로 끝냅니다.

namespace com.northline.plantops;
using { managed, cuid, Currency } from '@sap/cds/common';

@assert.unique.code: [ partCode ]
entity SparePart : cuid, managed {
  partCode      : String(24)  @mandatory @assert.format: '^NL-[A-Z0-9]{6}$';
  partName      : String(120) @mandatory;
  shelfLifeDays : Integer     @assert.range: [0, 3650];
  unitPrice     : Decimal(11,2);
  currency      : Currency;
  onHandQty     : Integer default 0;
  reorderPoint  : Integer default 0;
  // 계산 요소: DB에 저장하지 않고 조회 시점에 산출
  needsReorder  : Boolean = onHandQty < reorderPoint;
}

entity RepairTicket : cuid, managed {
  ticketNo   : String(16)  @Core.Computed;            // 서버 채번
  machineTag : String(32)  @mandatory;
  severity   : Integer     @assert.range: [1, 5];
  status     : String(12)  @Core.Computed default 'OPEN';
  openedAt   : Timestamp   @cds.on.insert: $now;
  openedBy   : String(120) @cds.on.insert: $user;
  issues     : Composition of many PartIssueLog on issues.ticket = $self;
}

// append-only 출고 이력
entity PartIssueLog : cuid {
  ticket   : Association to RepairTicket;
  part     : Association to SparePart @assert.target;
  issueQty : Integer   @mandatory @assert.range: [1, 9999];
  issuedAt : Timestamp @cds.on.insert: $now;
}

여기서 자주 헷갈리는 두 쌍을 정리합니다. @Core.Computed클라이언트 입력만 무시하고 서버 코드의 쓰기는 허용하므로 채번·상태값에 적합합니다. 반면 @readonly는 서비스 노출 레벨에서 쓰기 자체를 차단합니다. 또 @assert.target은 연관된 대상 레코드가 실제 존재하는지 검사해 주므로, "존재하지 않는 부품 ID로 출고 로그가 꽂히는" 사고를 별도 핸들러 없이 막을 수 있습니다.

2단계 — 실무: 서비스 계층에서 3·4축 적용 + 에러/로깅

같은 엔티티라도 어떤 서비스로 내보내느냐에 따라 규칙이 달라집니다. 노출 시점에 어노테이션을 다시 붙이는 것이 CAP 설계의 핵심 포인트입니다.

using { com.northline.plantops as db } from '../db/schema';

@path: 'plant-ops'
@requires: 'authenticated-user'
service PlantOpsService {

  // 편집 흐름이 필요한 화면 → draft 활성화
  @odata.draft.enabled
  @restrict: [
    { grant: 'READ',           to: 'PlantViewer' },
    { grant: ['READ','WRITE'], to: 'PlantPlanner',
      where: 'machineTag like $user.plantPrefix' }
  ]
  entity RepairTickets as projection on db.RepairTicket;

  // 현장 앱은 조회만, 단가는 아예 노출하지 않음
  @readonly
  entity SparePartCatalog as projection on db.SparePart
    excluding { unitPrice, currency };

  // 감사 이력: 넣을 수만 있고 고칠 수 없음
  @insertonly
  entity PartIssueLogs as projection on db.PartIssueLog;

  // 외부 조달 시스템 조회 결과 → 테이블 생성 안 함
  @cds.persistence.skip
  @readonly
  entity VendorLeadTimes {
    key partCode : String(24);
        avgDays  : Integer;
        vendorId : String(20);
  }

  @requires: 'PlantSupervisor'
  action closeTicket(ticketId : UUID, resolution : String(200)) returns String;
}

액션에 붙인 @requires는 서비스 전체 권한보다 좁은 게이트를 하나 더 두는 용도입니다. 반면 @restrictwhere는 인스턴스 단위 필터라서, XSUAA 역할에 plantPrefix 같은 속성이 매핑돼 있어야 실제로 동작합니다. 이제 선언으로 못 막는 교차 검증을 Java에서 처리합니다.

@Component
@ServiceName(PlantOpsService_.CDS_NAME)
public class RepairTicketHandler implements EventHandler {

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

  private final PersistenceService db;

  public RepairTicketHandler(PersistenceService db) { this.db = db; }

  // @Core.Computed 필드는 서버가 채운다
  @Before(event = CqnService.EVENT_CREATE, entity = RepairTickets_.CDS_NAME)
  public void assignTicketNo(List<RepairTickets> tickets) {
    for (RepairTickets t : tickets) {
      t.setTicketNo("RT-" + LocalDate.now().getYear() + "-"
          + String.format("%06d", ThreadLocalRandom.current().nextInt(1_000_000)));
      t.setStatus("OPEN");
      log.debug("ticket prepared: tag={}, severity={}", t.getMachineTag(), t.getSeverity());
    }
  }

  // 재고 대비 출고 수량 — 다른 레코드를 읽어야 하므로 어노테이션으로는 불가
  @Before(event = CqnService.EVENT_CREATE, entity = PartIssueLogs_.CDS_NAME)
  public void checkStock(List<PartIssueLogs> logs) {
    for (PartIssueLogs l : logs) {
      Integer onHand = db.run(Select.from(SparePart_.class)
              .columns(p -> p.onHandQty())
              .where(p -> p.ID().eq(l.getPartId())))
          .first(SparePart.class)
          .map(SparePart::getOnHandQty)
          .orElseThrow(() -> new ServiceException(
              ErrorStatuses.NOT_FOUND, "부품을 찾을 수 없습니다: {}", l.getPartId()));

      if (l.getIssueQty() > onHand) {
        log.warn("stock shortage part={} req={} onHand={}",
            l.getPartId(), l.getIssueQty(), onHand);
        throw new ServiceException(ErrorStatuses.CONFLICT,
            "재고 부족: 요청 {}, 가용 {}", l.getIssueQty(), onHand);
      }
    }
  }
}

ServiceExceptionErrorStatuses를 함께 넘기면 OData 응답 코드가 의미에 맞게 내려가고, 메시지 파라미터는 i18n 번들과 연결하기 좋습니다. 로그는 검증 실패(WARN)와 정상 흐름(DEBUG)을 구분해 남기는 편이 운영 시 추적에 유리합니다.

3단계 — 프로덕션: 커스텀 어노테이션 + 런타임 리플렉션 + 테스트

엔티티가 수십 개로 늘면 "감사 대상마다 핸들러 추가"는 유지되지 않습니다. 자체 어노테이션을 선언하고 CdsModel에서 읽어 공통 처리하는 방식이 확장성이 좋습니다.

annotate PlantOpsService.PartIssueLogs with @northline.audit: true;

annotate PlantOpsService.RepairTickets with
  @northline.audit: true
  @northline.retentionDays: 1825;
@Component
@ServiceName(value = "*", type = ApplicationService.class)
public class AuditTrailHandler implements EventHandler {

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

  @Before(event = { CqnService.EVENT_CREATE, CqnService.EVENT_UPDATE,
                    CqnService.EVENT_DELETE })
  public void traceMutations(EventContext context) {
    CdsEntity target = context.getTarget();
    if (target == null) return;

    boolean audited = target.getAnnotationValue("northline.audit", Boolean.FALSE);
    if (!audited) return;

    Integer retention = target.getAnnotationValue("northline.retentionDays", 365);
    String user = context.getUserInfo().getName();

    log.info("[AUDIT] entity={} event={} user={} retentionDays={}",
        target.getQualifiedName(), context.getEvent(), user, retention);
  }
}

성능 관점에서 getAnnotationValue는 이미 파싱된 모델을 읽는 호출이라 비용이 크지 않지만, 요청마다 엔티티 전체를 순회하는 코드는 피하고 위처럼 현재 타깃 엔티티만 확인하도록 범위를 좁히는 것이 좋습니다. 보안 측면에서는 두 가지를 반드시 기억해야 합니다. 첫째, @readonly·@restrict서비스 계층에서 평가되므로 PersistenceService로 직접 실행한 CQN에는 적용되지 않습니다. 우회가 목적이 아니라면 커스텀 핸들러에서도 애플리케이션 서비스를 거치는 편이 안전합니다. 둘째, 개인정보성 컬럼에는 @PersonalData/@Sensitive 계열을 함께 선언해 로그 마스킹·삭제 정책과 연결하는 것이 권장됩니다.

마지막으로 어노테이션은 "붙였으니 동작한다"고 가정하지 말고 테스트로 고정합니다.

@SpringBootTest
@AutoConfigureMockMvc
class AnnotationContractTest {

  @Autowired MockMvc mockMvc;

  @Test
  @WithMockUser(username = "viewer", roles = "PlantViewer")
  void readonlyEntityRejectsWrite() throws Exception {
    mockMvc.perform(post("/odata/v4/plant-ops/SparePartCatalog")
            .contentType(MediaType.APPLICATION_JSON)
            .content("{\"partCode\":\"NL-A00001\",\"partName\":\"베어링\"}"))
        .andExpect(status().is4xxClientError());
  }

  @Test
  @WithMockUser(username = "planner", roles = "PlantPlanner")
  void severityOutOfRangeIsRejected() throws Exception {
    mockMvc.perform(post("/odata/v4/plant-ops/RepairTickets")
            .contentType(MediaType.APPLICATION_JSON)
            .content("{\"machineTag\":\"NL-PRESS-02\",\"severity\":9}"))
        .andExpect(status().isBadRequest());
  }
}

⚠️ 자주 만나는 함정과 FAQ

Q1. @readonly를 붙였는데 값이 바뀌어 저장됩니다.

세 가지를 순서대로 확인하세요. (1) DB 엔티티가 아니라 서비스 프로젝션에 붙였는지, (2) 커스텀 핸들러에서 PersistenceService로 직접 쓰고 있는지 — 이 경로는 서비스 레벨 제약을 통과하지 않습니다, (3) 의도가 "클라이언트만 막고 서버는 쓰기"라면 애초에 @Core.Computed가 맞는 선택입니다.

Q2. @mandatory인데 빈 값이 통과합니다.

@mandatory는 입력 페이로드에 값이 없거나 빈 문자열일 때 거부하지만, default@cds.on.insert로 값이 채워지면 통과합니다. 또 draft 활성 엔티티에서는 초안 저장 단계가 아니라 활성화(activate) 시점에 평가되는 점을 감안해야 합니다. "다른 필드 값에 따라 필수"인 조건부 필수는 어노테이션 범위를 벗어나므로 @Before 핸들러로 처리하는 편이 좋습니다.

Q3. @cds.persistence.exists를 붙였더니 배포 후 "table not found"가 납니다.

이 어노테이션은 "테이블은 이미 있으니 만들지 말라"는 선언일 뿐, 접근 권한까지 열어 주지는 않습니다. HDI 컨테이너에서 외부 스키마 객체를 쓰려면 시노님(.hdbsynonym)과 grant 설정이 별도로 필요합니다. 반대로 테이블 자체가 필요 없는 조회용 엔티티라면 @cds.persistence.skip + 커스텀 @On 핸들러 조합이 맞습니다.

Q4. @restrictwhere 조건이 무시되는 것 같습니다.

$user.<속성>은 토큰의 사용자 속성에서 값을 가져오므로, xs-security.json의 attribute 정의와 역할 컬렉션 매핑이 없으면 조건이 성립하지 않습니다. 로컬 개발 시에는 application.yaml의 mock user에 동일한 속성을 넣어 두고 검증하는 것이 좋습니다.

Q5. @odata.draft.enabled를 켰더니 액션 호출이 실패합니다.

draft 엔티티는 초안 인스턴스와 활성 인스턴스가 서로 다른 키 조합으로 존재합니다. 액션 대상이 활성인지 초안인지에 따라 URL과 바인딩이 달라지므로, 상태 변경 액션은 활성 인스턴스 기준으로 설계하고 화면 갱신은 @Common.SideEffects로 유도하는 편이 안정적입니다.

🚀 이어서 살펴볼 주제

선택 기준이 잡혔다면 다음 순서로 넓혀 보세요. (1) 커스텀 @On 핸들러로 외부 시스템 연동@cds.persistence.skip 엔티티에 원격 서비스 결과를 매핑하는 패턴, (2) CQN 조작과 @Before 필터 주입 — 어노테이션으로 표현하기 어려운 테넌트·조직 단위 필터링, (3) 이벤트 메시징 — 감사 로그를 메시지 브로커로 흘려보내 서비스 부담 줄이기, (4) 다국어 메시지와 검증 오류 UXServiceException 메시지를 i18n 번들에 연결해 사용자 친화적으로 노출하기입니다.

📚 더 읽어볼 만한 자료

댓글 0

아직 댓글이 없습니다.