SAP BTP Java DAY1. SAP BTP 핵심 개념 + 첫 번째 CAP Java 프로젝트

이우철·2026년 5월 2일

SAP_BTP

목록 보기
7/11

[1일차] SAP BTP 핵심 개념 + 첫 번째 CAP Java 프로젝트

목표: BTP가 왜 존재하는지 이해하고, 실제로 OData API가 동작하는 것을 눈으로 확인한다.
시나리오: 5일간 만들 "출장비 승인 앱"의 뼈대를 세운다.
런타임: CAP Java (Spring Boot 기반) + Java 17 + Maven
소요 시간: 이론 2.5시간 + 실습 2.5시간


1일차 학습 목표

이론:
  SAP의 두 가지 세계(온프레미스 vs 클라우드)와 BTP의 위치 이해
  Clean Core 전략이 왜 중요한지 설명할 수 있다
  BTP 4대 기둥과 오늘 배울 영역의 위치를 파악한다
  Cloud Foundry와 Kyma의 차이를 한 줄로 설명할 수 있다
  CAP Node.js와 CAP Java의 차이, Java를 선택하는 이유

실습:
  CAP Java 프로젝트 생성 (Maven Archetype)
  CDS Hello World 작성 (Node.js 버전과 동일한 파일!)
  mvn spring-boot:run 으로 로컬 서버 실행
  OData 엔드포인트 브라우저에서 확인
  Git에 1일차 결과 커밋

이론 세션 (2.5시간)


이론 1. 왜 BTP인가? — "문제"부터 시작하기

Before BTP: 전통적인 SAP 확장의 문제점

SAP S/4HANA를 기업에서 도입하면 거의 반드시 커스터마이징이 필요합니다.
전통적인 방법은 ABAP으로 S/4HANA 내부를 직접 수정하는 것이었습니다.

[전통적 방법 — In-System Customization]

S/4HANA Core
├── 표준 모듈 (FI, MM, SD...)
├── 커스텀 ABAP 코드 (Z-program, User Exit, BAdI...)
│       ↑
│    여기에 사업 요구사항을 직접 구현
└── 표준 + 커스텀 코드가 뒤섞임

이 방법의 문제:

문제설명
업그레이드 공포SAP 버전 업그레이드 시 커스텀 코드 전체 재검증 필요
복잡도 폭발10년 된 시스템은 누가 뭘 만들었는지 아무도 모름
기술 종속성ABAP 개발자만 유지보수 가능 → 인력 확보 어려움
클라우드 전환 불가수정된 시스템을 SaaS로 마이그레이션 불가

After BTP: Clean Core + Side-by-Side 확장

[BTP 방식 — Side-by-Side Extension]

S/4HANA Core (표준 유지 — Clean!)
├── 표준 모듈 (FI, MM, SD...)
└── 표준 API만 노출 (OData, RFC)
          ↕ (API 통신)
SAP BTP (확장 레이어)
├── 커스텀 비즈니스 로직  ← Java로 개발!
├── 커스텀 UI
└── 워크플로우 자동화

핵심 메시지
"S/4HANA는 건드리지 않는다. 확장은 BTP 위에서 API로 연결한다."
이것이 Clean Core 전략입니다.

Clean Core + Java 조합의 장점:

  • S/4HANA 업그레이드가 커스텀 코드에 영향 없음
  • BTP 위의 확장앱은 기존 Java 개발자가 바로 참여 가능
  • Spring Boot 경험이 CAP Java에 그대로 적용됨
  • 엔터프라이즈 Java 생태계 (JUnit, Mockito, SLF4J 등) 활용

이론 2. BTP 4대 기둥 전체 지도


이론 3. 실행 환경: Cloud Foundry vs Kyma

구분Cloud Foundry (CF)Kyma (Kubernetes 기반)
패러다임PaaS (Platform as a Service)Container Orchestration
배포 단위Buildpack 기반 앱Docker 컨테이너 / Helm Chart
진입 장벽낮음 (cf push 한 줄)높음 (K8s 지식 필요)
유연성중간매우 높음
비용 효율소규모에 적합대규모에 유리
이 강좌사용개념만 소개

