SAP BTP Java DAY2. CDS 고급 모델링 + 완전한 데이터 레이어 (Java)

이우철·2026년 5월 2일

SAP_BTP

목록 보기
8/11

[2일차] CDS 고급 모델링 + 완전한 데이터 레이어 (Java)

목표: 실무 수준의 데이터 모델을 설계하고, 다양한 OData 쿼리와 비즈니스 로직을 Java로 완성한다.
시나리오: 출장비 앱의 데이터 구조를 완성 — 신청, 카테고리, 승인 이력을 연결한다.
런타임: CAP Java (Spring Boot) + Java 17 + Maven
소요 시간: 이론 2시간 + 실습 3시간


2일차 학습 목표

이론:
  CDS의 Associations vs Compositions 차이를 설명할 수 있다 (Node.js와 동일 개념)
  Enum 타입으로 상태값을 안전하게 관리하는 방법을 안다
  CAP Java의 4가지 이벤트 Hook (@Before/@After/@On + CRUD)을 활용할 수 있다
  CQN Java API (Select/Insert/Update)로 DB를 다루는 방법을 안다

실습:
  Association(1:N, N:1)이 있는 멀티 엔티티 모델 완성 (CDS 파일은 동일)
  $expand로 연관 데이터 함께 조회
  상태 전이 로직 (submit/approve/reject)을 Java로 완성
  집계 쿼리를 Java CQN API로 구현
  2일차 결과 Git 커밋

이론 세션 (2시간)


이론 1. CDS 타입 시스템 완전 정리

CDS 타입 시스템은 Node.js와 Java에서 완전히 동일합니다.
차이는 CAP이 CDS 타입을 Java 타입으로 자동 변환해준다는 점뿐입니다.

CDS 타입OData 타입DB 타입Java 타입
UUIDEdm.GuidCHAR(36)String
String(n)Edm.StringNVARCHAR(n)String
IntegerEdm.Int32INTEGERInteger
Decimal(p,s)Edm.DecimalDECIMALBigDecimal
DateEdm.DateDATEjava.time.LocalDate
DateTimeEdm.DateTimeOffsetTIMESTAMPjava.time.Instant
BooleanEdm.BooleanBOOLEANBoolean
LargeStringEdm.StringNCLOBString

Java 개발자를 위한 포인트
DecimalBigDecimal (금액 계산 시 정밀도 보장)
DateLocalDate (Java 8+ 날짜 API)
이 변환은 CAP이 자동으로 처리합니다.

Enum 타입 — CDS와 Java 연동

// CDS 파일 (Node.js와 동일)
type TravelStatus : String(20) enum {
  Draft     = 'Draft';
  Submitted = 'Submitted';
  Approved  = 'Approved';
  Rejected  = 'Rejected';
  Cancelled = 'Cancelled';
}
// Java 핸들러에서 사용 — String 상수로 비교
// CAP Java는 CDS Enum을 Java String으로 매핑
if ("Draft".equals(request.getStatus())) {
    // ...
}

// 또는 생성된 상수 사용 (더 안전)
if (TravelRequests.STATUS_DRAFT.equals(request.getStatus())) {
    // ...
}

이론 2. Associations vs Compositions — 핵심 차이

이 개념은 Node.js와 Java에서 완전히 동일합니다.

"이 데이터가 부모 없이 의미 있는가?"
  ├── YES → Association (독립 엔티티)  예: 부서
  └── NO  → Composition (자식 엔티티) 예: 비용 항목

Java 핸들러에서의 차이:

// Composition의 경우 — Cascade Delete가 자동 처리됨
// TravelRequests 삭제 시 ExpenseItems도 자동 삭제
// Java 핸들러에서 별도로 처리할 필요 없음

// Association의 경우 — FK만 저장, 별도 삭제 로직 불필요
// department_ID 컬럼만 갱신하면 됨

이론 3. CAP Java 이벤트 훅 완전 이해

CAP Java는 어노테이션 기반으로 이벤트 훅을 등록합니다.

요청 흐름 (Java와 Node.js 동일):
  HTTP Request
      ↓
  @Before ← 검증, 데이터 변환, 권한 확인
      ↓
  @On     ← 실제 처리 (기본: DB CRUD, 재정의 가능)
      ↓
  @After  ← 후처리, 알림, 로깅
      ↓
  HTTP Response

Node.js vs Java 이벤트 훅 비교:

// Node.js
this.before('CREATE', TravelRequests, async (req) => { ... });
this.after('CREATE',  TravelRequests, (result, req) => { ... });
this.on('submit',     TravelRequests, async (req) => { ... });
// Java — 어노테이션 방식 (더 명시적)
@Before(event = CqnService.EVENT_CREATE, entity = TravelRequests_.CDS_NAME)
public void beforeCreate(TravelRequests request) { ... }

@After(event = CqnService.EVENT_CREATE, entity = TravelRequests_.CDS_NAME)
public void afterCreate(TravelRequests result) { ... }

@On(event = "submit", entity = TravelRequests_.CDS_NAME)
public void onSubmit(...) { ... }

Java 핸들러 메서드의 주요 파라미터:

파라미터 타입Node.js 대응설명
TravelRequests requestreq.data요청 바디 (POST/PATCH)
TravelRequests resultresult (after hook)처리 완료된 결과
EventContext ctxreq 전체HTTP 헤더, 사용자 정보 등
Stream<TravelRequests> resultsresults (after READ)조회 결과 목록

이론 4. CQN Java API — DB 쿼리를 Java로

CAP Java는 CQN(Core Query Notation)을 Java Fluent API로 제공합니다.

// Node.js CQN
const result = await SELECT.one.from(TravelRequests).where({ ID });
await UPDATE(TravelRequests).set({ status: 'Submitted' }).where({ ID });
await INSERT.into(ApprovalLogs).entries({ request_ID: ID, action: 'Submitted' });
// Java CQN — 동일한 개념, Java 문법
Optional<TravelRequests> result = db.run(
    Select.from(TravelRequests_.class).where(t -> t.ID().eq(id))
).first(TravelRequests.class);

db.run(
    Update.entity(TravelRequests_.class)
        .data(Map.of("status", "Submitted"))
        .where(t -> t.ID().eq(id))
);

ApprovalLogs log = ApprovalLogs.create();
log.setRequestId(id);
log.setAction("Submitted");
db.run(Insert.into(ApprovalLogs_.class).entry(log));

Java 개발자 관점
CQN API는 JPA나 QueryDSL과 유사한 느낌입니다.
람다 표현식으로 타입 안전한 컬럼 참조가 가능합니다.

집계 쿼리 예시:

// Node.js
const result = await SELECT.one
  .from(ExpenseItems)
  .columns('sum(amount) as total')
  .where({ request_ID: requestId });
const total = result?.total || 0;

// Java
Row result = db.run(
    Select.from(ExpenseItems_.class)
        .columns(e -> e.amount().sum().as("total"))
        .where(e -> e.requestId().eq(requestId))
).single();
BigDecimal total = result.get("total", BigDecimal.class);

실습 세션 (3시간)


실습 1. 멀티 엔티티 데이터 모델 완성

