CAP for Java

CAP Java 롤백 catch 그만 — ChangeSet 격리 #shorts #SAP #CAPJava

▶ YouTube에서 보기

📖 개요: CAP Java 트랜잭션 롤백, 왜 자꾸 어긋나는가

SAP CAP(Cloud Application Programming Model) for Java는 개발자가 트랜잭션 경계를 직접 열고 닫지 않아도 되도록 ChangeSet 컨텍스트라는 추상화를 제공합니다. 편리하지만, 이 자동화를 오해하면 "예외를 잡았는데 커밋이 실패한다", "@After 핸들러에서 던졌는데 데이터가 남아 있다" 같은 문제를 만나게 됩니다. 이 글은 판매 주문(SalesOrder) 확정 시나리오를 통해 롤백이 실제로 일어나는 지점과 흔한 실수를 짚어봅니다.

  • ✅ ChangeSet 컨텍스트와 DB 트랜잭션의 관계 이해
  • ✅ 예외 전파에 의한 자동 롤백과 markForCancel()의 차이 구분
  • ✅ 예외를 catch했을 때 발생하는 TransactionException의 원인과 해결
  • ✅ 커밋 이후 로직(ChangeSetListener)과 롤백 불가 구간 구분
  • ✅ 롤백 동작을 검증하는 통합 테스트 작성

📚 미리 알고 있으면 좋은 것들

Java와 Spring Boot의 기본 사용법, CDS 모델링(entity, service 정의)의 기초, 그리고 CAP Java의 이벤트 핸들러(@Before/@On/@After) 개념을 알고 있다면 수월합니다. JDBC 트랜잭션(commit/rollback)의 일반 개념도 전제로 합니다. Spring의 @Transactional을 써본 경험이 있다면 비교하며 읽기 좋습니다.

🔧 환경 구성과 준비물

이 글의 예제는 다음 환경을 기준으로 작성했습니다.

  • CAP Java SDK 3.x (com.sap.cds:cds-services-*), Maven 아키타입 cds-services-archetype로 생성한 프로젝트
  • Java 17 이상, Spring Boot 3.x
  • 로컬 개발: H2 인메모리 DB / 배포: SAP BTP Cloud Foundry 환경 + SAP HANA Cloud
  • @sap/cds-dk(cds CLI) — CDS 모델 컴파일용

CAP Java는 Spring의 트랜잭션 매니저와 통합되어 동작하므로, 별도의 트랜잭션 설정 없이 PersistenceService를 주입받아 사용하는 것이 일반적입니다. 버전에 따라 API 패키지가 다를 수 있으니 2.x 이하를 쓰고 있다면 마이그레이션 가이드를 먼저 확인하는 것을 권장합니다.

💡 핵심 개념: ChangeSet은 "택배 상자", 트랜잭션은 "송장"

CAP Java에서 하나의 요청(예: OData POST)은 하나의 ChangeSet 컨텍스트 안에서 처리됩니다. ChangeSet은 택배 상자에 비유할 수 있습니다. 핸들러 체인(@Before → @On → @After)에서 발생하는 모든 쓰기 작업은 같은 상자에 담기고, 상자가 닫힐 때(요청 처리 완료) 한 번에 발송(커밋)됩니다. 상자에 담는 도중 문제가 생기면 상자 전체에 "발송 불가" 딱지가 붙습니다. 이것이 rollback-only 마킹입니다.

여기서 중요한 동작 원리 세 가지가 있습니다.

  1. 지연 시작(lazy) — DB 트랜잭션은 ChangeSet이 열릴 때가 아니라 PersistenceService를 통한 첫 쓰기 시점에 시작됩니다. 읽기만 하는 요청은 쓰기 트랜잭션을 열지 않습니다.
  2. 예외 = 취소 마킹 — 핸들러 체인 안에서 예외가 던져지면 해당 ChangeSet은 즉시 취소 대상으로 마킹됩니다. 핵심은, 그 예외를 상위에서 catch하더라도 마킹은 지워지지 않는다는 점입니다. 택배 상자에 이미 붙은 "발송 불가" 딱지는 떼어낼 수 없습니다.
  3. 커밋은 ChangeSet 종료 시점 — @After 핸들러까지는 아직 상자가 열려 있는 상태입니다. 반면 ChangeSetListener.afterClose()는 상자가 이미 발송된(커밋/롤백 완료) 뒤에 호출되므로, 여기서 예외를 던져도 되돌릴 수 없습니다.

Spring의 @Transactional과 비교하면, CAP의 ChangeSet은 "요청 단위의 선언 없는 트랜잭션 경계"에 가깝습니다. 둘은 통합되어 있어 @Transactional 메서드 안에서 PersistenceService를 호출하면 같은 트랜잭션에 참여하는 것이 일반적이지만, 경계 제어의 기본 축은 ChangeSet이라는 점을 기억해야 합니다.

