📖 이 글에서 다루는 내용
전통적인 Java 엔터프라이즈 개발에서는 이벤트 리스너 하나를 붙이려면 XML 설정 파일에 bean을 선언하고 wiring을 관리해야 했습니다. CAP Java(SAP Cloud Application Programming Model)는 이 과정을 뒤집었습니다. 애노테이션이 붙은 클래스를 작성하기만 하면 프레임워크가 시작 시점에 스스로 찾아 등록합니다. 이 글에서는 XML 기반 설정과 대비하여 CAP Java의 애노테이션 기반 핸들러 등록이 내부적으로 어떻게 동작하는지, 실무에서 어떤 이점을 주는지 물류 출고 도메인 예제로 살펴봅니다.
- XML bean wiring과 애노테이션 스캔 방식의 구조적 차이를 설명할 수 있다
- EventHandler 마커 인터페이스와 컴포넌트 스캔의 연결 고리를 이해한다
- 설정 파일 없이 검증·로깅·보안이 적용된 핸들러를 구현할 수 있다
- 핸들러가 등록되지 않는 상황을 스스로 진단할 수 있다
📚 시작 전에 갖추면 좋은 배경
Java 문법과 인터페이스 구현에 익숙해야 하며, Spring의 의존성 주입(DI) 개념을 대략 알고 있으면 이해가 빠릅니다. CDS 모델링 기초(entity, service 정의)와 Maven 빌드 경험이 있다면 예제를 그대로 따라 할 수 있습니다. Spring XML 설정 경험은 필수가 아니지만, 있다면 이 글의 대비 구도가 더 와닿을 것입니다.
🔧 개발 환경과 버전 구성
- CAP Java SDK 3.x (com.sap.cds:cds-services-bom 기준) — 2.x에서도 핸들러 등록 방식은 동일합니다
- Spring Boot 3.x, Java 17 이상 (SapMachine 17/21 권장)
- Maven 3.9+, Node.js 20+ (cds-dk는 CDS 컴파일용)
- SAP BTP Cloud Foundry 배포를 가정하지만 로컬 실행만으로도 확인 가능
프로젝트 뼈대는 mvn archetype:generate -DarchetypeArtifactId=cds-services-archetype으로 생성하는 것이 일반적입니다. 생성된 프로젝트에 XML 설정 파일이 하나도 없다는 점을 먼저 확인해 보세요. application.yaml은 존재하지만 런타임 프로퍼티일 뿐, bean wiring 정보는 담고 있지 않습니다.
💡 핵심 개념: XML wiring에서 애노테이션 스캔으로
과거 Spring XML 시대에는 객체 하나를 컨테이너에 올리려면 이렇게 선언해야 했습니다.
<!-- 전통적 방식: applicationContext.xml -->
<bean id="dispatchOrderHandler"
class="com.nexlogis.handlers.DispatchOrderHandler">
<property name="persistenceService" ref="persistenceService"/>
</bean>
<bean id="eventRegistry" class="com.legacy.EventRegistry">
<property name="listeners">
<list><ref bean="dispatchOrderHandler"/></list>
</property>
</bean>
클래스를 추가할 때마다 XML을 수정해야 하고, 클래스명을 리팩터링하면 XML 문자열도 함께 고쳐야 했습니다. 컴파일러가 XML 오타를 잡아주지 못해 런타임에야 오류를 발견하는 일도 잦았습니다.
CAP Java는 이를 3단 구조로 해결합니다. 비유하자면 "출입 명부에 수기로 이름을 적는 방식"에서 "사원증을 걸면 게이트가 자동 인식하는 방식"으로 바뀐 것입니다.
- 1단계 — 사원증 발급(@Component): Spring Boot 컴포넌트 스캔이 클래스패스를 훑어 bean으로 등록합니다. XML 선언을 대체하는 부분입니다.
- 2단계 — 소속 확인(EventHandler 인터페이스): CAP 런타임(cds-services)은 기동 시 ApplicationContext에서
com.sap.cds.services.handler.EventHandler마커 인터페이스를 구현한 bean만 골라냅니다. 수많은 bean 중 핸들러 후보를 추리는 필터입니다. - 3단계 — 게이트 배정(@On/@Before/@After + @ServiceName): 골라낸 bean의 메서드를 리플렉션으로 검사해 이벤트명·엔티티명과 클래스 레벨
@ServiceName을 읽고, 해당 서비스의 이벤트 디스패처에 메서드를 연결합니다.
즉 등록 정보가 별도 파일이 아니라 코드 자체에 메타데이터로 내장되고, 프레임워크가 기동 시점에 이를 읽어 런타임 레지스트리를 구성합니다. 핸들러 시그니처까지 이 시점에 검증되므로 잘못 작성하면 기동 단계에서 바로 실패합니다. XML 방식처럼 "특정 요청이 들어와야 비로소 터지는" 지연 오류가 크게 줄어드는 이유입니다.
💻 단계별 구현: 물류 출고 지시 서비스
가상의 물류 기업 NexLogis의 출고 지시(Dispatch Order) 관리 시나리오로 구현합니다.
1단계 — 기본: CDS 모델과 최소 핸들러
// db/schema.cds
namespace nexlogis.shipping;
entity DispatchOrder {
key ID : UUID;
orderNo : String(20);
carrierCode : String(10); // 운송사 코드
priority : Integer; // 1(긴급) ~ 5(일반)
status : String(15); // NEW, RELEASED, SHIPPED
plannedShipDate : Date;
totalWeightKg : Decimal(10,2);
}
// srv/dispatch-service.cds
using nexlogis.shipping as db from '../db/schema';
service DispatchService {
entity DispatchOrders as projection on db.DispatchOrder;
action releaseOrder(orderId : UUID) returns String;
}
@Component
@ServiceName("DispatchService")
public class DispatchOrderHandler implements EventHandler {
@Before(event = CqnService.EVENT_CREATE,
entity = "DispatchService.DispatchOrders")
public void presetStatus(List<DispatchOrders> orders) {
orders.forEach(o -> {
if (o.getStatus() == null) o.setStatus("NEW");
if (o.getPriority() == null) o.setPriority(5);
});
}
}
이것이 전부입니다. XML도, 등록 코드도 없습니다. mvn spring-boot:run으로 기동하면 CAP 런타임이 이 클래스를 찾아 CREATE 이벤트 앞단에 연결합니다.
2단계 — 실무: 검증 오류와 로깅 추가
실무에서는 잘못된 입력을 명확한 HTTP 상태와 메시지로 돌려줘야 합니다. ServiceException과 SLF4J 로거를 결합합니다.
@Component
@ServiceName("DispatchService")
public class DispatchValidationHandler implements EventHandler {
private static final Logger log =
LoggerFactory.getLogger(DispatchValidationHandler.class);
private static final Set<String> VALID_CARRIERS =
Set.of("KRX01", "SEA07", "AIR03");
@Before(event = { CqnService.EVENT_CREATE, CqnService.EVENT_UPDATE },
entity = "DispatchService.DispatchOrders")
public void validateCarrier(List<DispatchOrders> orders) {
for (DispatchOrders o : orders) {
if (o.getCarrierCode() != null
&& !VALID_CARRIERS.contains(o.getCarrierCode())) {
log.warn("잘못된 운송사 코드 요청: {}", o.getCarrierCode());
throw new ServiceException(ErrorStatuses.BAD_REQUEST,
"지원하지 않는 운송사 코드입니다: " + o.getCarrierCode());
}
if (o.getTotalWeightKg() != null
&& o.getTotalWeightKg().doubleValue() > 24000) {
throw new ServiceException(ErrorStatuses.BAD_REQUEST,
"단일 출고 중량 한도(24t)를 초과했습니다.");
}
}
}
}
핸들러 클래스를 하나 더 만들었지만 설정은 손대지 않았다는 점이 핵심입니다. 등록 비용이 0이므로 책임 단위로 핸들러를 잘게 나누는 설계가 자연스러워집니다.
3단계 — 프로덕션: 실행 순서·커스텀 액션·테스트
@Component
@ServiceName("DispatchService")
public class ReleaseOrderHandler implements EventHandler {
private final PersistenceService db;
public ReleaseOrderHandler(PersistenceService db) { this.db = db; }
@On(event = "releaseOrder")
@HandlerOrder(HandlerOrder.EARLY) // 같은 이벤트의 다른 @On보다 먼저
public void onRelease(ReleaseOrderContext ctx) {
var row = db.run(Select.from("nexlogis.shipping.DispatchOrder")
.where(o -> o.get("ID").eq(ctx.getOrderId())))
.first()
.orElseThrow(() -> new ServiceException(
ErrorStatuses.NOT_FOUND, "출고 지시를 찾을 수 없습니다."));
ctx.setResult("RELEASED:" + row.get("orderNo"));
ctx.setCompleted();
}
}
보안은 CDS 쪽에서 선언적으로 처리하는 방식이 일반적입니다. 서비스 정의에 @(requires: 'DispatchManager')를 붙이면 Java 코드 수정 없이 해당 롤 없는 호출이 차단됩니다. 테스트는 Spring 표준 도구를 그대로 씁니다.
@SpringBootTest
@AutoConfigureMockMvc
class DispatchValidationTest {
@Autowired MockMvc mvc;
@Test
void 잘못된_운송사코드는_400() throws Exception {
mvc.perform(post("/odata/v4/DispatchService/DispatchOrders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"orderNo\":\"DO-1001\",\"carrierCode\":\"XX999\"}"))
.andExpect(status().isBadRequest());
}
}
테스트 컨텍스트에서도 동일한 컴포넌트 스캔이 일어나므로 핸들러 등록용 테스트 전용 설정이 필요 없습니다. 성능 측면에서는 핸들러 안에서 행 단위 반복 조회를 피하고, 리스트를 한 번에 순회하거나 CQN 쿼리 한 번으로 묶어 읽는 패턴을 권장합니다.
⚠️ 자주 만나는 문제와 해결법
Q1. 핸들러 메서드가 전혀 호출되지 않습니다.
가장 흔한 원인은 클래스가 컴포넌트 스캔 범위 밖에 있는 경우입니다. Spring Boot는 기본적으로 @SpringBootApplication 클래스의 패키지 하위만 스캔하므로, 핸들러가 형제 패키지에 있으면 bean 자체가 만들어지지 않습니다. 패키지를 옮기거나 scanBasePackages를 확장하세요. EventHandler 구현을 빠뜨리면 bean은 생성돼도 CAP가 핸들러 후보로 인식하지 못합니다.
Q2. @ServiceName의 문자열 오타가 두렵습니다. XML 시절과 뭐가 다른가요?
문자열 대신 생성된 인터페이스 상수 DispatchService_.CDS_NAME을 쓰면 컴파일 타임에 검증됩니다. entity 파라미터도 DispatchOrders_.CDS_NAME으로 대체할 수 있습니다. XML은 이런 타입 안전 장치를 제공하지 못했습니다.
Q3. 같은 핸들러가 두 번 실행됩니다.
애노테이션 등록과 별개로 service.on(...) 같은 프로그래밍 방식 등록을 병행했는지 확인하세요. 두 경로가 겹치면 중복 등록됩니다. 일반적으로 애노테이션 방식 하나로 통일하는 것이 안전합니다.
Q4. 같은 페이즈 핸들러들의 실행 순서가 뒤죽박죽입니다.
@Before끼리의 순서는 기본적으로 정해져 있지 않다고 보는 편이 안전합니다. 순서가 중요하면 @HandlerOrder로 명시하고, 순서 의존 자체를 줄이는 설계를 우선 고려하세요.
🚀 여기서 더 나아가기
등록 구조를 이해했다면 다음 주제로 확장해 보세요. 첫째, @After 단계에서의 응답 데이터 가공과 계산 필드 채우기. 둘째, 커스텀 이벤트를 정의하고 EventContext를 상속해 타입 안전한 컨텍스트를 만드는 패턴. 셋째, 멀티테넌시 환경의 테넌트별 분기 처리. 넷째, Remote Service 호출을 핸들러 안에서 조합하는 side-by-side 확장까지 이어가면 CAP Java 이벤트 아키텍처의 전체 그림이 완성됩니다.
📚 더 깊이 볼 문서 모음
댓글 0
아직 댓글이 없습니다.