오늘의 결론: Cloud Foundry로 개발을 시작하고, Kyma는 "더 알고 싶을 때"의 다음 단계로 시도해 보세요.


이론 4. CAP이란 무엇인가? — Java 런타임 선택

CAP = Cloud Application Programming Model
SAP이 만든 오픈소스 풀스택 개발 프레임워크로, Node.js와 Java 두 가지 런타임을 공식 지원합니다.

CAP Java vs CAP Node.js 비교

구분CAP Node.jsCAP Java
기반Express.jsSpring Boot
빌드npmMaven
서버 실행cds watchmvn spring-boot:run
핸들러.js 파일Java 클래스 + 어노테이션
CDS 모델 파일.cds 파일.cds 파일 (동일!)
DI(의존성 주입)수동Spring DI (@Autowired)
테스트JestJUnit + MockMvc
회사 적합성JS 팀Java 개발사

핵심 포인트
CDS 데이터 모델 파일(.cds)은 Node.js와 Java가 완전히 동일합니다.
바뀌는 것은 서비스 핸들러 코드뿐입니다.

CAP Java가 자동으로 해주는 것:

  • CDS 파일로부터 OData V4 API 자동 생성
  • CDS 엔티티로부터 Java POJO 클래스 자동 생성 (mvn compile 시)
  • 기본 CRUD 처리 (별도 구현 없음)
  • SQLite ↔ HANA Cloud 전환 (application.yaml 설정만 변경)
  • Spring Security 연동 (@requires 어노테이션)

실습 세션 (2.5시간)


실습 0. 사전 환경 확인

# Java 17 확인
java -version
# 출력 예: openjdk version "17.0.x"

# Maven 확인
mvn -version
# 출력 예: Apache Maven 3.9.x

# CDS CLI 확인 (CDS 도구는 여전히 필요)
cds --version
# 출력 예: @sap/cds: 7.x.x

# Node.js 확인 (CDS 도구 실행에 필요)
node --version
# 출력 예: v18.x.x 이상

왜 Java 프로젝트인데 Node.js가 필요한가?
CDS 파일(.cds)을 컴파일하는 cds CLI 도구는 Node.js 기반입니다.
Maven 빌드 시 내부적으로 CDS 컴파일을 수행합니다.
하지만 개발자는 이 과정을 신경 쓸 필요 없이 mvn 명령어만 사용합니다.


실습 1. CAP Java 프로젝트 생성

1-1. Maven Archetype으로 프로젝트 생성

# 프로젝트를 만들 디렉토리로 이동
cd /home/user/projects/sap-btp-travel-expense

# CAP Java Archetype으로 프로젝트 생성
mvn archetype:generate \
  -DarchetypeGroupId=com.sap.cds \
  -DarchetypeArtifactId=cds-services-archetype \
  -DarchetypeVersion=LATEST \
  -DgroupId=com.travel \
  -DartifactId=travel-expense-java \
  -Dversion=1.0.0 \
  -Dpackage=com.travel \
  -DinteractiveMode=false

(WIN)
mvn org.apache.maven.plugins:maven-archetype-plugin:3.2.1:generate `-DarchetypeGroupId=com.sap.cds `-DarchetypeArtifactId=cds-services-archetype `-DarchetypeVersion=LATEST `-DgroupId=com.travel `-DartifactId=travel-expense-java `-Dversion=1.0.0 `-Dpackage=com.travel `-DinteractiveMode=false

# 생성된 프로젝트로 이동
cd travel-expense-java

mvn 실행 시 Groove script 플러그인 호환성 문제로 에러처럼 (BUILD FAIL) 보일 수 있으나 생성된 디렉토리와 파일들을 확인하여 생성에 문제가 없으면 다음으로 넘어갑니다.

생성된 폴더 구조:

travel-expense-java/
├── app/                              ← Fiori UI (3일차에 사용)
├── db/                               ← 데이터 모델 (CDS 파일 — Node.js와 동일!)
├── srv/                              ← 서비스 정의 (CDS 파일 — Node.js와 동일!)
│
├── src/
│   └── main/
│       ├── java/
│       │   └── com/travel/           ← Java 서비스 핸들러 (이것이 .js 대체)
│       └── resources/
│           └── application.yaml      ← Spring Boot 설정 (포트, DB 등)
│
├── pom.xml                           ← Maven 빌드 설정 (package.json 역할)
└── .cdsrc.json                       ← CAP 설정
  • CAP Java Archetype 버전업으로 생성 구조가 바뀌었습니다.
