아웃박스의 시차 — 커밋 뒤에 완성되는 세계에서 배차하기
아웃박스 소비자가 커밋 이후에 만드는 데이터를 배차 게이트가 전제해, 간편배차 운행이 전부 거부됐다. 게이트의 질문을 바꾸고 판정을 스냅샷 밖에서 읽어 풀었다.
트랜잭션이 끝난 뒤에 처리를 잇는 아웃박스 패턴은 전달 보장을 위한 표준적인 선택이다. 그런데 커밋 직후 "그 처리의 결과"를 전제로 하는 다음 단계가 같은 요청 안에 있다면, 아웃박스가 만든 시차는 기능이 아니라 장애가 된다. 우리는 그 시차 때문에 배차가 전부 거부되는 사고를 겪었다.
한 줄 요약: 아웃박스 소비자가 커밋 이후에야 만드는 데이터를 배차 게이트가 전제해 스폰 운행이 매번 거부됐다. 게이트의 질문을 "보급이 끝났는가"에서 "보급이 실패했는가"로 바꾸고, 스폰 경로만 새 트랜잭션 판정으로 짧게 기다리게 해 풀었다.
배경
우리 솔루션은 배출·운반·처리 사업자 사이의 주문, 배차, 계약, 정산을 다루는 B2B 물류 플랫폼이다. 한 조직이 주문을 발행하면 상대 조직에도 그 주문의 사본이 서야 한다 — 이 전파는 아웃박스 패턴으로 동작한다. 주문과 운행을 커밋하면서 발행 원장에 이벤트를 남기고, 별도 소비자가 커밋 이후 그 원장을 읽어 상대 조직의 사본을 만든다(이를 "보급"이라 부른다).
간편배차는 이 위에 얹힌 화면이다. 거래처 줄을 클릭하면 주문 생성 → 보급 → 배차 → 기사 알림까지 요청 하나 안에서 이어진다. 배차 직후 기사가 곧 출발하는 운행이라, 상대 사본이 먼저 서 있어야 하는 흐름이다.
문제를 느낀 계기
2026년 9월 2일, 간편배차로 만든 운행이 배차 단계에서 전부 400으로 거부되는 장애가 났다. 새 운행을 만들 수는 있는데 배차만 안 되는, 운영자 입장에서 이해할 수 없는 상태였다.
원인은 우리가 직전에 세운 안전장치였다.
- 얼마 전 "상대 보급이 끝나지 않은 운행은 배차하지 않는다"는 게이트를 넣었다. 보급이 덜 된 운행을 배차하면 상대 화면과 어긋나는 사고를 막기 위해서였다.
- 그런데 간편배차의 스폰 운행은 정의상 커밋 직후다. 아웃박스 소비자는 커밋 뒤 다른 스레드에서 돌기 때문에, 배차 시점에는 상대 사본이 아직 없다. 게이트 기준으로는 항상 "보급 대기" — 그래서 매번 거부됐다.
- 더 나쁜 건, 기다리며 되읽어도 소용이 없었다는 점이다. 배차 요청의 바깥 트랜잭션은 REPEATABLE READ 라 첫 스냅샷이 고정된다. 소비자가 그 사이 커밋을 해도 같은 트랜잭션 안에서는 그 갱신이 영영 보이지 않는다.
- 곁가지로, 상대가 아직 승낙하지 않은 운행도 배차 화면에 남아 있어서, 운영자는 게이트 400을 맞고서야 그 사실을 알 수 있었다.
안전장치 하나가 비동기 구조의 시차와 격리 수준의 스냅샷 규칙을 만나, "잘못된 배차 방지"가 "모든 배차 거부"로 뒤집힌 것이다.
제약
- 아웃박스는 유지한다 — 전달 보장과 도메인 경계(상대 사본은 소비자만 만든다)를 포기할 수 없다.
- 스폰을 다시 시도하게 할 수 없다 — 운행은 이미 커밋돼 있어, 다시 만들면 중복 운행이 생긴다.
- 소비자는 운반사 단위로 직렬화돼 있어 정착 시간이 대수에 비례해 늘어난다 — 대기 상한은 그걸 감안해야 한다.
- 게이트의 원래 목적(보급이 깨진 운행의 배차 방지)은 지켜야 한다.
선택지
1. 발행 트랜잭션에서 상대 사본까지 만든다(동기화) 장점: 시차 자체가 사라진다. 단점: 아웃박스를 포기한다 — 전달 보장이 약해지고, 상대 조직 데이터를 남의 트랜잭션이 만드는 경계 붕괴가 생긴다.
2. 게이트를 없앤다 장점: 배차는 즉시 된다. 단점: 게이트를 만들게 했던 원래 사고(보급 실패 운행의 배차)로 되돌아간다.
3. 게이트의 질문을 바꾸고, 스폰 경로만 짧게 기다린다 장점: 아웃박스도 게이트의 목적도 유지된다. 단점: 스폰 경로에 대기(폴링)가 생기고, 판정 읽기를 트랜잭션 경계 밖으로 빼는 장치가 필요하다.
결정
3을 택했다. 핵심 판단은 두 가지다.
첫째, 비동기가 만든 시차는 없앨 수 없으니 게이트의 질문을 바꾼다. "보급이 끝났는가"는 시차 앞에서 항상 거짓이 되는 질문이다. 게이트는 "보급이 실패했는가"만 묻는 경량 판정으로 좁혔다. 보급이 진행 중인 것은 배차에 무해하다 — 배차 도장은 상대 사본이 아니라 운행 자신에게 찍히므로, 보급이 뒤늦게 끝나도 잃는 것이 없다.
// 배차 게이트 — "안 끝난 보급"이 아니라 "실패한 보급"만 막는다.
// 종전 규칙(보급 대기 차단)은 커밋 직후인 스폰 운행을 매번 거부했다 — 2026-09-02 운영 사고.
private void assertProvisionNotFailed(Collection<Run> candidates) {
Set<Long> failed = envelopeService.findProvisionFailedRunIds(idsOf(candidates));
if (!failed.isEmpty()) {
throw new IllegalStateException("상대 보급에 실패한 주문은 배차할 수 없습니다 (재보급 후 다시 시도)");
}
}
둘째, 정착을 기다리는 판정은 스냅샷 밖에서 읽는다. 간편배차 스폰 경로만 배차 전에 상대 사본의 정착을 기다린다 — 150ms 간격으로 되읽고 상한은 10초다. 이때 되읽기를 바깥 트랜잭션 안에서 하면 스냅샷에 갇히므로, 판정 조회는 매번 REQUIRES_NEW 로 새 트랜잭션을 연다. 같은 클래스 안에서 자기 호출로 부르면 프록시를 우회해 효과가 없다는 함정도 함께 적어 뒀다.
// 판정은 매번 "새 트랜잭션"으로 — 바깥 트랜잭션(REPEATABLE READ)의 첫 스냅샷에 갇히면
// 소비자가 커밋한 갱신이 영영 보이지 않는다.
@Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true)
public Set<Long> findUnsettledRunIdsFresh(Collection<Long> runIds) {
return repository.findUnsettledRunIds(runIds);
}
// 스폰 경로 한정 — 150ms 되읽기, 상한 10초(소비자가 운반사 단위 직렬이라 대수만큼 늘어남을 감안).
private void awaitSpawnedRunsProvisioned(List<Long> spawnedRunIds) {
long deadline = System.nanoTime() + WAIT_LIMIT_MS * 1_000_000L;
Set<Long> unsettled = envelopeService.findUnsettledRunIdsFresh(spawnedRunIds);
while (!unsettled.isEmpty() && System.nanoTime() < deadline) {
sleep(POLL_MS);
unsettled = envelopeService.findUnsettledRunIdsFresh(spawnedRunIds);
}
if (!unsettled.isEmpty()) {
// 운행은 이미 커밋됐다 — 미배차로 남기고 거부한다.
// 재시도는 "그 운행을 지정해서" 하게 안내한다(다시 스폰하면 중복 운행).
throw new IllegalStateException("상대 사본 정착 대기 초과 — 보급 상태 확인 후 지정 재배차");
}
}
상한을 넘기면 운행을 미배차 상태로 남기고 400을 돌려주되, 메시지가 "보급 상태를 확인하고 그 운행을 지정해 다시 배차하라"고 다음 행동까지 안내한다. 실패를 없애는 대신, 실패했을 때 무엇을 해야 하는지가 명확한 실패로 바꾼 것이다. 승낙 대기 운행은 배차 화면에서 아예 숨겨, 게이트 400 으로만 알 수 있던 상태를 화면 단계에서 끝냈다 — 숨김과 게이트가 같은 술어 하나를 공유하므로 "보이면 배차되고, 안 보이면 배차되지 않는다"가 성립한다.
결과와 한계
- 얻은 것: 간편배차가 정상 동작하고, 게이트의 원래 목적(보급 실패 차단)은 그대로다. 정착 대기는 대부분 수백 ms 안에 끝난다.
- 잃은 것: 스폰 경로에 최대 10초의 대기가 생겼고, 폴링이라는 원시적인 수단이 코드에 남았다.
- 되돌릴 조건: 소비자 지연이 상한을 상습적으로 넘으면(운반사 대수 증가), 폴링을 이벤트 통지(정착 완료 신호) 기반으로 바꿔야 한다.
팀에 남긴 규칙은 한 줄이다: 커밋 이후에 완성되는 데이터를 전제로 하는 단계는, 그 시차를 설계에 명시적으로 넣는다. 게이트를 하나 세울 때는 "이 술어가 항상 거짓이 되는 경로는 없는가"를 먼저 묻는다 — 이번 사고의 게이트는 그 질문을 건너뛴 대가였다.