💻 실전 코드로 보는 롤백 처리 3단계

1단계 — 기본: 예외로 롤백 트리거하기

주문 확정 액션에서 재고가 부족하면 전체 변경을 되돌리는 가장 기본적인 패턴입니다. CDS 모델은 다음과 같습니다.

service OrderService {
  entity SalesOrders {
    key ID     : UUID;
    status     : String(20);
    quantity   : Integer;
    productId  : String(40);
  }
  action confirmOrder(orderId : UUID);
}
@Component
@ServiceName("OrderService")
public class ConfirmOrderHandler implements EventHandler {

  private final PersistenceService db;

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

  @On(event = "confirmOrder")
  public void onConfirm(ConfirmOrderContext ctx) {
    String orderId = ctx.getOrderId();

    // 1) 주문 상태 변경 — 이 시점에 DB 트랜잭션이 시작됨
    db.run(Update.entity("OrderService.SalesOrders")
        .data("status", "CONFIRMED")
        .where(o -> o.get("ID").eq(orderId)));

    // 2) 재고 차감 시도
    long available = readStock(orderId);
    if (available <= 0) {
      // 예외를 던지면 위의 UPDATE까지 전부 롤백된다
      throw new ServiceException(ErrorStatuses.CONFLICT,
          "재고 부족으로 주문 {}을 확정할 수 없습니다", orderId);
    }
    ctx.setCompleted();
  }
}

ServiceException은 HTTP 상태 코드와 함께 클라이언트에 전달되고, ChangeSet이 취소되어 상태 변경이 롤백됩니다. 명시적 rollback 호출이 필요 없다는 점이 핵심입니다.

2단계 — 실무: catch의 함정과 격리된 ChangeSet

실무에서 가장 자주 보는 실수는 "부가 기능 실패는 무시하고 진행"하려고 예외를 삼키는 코드입니다.

// ❌ 잘못된 코드: 예외를 잡아도 ChangeSet은 이미 취소 마킹됨
@After(event = CqnService.EVENT_CREATE, entity = "OrderService.SalesOrders")
public void afterCreate(CdsCreateEventContext ctx) {
  try {
    billingService.createDraftInvoice(ctx); // 내부에서 ServiceException 발생
  } catch (ServiceException e) {
    log.warn("송장 초안 생성 실패, 주문은 계속 진행: {}", e.getMessage());
    // 여기서 정상 리턴해도 커밋 시점에 TransactionException 발생!
  }
}

내부 서비스 호출이 같은 ChangeSet에서 실행되었기 때문에, 예외 발생 순간 이미 rollback-only 마킹이 붙었습니다. catch는 예외 전파만 막을 뿐 마킹을 해제하지 못하므로, 요청 마지막에 커밋을 시도하다 TransactionException류의 오류로 실패합니다. 해결책은 실패해도 되는 작업을 별도 ChangeSet으로 격리하는 것입니다.

// ✅ 올바른 코드: 새 ChangeSet 컨텍스트에서 실행해 실패를 격리
private final CdsRuntime runtime;

@After(event = CqnService.EVENT_CREATE, entity = "OrderService.SalesOrders")
public void afterCreate(CdsCreateEventContext ctx) {
  try {
    runtime.changeSetContext().run(changeSet -> {
      billingService.createDraftInvoice(ctx);
    });
  } catch (ServiceException e) {
    // 격리된 ChangeSet만 취소되고, 주문 생성 트랜잭션은 살아남는다
    log.warn("송장 초안 생성 실패(격리됨): {}", e.getMessage());
  }
}

반대로, 예외 없이 조용히 전체를 되돌리고 싶다면 markForCancel()을 사용합니다. 검증 로직에서 오류 응답 대신 조건부 취소가 필요할 때 유용합니다.

@After(event = CqnService.EVENT_UPDATE, entity = "OrderService.SalesOrders")
public void validateAmount(CdsUpdateEventContext ctx) {
  if (exceedsCreditLimit(ctx)) {
    ctx.getChangeSetContext().markForCancel(); // 예외 없이 롤백만 예약
  }
}

3단계 — 프로덕션: 커밋 이후 로직, 테스트, 외부 연동

알림 발송처럼 커밋이 확정된 뒤에만 실행돼야 하는 로직은 ChangeSetListener로 미룹니다. 롤백 여부를 completed 플래그로 구분할 수 있어 감사 로깅에도 적합합니다.