항목설명
app/ 없음최신 Archetype에서 Fiori 폴더를 기본 생성하지 않음. 3일차 때 직접 만들면 됨. 지금은 불필요
src/가 srv/ 안으로 이동CAP Java가 Maven Multi-Module 구조로 변경됨. srv/가 실제 Spring Boot 모듈
srv/pom.xml 추가멀티모듈 Maven 프로젝트의 자식 모듈로 pom.xml 추가됨

구조 이해 포인트
db/ = 무엇을 저장하나 (테이블 정의) — Node.js와 동일
srv/*.cds = 무엇을 API로 노출하나 — Node.js와 동일
srv/src/main/java/ = 비즈니스 로직 Java 코드 — Node.js의 srv/*.js 대체
pom.xml = 의존성 관리 — Node.js의 package.json 대체


1-2. pom.xml 확인

생성된 pom.xml을 열어 주요 의존성을 확인합니다.

<!-- pom.xml 주요 부분 -->
<dependencies>

    <!-- CDS SPRING BOOT STARTER -->
		<dependency>
			<groupId>com.sap.cds</groupId>
			<artifactId>cds-starter-spring-boot</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
			<optional>true</optional>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>

		<dependency>
			<groupId>com.sap.cds</groupId>
			<artifactId>cds-adapter-odata-v4</artifactId>
			<scope>runtime</scope>
		</dependency>

		<dependency>
			<groupId>com.h2database</groupId>
			<artifactId>h2</artifactId>
			<scope>runtime</scope>
		</dependency>

</dependencies>

cds-starter-spring-boot-odata가 하는 일
Spring Boot Auto-configuration으로 OData 서블릿, CDS 서비스 등록,
이벤트 핸들러 스캔을 자동으로 설정해줍니다. 직접 설정할 것이 거의 없습니다.


1-3. application.yaml 설정

src/main/resources/application.yaml을 확인합니다. (없으면 생성)

# src/main/resources/application.yaml

server:
  port: 8080

spring:
  sql:
    init:
      platform: h2  # schema-h2.sql을 찾아서 H2 DB 초기화 (테이블 생성)

cds:
  datasource:
    auto-config:
      enabled: false
  security:
    enabled: false
    mock:
      enabled: false
      users:
        alice:
          password: ""
          roles:
            - authenticated-user
            - Admin
            - TravelAdmin
            - Requester
            - Approver  # H2 사용 시 CAP 자동설정 비활성화 (Spring이 schema-h2.sql로 직접 초기화)

포트 변경 주의
CAP Node.js는 기본 포트가 4004이고,
CAP Java(Spring Boot)는 기본 포트가 8080입니다.
3일차 Fiori 연결 시 이 포트를 사용합니다.


1-4. 첫 번째 CDS 데이터 모델 작성

db/schema.cds 파일을 새로 만듭니다. 이 파일은 Node.js 버전과 완전히 동일합니다.

// db/schema.cds
// 이 파일은 CAP Java와 CAP Node.js에서 100% 동일합니다!
namespace com.travel;

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

// ──────────────────────────────────────────
//  출장비 신청 엔티티
// ──────────────────────────────────────────
entity TravelRequests : cuid, managed {
  title         : String(100)  @mandatory;
  description   : String(500);
  destination   : String(100)  @mandatory;
  departureDate : Date         @mandatory;
  returnDate    : Date         @mandatory;
  amount        : Decimal(10,2) @mandatory;
  currency      : String(3) default 'KRW';
  status        : String(20) default 'Draft';
  requester     : String(100);
}

코드 해설:

  • cuid : 자동으로 UUID를 Primary Key로 추가하는 Aspect
  • managed : createdAt, createdBy, modifiedAt, modifiedBy 자동 추가
  • @mandatory : null 불가 제약 어노테이션
  • Decimal(10,2) : 소수점 2자리까지의 숫자 (금액에 적합)

1-5. 서비스 정의 작성

srv/travel-service.cds 파일을 만듭니다. 이 파일도 Node.js 버전과 완전히 동일합니다.

// srv/travel-service.cds
// 이 파일도 CAP Java와 CAP Node.js에서 100% 동일합니다!
using com.travel from '../db/schema';

// 외부에 노출할 서비스 정의
service TravelService @(path: '/travel') {

  // TravelRequests 엔티티를 API로 노출
  entity TravelRequests as projection on travel.TravelRequests;

  // 상태를 'Submitted'로 변경하는 Custom Action (1일차 실습용)
  action submit(ID: UUID) returns { message: String; status: String; };
}

1-6. 프로젝트 빌드 (Java POJO 자동 생성)

npm install 하고 나서 mvn compile 하세요

# 프로젝트 루트에서 — CDS 컴파일 + Java 소스 생성 + 빌드
mvn compile



# 빌드 성공 후 생성된 파일 확인
ls srv/target/classes/cds/gen/travelservice/
# 출력 예:
# TravelRequests.java     ← 엔티티 POJO 인터페이스 (자동 생성!)
# TravelRequests_.java    ← CQN 쿼리 빌더 헬퍼 (자동 생성!)
# TravelService_.java     ← 서비스 상수 클래스 (자동 생성!)

CAP Java의 마법: 자동 POJO 생성
mvn compile을 실행하면 CDS 파일을 읽어 Java 인터페이스를 자동 생성합니다.
TravelRequests 인터페이스에는 getTitle(), setStatus() 등이 이미 만들어져 있습니다.
개발자는 이 클래스를 직접 만들 필요가 없습니다.

생성된 TravelRequests.java (참고용 — 직접 수정 금지):

srv/src/gen/java/cds/gen/travelservice/TravelRequests.java 에 위치

// target/generated-sources/...에 자동 생성됨
// 이 파일은 수정하지 마세요! mvn compile 시 덮어써집니다.
package cds.gen.travelservice;

import com.sap.cds.ql.cqn.CqnStructuredTypeRef;
import java.lang.String;
import java.math.BigDecimal;

public interface TravelRequests extends cds.gen.com.travel.TravelRequests {
    String getTitle();
    void setTitle(String title);
    String getStatus();
    void setStatus(String status);
    BigDecimal getAmount();
    void setAmount(BigDecimal amount);
    // ... 나머지 필드들도 자동 생성
}

1-7. 서버 실행

# Spring Boot 개발 서버 실행
mvn spring-boot:run

예상 출력:

   _________    ____         __
  / ____/   |  / __ \       / /___ __   ______ _
 / /   / /| | / /_/ /  __  / / __ `/ | / / __ `/
/ /___/ ___ |/ ____/  / /_/ / /_/ /| |/ / /_/ /
\____/_/  |_/_/       \____/\__,_/ |___/\__,_/      4.9.0

Powered by Spring Boot 3.5.13

2026-05-03T07:38:07.889+09:00  INFO 11624 --- [  restartedMain] com.travel.Application                   : Starting Application using Java 17.0.18 with PID 11624 (C:\dev\space\sap_btp\sap-btp-travel-expense\travel-expense-java\srv\target\classes started by wclee in C:\dev\space\sap_btp\sap-btp-travel-expense\travel-expense-java\srv)
2026-05-03T07:38:07.892+09:00  INFO 11624 --- [  restartedMain] com.travel.Application                   : No active profile set, falling back to 1 default profile: "default"
2026-05-03T07:38:07.953+09:00  INFO 11624 --- [  restartedMain] o.s.b.devtools.restart.ChangeableUrls    : The Class-Path manifest attribute in C:\Users\wclee\.m2\repository\com\cronutils\cron-utils\9.2.1\cron-utils-9.2.1.jar referenced one or more files that do not exist: file:/C:/Users/wclee/.m2/repository/com/cronutils/cron-utils/9.2.1/slf4j-api-2.0.7.jar
2026-05-03T07:38:07.954+09:00  INFO 11624 --- [  restartedMain] .e.DevToolsPro

Node.js의 cds watch와 비교

  • Node.js: cds watch (저장 즉시 자동 재시작)
  • Java: mvn spring-boot:run (수동 재시작 필요)
    개발 중 CDS 파일 변경 시에는 Ctrl+C로 종료 후 재실행이 필요합니다.

1-8. 브라우저에서 API 확인

브라우저에서 http://localhost:8080 접속:

Welcome to cds.services
─────────────────────────────────
TravelService
  - /travel/$metadata           ← OData 메타데이터
  - /travel/TravelRequests      ← 데이터 목록 API

Java CAP: http://localhost:8080/odata/v4

/travel/TravelRequests 응답:

{
  "@odata.context": "$metadata#TravelRequests",
  "value": []
}

축하합니다! CDS 파일 몇 줄 + Java 프로젝트 설정만으로
완전한 OData V4 API가 Spring Boot 위에서 동작합니다.


실습 2. Mock 데이터 추가 및 API 탐색

2-1. Mock 데이터 파일 생성

Node.js 버전과 완전히 동일한 CSV 파일을 사용합니다.

mkdir -p db/data

db/data/com.travel-TravelRequests.csv 파일 생성:

ID,title,description,destination,departureDate,returnDate,amount,currency,status,requester
"11111111-1111-1111-1111-111111111111","도쿄 고객사 미팅","Q4 제품 협의","도쿄, 일본","2025-09-01","2025-09-03",850000,"KRW","Draft","홍길동"
"22222222-2222-2222-2222-222222222222","싱가포르 컨퍼런스","SAP TechEd 참가","싱가포르","2025-10-15","2025-10-18",2100000,"KRW","Submitted","김영희"
"33333333-3333-3333-3333-333333333333","서울 본사 출장","월간 전략 회의","서울","2025-08-20","2025-08-20",50000,"KRW","Approved","이철수"
# DB 초기화 (CSV 데이터를 SQLite에 로드)
# CDS 도구로 DB 파일 생성
mvn spring-boot:run
# 서버 시작 시 자동으로 CSV 데이터를 DB에 로드합니다

2-2. HTTP 파일로 API 테스트

test/travel.http 파일을 만듭니다. (Node.js 버전과 URL만 포트 다름)

### travel_java.http — 1일차 CAP Java API 테스트
### ──────────────────────────────────────────
### 변수 설정
@baseUrl = http://localhost:8080/odata/v4/travel
@auth = Basic alice:

### 1. 전체 목록 조회
GET {{baseUrl}}/TravelRequests
Authorization: {{auth}}
Accept: application/json

###

### 2. 특정 항목 조회
GET {{baseUrl}}/TravelRequests(ID=22222222-2222-2222-2222-222222222222,IsActiveEntity=true)
Authorization: {{auth}}
Accept: application/json

###

### 3. 필터 조회 ($filter)
GET {{baseUrl}}/TravelRequests?$filter=status eq 'Draft'
Authorization: {{auth}}
Accept: application/json

###

### 4. 정렬 및 페이징 ($orderby, $top, $skip)
GET {{baseUrl}}/TravelRequests?$orderby=totalAmount desc&$top=2&$skip=0
Authorization: {{auth}}
Accept: application/json

###

### 5. 특정 컬럼만 조회 ($select)
GET {{baseUrl}}/TravelRequests?$select=title,totalAmount,status
Authorization: {{auth}}
Accept: application/json

###

### 6. 새 출장 신청 등록 (POST)
POST {{baseUrl}}/TravelRequests
Authorization: {{auth}}
Content-Type: application/json

{
  "ID": "44444444-4444-4444-4444-444444444444",
  "title": "부산 파트너사 미팅",
  "destination": "부산",
  "departureDate": "2025-11-01",
  "returnDate": "2025-11-01",
  "currency": "KRW",
  "requester": "박민준"
}

###

### 7. 항목 수정 (PATCH) - 제목 수정
PATCH {{baseUrl}}/TravelRequests(ID=44444444-4444-4444-4444-444444444444,IsActiveEntity=false)
Authorization: {{auth}}
Content-Type: application/json

{
  "title": "부산 파트너사 미팅 (수정됨3)"
}

###

### 8. Custom Action: 제출
POST {{baseUrl}}/TravelRequests(ID=44444444-4444-4444-4444-444444444444,IsActiveEntity=false)/TravelService.submit
Authorization: {{auth}}
Content-Type: application/json

{}

###

### 9. Custom Action: 승인
POST {{baseUrl}}/TravelRequests(ID=44444444-4444-4444-4444-444444444444,IsActiveEntity=false)/TravelService.approve
Authorization: {{auth}}
Content-Type: application/json

{
  "comment": "승인됩니다"
}

###

### 10. Custom Action: 반려
# 참고: 9번(승인)을 실행한 후에는 반려할 수 없습니다. 반려 테스트 시에는 6번(새로 생성) -> 8번(제출) -> 10번(반려) 순서로 진행하세요.
POST {{baseUrl}}/TravelRequests(ID=44444444-4444-4444-4444-444444444444,IsActiveEntity=false)/TravelService.reject
Authorization: {{auth}}
Content-Type: application/json

{
  "comment": "출장 사유가 명확하지 않습니다"
}

###

### 11. 항목 삭제 (DELETE)
DELETE {{baseUrl}}/TravelRequests(ID=44444444-4444-4444-4444-444444444444,IsActiveEntity=false)
Authorization: {{auth}}

Node.js 버전과 차이점
URL에서 localhost:4004localhost:8080으로만 바뀌었습니다.
OData 쿼리 옵션($filter, $orderby, $select 등)은 완전히 동일합니다.


실습 3. 서비스 핸들러 작성 (Java로 비즈니스 로직 구현)

이것이 Node.js와 가장 크게 다른 부분입니다.
src/main/java/com/travel/handlers/ 디렉토리를 만들고 Java 클래스를 작성합니다.

mkdir -p src/main/java/com/travel/handlers

src/main/java/com/travel/handlers/TravelServiceHandler.java 생성:

package com.travel.handlers;

import cds.gen.travelservice.TravelRequests;
import cds.gen.travelservice.TravelRequests_;
import cds.gen.travelservice.TravelService_;

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 com.sap.cds.services.cds.CqnService;
import com.sap.cds.services.handler.EventHandler;
import com.sap.cds.services.handler.annotations.After;
import com.sap.cds.services.handler.annotations.Before;
import com.sap.cds.services.handler.annotations.On;
import com.sap.cds.services.handler.annotations.ServiceName;
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.util.Map;
import java.util.Optional;

/**
 * TravelService 이벤트 핸들러
 *
 * Node.js: module.exports = cds.service.impl(async function () { ... })
 * Java:    @Component + @ServiceName 어노테이션으로 동일한 역할
 */