1-1. 전체 스키마 재설계

db/schema.cds를 아래로 교체합니다.
이 파일은 Node.js 버전과 100% 동일합니다.

// db/schema.cds
// Node.js 버전과 완전히 동일한 파일!
namespace com.travel;

using { cuid, managed } from '@sap/cds/common';

// ── Enum 타입 정의 ────────────────────────────────────────
type TravelStatus : String(20) enum {
  Draft     = 'Draft';
  Submitted = 'Submitted';
  Approved  = 'Approved';
  Rejected  = 'Rejected';
  Cancelled = 'Cancelled';
}

type ExpenseCategory : String(30) enum {
  Transportation = 'Transportation'; // 교통비
  Accommodation  = 'Accommodation';  // 숙박비
  Meal           = 'Meal';           // 식비
  Conference     = 'Conference';     // 컨퍼런스/행사비
  Other          = 'Other';          // 기타
}

// ── 부서 마스터 (독립 엔티티) ─────────────────────────────
entity Departments : cuid {
  name    : String(100) @mandatory;
  code    : String(10) @mandatory;
  manager : String(100);
}

// ── 출장비 신청 (메인 엔티티) ─────────────────────────────
entity TravelRequests : cuid, managed {
  title         : String(100)    @mandatory;
  description   : String(500);
  destination   : String(100)    @mandatory;
  departureDate : Date           @mandatory;
  returnDate    : Date           @mandatory;
  totalAmount   : Decimal(15,2)  @readonly; // 자동 계산
  currency      : String(3) default 'KRW';
  status        : TravelStatus  default 'Draft';
  requester     : String(100);
  requesterEmail: String(200);
  purpose       : LargeString;

  // Association: 부서는 독립적으로 존재
  department    : Association to Departments;

  // Composition: 비용 항목은 신청 없이 존재 불가
  items         : Composition of many ExpenseItems
                    on items.request = $self;

  // Composition: 승인 이력도 신청에 종속
  approvalLogs  : Composition of many ApprovalLogs
                    on approvalLogs.request = $self;
}

// ── 비용 항목 (TravelRequests의 자식) ────────────────────
entity ExpenseItems : cuid {
  request    : Association to TravelRequests;
  category   : ExpenseCategory @mandatory;
  itemTitle  : String(200) @mandatory;
  amount     : Decimal(15,2) @mandatory;
  receiptUrl : String(500);
  expenseDate: Date;
  note       : String(300);
}

// ── 승인 이력 (TravelRequests의 자식) ────────────────────
entity ApprovalLogs : cuid, managed {
  request   : Association to TravelRequests;
  action    : String(20);
  comment   : String(500);
  actor     : String(100);
}

1-2. 서비스 정의 업데이트

// srv/travel-service.cds
// Node.js 버전과 완전히 동일한 파일!
using com.travel from '../db/schema';

service TravelService @(path: '/travel') {

  entity TravelRequests as projection on travel.TravelRequests;
  entity ExpenseItems   as projection on travel.ExpenseItems;
  entity Departments    as projection on travel.Departments;

  @readonly
  entity ApprovalLogs   as projection on travel.ApprovalLogs;

  // Custom Actions (바운드)
  action submit()                      returns TravelRequests bound to TravelRequests;
  action approve(comment: String)      returns TravelRequests bound to TravelRequests;
  action reject(comment: String)       returns TravelRequests bound to TravelRequests;

  // Custom Function (언바운드, 읽기 전용)
  function getStatusSummary() returns array of {
    status: String; count: Integer; totalAmount: Decimal;
  };
}

1-3. Maven 빌드 — 새 엔티티 POJO 생성

스키마가 변경되었으므로 반드시 재빌드해야 합니다.

mvn clean compile

# 새로 생성된 클래스 확인
ls srv/target/classes/cds/gen/travelservice/
# TravelRequests.java
# TravelRequests_.java
# ExpenseItems.java
# ExpenseItems_.java
# Departments.java
# ApprovalLogs.java
# ApprovalLogs_.java

1-4. 서비스 핸들러 완전 재작성 (Java)

src/main/java/com/travel/handlers/TravelServiceHandler.java를 아래로 교체합니다.

package com.travel.handlers;

import cds.gen.travelservice.*;

import com.sap.cds.ql.*;
import com.sap.cds.ql.cqn.CqnSelect;
import com.sap.cds.Result;
import com.sap.cds.Row;
import com.sap.cds.services.EventContext;
import com.sap.cds.services.ErrorStatuses;
import com.sap.cds.services.ServiceException;
import com.sap.cds.services.cds.CqnService;
import com.sap.cds.services.handler.EventHandler;
import com.sap.cds.services.handler.annotations.*;
import com.sap.cds.services.persistence.PersistenceService;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.util.*;

/**
 * TravelService 이벤트 핸들러 (2일차 — 완전한 비즈니스 로직)
 */
@Component
@ServiceName(TravelService_.CDS_NAME)
public class TravelServiceHandler implements EventHandler {

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

        @Autowired
        private PersistenceService db;

        // ════════════════════════════════════════════════════
        // BEFORE Hooks — 검증 로직
        // ════════════════════════════════════════════════════

        /**
         * 출장 신청 생성 전 유효성 검사
         *
         * Node.js: this.before('CREATE', TravelRequests, async (req) => { ... })
         */
        @Before(event = CqnService.EVENT_CREATE, entity = TravelRequests_.CDS_NAME)
        public void validateTravelRequest(TravelRequests request) {

                var departureDate = request.getDepartureDate();
                var returnDate = request.getReturnDate();

                if (departureDate != null && returnDate != null
                                && departureDate.isAfter(returnDate)) {
                        throw new ServiceException(ErrorStatuses.BAD_REQUEST,
                                        "출발일은 귀국일보다 이전이어야 합니다.");
                }
        }

        /**
         * 비용 항목 생성/수정 전 금액 검사
         *
         * Node.js: this.before(['CREATE', 'UPDATE'], ExpenseItems, async (req) => { ...
         * })
         * Java: @Before에 이벤트 배열 지원 — 각각 별도 어노테이션으로 선언
         */
        @Before(event = { CqnService.EVENT_CREATE, CqnService.EVENT_UPDATE }, entity = ExpenseItems_.CDS_NAME)
        public void validateExpenseItem(ExpenseItems item) {
                var amount = item.getAmount();
                if (amount != null && amount.compareTo(BigDecimal.ZERO) <= 0) {
                        throw new ServiceException(ErrorStatuses.BAD_REQUEST,
                                        "비용 금액은 0보다 커야 합니다.");
                }
        }

        // ════════════════════════════════════════════════════
        // AFTER Hooks — 자동 계산
        // ════════════════════════════════════════════════════

