Never wrap an external system call inside your own transaction.
Redesigned transaction boundaries between manufacturing operations and financial systems using a staging-table and batch-worker pattern, cutting processing time by 60% and reducing reprocessing from 20 monthly cases to near zero.
- Problem: external call failure rolled back local writes too
- Floor moved forward, but local system recorded nothing
- Fix 1: split transaction; local write + staging row commit
- Fix 2: pull external call out; batch worker every minute
- Carry timestamp on staging row to keep correct business day
- Result: specific-issue tickets ~0, ~90% category drop
// 1. Local write + a staging row in ONE transaction (Spring)
@Transactional
public void onStageComplete(Product p) {
saveProductionState(p);
stagingRepo.save(new Outbox(p.id(), "pending",
Instant.now())); // stamp the post-date NOW
} // operator returns immediately — no external call in this tx
// 2. A separate worker drains the staging rows every minute
@Scheduled(fixedRate = 60_000)
public void drain() {
for (Outbox row : stagingRepo.findByStatus("pending")) {
boolean ok = erpClient.notify(row); // external ERP call, outside the tx
row.setStatus(ok ? "success" : "failed");
}
}- ·Two writes were in one transaction: ours and the external system's.
- ·When their system failed, ours rolled back too. Floor moved, we recorded nothing.
- ·Fix one: split it. Save our state plus a staging row, mark the external call separately.
- ·Fix two: pull the external call out entirely. A worker drains the staging table every minute.
- ·Specific-issue tickets dropped to near zero, about ninety percent off the category.
- Opendoor: offer-accept writes the offer + an outbox row in one tx; a worker charges payment. A payment timeout must not roll back the accepted offer.
- Proves: transaction-boundary + eventual-consistency thinking — core for money flows.
- Use it whenever a write triggers an external call.
- ERP now lags the floor by up to a minute; reads can show stale state.
- Poll-every-minute worker burns DB queries even when staging is empty.
- Failed rows need manual or next-cycle retry; no automatic backoff.
- Post-date stamped at save time drifts if the row sits unprocessed past midnight.
- Replace minute poll with CDC (Debezium) tailing the staging table for low latency.
- Make the ERP call idempotent via a dedup key so retries can't double-post.
- Add exponential backoff plus a dead-letter table instead of indefinite manual retry.
- Partition the staging table by line/region so multiple workers drain in parallel.
Understand it step by step (한국어로 이해 → 영어로 말하기)
- 1
공장 라인에서 제품이 한 단계 끝나면, 우리 시스템에 '여기까지 됐다'고 저장한다. 동시에 외부 시스템(ERP, 회사 회계 쪽)에도 그 사실을 알려줘야 한다. 작업자는 이 한 번의 클릭으로 둘 다 끝나길 바란다.
// MES save + ERP notify: the two writes SaveProductionState(); // (1) our system CallExternalSystem(order); // (2) external ERP
🗣 영어로 말해“When a product moves on the line, we save it and also tell the external ERP system.”
checking microphone…
- 2
원래는 이 두 가지를 한 묶음으로 묶었다. '둘 다 성공하거나, 둘 다 안 되거나' 하게 만든 것이다. 한쪽만 되는 어중간한 상태를 막으려고 그렇게 한 건데, 이게 문제의 씨앗이었다.
⚖️ Trade-off: 한 묶음으로 묶으면 깔끔해 보인다. 둘 다 되거나 둘 다 안 되니까 데이터가 어긋날 일이 없어 보인다.
✅ Fix: 문제는 그 묶음 안에 '내가 통제 못 하는 남의 시스템'이 들어있다는 것. 남이 자빠지면 내 것까지 같이 취소된다.
🔧 도구:@TransactionalSpring Data JPA
// BUG: external call wrapped in OUR transaction (all-or-nothing) @Transactional public void onStageComplete(Order order) { saveProductionState(); erpClient.notify(order); // ERP call INSIDE the tx }🗣 영어로 말해“We wrapped both writes in one transaction, so it was all-or-nothing.”
checking microphone…
- 3
어느 날 외부 시스템(ERP)이 응답이 늦거나 에러를 냈다. 그러자 묶음 규칙대로 우리 저장까지 통째로 취소(롤백)됐다. 제품은 진짜로 라인을 떠났는데, 우리 시스템엔 '아직 생산 중'으로 남았다.
⚖️ Trade-off: 현실(물건은 갔다)과 기록(아직 생산 중)이 따로 논다. 미국 작업자들이 '끝난 제품이 왜 생산 중이냐'고 신고하면서 들통났다.
✅ Fix: 내 일이, 남의 시스템 때문에 같이 취소되면 안 된다. 그게 핵심이다.
@Transactional public void onStageComplete(Order order) { saveProductionState(); erpClient.notify(order); // throws (timeout/error) } // exception -> the WHOLE tx rolls back, our save included🗣 영어로 말해“When their system failed, ours rolled back too. The floor moved but we recorded nothing.”
checking microphone…
- 4
첫 번째 고침: 묶음을 쪼갰다. 우리 저장과 함께 '할 일 쪽지' 한 장을 같은 묶음으로 적어둔다. 쪽지엔 '아직 외부에 안 알림(대기)'이라고 표시. 외부 호출은 이 묶음에서 빼버린다. 그래서 외부가 실패해도 우리 저장은 무조건 남는다.
⚖️ Trade-off: 외부 시스템에 즉시 안 알린다. 둘 사이에 시간 차가 생긴다(잠깐 어긋남).
✅ Fix: 쪽지(스테이징 행)에 '대기'로 남겨두니, 나중에 따로 처리하면 된다. 우리 기록은 절대 안 사라진다.
🔧 도구:staging tablestatus: pending
// Fix 1: local write + staging row in ONE tx; no external call inside @Transactional public void onStageComplete(Order order) { saveProductionState(); stagingRepo.save(new Outbox(order.id(), "pending", Instant.now())); } // our state is always durable🗣 영어로 말해“Fix one: split it. Save our state plus a to-do row, and handle the external call separately.”
checking microphone…
- 5
두 번째 고침: 외부 호출을 아예 따로 빼서, 1분마다 도는 일꾼(배치)이 대기 쪽지들을 모아서 ERP에 알려준다. 성공하면 쪽지에 '완료', 실패하면 '실패'라고 적는다. 작업자는 외부가 느려도 안 기다리고 바로 다음 일 한다.
⚖️ Trade-off: 1분마다 도니까, ERP는 라인보다 최대 1분 늦는다. 또 쪽지가 없을 때도 일꾼이 헛돈다(쓸데없는 DB조회).
✅ Fix: 공장 기록에서 1분 늦는 건 문제 안 된다. 대신 작업자 화면이 안 멈추고, 외부가 죽어도 나중에 재시도하면 된다.
🔧 도구:background worker (1분 주기)poll pending rows
// Fix 2: separate worker pulls the external call out, runs every minute foreach (var row in GetPendingRows()) { var ok = CallExternalSystem(row); // ERP, off the save path row.Status = ok ? "success" : "failed"; }🗣 영어로 말해“Fix two: pull the external call out. A worker drains the to-do rows every minute.”
checking microphone…
- 6
한 가지 함정: ERP엔 '며칠자 비용'인지(영업일) 날짜가 중요하다. 일꾼이 늦게 돌면 자정을 넘겨서 날짜가 하루 밀릴 수 있다. 그래서 날짜를 '일꾼이 처리한 시각'이 아니라 '작업자가 저장한 시각'으로 쪽지에 미리 찍어뒀다.
⚖️ Trade-off: 처리를 늦게 하면, 그 시각으로 날짜를 찍으면 영업일이 틀어진다(비용이 엉뚱한 날에 잡힘).
✅ Fix: 쪽지를 만들 때(저장 시점) 날짜를 미리 도장 찍어둔다. 나중에 언제 처리되든 영업일은 정확히 유지된다.
🔧 도구:postDate stamped at save time
// Stamp the business day at SAVE time, not at processing time InsertStagingRow(status: "pending", postDate: Now()); // worker may run after midnight; date stays correct🗣 영어로 말해“I stamped the date at save time, not at processing time, so the business day stayed correct.”
checking microphone…
- 7
내 마음대로 외부 시스템 방식을 바꾼 게 아니다. 먼저 ERP 팀 매니저를 찾아가 설명하고, 내 매니저와 관계자들을 참조로 넣었다. 30분 대화로 허락을 받고 진행했다.
⚖️ Trade-off: 방식을 바꾸면 ERP 팀의 동의가 필요하다. 혼자 결정하면 나중에 갈등이 난다.
✅ Fix: 먼저 찾아가 설명하고 합의했다. 짧은 대화 한 번으로 깔끔하게 풀렸다.
🗣 영어로 말해“I didn't change their system alone. I talked to the ERP team manager first and got the green light.”
checking microphone…
- 8
결과: '끝난 제품이 생산 중으로 보인다'는 그 특정 신고는 거의 0건이 됐고, 그 종류(SOP 오류) 전체로도 약 90% 줄었다. 교훈은 하나 — 내가 통제 못 하는 외부 호출을 절대 내 트랜잭션 안에 넣지 마라.
✅ Fix: Jira 티켓 추이로 확인. 특정 문제는 거의 0, 같은 종류 전체는 약 90% 감소.
🗣 영어로 말해“Specific tickets dropped to near zero, about ninety percent off the whole category.”
checking microphone…
8단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.