@Component
@ServiceName(TravelService_.CDS_NAME)
public class TravelServiceHandler implements EventHandler {

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

    // Node.js: const { TravelRequests } = this.entities;
    // Java:    Spring이 자동으로 주입해줌 (의존성 주입)
    @Autowired
    private PersistenceService db;

    // ════════════════════════════════════════════════════
    //  BEFORE Hook — 검증 로직
    //
    //  Node.js:  this.before('CREATE', TravelRequests, async (req) => { ... })
    //  Java:     @Before 어노테이션 + 메서드 파라미터로 엔티티 수신
    // ════════════════════════════════════════════════════

    @Before(event = CqnService.EVENT_CREATE, entity = TravelRequests_.CDS_NAME)
    public void validateCreate(TravelRequests request) {
        // Node.js: const { departureDate, returnDate, amount } = req.data;
        // Java:    CAP이 자동으로 TravelRequests 객체를 파라미터에 주입해줌

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

        // 출발일이 귀국일보다 늦으면 에러
        if (departureDate != null && returnDate != null
                && departureDate.isAfter(returnDate)) {

            // Node.js: return req.error(400, '...');
            // Java:    throw new ServiceException(...)
            throw new ServiceException(ErrorStatuses.BAD_REQUEST,
                    "출발일은 귀국일보다 이전이어야 합니다.");
        }

        // 금액이 0 이하면 에러
        if (amount != null && amount.signum() <= 0) {
            throw new ServiceException(ErrorStatuses.BAD_REQUEST,
                    "출장비 금액은 0보다 커야 합니다.");
        }
    }