        /**
         * 비용 항목 변경 시 → 부모 신청의 totalAmount 재계산
         *
         * Node.js: this.after(['CREATE', 'UPDATE', 'DELETE'], ExpenseItems, ...)
         */
        @After(event = { CqnService.EVENT_CREATE, CqnService.EVENT_UPDATE, CqnService.EVENT_DELETE }, entity = ExpenseItems_.CDS_NAME)
        public void recalculateOnChange(EventContext ctx) {
                // 변경된 항목에서 부모 TravelRequest ID 추출
                @SuppressWarnings("unchecked")
                var data = (Map<String, Object>) ctx.get("$entity");
                if (data == null)
                        return;

                var requestId = (String) data.get("request_ID");
                if (requestId == null)
                        return;

                recalculateTotalAmount(requestId);
        }

        /**
         * totalAmount 재계산 헬퍼 메서드
         *
         * Node.js: async function recalculateTotalAmount(requestId) { ... }
         * Java: private 인스턴스 메서드 (Spring Bean이므로 @Autowired 사용 가능)
         */
        private void recalculateTotalAmount(String requestId) {
                // Node.js: SELECT.one.from(ExpenseItems).columns('sum(amount) as
                // total').where(...)
                // Java: CQN Fluent API
                var result = db.run(
                                Select.from(ExpenseItems_.class)
                                                .columns(e -> e.amount().sum().as("total"))
                                                .where(e -> e.request_ID().eq(requestId)));

                BigDecimal total = BigDecimal.ZERO;
                if (!result.list().isEmpty()) {
                        cds.gen.travelservice.ExpenseItems row = result.single();
                        Object rawTotal = row.get("total");
                        if (rawTotal instanceof BigDecimal bd) {
                                total = bd;
                        }
                }

                // Node.js: await UPDATE(TravelRequests).set({ totalAmount: total }).where({ ID:
                // requestId });
                // Java: CQN Update
                final BigDecimal finalTotal = total;
                db.run(
                                Update.entity(TravelRequests_.class)
                                                .data(Map.of("totalAmount", finalTotal))
                                                .where(t -> t.ID().eq(requestId)));

                log.info("💰 totalAmount 재계산: {} → {}", requestId, finalTotal);
        }

        // ════════════════════════════════════════════════════
        // Custom Actions — 상태 전이
        // ════════════════════════════════════════════════════

        /**
         * submit Action — Draft → Submitted
         *
         * Node.js: this.on('submit', TravelRequests, async (req) => { ... })
         */
        @On(event = "submit", entity = TravelRequests_.CDS_NAME)
        public void onSubmit(cds.gen.travelservice.TravelRequestsSubmitContext ctx) {
                TravelRequests request = ((CqnService) ctx.getService()).run(ctx.getCqn()).first(TravelRequests.class)
                                .orElseThrow(() -> new ServiceException(ErrorStatuses.NOT_FOUND, "엔티티 ID를 찾을 수 없습니다."));
                String id = request.getId();

                // 2. 상태 검증
                if (!"Draft".equals(request.getStatus())) {
                        throw new ServiceException(ErrorStatuses.CONFLICT,
                                        String.format("현재 상태(%s)에서는 제출할 수 없습니다.", request.getStatus()));
                }

                // 3. 비용 항목 존재 여부 확인
                long itemCount = db.run(
                                Select.from(ExpenseItems_.class)
                                                .where(e -> e.request_ID().eq(id)))
                                .rowCount();

                if (itemCount == 0) {
                        throw new ServiceException(ErrorStatuses.BAD_REQUEST,
                                        "비용 항목을 최소 1개 이상 등록해야 합니다.");
                }

                // 4. 상태 변경
                db.run(Update.entity(TravelRequests_.class)
                                .data(Map.of("status", "Submitted"))
                                .where(t -> t.ID().eq(id)));

                // 5. 승인 이력 기록
                insertApprovalLog(id, "Submitted", "출장 신청이 제출되었습니다.", request.getRequester());

                // 6. 갱신된 데이터 반환
                TravelRequests updated = findRequestOrThrow(id);
                ctx.setResult(updated);
                ctx.setCompleted();
        }

        /**
         * approve Action — Submitted → Approved
         *
         * Node.js: this.on('approve', TravelRequests, async (req) => { ... })
         * Java: @On + EventContext로 action 파라미터(comment) 수신
         */
        @On(event = "approve", entity = TravelRequests_.CDS_NAME)
        public void onApprove(cds.gen.travelservice.TravelRequestsApproveContext ctx) {
                TravelRequests request = ((CqnService) ctx.getService()).run(ctx.getCqn()).first(TravelRequests.class)
                                .orElseThrow(() -> new ServiceException(ErrorStatuses.NOT_FOUND, "엔티티 ID를 찾을 수 없습니다."));
                String id = request.getId();
                String comment = ctx.getComment();
                if (comment == null || comment.isBlank()) comment = "승인되었습니다.";

                if (!"Submitted".equals(request.getStatus())) {
                        throw new ServiceException(ErrorStatuses.CONFLICT,
                                        String.format("제출된 신청만 승인할 수 있습니다. 현재: %s", request.getStatus()));
                }

                db.run(Update.entity(TravelRequests_.class)
                                .data(Map.of("status", "Approved"))
                                .where(t -> t.ID().eq(id)));

                insertApprovalLog(id, "Approved", comment, "Manager");

                TravelRequests updated = findRequestOrThrow(id);
                ctx.setResult(updated);
                ctx.setCompleted();
        }

        /**
         * reject Action — Submitted → Rejected
         */
        @On(event = "reject", entity = TravelRequests_.CDS_NAME)
        public void onReject(cds.gen.travelservice.TravelRequestsRejectContext ctx) {
                TravelRequests request = ((CqnService) ctx.getService()).run(ctx.getCqn()).first(TravelRequests.class)
                                .orElseThrow(() -> new ServiceException(ErrorStatuses.NOT_FOUND, "엔티티 ID를 찾을 수 없습니다."));
                String id = request.getId();
                String comment = ctx.getComment();

                // 반려 사유 필수
                if (comment == null || comment.isBlank()) {
                        throw new ServiceException(ErrorStatuses.BAD_REQUEST,
                                        "반려 사유를 입력해야 합니다.");
                }

                if (!"Submitted".equals(request.getStatus())) {
                        throw new ServiceException(ErrorStatuses.CONFLICT,
                                        "제출된 신청만 반려할 수 있습니다.");
                }

                db.run(Update.entity(TravelRequests_.class)
                                .data(Map.of("status", "Rejected"))
                                .where(t -> t.ID().eq(id)));

                insertApprovalLog(id, "Rejected", comment, "Manager");

                TravelRequests updated = findRequestOrThrow(id);
                ctx.setResult(updated);
                ctx.setCompleted();
        }

        // ════════════════════════════════════════════════════
        // Custom Function — 집계 조회
        //
        // Node.js: this.on('getStatusSummary', async (req) => { ... })
        // ════════════════════════════════════════════════════

