도입 — 정산 배치가 절반만 커밋되는 사고, 어디서 시작됐을까
CAP Java로 서비스를 만들다 보면 초반에는 트랜잭션을 의식할 일이 거의 없습니다. 프레임워크가 요청 단위로 알아서 커밋과 롤백을 처리해 주기 때문입니다. 그런데 어느 순간 이런 요구사항이 들어옵니다. "정산 배치 1만 건을 처리하다 8천 건째에서 실패하면, 앞의 8천 건은 살리고 싶다", "커밋이 확정된 뒤에만 외부 알림을 보내고 싶다". 이때부터 기본 동작만으로는 부족해지고, 트랜잭션 경계를 직접 설계해야 합니다.
이 글에서 다루는 내용을 미리 정리하면 다음과 같습니다.
- CAP Java가 하나의 요청을 ChangeSetContext로 감싸 자동 커밋/롤백하는 내부 구조 이해
- ChangeSetContext를 프로그래매틱하게 열어 여러 작업을 하나로 묶거나 청크 단위로 분리하는 방법
- ChangeSetListener를 등록해 커밋 성공/실패 여부에 따라 후속 처리를 분기하는 방법
- 세 가지 접근의 트레이드오프와 선택 기준
읽기 전에 Java와 Spring Boot 기본기, CDS 모델링 초급 수준, 그리고 CQL(CDS Query Language)로 Select/Insert를 작성해 본 경험이 있다면 수월합니다. JPA나 Spring의 @Transactional을 써 봤다면 개념 대비가 쉬워서 더 좋습니다.
실습 환경과 가상 도메인 준비
이 글의 코드는 다음 환경을 기준으로 작성했습니다.
- CAP Java SDK 3.x (2.x에서도 ChangeSetContext API는 거의 동일하게 동작)
- Spring Boot 3.x, Java 17 이상
- 로컬 개발은 H2, 배포 환경은 SAP BTP Cloud Foundry + SAP HANA Cloud를 가정
- 빌드 도구는 Maven, 프로젝트 생성은
cds init --add java기준
예제 도메인은 구독 서비스 정산 시스템으로 창작했습니다. 월말에 구독 계약별 사용량을 집계해 정산 전표를 만들고 원장에 반영하는 시나리오입니다. CDS 모델은 이렇습니다.
namespace subscribly.billing;
entity SubscriptionContracts {
key ID : UUID;
planCode : String(20);
customerName : String(100);
status : String(10); // ACTIVE, SUSPENDED
}
entity UsageRecords {
key ID : UUID;
contract : Association to SubscriptionContracts;
meteredUnits : Integer;
billed : Boolean default false;
}
entity SettlementDocs {
key ID : UUID;
contract : Association to SubscriptionContracts;
totalAmount : Decimal(15,2);
docStatus : String(10); // DRAFT, POSTED, FAILED
}
service SettlementService {
entity Contracts as projection on SubscriptionContracts;
action postSettlement(contractId : UUID) returns String;
action runMonthlyBatch() returns Integer;
}
mvn cds:watch로 로컬 기동 후 http://localhost:8080에서 OData 엔드포인트를 확인할 수 있습니다.
기본 동작 원리 — 요청 하나가 곧 트랜잭션 하나
CAP Java의 트랜잭션 단위는 ChangeSet입니다. 비유하자면 ChangeSetContext는 "택배 상자"입니다. 하나의 OData/REST 요청이 들어오면 프레임워크가 상자를 하나 열고, 그 요청을 처리하는 동안 발생한 모든 DB 변경(핸들러가 몇 개가 실행되든, PersistenceService를 몇 번 호출하든)을 이 상자에 담습니다. 요청이 끝나는 시점에 상자를 닫는데, 이때 두 가지 경우로 갈립니다.
- 정상 종료 — 상자째로 커밋. 담긴 변경이 한꺼번에 반영됩니다.
- 예외 발생 또는 취소 표시 — 상자째로 롤백. 어떤 변경도 남지 않습니다.
내부적으로 첫 번째 쓰기 작업이 실행될 때 DB 트랜잭션이 늦게(lazy) 시작되고, ChangeSetContext가 닫힐 때 커밋/롤백이 결정됩니다. OData $batch 요청에서 changeset 블록으로 묶인 여러 서브 요청도 하나의 ChangeSet을 공유하므로, 그중 하나만 실패해도 블록 전체가 롤백됩니다. Spring 통합 환경에서는 이 메커니즘이 Spring의 트랜잭션 관리와 연결되어 있어서, 일반적으로 @Transactional을 별도로 붙이지 않아도 CAP 핸들러 안의 DB 작업은 일관성 있게 묶입니다.
핵심은 이겁니다. 개발자가 아무것도 하지 않아도 "요청 = 트랜잭션"이라는 경계가 이미 존재한다. 따라서 트랜잭션 코드를 직접 쓰기 전에, 지금 필요한 경계가 정말 요청 단위와 다른지부터 따져야 합니다.
방법 1 — 자동 트랜잭션에 맡기고 예외로만 제어하기
가장 권장되는 기본기입니다. 핸들러 안에서 여러 번 쓰기를 수행하고, 실패 조건에서는 ServiceException을 던지기만 하면 됩니다. 아래는 postSettlement 액션 핸들러입니다. 정산 전표 상태 변경과 사용량 레코드 마감 처리 두 가지 쓰기가 일어나지만, 별도 트랜잭션 코드는 한 줄도 없습니다.
@Component
@ServiceName("SettlementService")
public class SettlementHandler implements EventHandler {
private final PersistenceService db;
public SettlementHandler(PersistenceService db) {
this.db = db;
}
@On(event = "postSettlement")
public void onPostSettlement(PostSettlementContext ctx) {
String contractId = ctx.getContractId();
// 쓰기 1: 전표 상태를 POSTED로 변경
long updated = db.run(
Update.entity("subscribly.billing.SettlementDocs")
.data("docStatus", "POSTED")
.where(d -> d.get("contract_ID").eq(contractId)
.and(d.get("docStatus").eq("DRAFT"))))
.rowCount();
if (updated == 0) {
// 예외를 던지면 이 요청에서 수행된 모든 변경이 함께 롤백된다
throw new ServiceException(ErrorStatuses.CONFLICT,
"DRAFT 상태의 전표가 없어 정산을 확정할 수 없습니다.");
}
// 쓰기 2: 해당 계약의 미청구 사용량을 마감 처리
db.run(Update.entity("subscribly.billing.UsageRecords")
.data("billed", true)
.where(u -> u.get("contract_ID").eq(contractId)
.and(u.get("billed").eq(false))));
ctx.setResult("POSTED");
ctx.setCompleted();
}
}
쓰기 2에서 DB 오류가 나면 쓰기 1도 자동으로 되돌아갑니다. 예외 없이 롤백만 지시하고 싶다면 ChangeSetContext.getCurrent().markForCancel()을 호출하는 방법도 있습니다. 이 경우 응답은 정상 흐름으로 만들되 DB 반영만 취소하는 식의 처리가 가능합니다. 방법 1의 원칙은 하나입니다. 트랜잭션을 코드로 다루지 말고, 실패를 예외로 표현하라.
방법 2 — ChangeSetContext를 직접 열어 경계를 쪼개기
월말 배치 runMonthlyBatch를 생각해 봅시다. 활성 계약 1만 건을 정산하는데, 9,999건째에서 한 건이 실패했다고 전체를 롤백하면 배치를 처음부터 다시 돌려야 합니다. 이럴 때는 요청 트랜잭션과 별개의 트랜잭션을 청크 단위로 직접 열어야 합니다. CdsRuntime.changeSetContext().run(...)이 그 도구입니다.
@Component
@ServiceName("SettlementService")
public class MonthlyBatchHandler implements EventHandler {
private static final Logger log =
LoggerFactory.getLogger(MonthlyBatchHandler.class);
private static final int CHUNK_SIZE = 500;
private final CdsRuntime runtime;
private final PersistenceService db;
public MonthlyBatchHandler(CdsRuntime runtime, PersistenceService db) {
this.runtime = runtime;
this.db = db;
}
@On(event = "runMonthlyBatch")
public void onRunMonthlyBatch(RunMonthlyBatchContext ctx) {
List contracts = db.run(
Select.from("subscribly.billing.SubscriptionContracts")
.where(c -> c.get("status").eq("ACTIVE")))
.list();
int success = 0;
for (List chunk : partition(contracts, CHUNK_SIZE)) {
try {
// 청크마다 독립된 ChangeSet을 연다 → 청크 단위 커밋
runtime.changeSetContext().run(changeSet -> {
for (Row contract : chunk) {
createSettlementDoc(contract); // 내부에서 db.run(...) 수행
}
});
success += chunk.size();
} catch (Exception e) {
// 실패한 청크만 롤백되고, 이전 청크의 커밋은 유지된다
log.error("정산 청크 실패 (size={}): {}",
chunk.size(), e.getMessage(), e);
}
}
ctx.setResult(success);
ctx.setCompleted();
}
}
여기서 반드시 이해해야 할 내부 동작이 있습니다. run(...) 블록 안에서 실행되는 db.run(...)은 바깥 요청의 ChangeSet이 아니라 새로 열린 ChangeSet에 참여합니다. 블록이 예외 없이 끝나면 그 시점에 즉시 커밋되고, 예외가 전파되면 그 블록만 롤백됩니다. 즉 "요청 전체 성공/실패"라는 기본 계약을 깨고, 부분 성공을 허용하는 설계로 바꾸는 것입니다. 그만큼 로깅이 중요해집니다. 어떤 청크가 실패했는지 기록하지 않으면 재처리가 불가능하므로, 실패 청크의 키 목록을 별도 테이블에 남기는 패턴을 함께 쓰는 것이 일반적입니다.
방법 3 — ChangeSetListener로 커밋 이후에만 후속 처리 실행하기
정산이 확정되면 고객에게 이메일을 보내야 한다고 합시다. 이메일 발송을 핸들러 안에서 바로 호출하면 위험합니다. 핸들러 실행 이후 다른 After 핸들러나 DB 제약 위반으로 트랜잭션이 롤백될 수 있는데, 이메일은 이미 나가 버렸기 때문입니다. DB는 "정산 안 됨", 고객 메일함에는 "정산 완료 안내"가 남는 불일치가 생깁니다.
해결책은 현재 ChangeSetContext에 리스너를 등록해, 트랜잭션이 닫힌 뒤 결과에 따라 분기하는 것입니다.
@After(event = "postSettlement")
public void afterPostSettlement(PostSettlementContext ctx) {
String contractId = ctx.getContractId();
ChangeSetContext changeSet = ChangeSetContext.getCurrent();
changeSet.register(new ChangeSetListener() {
@Override
public void beforeClose() {
// 커밋 직전 훅: 마지막 검증이나 캐시 무효화 준비에 활용
log.debug("정산 트랜잭션 종료 직전, contract={}", contractId);
}
@Override
public void afterClose(boolean completed) {
if (completed) {
// 커밋이 확정된 경우에만 외부 시스템 호출
notificationClient.sendSettlementMail(contractId);
} else {
// 롤백된 경우: 알림 대신 운영 로그/모니터링 이벤트만 남긴다
log.warn("정산 롤백됨, 알림 스킵. contract={}", contractId);
}
}
});
}
afterClose(true)는 커밋 성공, afterClose(false)는 롤백을 의미합니다. 프로덕션 관점에서 세 가지를 더 챙겨야 합니다. 첫째, afterClose는 이미 트랜잭션이 닫힌 뒤라 여기서 DB 쓰기를 하려면 방법 2처럼 새 ChangeSet을 열어야 합니다. 둘째, 콜백 안의 예외는 이미 커밋된 결과를 되돌리지 못하므로 외부 호출은 재시도 큐(예: SAP Event Mesh 발행)로 감싸는 편이 안전합니다. 셋째, 알림 페이로드에 개인정보를 담을 때는 콜백에서 로그로 남기지 않도록 마스킹해야 합니다. 테스트는 의도적으로 예외를 던지는 통합 테스트를 만들어 completed=false 경로가 실제로 알림을 건너뛰는지 검증하는 것이 좋습니다.
세 방식 비교 — 언제 무엇을 선택할 것인가
| 구분 | 방법 1: 자동 위임 | 방법 2: 명시적 ChangeSet | 방법 3: 리스너 훅 |
|---|---|---|---|
| 트랜잭션 경계 | 요청 = 트랜잭션 | 개발자가 정의한 블록 | 기존 경계 유지 + 종료 후 분기 |
| 대표 시나리오 | 단건 CRUD, 액션 처리 | 대량 배치의 청크 커밋, 실패 격리 | 메일/이벤트 발행 등 외부 연동 |
| 실패 처리 | 예외 → 전체 롤백 | 블록 단위 롤백, 부분 성공 허용 | completed 플래그로 분기 |
| 복잡도/위험 | 낮음 | 중간 — 재처리 설계 필수 | 중간 — 콜백 내 예외 관리 필요 |
판단 순서는 단순합니다. (1) 기본 동작으로 요구사항이 충족되는지 먼저 확인하고, (2) "부분 성공"이 필요할 때만 방법 2로 경계를 쪼개고, (3) "커밋 확정 후에만 해야 하는 일"이 있을 때 방법 3을 얹습니다. 방법 2와 3은 배타적이지 않아서, 청크 트랜잭션 각각에 리스너를 등록하는 조합도 실무에서 자주 쓰입니다.
자주 만나는 함정
- 예외를 삼키고 로그만 남기는 실수 — 핸들러에서
try-catch로 예외를 먹으면 프레임워크는 성공으로 판단해 커밋합니다. 롤백을 원하면 예외를 다시 던지거나markForCancel()을 호출해야 합니다. - 새 스레드에서
ChangeSetContext.getCurrent()호출 — 컨텍스트는 스레드 로컬 기반이라 별도 스레드에서는 비어 있습니다. 비동기 처리라면runtime.requestContext()와changeSetContext()를 해당 스레드에서 새로 열어야 합니다. - afterClose에서 기존 트랜잭션으로 DB 쓰기 시도 — 이미 닫힌 트랜잭션입니다. 새 ChangeSet을 열지 않으면 의도대로 동작하지 않습니다.
Q1. Spring의 @Transactional과 섞어 써도 되나요? CAP Java의 트랜잭션은 일반적으로 Spring 트랜잭션 관리와 연동되어 함께 동작합니다. 다만 두 메커니즘의 경계를 동시에 수동 조작하면 추적이 어려워지므로, CAP 핸들러 안에서는 ChangeSetContext 쪽 API로 일원화하는 것을 권장합니다.
Q2. $batch의 changeset과 이 글의 ChangeSet은 같은 건가요? 개념적으로 연결되어 있습니다. OData changeset 블록의 서브 요청들은 하나의 ChangeSetContext를 공유하므로, 클라이언트가 원자성 단위를 지정하는 수단이라고 보면 됩니다.
Q3. 방법 2에서 바깥 요청 트랜잭션과 안쪽 ChangeSet이 충돌하지 않나요? 안쪽 블록은 독립적으로 커밋되므로, 바깥 요청이 나중에 롤백돼도 안쪽 커밋은 남습니다. 이 비대칭이 바로 방법 2의 목적이자 위험이므로, 남아도 무방한 데이터(로그성 전표, 재처리 가능한 레코드)에만 적용해야 합니다.
핵심 한 줄과 이어서 보면 좋은 글
정리하면, CAP Java에서 트랜잭션 관리의 출발점은 "아무것도 하지 않는 것"입니다. 요청 단위 ChangeSet이 대부분의 일관성 요구를 처리해 주고, 개발자는 실패를 예외로 표현하기만 하면 됩니다. 그 위에 부분 커밋이 필요하면 changeSetContext().run(...)으로 경계를 쪼개고, 커밋 확정 후 후속 처리가 필요하면 ChangeSetListener를 등록합니다. 다음에는 CAP Java의 이벤트 핸들러 실행 순서(Before/On/After), Outbox 패턴을 활용한 신뢰성 있는 이벤트 발행, Remote Service 호출 시의 트랜잭션 격리를 이어서 살펴보면 이 글의 내용이 한층 입체적으로 연결됩니다.
댓글 0
아직 댓글이 없습니다.