    // ════════════════════════════════════════════════════
    //  AFTER Hook — 생성 후 로깅
    //
    //  Node.js:  this.after('CREATE', TravelRequests, (result, req) => { ... })
    //  Java:     @After 어노테이션 + 결과 엔티티를 파라미터로 수신
    // ════════════════════════════════════════════════════

    @After(event = CqnService.EVENT_CREATE, entity = TravelRequests_.CDS_NAME)
    public void afterCreate(TravelRequests result) {
        // Node.js: console.log(`새 출장 신청 등록: ${result.title}`)
        // Java:    SLF4J Logger 사용 (Spring Boot 표준)
        log.info("새 출장 신청 등록: {} (신청자: {})",
                result.getTitle(), result.getRequester());
    }

    // ════════════════════════════════════════════════════
    //  Custom Action — 상태 변경 (제출)
    //
    //  Node.js:  this.on('submit', TravelRequests, async (req) => { ... })
    //  Java:     @On 어노테이션 + Map으로 파라미터 수신
    // ════════════════════════════════════════════════════

    @On(event = "submit", entity = TravelRequests_.CDS_NAME)
    public void onSubmit(Map<String, Object> params,
                         com.sap.cds.services.EventContext ctx) {

        // 요청 대상 엔티티의 ID 추출
        // Node.js: const { ID } = req.params[0];
        @SuppressWarnings("unchecked")
        var keyMap = (Map<String, Object>) ctx.get("$keys");
        var id = (String) (keyMap != null ? keyMap.get("ID") : params.get("ID"));

        // DB에서 현재 상태 조회
        // Node.js: const request = await SELECT.one.from(TravelRequests).where({ ID });
        var query = Select.from(TravelRequests_.class)
                .where(t -> t.ID().eq(id));

        Optional<TravelRequests> optRequest = db.run(query).first(TravelRequests.class);

        if (optRequest.isEmpty()) {
            throw new ServiceException(ErrorStatuses.NOT_FOUND,
                    "해당 출장 신청을 찾을 수 없습니다.");
        }

        var request = optRequest.get();

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

        // 상태 업데이트
        // Node.js: await UPDATE(TravelRequests).set({ status: 'Submitted' }).where({ ID });
        db.run(Update.entity(TravelRequests_.class)
                .data(Map.of("status", "Submitted"))
                .where(t -> t.ID().eq(id)));

        // 결과 반환
        ctx.setResult(Map.of("message", "제출이 완료되었습니다.", "status", "Submitted"));
        ctx.setCompleted();
    }
}