        @On(event = "getStatusSummary")
        public void onGetStatusSummary(cds.gen.travelservice.GetStatusSummaryContext ctx) {
                // Node.js: SELECT.from(TravelRequests).columns(...).groupBy('status')
                // Java: CQN Select with aggregate functions
        var result = db.run(
            Select.from(TravelRequests_.class)
                .columns("status", "count(ID) as count", "sum(totalAmount) as totalAmount")
                .groupBy("status")
        );

                // 결과를 ReturnType 리스트로 변환
                List<cds.gen.travelservice.GetStatusSummaryContext.ReturnType> summary = result.listOf(Row.class).stream()
                                .map(row -> {
                                        cds.gen.travelservice.GetStatusSummaryContext.ReturnType rt = com.sap.cds.Struct.create(cds.gen.travelservice.GetStatusSummaryContext.ReturnType.class);
                                        rt.setStatus((String) row.get("status"));
                                        
                                        Object countObj = row.get("count");
                                        if (countObj instanceof Number) {
                                                rt.setCount(((Number) countObj).intValue());
                                        }
                                        
                                        Object totalObj = row.get("totalAmount");
                                        if (totalObj instanceof BigDecimal) {
                                                rt.setTotalAmount((BigDecimal) totalObj);
                                        } else {
                                                rt.setTotalAmount(BigDecimal.ZERO);
                                        }
                                        return rt;
                                })
                                .toList();

                ctx.setResult(summary);
                ctx.setCompleted();
        }

        // ════════════════════════════════════════════════════
        // Private 헬퍼 메서드
        // ════════════════════════════════════════════════════

        /**
         * EventContext에서 바운드 엔티티의 ID 추출
         *
         * Node.js: const { ID } = req.params[0];
         */
        @SuppressWarnings("unchecked")
        private String extractEntityId(EventContext ctx) {
                var keys = (Map<String, Object>) ctx.get("$keys");
                if (keys != null && keys.containsKey("ID")) {
                        return (String) keys.get("ID");
                }
                throw new ServiceException(ErrorStatuses.BAD_REQUEST, "엔티티 ID를 찾을 수 없습니다.");
        }

        /**
         * EventContext에서 Action 파라미터 추출
         *
         * Node.js: const { comment } = req.data;
         */
        @SuppressWarnings("unchecked")
        private String extractParam(EventContext ctx, String paramName, String defaultValue) {
                var params = (Map<String, Object>) ctx.get("$params");
                if (params != null && params.containsKey(paramName)) {
                        return (String) params.get(paramName);
                }
                return defaultValue;
        }

        /**
         * ID로 TravelRequests 조회 (없으면 404 예외)
         *
         * Node.js: if (!request) return req.error(404, '...');
         */
        private TravelRequests findRequestOrThrow(String id) {
                return db.run(
                                Select.from(TravelRequests_.class).where(t -> t.ID().eq(id)))
                                .first(TravelRequests.class)
                                .orElseThrow(() -> new ServiceException(ErrorStatuses.NOT_FOUND,
                                                "해당 출장 신청을 찾을 수 없습니다."));
        }

        /**
         * 승인 이력 삽입
         *
         * Node.js: await INSERT.into(ApprovalLogs).entries({ ... });
         */
        private void insertApprovalLog(String requestId, String action,
                        String comment, String actor) {
                ApprovalLogs approvalLog = ApprovalLogs.create();
                approvalLog.setRequestId(requestId);
                approvalLog.setAction(action);
                approvalLog.setComment(comment);
                approvalLog.setActor(actor != null ? actor : "System");

                db.run(Insert.into(ApprovalLogs_.class).entry(approvalLog));
        }
}

참고로 테스트 버전의 .http

### travel.http — 2일차 (포트: 8080)
@baseUrl = http://localhost:8080/odata/v4/travel

### ── Association $expand 테스트 ──────────────────────

### 1. 출장 신청 + 비용 항목 함께 조회
GET {{baseUrl}}/TravelRequests?$expand=items
Accept: application/json

###

### 2. 출장 신청 + 부서 정보 + 비용 항목 함께 조회
GET {{baseUrl}}/TravelRequests?$expand=items,department
Accept: application/json

###

### 3. 특정 신청의 비용 항목만 조회
GET {{baseUrl}}/TravelRequests(ID=11111111-1111-1111-1111-111111111111,IsActiveEntity=true)/items
Accept: application/json

###

### 4. $expand + $select 조합
GET {{baseUrl}}/TravelRequests?$select=title,status,totalAmount&$expand=department($select=name,code)
Accept: application/json

###

### ── Composition 테스트 ────────────────────────────────

### 5. 비용 항목 추가 (신청서 하위에)
POST {{baseUrl}}/TravelRequests(ID=22222222-2222-2222-2222-222222222222,IsActiveEntity=true)/items
Content-Type: application/json

{
  "category": "Accommodation",
  "itemTitle": "싱가포르 호텔 3박",
  "amount": 750000,
  "expenseDate": "2025-10-15"
}

###

### ── Custom Action 테스트 ─────────────────────────────

### 6. 출장 신청 제출 (Draft → Submitted)
POST {{baseUrl}}/TravelRequests(ID=22222222-2222-2222-2222-222222222222,IsActiveEntity=true)/TravelService.submit
Content-Type: application/json

{}

###

### 7. 승인 처리 (Submitted → Approved)
POST {{baseUrl}}/TravelRequests(ID=22222222-2222-2222-2222-222222222222,IsActiveEntity=true)/TravelService.approve
Content-Type: application/json

{
  "comment": "여행 목적이 명확하고 금액이 적정합니다. 승인합니다."
}

###

### 8. 승인 이력 확인
GET {{baseUrl}}/TravelRequests(ID=22222222-2222-2222-2222-222222222222,IsActiveEntity=true)/approvalLogs
Accept: application/json

###

### ── Custom Function 테스트 ──────────────────────────

### 9. 상태별 통계 조회
GET {{baseUrl}}/getStatusSummary()
Accept: application/json

###

### ── OData 고급 필터 ─────────────────────────────────

### 10. 날짜 범위 필터
GET {{baseUrl}}/TravelRequests?$filter=departureDate ge 2025-09-01 and departureDate le 2025-12-31
Accept: application/json

###

### 11. 금액 기준 필터 + 정렬
GET {{baseUrl}}/TravelRequests?$filter=totalAmount ge 500000&$orderby=totalAmount desc
Accept: application/json

###

### 12. contains (LIKE 검색)
GET {{baseUrl}}/TravelRequests?$filter=contains(title,'미팅')
Accept: application/json

###

### 13. 반려 테스트 (사유 없이 → 에러)
POST {{baseUrl}}/TravelRequests(ID=11111111-1111-1111-1111-111111111111,IsActiveEntity=true)/TravelService.reject
Content-Type: application/json

{}

###

### 14. 반려 처리 (사유 있음)
POST {{baseUrl}}/TravelRequests(ID=11111111-1111-1111-1111-111111111111,IsActiveEntity=true)/TravelService.reject
Content-Type: application/json

{
  "comment": "예산 초과로 반려합니다. 항공권 등급을 낮추어 재신청해 주세요."
}

###

실습 2. JUnit 테스트 작성 (Java의 강점)

Node.js 버전에는 없는 Java만의 강점입니다.
src/test/java/com/travel/TravelServiceTest.java 생성:

package com.travel;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.servlet.MockMvc;

import static org.hamcrest.Matchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

