CAP Java Handler 자동 등록 모르면 큰일 #shorts #SAP #CAPforJava

Moderator
CAP Java Handler 자동 등록 타이틀 CAP Java Handler 자동 등록 핵심 포인트 CAP Java Handler 자동 등록 CTA

Handler 자동 등록이란? 왜 중요한가

캡(CAP) for Java에서 가장 강력하지만 동시에 가장 헷갈리는 개념이 바로 Handler 자동 등록(auto-registration)입니다. 아밥(ABAP)에서 BAdI나 Enhancement를 SE19/SE80으로 명시적으로 등록하던 방식과 달리, 캡 자바는 Spring Boot의 컴포넌트 스캔과 EventHandler 인터페이스를 결합해 핸들러를 자동으로 발견하고 런타임에 연결합니다. 이 메커니즘을 모르면 "왜 내 핸들러가 호출되지 않지?"라는 미스터리에 시간을 허비하게 됩니다.

학습 목표 체크리스트:

선수 지식

본 문서는 다음 지식을 전제로 합니다.

아밥 개발자라면 BAdI/Enhancement Spot을, 자바 백엔드 개발자라면 Spring AOP의 @Around 패턴을 떠올리면 직관적으로 접근할 수 있습니다.

환경 / 버전 / 준비물

본 튜토리얼은 다음 환경을 기준으로 작성되었습니다. 캡 자바는 비교적 빠르게 진화하므로 사용 중인 버전에 맞춰 일부 API가 다를 수 있다는 점을 일반적으로 염두에 두는 것이 권장됩니다.

프로젝트 골격은 cds init --add java 또는 mvn archetype:generate -DarchetypeArtifactId=cds-services-archetype로 만들면 자동 등록에 필요한 설정이 미리 구성됩니다.

핵심 개념: Spring 자동 발견 메커니즘

캡 자바의 런타임 철학은 한 문장으로 요약됩니다. "everything that happens at runtime is an event." CRUD 요청, 액션 호출, 드래프트 저장, 외부 시스템 연동 모두 이벤트로 추상화됩니다. 그리고 이 이벤트를 처리하는 코드 단위가 Event Handler입니다.

자동 등록은 두 개의 마커가 만났을 때만 작동합니다.

  1. @Component — Spring이 클래스를 빈으로 등록하고 컨텍스트에 보관
  2. implements EventHandler — 캡 런타임이 "이 빈은 이벤트 핸들러다"라고 식별하는 마커 인터페이스

도식으로 보면 다음과 같습니다.

[Spring Boot 시작]
      │
      ▼
[ComponentScan] ──검출──▶ @Component 클래스들
      │
      ▼
[CAP Runtime] ──필터──▶ implements EventHandler 인 빈만 선별
      │
      ▼
[메서드 스캔] ──@Before/@On/@After 어노테이션 메타데이터 수집
      │
      ▼
[ServiceCatalog 바인딩] ──@ServiceName + entity 속성으로 라우팅 테이블 구축
      │
      ▼
[요청 발생 시] EventContext → 매칭되는 핸들러 체인 호출

비유하자면 우체국 분류 시스템과 같습니다. @Component는 "이 사람을 직원으로 채용"하는 행위, implements EventHandler는 "분류 부서 소속"이라는 명찰, @ServiceName은 "담당 지역", @Before/@On/@After는 "처리 단계(접수/배달/사후처리)"에 해당합니다. 어느 하나라도 빠지면 우편물이 책상 위에 도착하지 않습니다.

실전 코드 3단계

1단계: 기본 예제 — 최소 핸들러

가장 단순한 형태로, Books 엔티티 생성 전에 가격을 검증하는 핸들러입니다. 이 한 클래스만 있어도 추가 등록 코드 없이 즉시 작동합니다.

package com.example.bookshop.handlers;

import org.springframework.stereotype.Component;
import com.sap.cds.services.cds.CqnService;
import com.sap.cds.services.handler.EventHandler;
import com.sap.cds.services.handler.annotations.Before;
import com.sap.cds.services.handler.annotations.ServiceName;

import cds.gen.adminservice.Books;
import cds.gen.adminservice.Books_;

import java.util.List;

@Component
@ServiceName("AdminService")
public class AdminServiceHandler implements EventHandler {

    @Before(event = CqnService.EVENT_CREATE, entity = Books_.CDS_NAME)
    public void validateBooks(List<Books> books) {
        for (Books b : books) {
            if (b.getPrice() == null || b.getPrice().signum() < 0) {
                throw new IllegalArgumentException("Price must be non-negative");
            }
        }
    }
}

주목할 점: Books_Books는 손으로 작성한 클래스가 아니라 cds-maven-plugin이 CDS 모델로부터 자동 생성한 것입니다. 컴파일 타임에 엔티티 이름 오타가 잡히는 이유입니다.

2단계: 실무 시나리오 — @On 처리 + 로깅 + 에러 분기

외부 결제 서비스 호출을 시뮬레이션하는 @On 핸들러입니다. @Before에서 검증, @On에서 핵심 처리, @After에서 감사 로그를 남기는 3단계 패턴을 보여줍니다.

package com.example.bookshop.handlers;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import com.sap.cds.services.ErrorStatuses;
import com.sap.cds.services.ServiceException;
import com.sap.cds.services.cds.CdsCreateEventContext;
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 cds.gen.adminservice.Orders;
import cds.gen.adminservice.Orders_;

import java.util.List;

@Component
@ServiceName("AdminService")
public class OrderHandler implements EventHandler {

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

    @Autowired
    private PersistenceService db;