ctx.getChangeSetContext().register(new ChangeSetListener() {
  @Override
  public void afterClose(boolean completed) {
    if (completed) {
      notifier.sendOrderConfirmed(orderId);   // 커밋 확정 후에만 발송
    } else {
      log.error("주문 {} 트랜잭션 롤백 감지", orderId);
    }
    // 주의: 이 블록에서 예외를 던져도 이미 커밋/롤백된 결과는 바뀌지 않음
  }
});

외부 API 호출(이메일, 메시징)은 DB처럼 롤백되지 않으므로, 커밋과 원자적으로 묶고 싶다면 CAP의 Transactional Outbox를 쓰는 것이 일반적입니다. 롤백 시 아웃박스 항목도 함께 사라져 "롤백됐는데 메일은 나간" 사고를 예방합니다. 마지막으로, 롤백 동작은 반드시 통합 테스트로 검증합니다.

@SpringBootTest
class ConfirmOrderRollbackTest {

  @Autowired PersistenceService db;
  @Autowired CqnService orderService;

  @Test
  void 재고부족시_주문상태가_원복되어야_한다() {
    UUID orderId = createOrderWithoutStock();   // status = "NEW"

    assertThatThrownBy(() -> confirm(orderId))
        .isInstanceOf(ServiceException.class);

    Row row = db.run(Select.from("OrderService.SalesOrders")
        .where(o -> o.get("ID").eq(orderId.toString()))).single();
    assertThat(row.get("status")).isEqualTo("NEW"); // 롤백 확인
  }
}

성능 관점에서는 트랜잭션을 짧게 유지하는 것이 중요합니다. 핸들러 안에서 외부 HTTP 호출을 동기로 기다리면 DB 커넥션과 락을 그 시간만큼 점유하므로, 무거운 작업은 아웃박스나 비동기 처리로 분리하는 것을 권장합니다. 보안 측면에서는 롤백 로그에 주문 금액·개인정보 같은 민감 데이터를 그대로 남기지 않도록 마스킹을 적용하세요.

⚠️ 흔한 실수와 트러블슈팅 FAQ

Q1. @After 핸들러에서 예외를 던졌는데 왜 데이터가 남아 있죠?

먼저 그 코드가 정말 @After 핸들러인지 확인하세요. @After는 아직 ChangeSet이 열려 있는 구간이라 예외를 던지면 롤백됩니다. 데이터가 남는 경우는 대부분 (1) 예외를 던진 곳이 ChangeSetListener.afterClose()처럼 커밋 이후 구간이거나, (2) 해당 로직을 별도 스레드/비동기로 실행해 원래 ChangeSet 밖에서 돌고 있는 경우입니다. 비동기 코드는 요청의 트랜잭션에 참여하지 않는다는 점을 기억하세요.

Q2. 예외를 전부 catch했는데 커밋 때 TransactionException이 납니다.

같은 ChangeSet 안에서 예외가 한 번이라도 발생하면 rollback-only 마킹이 붙고, catch로는 해제되지 않습니다. 실패를 허용해야 하는 호출은 runtime.changeSetContext().run(...)으로 새 ChangeSet에 격리한 뒤 바깥에서 catch하는 것이 올바른 패턴입니다.

Q3. Spring @Transactional과 CAP ChangeSet을 같이 쓰면 어느 쪽이 이기나요?

둘은 통합되어 같은 트랜잭션 자원을 공유하는 것이 일반적입니다. 다만 경계가 이중으로 생기면 디버깅이 어려워지므로, CAP 핸들러 코드에서는 ChangeSet 기반 제어(markForCancel, 격리 ChangeSet)로 통일하고 @Transactional은 CAP 밖의 순수 Spring 계층에서만 쓰는 편이 관리하기 쉽습니다.

Q4. 롤백됐는데 알림 메일은 이미 발송됐습니다.

외부 시스템 호출은 DB 롤백의 영향을 받지 않습니다. 커밋 확정 후 발송(afterClose(true))으로 미루거나, 재시도까지 필요하면 Transactional Outbox를 사용하세요.

🚀 더 깊이 들어가려면

이 글에서 다룬 ChangeSet 격리를 확장하면, 대량 배치 처리에서 청크 단위 커밋(부분 실패 허용) 패턴을 구현할 수 있습니다. 이어서 살펴볼 만한 주제로는 CAP Java의 Transactional Outbox를 이용한 이벤트 발행 보장, Remote Service 호출과 로컬 트랜잭션의 경계 설계, SAP HANA Cloud에서의 락 경합 모니터링, 그리고 멀티테넌트 환경에서 테넌트별 트랜잭션 격리가 있습니다. draft 활성화(Draft-enabled entity)의 저장 시점 트랜잭션 흐름도 함께 보면 그림이 완성됩니다.

📚 참고할 만한 자료

댓글 0

아직 댓글이 없습니다.