CAP for Java

CAP @Before vs @On vs @After 핸들러 차이 #shorts #SAP #CAPJava

개요 — 핸들러 실행 순서를 알아야 하는 이유

CAP Java(SAP Cloud Application Programming Model, Java 런타임)에서 커스텀 비즈니스 로직은 @Before, @On, @After 세 페이즈(Phase)의 이벤트 핸들러로 작성합니다. 이 세 페이즈의 실행 순서와 역할을 정확히 이해하지 못하면 "After에서 넣은 값이 DB에 없다", "데이터가 두 번 저장된다" 같은 원인 파악이 어려운 버그를 만나게 됩니다. 이 글은 판매 주문(SalesOrder) 처리라는 실무 시나리오로 세 페이즈의 차이와 활용 패턴을 단계별로 정리합니다.

  • Before / On / After 페이즈의 정확한 실행 순서와 각 페이즈의 책임 구분
  • On 페이즈의 "이벤트 완료(completion)" 메커니즘과 기본 핸들러 스킵 조건 이해
  • @Before로 입력값 검증, @On으로 기본 로직 교체, @After로 응답 후처리 구현
  • 로깅·예외 처리·테스트·보안까지 포함한 프로덕션 수준 핸들러 구성

미리 알아두면 좋은 배경

이 글은 중급 난이도로, 다음 경험이 있으면 이해가 빠릅니다. CDS(Core Data Services)로 entity와 service를 정의하는 기본 문법, Java 17 문법(람다, Stream), Spring Boot의 @Component 빈 등록 개념, 그리고 CAP 프로젝트를 mvn spring-boot:run으로 구동해 본 경험입니다. Node.js 런타임 CAP을 써봤다면 개념은 거의 같고 "완료 처리" 문법만 다르다고 보면 됩니다.

환경 / 버전 / 준비물

이 글의 예제는 다음 환경을 기준으로 작성했습니다. 버전이 다르면 API 시그니처가 일부 다를 수 있으니 확인을 권장합니다.

  • CAP Java SDK: 3.x 계열 (com.sap.cds:cds-services-api 등) — 2.x에서도 핸들러 API는 대부분 동일
  • Java: 17 이상 (SapMachine 17 권장, CAP Java 3.x 최소 요구 버전)
  • Spring Boot: 3.x (cds-starter-spring-boot 사용)
  • 빌드/DB: Maven 3.8+, 로컬은 H2 in-memory, 운영은 SAP HANA Cloud를 일반적으로 사용
  • 배포 대상: SAP BTP Cloud Foundry 환경 (로컬 실행만으로도 예제 진행 가능)

프로젝트가 없다면 cds init --java 또는 mvn archetype:generate -DarchetypeArtifactId=cds-services-archetype으로 골격을 만들 수 있습니다. 핸들러 클래스는 Spring 컴포넌트 스캔 대상 패키지(srv/src/main/java 하위)에 두어야 자동 등록됩니다.

핵심 개념 — 세 개의 관문을 통과하는 요청

CAP Java에서 모든 요청(READ, CREATE, UPDATE, DELETE, 커스텀 액션/펑션)은 하나의 이벤트로 취급되며, 공항 출국 절차처럼 세 관문을 순서대로 통과합니다. Before는 보안 검색대(검증·인가·기본값 세팅), On은 실제 탑승과 비행(요청을 실제로 수행하고 결과를 만드는 본 처리), After는 도착 후 수하물 수취(확정된 결과의 후처리)입니다. 검색대를 통과하지 못하면 비행 자체가 없고, 비행이 실패하면 수하물 수취도 없습니다.

요청 수신
  └─ @Before 핸들러들     # 검증/전처리 — 등록된 전체 실행
      └─ @On 핸들러들     # 본 처리 — "완료"될 때까지 순차 실행
          └─ Default On Handler → DB 접근   # 커스텀 On이 완료하지 않았을 때만
              └─ @After 핸들러들            # 결과 후처리 — 등록된 전체 실행
                  └─ 응답 반환

가장 중요한 것이 On 페이즈의 완료(completion) 메커니즘입니다. Before와 After는 등록된 핸들러가 전부 실행되지만, On은 어떤 핸들러가 context.setResult()를 호출하거나 값을 반환하는 순간 이벤트가 "완료"되어 나머지 On 핸들러(CAP의 기본 영속성 핸들러 포함)는 실행되지 않습니다. 반대로 커스텀 On 핸들러가 완료 처리를 하지 않으면 흐름이 다음 On 핸들러로 넘어가 최종적으로 기본 DB 핸들러가 처리합니다. 즉 On은 프레임워크 동작을 통째로 교체할 수 있는 지점입니다.

추가로 기억할 동작 원리 세 가지입니다. 첫째, 동일 페이즈 내 순서는 기본적으로 보장되지 않으므로 @HandlerOrder(작은 값이 먼저)로 명시합니다. 둘째, 어느 페이즈에서든 예외가 발생하면 트랜잭션이 롤백되고 이후 페이즈는 실행되지 않습니다(After에서 던져도 롤백됩니다). 셋째, After에서 수정한 값은 응답 payload에만 반영되고 DB에는 쓰이지 않습니다.