    @Before(event = CqnService.EVENT_CREATE, entity = Orders_.CDS_NAME)
    public void validateOrders(List<Orders> orders) {
        orders.forEach(o -> {
            if (o.getQuantity() == null || o.getQuantity() <= 0) {
                throw new ServiceException(ErrorStatuses.BAD_REQUEST,
                        "Quantity must be positive");
            }
        });
    }

    @On(event = CqnService.EVENT_CREATE, entity = Orders_.CDS_NAME)
    public void handleOrderCreate(CdsCreateEventContext context, List<Orders> orders) {
        log.info("Processing {} order(s)", orders.size());
        // 핵심 처리: 재고 차감 등 도메인 로직
        // context.setCompleted() 를 호출하지 않으면 다음 @On 으로 체인 진행
    }

    @After(event = CqnService.EVENT_CREATE, entity = Orders_.CDS_NAME)
    public void auditOrders(List<Orders> orders) {
        orders.forEach(o -> log.info("Order {} created", o.getId()));
    }
}

여기서 ServiceException은 OData 에러 페이로드로 자동 변환되어 클라이언트에 적절한 HTTP 상태 코드와 함께 반환됩니다.

3단계: 프로덕션 — @HandlerOrder + 권한 + 단위 테스트

실행 순서를 강제하고, Spring Security 기반 역할 검사를 추가하며, 테스트 가능하도록 분리한 형태입니다.

package com.example.bookshop.handlers;

import com.sap.cds.services.handler.annotations.HandlerOrder;
import com.sap.cds.services.handler.annotations.Before;
import com.sap.cds.services.handler.annotations.ServiceName;
import com.sap.cds.services.handler.EventHandler;
import com.sap.cds.services.cds.CqnService;
import com.sap.cds.services.request.UserInfo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import cds.gen.adminservice.Orders_;

@Component
@ServiceName("AdminService")
public class SecurityHandler implements EventHandler {

    @Autowired
    private UserInfo userInfo;

    @Before(event = CqnService.EVENT_CREATE, entity = Orders_.CDS_NAME)
    @HandlerOrder(HandlerOrder.EARLY)
    public void checkRole() {
        if (!userInfo.hasRole("admin")) {
            throw new SecurityException("admin role required");
        }
    }
}

JUnit 5 + Spring Boot Test로 검증하는 예시입니다.

@SpringBootTest
@AutoConfigureMockMvc
class OrderHandlerTest {

    @Autowired
    private MockMvc mvc;

    @Test
    @WithMockUser(roles = "admin")
    void createOrder_negativeQuantity_returns400() throws Exception {
        mvc.perform(post("/odata/v4/AdminService/Orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"quantity\": -1}"))
           .andExpect(status().isBadRequest());
    }
}

EARLY 순서 덕분에 권한 검사가 검증 로직보다 먼저 실행되어, 권한 없는 사용자에게는 입력값 정보조차 노출되지 않습니다.

흔한 실수 / 트러블슈팅 FAQ

Q1. 핸들러 메서드가 호출되지 않습니다. 90% 이상은 다음 중 하나입니다. (1) implements EventHandler를 빠뜨림 — Spring 빈으로는 등록되지만 캡 런타임이 인식하지 못합니다. (2) @Component가 누락 — 컴포넌트 스캔 대상이 아님. (3) @ServiceName 문자열 오타 — service.cds의 service 이름과 정확히 일치해야 합니다. 로그에서 Registered handler 메시지가 출력되는지 먼저 확인하세요.

Q2. entity 속성에 문자열 리터럴을 써도 되나요? 동작은 합니다만 권장되지 않습니다. Books_.CDS_NAME 같은 생성된 상수를 쓰면 CDS 모델 변경 시 컴파일 에러로 잡혀 안전합니다. Maven 플러그인이 만든 cds.gen.* 패키지를 일반적으로 활용하세요.

Q3. 같은 이벤트에 핸들러가 여러 개면 순서가 어떻게 되나요? 기본은 정의되지 않은 순서이며, @HandlerOrder(HandlerOrder.EARLY) 또는 HandlerOrder.LATE, 그리고 정수 값으로 명시할 수 있습니다. 같은 우선순위 내에서는 클래스 로딩 순서에 의존하므로, 순서가 중요한 로직은 반드시 명시적으로 지정하는 것이 권장됩니다.

Q4. @On 핸들러가 두 개면 둘 다 실행되나요? 첫 번째 @On 핸들러가 context.setCompleted()를 호출하면 체인이 종료됩니다. Application Service의 기본 CRUD 핸들러를 덮어쓰려면 명시적으로 setCompleted를 호출해야 한다는 점을 기억하세요.

Q5. Persistence Service를 주입했는데 NPE가 납니다. 생성자 주입이 아닌 필드 @Autowired를 쓸 때는 객체를 new로 직접 생성하지 말고 항상 Spring 컨테이너를 통해 가져와야 합니다. 테스트에서는 @SpringBootTest 또는 @MockBean을 활용합니다.

다음 단계 / 관련 주제

자동 등록을 이해했다면 다음 주제로 확장해보는 것이 자연스럽습니다.

참고 자료

핵심 한 줄

@Component + implements EventHandler — 이 두 마커가 만나야 캡 자바 런타임이 핸들러를 자동으로 발견하고, @ServiceName과 @Before/@On/@After 메타데이터로 라우팅 테이블을 짠다. 둘 중 하나라도 빠지면 코드는 컴파일되지만 호출되지 않는다는 사실이, 캡 자바 핸들러 디버깅의 출발점입니다.