개요: 핸들러 실행 순서를 알아야 하는 이유
CAP Java(SAP Cloud Application Programming Model, Java 런타임)에서 커스텀 로직은 @Before, @On, @After 세 가지 페이즈(Phase)의 이벤트 핸들러로 작성합니다. 문제는 이 세 페이즈의 실행 순서와 각 페이즈의 역할을 정확히 이해하지 못하면, "저장 직전에 넣은 기본값이 DB에 없다", "핸들러가 두 번 실행된다" 같은 원인 파악이 어려운 버그가 생긴다는 점입니다. 이 글에서는 실행 순서의 동작 원리부터 실무 코드 패턴까지 단계별로 정리합니다.
- Before / On / After 페이즈의 정확한 실행 순서와 각 페이즈의 책임 이해
- On 페이즈의 "이벤트 완료(completion)" 메커니즘과 기본 핸들러 스킵 조건 파악
@HandlerOrder로 동일 페이즈 내 순서를 제어하는 방법 습득- 페이즈 오용으로 생기는 대표 버그 3가지와 해결 패턴 정리
미리 알아두면 좋은 배경
이 글은 중급 수준의 내용을 다루므로 다음 경험이 있으면 이해가 빠릅니다. Java 17 이상 문법과 Spring Boot의 @Component 빈 등록 개념, CDS 모델링 기초(entity, service 정의), 그리고 CAP 프로젝트를 mvn spring-boot:run으로 한 번이라도 구동해 본 경험입니다. CQL(CDS Query Language) 기초를 알면 3단계 예제가 더 수월합니다.
환경 및 버전 준비
이 글의 예제는 다음 환경을 기준으로 작성했습니다. 버전이 다르면 API 시그니처가 일부 다를 수 있으니 확인을 권장합니다.
- CAP Java SDK: 3.x 계열 (예:
com.sap.cds:cds-services-api3.4 이상) - Java: 17 이상 (CAP Java 3.x의 최소 요구 버전)
- Spring Boot: 3.x (cds-starter-spring-boot 사용)
- 빌드 도구: Maven, 로컬 DB는 H2 또는 SQLite, 운영은 SAP HANA Cloud를 일반적으로 사용
프로젝트가 없다면 mvn archetype:generate -DarchetypeArtifactId=cds-services-archetype으로 골격을 만들 수 있습니다. 핸들러 클래스는 Spring 컴포넌트 스캔 대상 패키지에 두어야 자동 등록됩니다.
핵심 개념: 세 개의 관문을 통과하는 요청
CAP Java에서 하나의 이벤트(READ, CREATE, 커스텀 액션 등)는 공항 탑승 절차에 비유할 수 있습니다. Before는 보안 검색대(검증·인가·데이터 보강), On은 실제 탑승/비행(요청을 실제로 수행하고 결과를 만드는 단계), After는 도착 후 수하물 수취(만들어진 결과의 후처리)입니다. 검색대를 통과하지 못하면 비행 자체가 없고, 비행이 실패하면 수하물 수취도 없습니다.
실행 흐름을 도식으로 표현하면 다음과 같습니다.
요청 → @Before(모두 실행) → @On(완료될 때까지 순차 실행, 완료 시 나머지 스킵) → @After(모두 실행) → 응답
※ 어느 단계에서든 예외가 발생하면 이후 페이즈는 실행되지 않음
여기서 가장 중요한 것이 On 페이즈의 완료(completion) 메커니즘입니다. Before와 After는 등록된 핸들러가 전부 실행되지만, On 페이즈는 다릅니다. 어떤 On 핸들러가 EventContext에 결과를 설정(setResult())하거나 메서드에서 값을 반환하면 이벤트가 "완료" 상태가 되고, 같은 페이즈의 나머지 On 핸들러(프레임워크의 제네릭 영속성 핸들러 포함)는 실행되지 않습니다. 반대로 커스텀 On 핸들러가 아무것도 완료하지 않으면 흐름은 다음 On 핸들러로 넘어가 최종적으로 CAP의 기본 DB 핸들러가 처리합니다. 이 규칙을 모르면 "내 핸들러도 실행되고 DB 저장도 또 일어난다" 혹은 "기본 저장이 아예 스킵된다" 같은 현상을 만나게 됩니다.
동일 페이즈 안에서의 순서는 기본적으로 보장되지 않으므로, 순서 의존성이 있다면 @HandlerOrder로 명시해야 합니다. 값이 작을수록 먼저 실행되며, 프레임워크 제공 핸들러는 커스텀 핸들러보다 극단값(매우 이르거나 매우 늦은 순서)에 배치되는 것이 일반적입니다.
| 페이즈 | 실행 범위 | 주 용도 | 주의점 |
|---|---|---|---|
| @Before | 등록된 전체 | 검증, 기본값 세팅, 인가 | 여기서 수정한 데이터가 DB에 반영됨 |
| @On | 완료 시점까지 | 비즈니스 로직 본체, 외부 시스템 위임 | 완료 여부에 따라 기본 핸들러 스킵 |
| @After | 등록된 전체 | 결과 보강, 후속 이벤트 발행 | 여기서의 수정은 응답에만 반영, DB 미반영 |
실전 예제 1단계: 실행 순서를 눈으로 확인하기
구매오더(PurchaseOrder) 서비스를 가정하고, 세 페이즈에 로그만 남겨 순서를 확인합니다. CDS 서비스 정의는 다음과 같습니다.
service ProcurementService {
entity PurchaseOrders {
key ID : UUID;
supplier : String(80);
netAmount : Decimal(15,2);
status : String(20);
}
}
@Component
@ServiceName("ProcurementService")
public class PurchaseOrderHandler implements EventHandler {
private static final Logger log =
LoggerFactory.getLogger(PurchaseOrderHandler.class);
@Before(event = CqnService.EVENT_CREATE, entity = "ProcurementService.PurchaseOrders")
public void beforeCreate(List<PurchaseOrders> orders) {
log.info("[1] BEFORE: 검증/기본값 단계");
orders.forEach(po -> po.setStatus("NEW")); // DB에 저장됨
}
@On(event = CqnService.EVENT_CREATE, entity = "ProcurementService.PurchaseOrders")
public void onCreate(CdsCreateEventContext ctx) {
log.info("[2] ON: 완료하지 않으므로 기본 영속성 핸들러가 이어서 실행");
// ctx.setResult(...) 를 호출하지 않음 → CAP 기본 DB 저장 수행
}
@After(event = CqnService.EVENT_CREATE, entity = "ProcurementService.PurchaseOrders")
public void afterCreate(List<PurchaseOrders> orders) {
log.info("[3] AFTER: 응답 후처리 단계");
}
}
POST 요청을 보내면 로그가 항상 [1] → [2] → (기본 저장) → [3] 순으로 출력됩니다. 핵심은 onCreate가 결과를 설정하지 않았기 때문에 프레임워크의 기본 저장 로직이 그대로 실행된다는 점입니다.
실전 예제 2단계: 검증 실패 처리와 결과 보강
금액 상한 검증을 Before에서 수행하고, 실패 시 ServiceException으로 요청을 중단합니다. 예외가 발생하면 On/After는 실행되지 않고 클라이언트는 HTTP 400 계열 응답을 받습니다.
@Before(event = CqnService.EVENT_CREATE, entity = "ProcurementService.PurchaseOrders")
public void validateAmount(List<PurchaseOrders> orders, EventContext ctx) {
for (PurchaseOrders po : orders) {
if (po.getNetAmount() == null) {
throw new ServiceException(ErrorStatuses.BAD_REQUEST,
"netAmount는 필수입니다");
}
if (po.getNetAmount().compareTo(new BigDecimal("500000")) > 0) {
log.warn("한도 초과 시도: user={}, amount={}",
ctx.getUserInfo().getName(), po.getNetAmount());
throw new ServiceException(ErrorStatuses.BAD_REQUEST,
"구매오더 한도(500,000)를 초과했습니다");
}
}
}
@After(event = CqnService.EVENT_READ, entity = "ProcurementService.PurchaseOrders")
public void enrichOnRead(List<PurchaseOrders> orders) {
// 응답에만 반영되는 계산 필드 성격의 보강 — DB에는 쓰지 않음
orders.forEach(po -> {
if ("NEW".equals(po.getStatus()) && po.getNetAmount() != null
&& po.getNetAmount().compareTo(new BigDecimal("100000")) > 0) {
po.setStatus("NEW_REVIEW_REQUIRED");
}
});
}
주의할 점: 위 enrichOnRead처럼 After에서 바꾼 값은 응답 payload에만 반영됩니다. 만약 이 상태값을 DB에 저장해야 한다면 CREATE/UPDATE의 Before 페이즈로 옮겨야 합니다. 이것이 "After에서 세팅했는데 DB에 없다"는 버그의 정체입니다.
실전 예제 3단계: 순서 제어, On 완료 처리, 테스트
커스텀 액션 approve를 On 핸들러가 완전히 대체 처리하고, @HandlerOrder로 동일 페이즈 내 순서를 고정합니다.
@Before(event = "approve", entity = "ProcurementService.PurchaseOrders")
@HandlerOrder(HandlerOrder.EARLY) // 인가 검사는 가장 먼저
public void checkAuthority(EventContext ctx) {
if (!ctx.getUserInfo().hasRole("po.approver")) {
throw new ServiceException(ErrorStatuses.FORBIDDEN, "승인 권한이 없습니다");
}
}
@On(event = "approve", entity = "ProcurementService.PurchaseOrders")
public void onApprove(ApproveContext ctx, PersistenceService db) {
CqnUpdate update = Update.entity("ProcurementService.PurchaseOrders")
.where(po -> po.get("ID").eq(ctx.getCqn().ref()))
.data(Map.of("status", "APPROVED"));
db.run(update);
ctx.setCompleted(); // 반드시 완료 처리 — 누락 시 "No handler" 오류
}
테스트는 Spring MockMvc로 페이즈 전체 흐름을 검증하는 방식을 권장합니다.
@SpringBootTest
@AutoConfigureMockMvc
class PurchaseOrderHandlerTest {
@Autowired MockMvc mvc;
@Test
void 한도초과_생성은_400을_반환한다() throws Exception {
mvc.perform(post("/odata/v4/ProcurementService/PurchaseOrders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"supplier\":\"ACME\",\"netAmount\":900000}"))
.andExpect(status().isBadRequest());
}
}
성능 관점에서는 Before/After가 리스트 단위로 호출된다는 점을 활용해 루프 안에서 개별 DB 조회를 하지 말고, 필요한 데이터를 한 번의 쿼리로 모아서 조회하는 패턴을 권장합니다. 보안 관점에서는 인가 검사를 HandlerOrder.EARLY의 Before에 배치해 불필요한 로직 실행 전에 차단하는 것이 일반적입니다.
흔한 실수와 트러블슈팅 FAQ
Q1. Before에서 세팅한 값은 저장되는데 After에서 세팅한 값은 왜 DB에 없나요?
A. On 페이즈(기본 영속성 핸들러)가 DB 쓰기를 수행하는 시점은 Before 이후, After 이전입니다. After는 이미 저장이 끝난 뒤의 "결과 객체"만 다루므로 여기서의 수정은 응답에만 반영됩니다. DB 반영이 목적이면 Before로 옮기거나, After에서 명시적으로 PersistenceService 업데이트를 실행해야 합니다(후자는 추가 쿼리 비용 발생).
Q2. 커스텀 액션에서 "No ON handler" 류의 오류가 발생합니다.
A. 커스텀 액션/펑션은 CAP이 대신 처리해 줄 기본 핸들러가 없습니다. On 핸들러에서 반드시 값을 반환하거나 ctx.setResult() / ctx.setCompleted()로 완료 처리를 해야 합니다. 반대로 CRUD 이벤트의 On 핸들러에서 실수로 완료 처리를 하면 기본 DB 저장이 스킵되어 "응답은 성공인데 데이터가 없는" 상황이 됩니다.
Q3. 같은 페이즈의 핸들러 두 개가 실행될 때마다 순서가 달라집니다.
A. 동일 페이즈 내 순서는 기본적으로 비결정적입니다. 순서 의존성이 있다면 @HandlerOrder(작은 값이 먼저)를 명시하세요. 가능하면 핸들러 간 의존성 자체를 없애는 설계가 더 안전합니다.
Q4. 예외 발생 시 After에 넣어둔 정리 로직이 실행되지 않습니다.
A. 의도된 동작입니다. Before나 On에서 예외가 던져지면 이후 페이즈는 실행되지 않고 트랜잭션도 롤백됩니다. 성공/실패와 무관하게 실행돼야 하는 로직은 After가 아니라 트랜잭션 훅이나 별도 아웃박스 패턴으로 처리하는 것을 권장합니다.
실무 적용 시 핵심 체크리스트
- 검증·인가·기본값 세팅은 반드시 Before에 배치 (DB 반영이 필요한 경우)
- 커스텀 액션/펑션의 On 핸들러에는 반드시
ctx.setCompleted()또는 결과 반환 - After에서 수정한 값이 "왜 DB에 없지?"라면 → Before로 이동 검토
- 동일 페이즈 내 순서 의존성이 있으면
@HandlerOrder명시 - 핸들러 리스트 처리 시 루프 안 DB 조회는 N+1 문제 발생 → 배치 조회로 전환
- Before에서 예외를 던지면 이후 On/After는 전혀 실행되지 않음을 활용해 조기 종료 패턴 구현
이어서 살펴볼 주제들
실행 순서를 이해했다면 다음 주제로 확장하는 것을 권장합니다. 첫째, 타입드 이벤트 컨텍스트(코드 생성으로 만들어지는 ApproveContext 같은 인터페이스)로 핸들러 시그니처를 안전하게 만드는 방법. 둘째, ChangeSetContext와 트랜잭션 경계 — 여러 이벤트가 하나의 변경 집합으로 묶일 때의 롤백 동작. 셋째, Outbox를 통한 비동기 후속 처리로 After 페이즈의 한계를 보완하는 패턴. 넷째, CAP Node.js 런타임과의 핸들러 모델 차이 비교도 멀티 런타임 팀이라면 유용합니다.
댓글 0
아직 댓글이 없습니다.