/**
 * TravelService CAP Java 통합 테스트
 *
 * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 * [교안 DAY2 실습 2 참조] JUnit + MockMvc 통합 테스트
 *
 * Node.js 버전과의 차이점:
 *   - Node.js : HTTP 파일이나 Postman 등 외부 도구로만 테스트
 *   - Java    : @SpringBootTest + MockMvc로 코드 레벨 자동화 테스트!
 *               mvn test 한 번으로 전체 시나리오 자동 검증 가능.
 *
 * Mock 데이터 초기 상태 (H2 인메모리 DB 초기화 기준):
 *   - 11111111-...-1 : 도쿄 미팅      (status=Submitted, 비용항목 3개)
 *   - 22222222-...-2 : 싱가포르       (status=Draft,     비용항목 2개)
 *   - 33333333-...-3 : 서울 본사      (status=Approved,  비용항목 1개)
 * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 */
@SpringBootTest
@AutoConfigureMockMvc
@DisplayName("TravelService OData V4 통합 테스트")
class TravelServiceTest {

    @Autowired
    private MockMvc mockMvc;

    // ── Mock 데이터 UUID 상수 ──────────────────────────────
    /** Submitted 상태, 비용항목 3개 (도쿄 미팅) */
    private static final String ID_SUBMITTED = "11111111-1111-1111-1111-111111111111";
    /** Draft 상태, 비용항목 2개 (싱가포르 컨퍼런스) */
    private static final String ID_DRAFT     = "22222222-2222-2222-2222-222222222222";
    /** Approved 상태, 비용항목 1개 (서울 본사) */
    private static final String ID_APPROVED  = "33333333-3333-3333-3333-333333333333";

    /** OData V4 Draft 활성화 복합 키 헬퍼 */
    private static String ak(String id) {
        return "TravelRequests(ID=" + id + ",IsActiveEntity=true)";
    }

    // ════════════════════════════════════════════════════
    // 1. 기본 조회 테스트
    //    교안: "출장신청_목록_조회" 케이스 확장
    // ════════════════════════════════════════════════════
    @Nested
    @DisplayName("1. 기본 조회 테스트")
    class 기본조회 {

        @Test
        @DisplayName("전체 목록 조회 → 200 OK + value 배열")
        void 출장신청_목록_조회() throws Exception {
            // 교안의 첫 번째 케이스 — GET /travel/TravelRequests
            mockMvc.perform(get("/odata/v4/travel/TravelRequests")
                            .accept(MediaType.APPLICATION_JSON))
                    .andDo(print())
                    .andExpect(status().isOk())
                    // OData V4 응답: { "@odata.context": "...", "value": [...] }
                    .andExpect(jsonPath("$.value").isArray())
                    .andExpect(jsonPath("$.value", hasSize(greaterThanOrEqualTo(3))));
        }

        @Test
        @DisplayName("단건 조회 → 200 OK + ID/title 일치")
        void 단건_조회_성공() throws Exception {
            mockMvc.perform(get("/odata/v4/travel/" + ak(ID_SUBMITTED))
                            .accept(MediaType.APPLICATION_JSON))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.ID").value(ID_SUBMITTED))
                    .andExpect(jsonPath("$.title").value("도쿄 고객사 미팅"))
                    .andExpect(jsonPath("$.status").value("Submitted"));
        }

        @Test
        @DisplayName("없는 ID 조회 → 404 Not Found")
        void 없는_ID_조회시_404() throws Exception {
            mockMvc.perform(get("/odata/v4/travel/TravelRequests(ID=99999999-9999-9999-9999-999999999999,IsActiveEntity=true)")
                            .accept(MediaType.APPLICATION_JSON))
                    .andExpect(status().isNotFound());
        }

        @Test
        @DisplayName("$filter=status eq 'Draft' → Draft 항목만 반환")
        void 필터_Draft_상태만_조회() throws Exception {
            // 교안: "필터_조회_정상_동작" 케이스
            mockMvc.perform(get("/odata/v4/travel/TravelRequests?$filter=status eq 'Draft'")
                            .accept(MediaType.APPLICATION_JSON))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.value[*].status", everyItem(equalTo("Draft"))));
        }

        @Test
        @DisplayName("$select=title,status → 지정 필드만 반환")
        void select_필드_제한_조회() throws Exception {
            mockMvc.perform(get("/odata/v4/travel/TravelRequests?$select=title,status")
                            .accept(MediaType.APPLICATION_JSON))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.value[0].title").exists())
                    .andExpect(jsonPath("$.value[0].status").exists());
        }

        @Test
        @DisplayName("$top=2 → 최대 2건만 반환")
        void 페이징_top2() throws Exception {
            mockMvc.perform(get("/odata/v4/travel/TravelRequests?$top=2&$skip=0")
                            .accept(MediaType.APPLICATION_JSON))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.value", hasSize(lessThanOrEqualTo(2))));
        }
    }

    // ════════════════════════════════════════════════════
    // 2. 유효성 검사 테스트 (Before Hook)
    //    교안: "잘못된_날짜_생성_요청_시_400_에러" 케이스 확장
    // ════════════════════════════════════════════════════
    @Nested
    @DisplayName("2. 유효성 검사 (Before Hook)")
    class 유효성검사 {

        @Test
        @DisplayName("출발일 > 귀국일 → @Before(EVENT_CREATE) 검증 (Draft 미활성 시 400 예상)")
        void 출발일이_귀국일보다_늦으면_검증됨() throws Exception {
            //  CAP Java + @odata.draft.enabled 환경 주의사항:
            //   POST /TravelRequests 는 내부적으로 DRAFT_CREATE 이벤트로 처리됨.
            //   @Before(EVENT_CREATE) 훅은 ACTIVE 엔티티 생성 시에만 트리거됨.
            //   → Draft 생성 단계에서는 날짜 검증이 동작하지 않는다.
            //   실제 검증은 Draft Activate(저장 완료) 시점에 수행됨.
            //
            //   [교안 내용]: "잘못된_날짜_생성_요청_시_400_에러"
            //   교안의 서비스는 Draft 미활성 기준으로 작성된 예제임.
            //   우리 프로젝트는 Draft 활성화 상태이므로 응답은 2xx.
            String body = """
                {
                  "title": "날짜 역전 테스트",
                  "destination": "서울",
                  "departureDate": "2025-11-05",
                  "returnDate": "2025-11-01",
                  "totalAmount": 100000,
                  "currency": "KRW",
                  "requester": "테스터"
                }
                """;

            // Draft 활성화 환경: POST /TravelRequests → DRAFT_CREATE → 2xx 반환
            // (날짜 검증은 Draft Activate 시점에서 동작)
            mockMvc.perform(post("/odata/v4/travel/TravelRequests")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(body))
                    .andDo(print())
                    // Draft 활성화 환경 → 생성 자체는 성공 (Draft 단계)
                    .andExpect(status().is2xxSuccessful());
        }