페이즈실행 범위주 용도주의점
@Before등록된 전체검증, 기본값, 인가여기서 수정한 데이터가 DB에 반영됨
@On완료 시점까지비즈니스 로직 본체, 기본 동작 교체완료 여부에 따라 기본 핸들러 스킵
@After등록된 전체결과 보강, 후속 처리수정은 응답에만 반영, DB 미반영

실전 예제 — 판매 주문 처리 3단계

예제 전반에서 사용할 CDS 모델입니다. 판매 주문과 재고 품목을 정의합니다.

// db/schema.cds
namespace demo.sales;

entity SalesOrders {
  key ID        : UUID;
  customerName  : String(100);
  productCode   : String(20);
  quantity      : Integer;
  unitPrice     : Decimal(10,2);
  totalAmount   : Decimal(12,2) @Core.Computed;  // After에서 계산
  status        : String(20) default 'NEW';
}

entity InventoryItems {
  key productCode : String(20);
  availableStock  : Integer;
}

// srv/sales-service.cds
using demo.sales as db from '../db/schema';
service SalesService {
  entity SalesOrders    as projection on db.SalesOrders;
  entity InventoryItems as projection on db.InventoryItems;
}

1단계 (기본) — @Before로 입력값 검증

@Component
@ServiceName(SalesService_.CDS_NAME)
public class SalesOrderValidationHandler implements EventHandler {

  @Before(event = CqnService.EVENT_CREATE, entity = SalesOrders_.CDS_NAME)
  public void validateNewOrder(List<SalesOrders> orders) {
    for (SalesOrders order : orders) {
      if (order.getQuantity() == null || order.getQuantity() <= 0) {
        throw new ServiceException(ErrorStatuses.BAD_REQUEST,
            "주문 수량은 1 이상이어야 합니다.");
      }
      if (order.getCustomerName() == null || order.getCustomerName().isBlank()) {
        throw new ServiceException(ErrorStatuses.BAD_REQUEST,
            "고객명은 필수 입력입니다.");
      }
      order.setStatus("VALIDATED"); // Before의 수정은 그대로 DB에 저장됨
    }
  }
}

핸들러 파라미터로 CDS 모델에서 생성된 타입 세이프 액세서 인터페이스(SalesOrders) 리스트를 바로 받는 점에 주목하세요. Map 기반 접근보다 안전하며, Before 단계에서 수정한 데이터는 이어지는 On 단계(DB 저장)에 그대로 반영됩니다. 예외를 던지면 On/After는 실행되지 않고 클라이언트는 HTTP 400을 받습니다.

2단계 (실무) — @On으로 기본 로직 교체 + 재고 검증/로깅

주문 생성을 프레임워크에 맡기지 않고, 재고 확인 → 재고 차감 → 주문 저장을 하나의 커스텀 로직으로 묶습니다.

@Component
@ServiceName(SalesService_.CDS_NAME)
public class SalesOrderProcessingHandler implements EventHandler {

  private static final Logger logger =
      LoggerFactory.getLogger(SalesOrderProcessingHandler.class);

  private final PersistenceService db;

  public SalesOrderProcessingHandler(PersistenceService db) {
    this.db = db;
  }

  @On(event = CqnService.EVENT_CREATE, entity = SalesOrders_.CDS_NAME)
  public void createOrderWithStockCheck(CdsCreateEventContext context,
                                        List<SalesOrders> orders) {
    for (SalesOrders order : orders) {
      String productCode = order.getProductCode();

      // 1) 재고 조회
      InventoryItems item = db.run(
              Select.from(InventoryItems_.class)
                    .where(i -> i.productCode().eq(productCode)))
          .first(InventoryItems.class)
          .orElseThrow(() -> new ServiceException(ErrorStatuses.NOT_FOUND,
              "품목 {0} 을(를) 찾을 수 없습니다.", productCode));

      // 2) 재고 부족 검증
      if (item.getAvailableStock() < order.getQuantity()) {
        logger.warn("재고 부족: product={}, 요청={}, 가용={}",
            productCode, order.getQuantity(), item.getAvailableStock());
        throw new ServiceException(ErrorStatuses.CONFLICT,
            "재고가 부족합니다. 가용 수량: {0}", item.getAvailableStock());
      }

      // 3) 재고 차감 — 같은 트랜잭션이므로 실패 시 함께 롤백
      db.run(Update.entity(InventoryItems_.class)
          .where(i -> i.productCode().eq(productCode))
          .data("availableStock", item.getAvailableStock() - order.getQuantity()));

      order.setStatus("CONFIRMED");
      logger.info("주문 확정: product={}, qty={}", productCode, order.getQuantity());
    }

    // 4) 직접 저장 후 결과 확정 → 기본 On 핸들러는 실행되지 않음
    Result result = db.run(Insert.into(SalesOrders_.class).entries(orders));
    context.setResult(result);
  }
}

