[CAP for Java] @On/@Before/@After 이벤트 핸들러 — Spring Boot 기반 실전 패턴
![[CAP for Java] @On/@Before/@After 이벤트 핸들러 — Spring Boot 기반 실전 패턴](https://btpstacks.com/uploads/63780717-6b1a-4d67-8f71-ce27f624f28d.png)
📖 개요 및 학습 목표
CAP(Cloud Application Programming) Java에서 이벤트 핸들러는 비즈니스 로직을 구현하는 핵심 메커니즘입니다. 모든 런타임 동작은 서비스로 전송되는 '이벤트'이며, @Before, @On, @After 어노테이션으로 이벤트 처리 파이프라인의 각 단계에 커스텀 로직을 삽입할 수 있습니다. Spring Boot와 완전히 통합되어 DI(의존성 주입), AOP, 트랜잭션 관리 등 Spring 생태계의 모든 기능을 활용할 수 있습니다.
이 글을 읽으면 다음을 할 수 있습니다:
- ✅ @Before/@On/@After 3단계 이벤트 흐름의 역할과 실행 순서를 명확히 이해
- ✅ CqnService와 PersistenceService의 차이를 알고 적절히 사용
- ✅ 입력 검증, 권한 체크, 감사 로깅을 이벤트 핸들러로 구현
- ✅ 여러 핸들러 간의 실행 순서를 @Order로 제어
- ✅ 프로덕션 수준의 에러 처리와 트랜잭션 관리 적용
대상 독자: Java/Spring Boot 기본기가 있고, CAP Java 프로젝트를 처음 시작하는 중급 개발자
📚 선수 지식
- Java 17+ 기본 문법 및 어노테이션 이해
- Spring Boot 기초 —
@Component,@Autowired, Bean 생명주기 - CDS(Core Data Services) 기본 모델 정의 (entity, service)
- OData V4 CRUD 개념 (GET/POST/PUT/DELETE)
- Maven 프로젝트 구조 이해
🔧 환경 / 버전 / 준비물
- CAP Java SDK: 3.x (2024년 10월 이후 릴리스, capire 공식 문서 기준)
- Java: 17 이상 (CAP Java 3.x 최소 요구사항)
- Spring Boot: 3.x (CAP Java 3.x와 호환)
- 개발 도구: VS Code + SAP CDS Language Support 확장, 또는 IntelliJ IDEA
- 빌드 도구: Maven 3.9+ (
mvn spring-boot:run으로 로컬 실행) - 테스트 환경: 로컬 SQLite(기본) 또는 SAP HANA Cloud(프로덕션)
- BTP Trial 계정: 클라우드 배포 시 필요 (로컬 개발은 불필요)
프로젝트 생성: mvn archetype:generate -DarchetypeArtifactId=cds-services-archetype -DarchetypeGroupId=com.sap.cds

💡 핵심 개념
CAP Java의 이벤트 처리를 레스토랑 주문 시스템에 비유하면 이해가 쉽습니다:
- @Before = 주문 접수 직원 — 주문이 주방에 전달되기 전에 메뉴 유효성 확인, 재고 체크, 고객 알레르기 정보 확인을 수행합니다. 데이터베이스에 쓰기 전의 검증/변환 단계입니다.
- @On = 주방장 — 실제 요리를 수행합니다. CAP은 기본적으로 CRUD 작업을 자동 처리(Generic Provider)하지만,
@On핸들러를 등록하면 기본 동작을 완전히 대체할 수 있습니다. 주의:@On을 등록하면 Generic Provider가 호출되지 않으므로 직접 DB 작업을 수행해야 합니다. - @After = 서빙 직원 — 요리가 완성된 후, 플레이팅을 다듬고 추가 소스를 곁들입니다. DB 결과가 반환된 후에 데이터 가공, 감사 로깅, 알림 발송 등 후처리를 수행합니다.
실행 순서: @Before(여러 개 가능) → @On(기본 1개, Generic Provider 또는 커스텀) → @After(여러 개 가능)
흔한 오개념 바로잡기:
- ❌ "@On에서 검증하면 된다" → ⭕ 검증은 반드시 @Before에서. @On에서 검증하면 Generic Provider가 대체되어 CRUD 자동 처리가 사라집니다.
- ❌ "@After에서 데이터를 수정하면 DB에 반영된다" → ⭕ @After는 이미 커밋된 후이므로 응답 데이터만 가공 가능합니다. DB 변경이 필요하면 @Before를 사용하세요.
- ❌ "핸들러 클래스 하나에 모든 로직을 넣는다" → ⭕ 관심사별로 분리하고
@Order로 실행 순서를 제어하는 것이 권장됩니다.
# 이벤트 처리 파이프라인 흐름도
Client Request (OData)
│
▼
┌─────────────────────┐
│ @Before 핸들러들 │ ← 검증, 변환, 권한 체크
│ (순서: @Order 기준) │
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ @On 핸들러 │ ← 실제 비즈니스 로직 / Generic Provider
│ (1개만 실행) │
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ @After 핸들러들 │ ← 후처리, 로깅, 알림
│ (순서: @Order 기준) │
└─────────┬───────────┘
│
▼
Response to Client
💻 실전 코드 — 3단계
1단계: 기본 예제 — Books 서비스에 이벤트 핸들러 등록
CDS 모델과 함께 가장 기본적인 핸들러 구조를 살펴봅니다.
// srv/cat-service.cds
using { bookshop.Books } from '../db/schema';
service CatalogService {
entity Books as projection on bookshop.Books;
action submitOrder(book: Books:ID, quantity: Integer) returns { stock: Integer };
}
// srv/src/main/java/com/example/handlers/CatalogServiceHandler.java
package com.example.handlers;
import cds.gen.catalogservice.*;
import com.sap.cds.services.handler.EventHandler;
import com.sap.cds.services.handler.annotations.*;
import com.sap.cds.services.cds.CqnService;
import org.springframework.stereotype.Component;
@Component
@ServiceName(CatalogService_.CDS_NAME)
public class CatalogServiceHandler implements EventHandler {
// @Before — CREATE 전에 제목 공백 제거
@Before(event = CqnService.EVENT_CREATE, entity = Books_.CDS_NAME)
public void beforeCreateBooks(Books book) {
if (book.getTitle() != null) {
book.setTitle(book.getTitle().trim());
}
}
// @After — READ 후에 재고 상태 필드 추가
@After(event = CqnService.EVENT_READ, entity = Books_.CDS_NAME)
public void afterReadBooks(java.util.List<Books> books) {
for (Books book : books) {
if (book.getStock() != null && book.getStock() == 0) {
book.put("availability", "Out of Stock");
} else {
book.put("availability", "In Stock");
}
}
}
}
실행 결과: GET /catalog/Books 호출 시 각 도서에 availability 필드가 자동으로 추가됩니다. POST /catalog/Books 시 제목 앞뒤 공백이 자동 제거됩니다.
2단계: 실무 시나리오 — 주문 액션과 검증 로직
// 주문 처리 — @On으로 Custom Action 구현
@Component
@ServiceName(CatalogService_.CDS_NAME)
public class OrderHandler implements EventHandler {
@Autowired
private PersistenceService db;
// @Before — 입력값 검증
@Before(event = "submitOrder")
public void validateOrder(SubmitOrderContext context) {
Integer quantity = context.getQuantity();
if (quantity == null || quantity < 1) {
throw new ServiceException(ErrorStatuses.BAD_REQUEST,
"주문 수량은 1 이상이어야 합니다. 입력값: " + quantity);
}
}
// @On — 실제 주문 처리
@On(event = "submitOrder")
public void onSubmitOrder(SubmitOrderContext context) {
String bookId = context.getBook();
int quantity = context.getQuantity();
// PersistenceService로 직접 DB 조회 (Application Service 재진입 방지)
CqnSelect select = Select.from(Books_.class)
.where(b -> b.ID().eq(bookId));
Books book = db.run(select).single(Books.class);
if (book.getStock() < quantity) {
throw new ServiceException(ErrorStatuses.CONFLICT,
"재고 부족: 현재 " + book.getStock() + "권, 요청 " + quantity + "권");
}
// 재고 차감
book.setStock(book.getStock() - quantity);
CqnUpdate update = Update.entity(Books_.class)
.data(book)
.where(b -> b.ID().eq(bookId));
db.run(update);
// 결과 반환
context.setResult(Collections.singletonMap("stock", book.getStock()));
context.setCompleted(); // 이벤트 처리 완료 표시
}
// @After — 감사 로그 기록
@After(event = "submitOrder")
public void logOrder(SubmitOrderContext context) {
logger.info("주문 완료: bookId={}, quantity={}, remainingStock={}",
context.getBook(), context.getQuantity(),
context.getResult().get("stock"));
}
}
3단계: 고급 / 프로덕션 고려사항
// 여러 핸들러 간 실행 순서 제어 — @Order 활용
@Component
@ServiceName(CatalogService_.CDS_NAME)
@Order(1) // 가장 먼저 실행
public class AuthorizationHandler implements EventHandler {
@Before(event = {CqnService.EVENT_CREATE, CqnService.EVENT_UPDATE, CqnService.EVENT_DELETE},
entity = Books_.CDS_NAME)
public void checkAuthorization(EventContext context) {
// Spring Security의 SecurityContext에서 사용자 정보 추출
UserInfo user = context.getUserInfo();
if (!user.hasRole("Admin") && !user.hasRole("BookManager")) {
throw new ServiceException(ErrorStatuses.FORBIDDEN,
"도서 관리 권한이 없습니다. 필요한 역할: Admin 또는 BookManager");
}
}
}
@Component
@ServiceName(CatalogService_.CDS_NAME)
@Order(2) // 권한 체크 이후 실행
public class ValidationHandler implements EventHandler {
@Before(event = CqnService.EVENT_CREATE, entity = Books_.CDS_NAME)
public void validateBook(Books book) {
// 비즈니스 규칙 검증
if (book.getPrice() != null && book.getPrice().compareTo(BigDecimal.ZERO) < 0) {
throw new ServiceException(ErrorStatuses.BAD_REQUEST,
"가격은 0 이상이어야 합니다");
}
if (book.getTitle() == null || book.getTitle().isBlank()) {
throw new ServiceException(ErrorStatuses.BAD_REQUEST,
"제목은 필수 입력 항목입니다");
}
}
}
// 트랜잭션 내 안전한 서비스 간 호출
@Component
@ServiceName(CatalogService_.CDS_NAME)
public class CrossServiceHandler implements EventHandler {
@Autowired
@Qualifier(CatalogService_.CDS_NAME)
private CqnService catalogService;
@Autowired
private PersistenceService db;
@After(event = CqnService.EVENT_CREATE, entity = Books_.CDS_NAME)
public void afterCreate(Books book) {
// ⚠️ 권장: 같은 서비스의 데이터 조회 시 PersistenceService 사용
// (Application Service를 통하면 핸들러가 재귀 호출될 수 있음)
CqnSelect query = Select.from(Authors_.class)
.where(a -> a.ID().eq(book.getAuthorId()));
Authors author = db.run(query).single(Authors.class);
logger.info("새 도서 등록: '{}' by '{}'", book.getTitle(), author.getName());
}
}
⚠️ 흔한 실수 / 트러블슈팅
Q1: 핸들러가 전혀 호출되지 않아요
- 증상: @Before/@On/@After 메서드에 브레이크포인트를 걸어도 진입하지 않음
- 원인:
@ServiceName어노테이션이 누락되었거나 잘못된 서비스 이름을 사용. CAP은 이 어노테이션으로 핸들러를 서비스에 매핑합니다. - 해결:
@ServiceName(CatalogService_.CDS_NAME)처럼 생성된 상수를 사용하세요. 문자열 직접 입력("CatalogService")은 오타 위험이 있습니다.
Q2: @On 핸들러를 등록했더니 기본 CRUD가 안 돼요
- 증상:
@On(event = CqnService.EVENT_READ)를 등록한 뒤 다른 엔티티의 READ도 빈 결과를 반환 - 원인: @On은 Generic Provider(CAP 기본 CRUD 처리기)를 대체합니다.
entity속성을 지정하지 않으면 모든 엔티티에 적용됩니다. - 해결: 반드시
entity = Books_.CDS_NAME을 명시하여 특정 엔티티에만 적용하세요. 기본 CRUD를 유지하면서 로직만 추가하려면 @Before나 @After를 사용하세요.
Q3: @After에서 수정한 데이터가 DB에 반영되지 않아요
- 증상: @After에서
book.setStock(0)을 호출했지만 DB에는 원래 값이 유지됨 - 원인: @After는 이미 DB 트랜잭션이 커밋된 후에 실행됩니다. @After에서의 데이터 변경은 클라이언트 응답에만 반영됩니다.
- 해결: DB 변경이 필요하면 @Before에서 처리하거나, @After에서 별도의
PersistenceService.run(update)를 호출하세요 (단, 별도 트랜잭션으로 실행됨에 유의).
🚀 다음 단계 / 관련 주제
- OData Actions/Functions —
submitOrder같은 커스텀 액션을 더 깊이 다루기 - CAP Java 인증/인가 —
@PreAuthorize와 XSUAA 연동으로 역할 기반 접근 제어 - CAP Java 테스트 —
@SpringBootTest+@CdsTest로 핸들러 통합 테스트 작성 - HANA Cloud 배포 — 로컬 SQLite에서 HANA Cloud HDI Container로 전환
- CAP Java + Remote Service — S/4HANA OData API를 CAP 서비스에서 소비하기

📚 참고 자료
- CAP 공식 문서: Event Handlers — @Before/@On/@After 상세 설명 및 API 레퍼런스
- CAP 공식 문서: Application Services — CqnService 인터페이스와 이벤트 상수
- CAP 공식 문서: CQN Services — PersistenceService와 서비스 계층 아키텍처
- SAP Tutorial: Add a Custom Event Handler — 단계별 핸들러 구현 실습
- SAP Learning: Explaining Event Handling in CAP — 이벤트 핸들링 개념 학습 경로
- SAP Community: BTP Developer's Guide CAP Java Tutorials — 최신 튜토리얼 모음
- DEV Community: CAP Java Architecture — CDS, Event Handlers & CQN Query API 아키텍처 분석
📌 본 게시물은 AI(Claude)가 공개된 자료를 기반으로 자동 생성한 콘텐츠입니다. 기술 내용의 정확성은 SAP 공식 문서 와 교차 확인하시기 바랍니다.
™ SAP, S/4HANA, ABAP, Fiori, SAP BTP 등은 SAP SE 또는 그 계열사의 등록 상표입니다. 본 사이트는 SAP SE 와 공식적인 관련이 없는 비공식 학습 자료 입니다.
📧 저작권 침해 / 오류 / 콘텐츠 신고: btpstacks.com 의 "문의" 메뉴를 이용해주세요.