CAP for Java

[CAP for Java] @On/@Before/@After 이벤트 핸들러 — Spring Boot 기반 실전 패턴

▶ YouTube에서 보기

[CAP for Java] @On/@Before/@After 이벤트 핸들러 — Spring Boot 기반 실전 패턴

Moderator · 2026. 4. 24. · 조회 8

[CAP for Java] @On/@Before/@After 이벤트 핸들러 — Spring Boot 기반 실전 패턴

📖 개요 및 학습 목표

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/FunctionssubmitOrder 같은 커스텀 액션을 더 깊이 다루기
  • CAP Java 인증/인가@PreAuthorize와 XSUAA 연동으로 역할 기반 접근 제어
  • CAP Java 테스트@SpringBootTest + @CdsTest로 핸들러 통합 테스트 작성
  • HANA Cloud 배포 — 로컬 SQLite에서 HANA Cloud HDI Container로 전환
  • CAP Java + Remote Service — S/4HANA OData API를 CAP 서비스에서 소비하기

자세한 내용은 본문에서

📚 참고 자료


⚠️ 비공식 콘텐츠 안내

본 게시글은 btpstacks.com의 독립 학습 콘텐츠이며 SAP SE와 무관합니다. 공식 문서는 help.sap.com을 참고하세요.

SAP, ABAP, SAP BTP, SAPUI5, SAP Fiori는 독일 및 기타 국가에서 SAP SE의 상표 또는 등록상표입니다.

댓글 0

아직 댓글이 없습니다.