서버 재실행:

# Ctrl+C로 기존 서버 종료 후
mvn spring-boot:run

테스트 — test/travel.http에 추가:

### 8. Custom Action: 제출
POST {{baseUrl}}/TravelRequests(ID=44444444-4444-4444-4444-444444444444,IsActiveEntity=false)/TravelService.submit
Authorization: {{auth}}
Content-Type: application/json

{}

###

### 9. Custom Action: 승인
POST {{baseUrl}}/TravelRequests(ID=44444444-4444-4444-4444-444444444444,IsActiveEntity=false)/TravelService.approve
Authorization: {{auth}}
Content-Type: application/json

{
  "comment": "승인됩니다"
}

###

### 10. Custom Action: 반려
# 참고: 9번(승인)을 실행한 후에는 반려할 수 없습니다. 반려 테스트 시에는 6번(새로 생성) -> 8번(제출) -> 10번(반려) 순서로 진행하세요.
POST {{baseUrl}}/TravelRequests(ID=44444444-4444-4444-4444-444444444444,IsActiveEntity=false)/TravelService.reject
Authorization: {{auth}}
Content-Type: application/json

{
  "comment": "출장 사유가 명확하지 않습니다"
}

###

### 11. 항목 삭제 (DELETE)
DELETE {{baseUrl}}/TravelRequests(ID=44444444-4444-4444-4444-444444444444,IsActiveEntity=false)
Authorization: {{auth}}