핵심은 마지막 줄 context.setResult(result)입니다. 이 호출로 이벤트가 완료되어 CAP의 기본 저장 로직이 생략됩니다. 이를 누락하면 커스텀 저장 + 기본 저장이 모두 실행되어 데이터가 이중 저장됩니다. 반대로 "기본 동작 + 추가 검증"만 필요하면 이 로직을 @Before로 옮기는 편이 단순합니다. 일반적으로 기본 동작을 유지하면 Before/After, 기본 동작 자체를 바꾸면 On을 선택하는 것이 권장 패턴입니다.

3단계 (프로덕션) — @After 후처리 + 성능/보안/테스트

@Component
@ServiceName(SalesService_.CDS_NAME)
public class SalesOrderEnrichmentHandler implements EventHandler {

  @After(event = CqnService.EVENT_READ, entity = SalesOrders_.CDS_NAME)
  public void enrichTotalAmount(List<SalesOrders> orders) {
    // 결과 리스트를 메모리에서 일괄 계산 — 루프 내 DB 조회 금지 (N+1 방지)
    for (SalesOrders order : orders) {
      if (order.getUnitPrice() != null && order.getQuantity() != null) {
        order.setTotalAmount(order.getUnitPrice()
            .multiply(BigDecimal.valueOf(order.getQuantity())));
      }
    }
  }
}

보안은 핸들러 코드보다 CDS 어노테이션으로 선언하고, 코드 레벨 검사가 필요하면 @HandlerOrder(HandlerOrder.EARLY)의 Before에 배치해 조기 차단하는 것이 일반적입니다.

annotate SalesService.SalesOrders with @(restrict: [
  { grant: 'READ', to: 'SalesViewer' },
  { grant: '*',    to: 'SalesManager' }
]);

핸들러 전체 흐름은 Spring Boot 통합 테스트로 검증합니다.

@SpringBootTest
@AutoConfigureMockMvc
class SalesOrderHandlerTest {

  @Autowired MockMvc mockMvc;

  @Test
  void 재고부족_주문은_409를_반환한다() throws Exception {
    mockMvc.perform(post("/odata/v4/SalesService/SalesOrders")
            .contentType(MediaType.APPLICATION_JSON)
            .content("{\"customerName\":\"ACME\",\"productCode\":\"P-1001\",\"quantity\":99999}"))
        .andExpect(status().isConflict());
  }
}

흔한 실수 / 트러블슈팅 FAQ

Q1. Before에서 세팅한 값은 저장되는데 After에서 세팅한 값은 왜 DB에 없나요?
A. DB 쓰기는 On 페이즈(기본 영속성 핸들러)에서 일어납니다. After는 이미 저장이 끝난 결과 객체만 다루므로 수정은 응답에만 반영됩니다. DB 반영이 목적이면 Before로 옮기거나 After에서 명시적으로 PersistenceService 업데이트를 실행해야 합니다(추가 쿼리 비용 발생).

Q2. @On 핸들러 이후 데이터가 두 번 저장되거나, 아예 저장되지 않습니다.
A. 완료 처리 문제입니다. On에서 직접 Insert를 실행했다면 반드시 setResult()로 기본 핸들러를 생략시켜야 하고(누락 시 이중 저장), 반대로 setResult()만 호출하고 저장 코드를 빠뜨리면 아무것도 저장되지 않습니다. 커스텀 액션/펑션은 기본 핸들러가 없으므로 setCompleted() 또는 결과 반환을 누락하면 "no handler" 류 오류가 발생합니다.

Q3. 핸들러가 아예 호출되지 않습니다.
A. (1) @Component가 있고 컴포넌트 스캔 범위 안인지, (2) @ServiceName이 CDS 서비스 이름과 일치하는지(생성된 SalesService_.CDS_NAME 상수 사용 권장), (3) entity에 DB 엔티티가 아닌 서비스 프로젝션 엔티티를 지정했는지 순서대로 확인하세요.

Q4. After의 알림 발송이 실패하면 주문까지 롤백됩니다.
A. After도 같은 트랜잭션 안에서 실행되므로 예외가 전파되면 롤백됩니다. 실패해도 본 처리가 유지돼야 하는 작업은 try-catch로 격리하거나, 커밋 이후 처리되는 아웃박스(Outbox) 패턴 사용을 권장합니다.

이어서 살펴볼 주제들

세 페이즈를 익혔다면 다음으로 확장해 보세요. 첫째, 커스텀 액션/펑션에 타입드 이벤트 컨텍스트(코드 생성된 ApproveContext 등)를 적용해 시그니처를 안전하게 만드는 방법. 둘째, ChangeSetContext와 트랜잭션 경계 — 여러 이벤트가 하나의 변경 집합으로 묶일 때의 롤백 동작. 셋째, Outbox 기반 비동기 후속 처리로 After의 한계를 보완하는 패턴. 넷째, Remote Service로 S/4HANA API를 On 핸들러에서 호출하는 사이드바이사이드 확장. 다섯째, Node.js 런타임과의 핸들러 모델 비교입니다.

더 읽어보면 좋은 문서

댓글 0

아직 댓글이 없습니다.