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

AI News

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

📖 개요 및 학습 목표

CAP(Cloud Application Programming) Java에서 이벤트 핸들러는 비즈니스 로직을 구현하는 핵심 메커니즘입니다. 모든 런타임 동작은 서비스로 전송되는 '이벤트'이며, @Before, @On, @After 어노테이션으로 이벤트 처리 파이프라인의 각 단계에 커스텀 로직을 삽입할 수 있습니다. Spring Boot와 완전히 통합되어 DI(의존성 주입), AOP, 트랜잭션 관리 등 Spring 생태계의 모든 기능을 활용할 수 있습니다.

이 글을 읽으면 다음을 할 수 있습니다:

대상 독자: Java/Spring Boot 기본기가 있고, CAP Java 프로젝트를 처음 시작하는 중급 개발자

📚 선수 지식

🔧 환경 / 버전 / 준비물

프로젝트 생성: mvn archetype:generate -DarchetypeArtifactId=cds-services-archetype -DarchetypeGroupId=com.sap.cds

이 글에서 다루는 것

💡 핵심 개념

CAP Java의 이벤트 처리를 레스토랑 주문 시스템에 비유하면 이해가 쉽습니다:

실행 순서: @Before(여러 개 가능) → @On(기본 1개, Generic Provider 또는 커스텀) → @After(여러 개 가능)

흔한 오개념 바로잡기:

# 이벤트 처리 파이프라인 흐름도
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: 핸들러가 전혀 호출되지 않아요

Q2: @On 핸들러를 등록했더니 기본 CRUD가 안 돼요

Q3: @After에서 수정한 데이터가 DB에 반영되지 않아요

🚀 다음 단계 / 관련 주제

자세한 내용은 본문에서

📚 참고 자료


📌 본 게시물은 AI(Claude)가 공개된 자료를 기반으로 자동 생성한 콘텐츠입니다. 기술 내용의 정확성은 SAP 공식 문서 와 교차 확인하시기 바랍니다.

™ SAP, S/4HANA, ABAP, Fiori, SAP BTP 등은 SAP SE 또는 그 계열사의 등록 상표입니다. 본 사이트는 SAP SE 와 공식적인 관련이 없는 비공식 학습 자료 입니다.

📧 저작권 침해 / 오류 / 콘텐츠 신고: btpstacks.com 의 "문의" 메뉴를 이용해주세요.