💡 Node.js 핸들러와 Java 핸들러 비교

개념Node.jsJava
서비스 등록cds.service.impl()@ServiceName 어노테이션
이벤트 훅 등록this.before()@Before 어노테이션
엔티티 데이터 접근req.data.titlerequest.getTitle()
에러 반환req.error(400, '...')throw new ServiceException(...)
DB 조회await SELECT.one.from(...)db.run(Select.from(...))
로깅console.log(...)log.info(...)

실습 4. Git 커밋

# .gitignore 생성
cat > .gitignore << 'EOF'
target/
db/travel.db
*.class
.settings/
.project
.classpath
EOF

git init
git add .
git commit -m "day1: CAP Java 프로젝트 초기 설정 및 TravelRequests 엔티티/서비스 정의"
git push origin main

# 다음 날을 위한 브랜치 생성
git checkout -b day2-start
git push origin day2-start
git checkout main

의도적 에러 실습

에러 1: CDS 파일 문법 오류 (Node.js와 동일)

// 잘못된 코드 — 세미콜론 없음
entity TravelRequests : cuid, managed {
  title : String(100) @mandatory   // ← 여기 세미콜론 없음
  amount : Decimal(10,2);
}

에러는 빌드 단계에서 발생합니다:

[ERROR] Failed to execute goal com.sap.cds:cds-maven-plugin:generate
[ERROR] db/schema.cds:5:3: Extraneous input 'amount' expecting...

