📖 개요: Before와 After, 이름만 보고 쓰면 반드시 사고가 난다
CAP Java에서 커스텀 핸들러를 처음 작성할 때 가장 많이 겪는 사고는 문법 오류가 아니라 "코드는 정상 실행됐는데 DB에는 아무 것도 반영되지 않는" 침묵의 버그입니다. 특히 @After 핸들러에서 엔티티 값을 바꾸거나 JPA 스타일로 persist를 시도하는 안티패턴이 대표적입니다. 이 글은 구매오더(Purchase Order) 시나리오를 바탕으로 실무 gotcha를 안티패턴 중심으로 해부합니다.
- @After에서 값을 바꿔도 DB에 저장되지 않는 구조적 이유
- @Before에서 입력 데이터를 "제대로" 변경하는 방법과 흔한 실수
- @On에서 setResult()를 빠뜨리면 생기는 일
- @Before 핸들러가 여러 개일 때 실행 순서 제어(@HandlerOrder)
📚 이 글을 읽기 전에 알아두면 좋은 것
CDS 모델링 기초(entity, service 정의), Java와 Spring Boot의 컴포넌트/DI 개념, 그리고 CAP의 요청이 OData → 서비스 계층 → Persistence Service로 흐른다는 큰 그림을 알고 있으면 충분합니다. JPA 경험자는 오히려 dirty checking 습관 때문에 함정에 빠지기 쉬우니 주의가 필요합니다.
🔧 검증 환경과 준비물
이 글의 예제는 다음 환경을 기준으로 작성했습니다.
- CAP Java SDK 3.x (com.sap.cds:cds-services-bom 기반), Java 17, Spring Boot 3.x
- 로컬 개발: cds-maven-plugin + H2/SQLite, 배포 대상: SAP BTP Cloud Foundry 환경 + SAP HANA Cloud
- 프로젝트 생성:
mvn archetype:generate -DarchetypeArtifactId=cds-services-archetype또는cds init --java
CAP Java 2.x에서도 핸들러 개념은 동일하지만, 3.x부터 Spring Boot 3/Jakarta 기반으로 바뀌었으므로 패키지 임포트가 다를 수 있습니다. 일반적으로 신규 프로젝트는 3.x 사용이 권장됩니다.
💡 핵심 개념: 이벤트 처리 3단계와 "트랜잭션 열차" 비유
CAP Java는 모든 요청(READ, CREATE, UPDATE, 커스텀 액션 등)을 이벤트로 취급하고, 하나의 이벤트를 세 단계(phase)로 나눠 핸들러를 실행합니다.
| 단계 | 역할 | 실무 용도 |
|---|---|---|
@Before | 본 처리 이전. 입력 데이터가 아직 DB로 가기 전 | 검증, 기본값 채우기, 파생 필드 계산 |
@On | 본 처리 자체. 기본적으로 Persistence Service가 INSERT/SELECT 실행 | 기본 동작 교체, 커스텀 액션 구현 |
@After | 본 처리 이후. DB 작업이 이미 끝난 상태 | 응답 데이터 가공, 후속 이벤트 발행 |
기차역 비유로 이해하면 명확합니다. @Before는 출발 전 플랫폼입니다. 여기서 승객(입력 데이터)의 짐을 바꾸면 그 상태 그대로 열차(Persistence Service)에 실려 DB까지 갑니다. @After는 열차가 이미 종착역(DB)에 도착한 뒤입니다. 이 시점에 승객의 짐을 바꿔봐야 열차는 되돌아가지 않습니다. 바뀐 짐은 오직 "응답이라는 사진"에만 찍힐 뿐입니다.
또 하나 중요한 사실: CAP Java에는 JPA 같은 영속성 컨텍스트나 dirty checking이 없습니다. 핸들러가 다루는 데이터는 Map<String, Object> 기반의 CdsData이며, 프레임워크가 트랜잭션 커밋 시점에 변경분을 자동 감지해 UPDATE를 날려주는 메커니즘 자체가 존재하지 않습니다. "객체를 수정했으니 커밋 때 알아서 저장되겠지"라는 JPA식 기대가 CAP에서 침묵의 버그를 만드는 근본 원인입니다.
💻 실전 코드: 안티패턴 → 올바른 패턴 3단계
먼저 공통으로 사용할 구매오더 도메인 모델입니다.
// db/schema.cds
namespace corp.procure;
entity PurchaseOrders {
key ID : UUID;
orderNo : String(20);
buyer : Association to Customers;
netAmount : Decimal(15,2);
taxAmount : Decimal(15,2);
grossAmount : Decimal(15,2);
status : String(2) default 'N'; // N=신규, A=승인
}
entity Customers {
key ID : UUID;
name : String(100);
vip : Boolean default false;
}
// srv/procure-service.cds
service ProcureService {
entity PurchaseOrders as projection on corp.procure.PurchaseOrders
actions { action approve() returns String; };
}
1단계 — 대표 안티패턴: @After에서 값 변경/저장 시도
@Component
@ServiceName("ProcureService")
public class OrderHandlerBad implements EventHandler {
// ❌ 안티패턴: CREATE가 끝난 뒤 세액을 계산해서 넣으면 저장될 것이라 기대
@After(event = CqnService.EVENT_CREATE, entity = "ProcureService.PurchaseOrders")
public void afterCreate(List<PurchaseOrders> orders) {
for (PurchaseOrders po : orders) {
po.setTaxAmount(po.getNetAmount().multiply(new BigDecimal("0.1")));
po.setGrossAmount(po.getNetAmount().add(po.getTaxAmount()));
// JPA 습관: em.persist(po) 같은 것을 찾게 되지만 CAP에는 없다.
// 이 변경은 "이번 HTTP 응답 JSON"에만 나타나고 DB의 taxAmount는 NULL이다.
}
}
}
왜 반영되지 않을까요? @After 시점에는 @On 단계의 Persistence Service가 이미 INSERT를 실행 완료했기 때문입니다. 핸들러 인자로 받은 List<PurchaseOrders>는 "DB에 쓴 결과를 응답용으로 들고 있는 스냅샷"이며, 여기에 setter를 호출해도 응답 페이로드만 바뀝니다. 트랜잭션이 아직 열려 있으니 db.run(Update...)를 직접 실행하면 물리적으로는 저장이 되지만, 같은 데이터를 두 번 쓰는 이중 쓰기이므로 성능·일관성 면에서 피해야 할 패턴입니다.
2단계 — 올바른 패턴: @Before에서 입력 데이터 변경 + 검증/로깅
@Component
@ServiceName("ProcureService")
public class OrderHandlerGood implements EventHandler {
private static final Logger log = LoggerFactory.getLogger(OrderHandlerGood.class);
// ✅ 파라미터로 받은 리스트는 CQN INSERT의 실제 entries와 연결되어 있어
// 수정하면 그대로 DB에 INSERT된다.
@Before(event = CqnService.EVENT_CREATE, entity = "ProcureService.PurchaseOrders")
public void enrichOrder(List<PurchaseOrders> orders) {
for (PurchaseOrders po : orders) {
if (po.getNetAmount() == null || po.getNetAmount().signum() <= 0) {
throw new ServiceException(ErrorStatuses.BAD_REQUEST,
"netAmount는 0보다 커야 합니다: {}", po.getOrderNo());
}
BigDecimal tax = po.getNetAmount().multiply(new BigDecimal("0.1"));
po.setTaxAmount(tax); // DB에 반영됨
po.setGrossAmount(po.getNetAmount().add(tax)); // DB에 반영됨
log.info("PO enrich 완료 orderNo={}, gross={}", po.getOrderNo(), po.getGrossAmount());
}
}
// ✅ @After의 올바른 용도: DB에는 없는 "응답 전용" 가공
@After(event = CqnService.EVENT_READ, entity = "ProcureService.PurchaseOrders")
public void maskForDisplay(List<PurchaseOrders> orders) {
orders.forEach(po -> po.put("displayNo", "PO-" + po.getOrderNo()));
}
}
주의할 변형 실수가 있습니다. ctx.getCqn().entries()를 꺼내 스트림으로 새 Map을 만들어 수정하면 복사본만 바뀌고 원본 CQN은 그대로입니다. 새 데이터로 통째로 바꾸고 싶다면 수정한 entries로 Insert 문을 다시 만들어 ctx.setCqn(...)으로 교체해야 합니다. 일반적으로는 위처럼 핸들러 파라미터의 POJO 리스트를 직접 수정하는 방식이 가장 안전하고 권장됩니다.
3단계 — 프로덕션 수준: @On의 setResult()와 @HandlerOrder
@Component
@ServiceName("ProcureService")
public class OrderProductionHandler implements EventHandler {
@Autowired
private PersistenceService db;
// ❌ setResult() 누락 → 이벤트가 '완료'되지 않아 다음 @On 핸들러로 넘어가거나,
// 처리 주체가 없으면 빈 응답/오류가 된다.
// ✅ 커스텀 액션 @On은 반드시 setResult() 또는 setCompleted()로 완료를 선언한다.
@On(event = "approve", entity = "ProcureService.PurchaseOrders")
public void onApprove(EventContext raw) {
var ctx = raw.as(ApproveContext.class); // cds-maven-plugin이 생성한 액션 컨텍스트
String id = (String) analyzeTargetId(ctx); // CqnAnalyzer로 키 추출(생략)
db.run(Update.entity(PurchaseOrders_.class)
.data(Map.of("status", "A"))
.where(po -> po.ID().eq(id)));
ctx.setResult("승인 완료: " + id); // 이 한 줄이 빠지면 응답이 비어버린다
}
// 여러 @Before가 한 이벤트에 걸릴 때: 순서가 보장되지 않으므로 명시한다.
@Before(event = CqnService.EVENT_CREATE, entity = "ProcureService.PurchaseOrders")
@HandlerOrder(HandlerOrder.EARLY) // 인증/권한 등 게이트 역할은 먼저
public void checkBuyerAuthorization(EventContext ctx, List<PurchaseOrders> orders) {
// UserInfo 검사 후 미인가 시 ServiceException(FORBIDDEN)
}
@Before(event = CqnService.EVENT_CREATE, entity = "ProcureService.PurchaseOrders")
@HandlerOrder(HandlerOrder.LATE) // 파생값 계산은 다른 Before가 끝난 뒤
public void finalizeAmounts(List<PurchaseOrders> orders) { /* 세액 확정 */ }
}
포인트를 정리하면 이렇습니다. 첫째, @On 핸들러는 setResult()(또는 setCompleted())를 호출해야 이벤트가 완료 처리됩니다. 호출하지 않고 반환하면 프레임워크는 "아직 처리 안 됨"으로 보고 다음 @On 핸들러(예: 기본 Persistence 핸들러)에게 넘기며, 커스텀 액션처럼 기본 핸들러가 없는 이벤트라면 빈 응답이나 미처리 오류로 이어집니다. 둘째, 같은 단계의 핸들러가 여러 개면 실행 순서는 정의되지 않으므로 @HandlerOrder로 명시하는 것이 안전합니다. 값이 작을수록 먼저 실행되며 EARLY / DEFAULT / LATE 상수가 제공됩니다. 셋째, 검증 실패는 ServiceException을 던지면 트랜잭션이 롤백되고 OData 오류 응답으로 변환되므로, 별도의 수동 롤백 코드는 필요하지 않습니다.
⚠️ 흔한 실수와 트러블슈팅 FAQ
Q1. @After에서 필드를 수정했는데 재조회하면 값이 없습니다.
정상 동작입니다. @After의 데이터 수정은 응답 페이로드에만 반영됩니다. DB에 저장할 값이면 @Before로 옮기세요. "저장 후에만 알 수 있는 값"(예: DB 생성 키 기반 문서번호)이라면 @After에서 명시적으로 db.run(Update...)를 실행할 수는 있지만, 가능하면 설계를 바꿔 @Before에서 UUID를 미리 채우는 편이 권장됩니다.
Q2. @Before에서 entries()를 수정했는데 INSERT에 반영이 안 됩니다.
entries를 스트림/복사로 새 컬렉션에 담아 수정하지 않았는지 확인하세요. 복사본 수정은 원본 CQN에 영향이 없습니다. 핸들러 시그니처에 List<PurchaseOrders>처럼 타입드 파라미터를 선언해 그 객체를 직접 수정하거나, 새 Insert를 만들어 ctx.setCqn()으로 교체해야 합니다.
Q3. 커스텀 액션이 항상 null/빈 값을 반환합니다.
@On 핸들러에서 ctx.setResult() 호출 여부를 먼저 확인하세요. 메서드에서 값을 return해도 EventContext 기반 시그니처에서는 결과로 인식되지 않습니다.
Q4. Before 핸들러 두 개가 환경(로컬 vs BTP)에 따라 다른 순서로 실행됩니다.
순서 미지정 핸들러의 실행 순서는 정의되지 않은 동작입니다. 순서 의존성이 있다면 @HandlerOrder를 반드시 지정하고, 이상적으로는 순서에 의존하지 않게 각 핸들러를 독립적으로 설계하세요.
🚀 여기서 더 나아가기
이벤트 3단계를 체득했다면 다음 주제로 확장해 보세요. 트랜잭션 커밋 후 실행이 필요한 로직을 위한 ChangeSetListener(@After와의 차이가 핵심), 비동기 사이드이펙트를 위한 CAP Messaging(이벤트 발행/구독), 감사 필드를 자동 처리하는 @cds.on.insert/@cds.on.update 어노테이션, 그리고 대량 데이터 처리 시 @Before에서의 배치 검증 성능 최적화가 자연스러운 다음 코스입니다.
📚 더 읽어볼 문서
댓글 0
아직 댓글이 없습니다.