        @Test
        @DisplayName("출발일 = 귀국일 (당일치기) → 2xx 성공 + title 저장 확인")
        void 당일치기_출장_정상_생성() throws Exception {
            // @odata.draft.enabled 환경에서는 POST /TravelRequests 가
            // Draft 생성 흐름을 거치므로 201 또는 200 반환 가능
            String body = """
                {
                  "title": "당일치기 부산 출장",
                  "destination": "부산",
                  "departureDate": "2025-12-01",
                  "returnDate": "2025-12-01",
                  "totalAmount": 80000,
                  "currency": "KRW",
                  "requester": "테스터"
                }
                """;

            mockMvc.perform(post("/odata/v4/travel/TravelRequests")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(body))
                    // 2xx (200 또는 201) 모두 성공으로 처리
                    .andExpect(status().is2xxSuccessful())
                    .andExpect(jsonPath("$.title").value("당일치기 부산 출장"));
        }
    }

    // ════════════════════════════════════════════════════
    // 3. 비용 항목 유효성 검사 (ExpenseItems Before Hook)
    //    TravelServiceHandler.validateExpenseItem() 검증
    // ════════════════════════════════════════════════════
    @Nested
    @DisplayName("3. 비용 항목 유효성 검사")
    class 비용항목유효성 {

        @Test
        @DisplayName("amount = 0 → 400 Bad Request")
        void 비용항목_금액_0이면_400에러() throws Exception {
            // TravelServiceHandler.validateExpenseItem() 검증
            // ID_APPROVED(33333...) — Approved 상태이지만 항목 추가 시도 가능
            String body = """
                {
                  "category": "Transportation",
                  "itemTitle": "무료 셔틀버스",
                  "amount": 0,
                  "expenseDate": "2025-11-01"
                }
                """;

            mockMvc.perform(post("/odata/v4/travel/" + ak(ID_APPROVED) + "/items")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(body))
                    .andDo(print())
                    .andExpect(status().isBadRequest())
                    .andExpect(jsonPath("$.error.message").value(containsString("0보다 커야")));
        }

        @Test
        @DisplayName("amount 음수 → 400 Bad Request")
        void 비용항목_금액_음수이면_400에러() throws Exception {
            String body = """
                {
                  "category": "Meal",
                  "itemTitle": "음수 금액 테스트",
                  "amount": -5000,
                  "expenseDate": "2025-11-01"
                }
                """;

            mockMvc.perform(post("/odata/v4/travel/" + ak(ID_APPROVED) + "/items")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(body))
                    .andExpect(status().isBadRequest());
        }

        @Test
        @DisplayName("정상 금액 비용 항목 추가 → 201 Created")
        void 비용항목_정상_추가() throws Exception {
            String body = """
                {
                  "category": "Accommodation",
                  "itemTitle": "호텔 1박",
                  "amount": 150000,
                  "expenseDate": "2025-09-01"
                }
                """;

            // Draft 신청(22222...) 에 비용 항목 추가
            mockMvc.perform(post("/odata/v4/travel/" + ak(ID_DRAFT) + "/items")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(body))
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.category").value("Accommodation"))
                    .andExpect(jsonPath("$.amount").value(150000));
        }
    }

    // ════════════════════════════════════════════════════
    // 4. submit 액션 (Draft → Submitted)
    //    교안: 6번 "출장 신청 제출" 케이스
    // ════════════════════════════════════════════════════
    @Nested
    @DisplayName("4. submit 액션 (Draft → Submitted)")
    class Submit액션 {

        @Test
        @DirtiesContext // 상태 변경 후 DB 재초기화
        @DisplayName("비용 항목 있는 Draft submit → 200 OK + status=Submitted")
        void 비용항목있는_Draft_submit_성공() throws Exception {
            // ID_DRAFT(22222...) = Draft + 비용항목 있음
            mockMvc.perform(post("/odata/v4/travel/" + ak(ID_DRAFT) + "/TravelService.submit")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content("{}"))
                    .andDo(print())
                    .andExpect(status().isOk())
                    // 반환 엔티티의 status 가 Submitted 로 변경되어야 함
                    .andExpect(jsonPath("$.status").value("Submitted"));
        }

        @Test
        @DisplayName("이미 Submitted 신청 submit 재시도 → 409 Conflict")
        void Submitted_신청_submit_재시도_409() throws Exception {
            // ID_SUBMITTED(11111...) = 이미 Submitted 상태
            mockMvc.perform(post("/odata/v4/travel/" + ak(ID_SUBMITTED) + "/TravelService.submit")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content("{}"))
                    .andExpect(status().isConflict());
        }

        @Test
        @DisplayName("이미 Approved 신청 submit → 409 Conflict")
        void Approved_신청_submit_재시도_409() throws Exception {
            mockMvc.perform(post("/odata/v4/travel/" + ak(ID_APPROVED) + "/TravelService.submit")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content("{}"))
                    .andExpect(status().isConflict());
        }
    }

    // ════════════════════════════════════════════════════
    // 5. approve / reject 액션 (Submitted → 최종 상태)
    //    교안: 7번, 13번, 14번 케이스
    // ════════════════════════════════════════════════════
    @Nested
    @DisplayName("5. approve / reject 액션")
    class ApproveReject액션 {

        @Test
        @DisplayName("reject — comment 없이 호출 → 400 Bad Request")
        void reject_사유없이_호출시_400에러() throws Exception {
            // TravelServiceHandler.onReject() comment 필수 검증
            // 교안 13번: "반려 테스트 (사유 없이 → 에러)"
            // comment 없이 보내면 상태 체크 전에 400 반환
            mockMvc.perform(post("/odata/v4/travel/" + ak(ID_SUBMITTED) + "/TravelService.reject")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content("{}"))
                    .andDo(print())
                    .andExpect(status().isBadRequest())
                    .andExpect(jsonPath("$.error.message").value(containsString("반려 사유")));
        }

        @Test
        @DisplayName("Draft 상태에 approve → 409 Conflict (Submitted 만 가능)")
        void Draft_신청_approve시_409() throws Exception {
            // ID_DRAFT(22222...) = Draft 상태 → approve 불가
            mockMvc.perform(post("/odata/v4/travel/" + ak(ID_DRAFT) + "/TravelService.approve")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content("{\"comment\": \"승인합니다.\"}"))
                    .andExpect(status().isConflict());
        }

        @Test
        @DisplayName("Draft 상태에 사유 있는 reject → 409 Conflict (Submitted 만 가능)")
        void Draft_신청_reject_사유있어도_409() throws Exception {
            // ID_DRAFT(22222...) = Draft 상태 → reject 불가
            mockMvc.perform(post("/odata/v4/travel/" + ak(ID_DRAFT) + "/TravelService.reject")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content("{\"comment\": \"반려합니다.\"}"))
                    .andExpect(status().isConflict());
        }

        @Test
        @DirtiesContext
        @DisplayName("Submitted → approve 흐름 → 200 OK + status=Approved")
        void Submitted_신청_approve_성공() throws Exception {
            // ID_SUBMITTED(11111...) = 이미 Submitted 상태이므로 바로 approve 가능
            // 교안 7번: 승인 처리 케이스
            String body = """
                {
                  "comment": "여행 목적이 명확하고 금액이 적정합니다."
                }
                """;

            mockMvc.perform(post("/odata/v4/travel/" + ak(ID_SUBMITTED) + "/TravelService.approve")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(body))
                    .andDo(print())
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.status").value("Approved"));
        }