고치기: 세미콜론 복원 후 mvn spring-boot:run


에러 2: 핸들러에서 생성된 클래스 import 오류

// 잘못된 import
import com.travel.TravelRequests;  // ❌ 이 패키지에는 없음

// 올바른 import (CAP이 자동 생성한 클래스)
import cds.gen.travelservice.TravelRequests;  // 

CAP Java 자주 하는 실수
자동 생성 클래스는 cds.gen.[서비스명소문자] 패키지에 있습니다.
빌드 전에는 클래스가 없어 IDE가 빨간 줄을 표시할 수 있습니다.
mvn compile 먼저 실행하세요.


에러 3: 빌드 없이 서버 실행

[ERROR] cannot find symbol
[ERROR]   symbol: class TravelRequests_
[ERROR]   location: package cds.gen.travelservice

원인: CDS 파일을 수정했지만 mvn compile을 실행하지 않아 Java 클래스가 갱신되지 않음.

해결:

mvn clean compile   # CDS 재컴파일 + Java 클래스 재생성
mvn spring-boot:run

1일차 마무리 체크

확인 항목:
[ ] mvn spring-boot:run 실행 시 에러 없이 서버 시작됨 (포트 8080)
[ ] 브라우저에서 /travel/TravelRequests 로 3개 Mock 데이터 확인
[ ] HTTP 파일로 CRUD 테스트 성공
[ ] submit Action 호출 성공 (9번 테스트)
[ ] 유효성 검사 실패 시 에러 메시지 반환 확인 (10번 테스트)
[ ] Git 커밋 완료

Q&A 주제 (강사에게 물어보기):
[ ] CAP Java에서 mvn compile 없이 자동 재빌드하는 방법?
[ ] @Component와 @ServiceName의 동작 원리
[ ] PersistenceService vs JPA Repository의 차이?
[ ] OData V2와 V4의 실질적 차이?

1일차 핵심 정리

오늘 배운 것:
┌────────────────────────────────────────────────────────┐
│  BTP = S/4HANA 외부에서 API로 확장하는 플랫폼          │
│                                                        │
│  CAP Java = CDS(데이터 모델) + Java(Spring Boot 로직)  │
│             조합으로 OData API를 자동 생성              │
│                                                        │
│  CDS 파일(.cds)은 Node.js와 Java에서 완전히 동일!      │
│                                                        │
│  Java 핸들러 = @Component + @Before/@After/@On         │
│                                                        │
│  Node.js와 Java의 핵심 차이:                           │
│  - 프로젝트 생성: npm → mvn archetype:generate         │
│  - 서버 실행: cds watch → mvn spring-boot:run          │
│  - 핸들러: .js 파일 → Java @Component 클래스           │
│  - 포트: 4004 → 8080                                   │
└────────────────────────────────────────────────────────┘

내일 할 것:
→ CDS 모델을 더 정교하게 (Association, Enum, Composition)
→ Java 핸들러에서 복잡한 비즈니스 로직 구현
→ 상태 전이(submit/approve/reject) Java로 완성

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

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

0개의 댓글