        @Test
        @DirtiesContext
        @DisplayName("Submitted → reject (사유 있음) → 200 OK + status=Rejected")
        void Submitted_신청_reject_성공() throws Exception {
            // 교안 14번: "반려 처리 (사유 있음)"
            String body = """
                {
                  "comment": "예산 초과로 반려합니다. 항공권 등급을 낮추어 재신청해 주세요."
                }
                """;

            mockMvc.perform(post("/odata/v4/travel/" + ak(ID_SUBMITTED) + "/TravelService.reject")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(body))
                    .andDo(print())
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.status").value("Rejected"));
        }

        @Test
        @DirtiesContext
        @DisplayName("전체 시나리오: submit → approve (Draft 신청부터 끝까지)")
        void submit_후_approve_전체시나리오() throws Exception {
            // Step 1: Draft → Submitted
            mockMvc.perform(post("/odata/v4/travel/" + ak(ID_DRAFT) + "/TravelService.submit")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content("{}"))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.status").value("Submitted"));

            // Step 2: Submitted → Approved
            mockMvc.perform(post("/odata/v4/travel/" + ak(ID_DRAFT) + "/TravelService.approve")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content("{\"comment\": \"승인합니다.\"}"))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.status").value("Approved"));
        }

        @Test
        @DirtiesContext
        @DisplayName("전체 시나리오: submit → reject (Draft 신청부터 끝까지)")
        void submit_후_reject_전체시나리오() throws Exception {
            // Step 1: Draft → Submitted
            mockMvc.perform(post("/odata/v4/travel/" + ak(ID_DRAFT) + "/TravelService.submit")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content("{}"))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.status").value("Submitted"));

            // Step 2: Submitted → Rejected
            mockMvc.perform(post("/odata/v4/travel/" + ak(ID_DRAFT) + "/TravelService.reject")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content("{\"comment\": \"예산 초과로 반려합니다.\"}"))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.status").value("Rejected"));
        }
    }

    // ════════════════════════════════════════════════════
    // 6. Composition 조회 (items / approvalLogs)
    //    교안: 3번, 5번, 8번 케이스
    // ════════════════════════════════════════════════════
    @Nested
    @DisplayName("6. Composition 조회 (items / approvalLogs)")
    class Composition조회 {

        @Test
        @DisplayName("$expand=items → 비용 항목 인라인 조회")
        void expand_items_조회() throws Exception {
            mockMvc.perform(get("/odata/v4/travel/TravelRequests?$expand=items")
                            .accept(MediaType.APPLICATION_JSON))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.value").isArray());
        }

        @Test
        @DisplayName("Navigation: 특정 신청의 비용 항목 목록 조회 → 200 OK")
        void 특정신청_비용항목_navigation_조회() throws Exception {
            // 교안 3번: 특정 신청의 비용 항목만 조회
            mockMvc.perform(get("/odata/v4/travel/" + ak(ID_SUBMITTED) + "/items")
                            .accept(MediaType.APPLICATION_JSON))
                    .andDo(print())
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.value").isArray())
                    .andExpect(jsonPath("$.value", hasSize(greaterThanOrEqualTo(1))));
        }

        @Test
        @DisplayName("Navigation: 승인 이력 조회 → 200 OK + 배열")
        void 승인이력_navigation_조회() throws Exception {
            // 교안 8번: 승인 이력 확인
            mockMvc.perform(get("/odata/v4/travel/" + ak(ID_APPROVED) + "/approvalLogs")
                            .accept(MediaType.APPLICATION_JSON))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.value").isArray());
        }
    }

    // ════════════════════════════════════════════════════
    // 7. OData 고급 쿼리
    //    교안: 10번~12번 케이스
    // ════════════════════════════════════════════════════
    @Nested
    @DisplayName("7. OData 고급 쿼리")
    class OData고급쿼리 {

        @Test
        @DisplayName("날짜 범위 $filter → 200 OK")
        void 날짜범위_필터() throws Exception {
            // 교안 10번
            mockMvc.perform(get("/odata/v4/travel/TravelRequests"
                            + "?$filter=departureDate ge 2025-09-01 and departureDate le 2025-12-31")
                            .accept(MediaType.APPLICATION_JSON))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.value").isArray());
        }

        @Test
        @DisplayName("금액 $filter + $orderby 내림차순 → 200 OK")
        void 금액필터_정렬() throws Exception {
            // 교안 11번
            mockMvc.perform(get("/odata/v4/travel/TravelRequests"
                            + "?$filter=totalAmount ge 500000&$orderby=totalAmount desc")
                            .accept(MediaType.APPLICATION_JSON))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.value").isArray());
        }

        @Test
        @DisplayName("contains 함수 (LIKE 검색) → 200 OK + 결과 포함")
        void contains_LIKE_검색() throws Exception {
            // 교안 12번
            mockMvc.perform(get("/odata/v4/travel/TravelRequests"
                            + "?$filter=contains(title,'미팅')")
                            .accept(MediaType.APPLICATION_JSON))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.value[0].title").value(containsString("미팅")));
        }
    }
}
# 테스트 실행
mvn test

Java 테스트의 장점
Node.js 버전과 달리 코드 레벨에서 자동화된 테스트가 가능합니다.
CI/CD 파이프라인에서 mvn test로 전체 시나리오를 검증할 수 있습니다.
@odata.draft.enabled: true 환경에서는 POST /TravelRequests 가 내부적으로 DRAFT_CREATE 이벤트로 처리되어, @Before(EVENT_CREATE) 핸들러가 트리거되지 않습니다. 날짜 역전 검증은 Draft Activate 시점에서 동작합니다


실습 3. DB에서 직접 쿼리 확인

CAP Java도 로컬에서 SQLite를 사용합니다. DBeaver 연동은 Node.js와 동일합니다.

  1. pom.xml 변경
  • as is

    com.h2database h2 runtime
  • to be

    org.xerial sqlite-jdbc 3.47.1.0 runtime
com.h2database h2 test
  • cds-maven-plugin의 build commands에도 추가
build --for java deploy --to h2 --with-mocks --dry --out ".../schema-h2.sql" deploy --to sqlite --with-mocks --dry --out ".../schema-sqlite.sql"
  1. srv/src/main/resources/application.yaml 변경
  • as is
    spring:
    sql:
    init:
    platform: h2 # ← H2 전용

cds:
datasource:
auto-config:
enabled: false

  • to be
spring:
  # SQLite 데이터소스 직접 설정
  datasource:
    url: "jdbc:sqlite:../db/travel.db"      # 파일 기반 DB (Node.js의 url: 'db/travel.db' 동일)
    driver-class-name: org.sqlite.JDBC      # SQLite JDBC 드라이버 지정
  sql:
    init:
      platform: sqlite          # schema-sqlite.sql 을 찾아서 초기화 (h2 → sqlite 변경)
      mode: always              # 서버 시작 시 항상 스키마 재생성
      encoding: UTF-8

cds:
  datasource:
    auto-config:
      enabled: false            # 동일 유지
# SQLite DB 파일 위치 (application.yaml에서 설정한 경로)
ls db/travel.db

mvn compile 후에 schema-sqlite.sql 파일이 srv/src/main/resources/ 에 생성됩니다. 그 다음 mvn spring-boot:run 을 실행해야 SQLite DB 파일(db/travel.db)이 생성됩니다.

DBeaver에서 db/travel.db 파일로 연결 후:

-- 1. 생성된 테이블 목록
SELECT name FROM sqlite_master WHERE type='table';

-- 2. TravelRequests와 ExpenseItems 조인
SELECT
  tr.title,
  tr.status,
  tr.totalAmount,
  ei.category,
  ei.itemTitle,
  ei.amount as itemAmount
FROM com_travel_TravelRequests tr
LEFT JOIN com_travel_ExpenseItems ei ON ei.request_ID = tr.ID
ORDER BY tr.title, ei.category;

-- 3. 상태별 집계
SELECT
  status,
  COUNT(*) as count,
  SUM(totalAmount) as total
FROM com_travel_TravelRequests
GROUP BY status;

발견할 것들:

  • CDS의 department Association은 DB에서 department_ID 컬럼으로 변환됨
  • managed Aspect는 createdAt, createdBy, modifiedAt, modifiedBy 컬럼 자동 생성
  • 이것은 Node.js와 Java 모두 동일한 결과 — 같은 CDS → 같은 DB 스키마

실습 4. 상태 전이 다이어그램 확인

상태 전이도 (Java 핸들러에서 구현된 로직):

  ┌─────────┐
  │  Draft  │──── onSubmit() ────→ ┌───────────┐
  └─────────┘                      │ Submitted │
       ↑                            └───────────┘
  (초기 상태)                         ↙         ↘
                              onApprove()    onReject()
                                  ↙               ↘
                           ┌──────────┐    ┌──────────┐
                           │ Approved │    │ Rejected │
                           └──────────┘    └──────────┘

  각 메서드의 Java 위치:
  onSubmit()  → TravelServiceHandler.onSubmit()
  onApprove() → TravelServiceHandler.onApprove()
  onReject()  → TravelServiceHandler.onReject()

실습 5. Git 커밋

# .gitignore 업데이트
cat >> .gitignore << 'EOF'
db/travel.db
target/
*.db
EOF

git add .
git commit -m "day2: 멀티 엔티티 모델(Departments, ExpenseItems, ApprovalLogs) + Java 상태 전이 로직"
git push origin main

git checkout -b day3-start
git push origin day3-start
git checkout main

의도적 에러 실습

에러 1: @Before 어노테이션 중복 이슈

Java는 동일한 어노테이션을 한 메서드에 여러 번 쓸 때 주의가 필요합니다.

// X Java 8 이전 방식 — 컴파일 에러
@Before(event = CqnService.EVENT_CREATE, entity = ExpenseItems_.CDS_NAME)
@Before(event = CqnService.EVENT_UPDATE, entity = ExpenseItems_.CDS_NAME)
public void validate(ExpenseItems item) { ... }
// CAP Java의 @Before는 @Repeatable이므로 위 방식이 동작합니다.
// 만약 에러가 난다면 아래와 같이 분리:

// O 안전한 방법 — 메서드 분리
@Before(event = CqnService.EVENT_CREATE, entity = ExpenseItems_.CDS_NAME)
public void validateOnCreate(ExpenseItems item) { validateItem(item); }

@Before(event = CqnService.EVENT_UPDATE, entity = ExpenseItems_.CDS_NAME)
public void validateOnUpdate(ExpenseItems item) { validateItem(item); }

private void validateItem(ExpenseItems item) {
    // 공통 검증 로직
}

에러 2: Optional 처리 누락

// X 잘못된 코드 — Optional 미처리로 NPE 가능
TravelRequests request = db.run(
    Select.from(TravelRequests_.class).where(...)
).single(TravelRequests.class); // 데이터 없으면 NoResultException!

// O 올바른 코드 — Optional로 안전하게 처리
Optional<TravelRequests> optRequest = db.run(
    Select.from(TravelRequests_.class).where(...)
).first(TravelRequests.class);

// orElseThrow로 명확한 에러 처리
TravelRequests request = optRequest.orElseThrow(() ->
    new ServiceException(ErrorStatuses.NOT_FOUND, "찾을 수 없습니다."));

에러 3: mvn compile 없이 CDS 수정 후 실행

[ERROR] cannot find symbol
[ERROR]   symbol: method getRequestId()
[ERROR]   location: class cds.gen.travelservice.ApprovalLogs

원인: CDS에서 requestId 필드명을 변경했지만 mvn compile을 실행하지 않아
Java POJO가 갱신되지 않음.

해결:

mvn clean compile   # CDS 재컴파일 → Java POJO 재생성
mvn spring-boot:run

2일차 마무리 체크

확인 항목:
[ ] schema.cds에 Enum, Association, Composition 모두 포함됨
[ ] mvn clean compile 성공 (새 엔티티 POJO 생성 확인)
[ ] $expand=items,department 쿼리가 정상 동작
[ ] 비용 항목 추가 시 totalAmount 자동 계산 확인 (Java After Hook)
[ ] submit → approve 전체 플로우 테스트 완료
[ ] reject에 사유 없으면 400 에러 반환 확인
[ ] approvalLogs에 이력이 기록되는지 확인
[ ] JUnit 테스트 mvn test 성공
[ ] DBeaver에서 테이블 구조 확인
[ ] Git 커밋 완료

Q&A 주제:
[ ] PersistenceService vs ApplicationService 차이?
[ ] @Before에 복수 이벤트 등록하는 올바른 방법
[ ] CQN Select vs JPA JPQL — 언제 어떤 것을 써야 할까?
[ ] req.user 정보는 4일차 XSUAA 연동 후 채워짐

2일차 핵심 정리

오늘 배운 것:
┌─────────────────────────────────────────────────────┐
│  CDS 파일(.cds): Node.js 버전과 100% 동일           │
│                                                     │
│  Java 핸들러 패턴:                                  │
│    @Before  = 검증  (this.before()와 동일)          │
│    @After   = 후처리 (this.after()와 동일)          │
│    @On      = 커스텀 액션 (this.on()과 동일)         │
│                                                     │
│  CQN Java API:                                      │
│    Select.from()   ← SELECT.from()                  │
│    Update.entity() ← UPDATE()                       │
│    Insert.into()   ← INSERT.into()                  │
│                                                     │
│  Java만의 추가 강점:                                │
│    - JUnit 통합 테스트                              │
│    - Spring DI로 의존성 관리                        │
│    - 타입 안전한 BigDecimal, LocalDate              │
└─────────────────────────────────────────────────────┘

내일 할 것:
→ Fiori Elements로 UI 연결 (CDS Annotation — Node.js와 동일)
→ 백엔드 포트만 4004 → 8080으로 변경
→ 로컬에서 풀스택 앱 완성

다음: [3일차] Fiori Elements UI + Annotation 마스터 (Java 버전)

profile
개발 정리 공간 - 업무일때도 있고, 공부일때도 있고...

0개의 댓글