Treasury 오케스트레이션은 원장·가용 자금·이체·정산을 분리하는 데서 시작한다
원장의 회계상 진실, 지금 사용할 수 있는 금액, 이체 실행, 외부 정산을 별도 기록으로 유지하고 오케스트레이터가 각 근거를 연결해야 금융 업무를 설명하고 복구할 수 있다.
Treasury에는 하나의 잔액만 존재하지 않는다
Treasury 화면에 ‘잔액’이라는 숫자 하나만 보여줄 수는 있다. 그러나 그 숫자만으로는 운영에 필요한 질문에 답하기 어렵다. 지금 바로 사용할 수 있는 금액은 얼마인가. 회계에는 이미 무엇이 반영됐는가. 현재 이동 중인 금액은 얼마인가. 외부 기관에서 실제 정산이 끝난 금액은 얼마인가.
이 네 가지는 서로 다른 사실이다. 하나의 변경 가능한 잔액 필드에 모두 담으면 정상 흐름은 짧아 보인다. 대신 은행 응답 지연, 중복 메시지, 이체 거절, 대사 불일치가 생길 때마다 어느 값이 진짜인지 다시 다퉈야 한다.
Treasury 오케스트레이션의 역할은 모든 금융 상태를 직접 소유하는 것이 아니라, 서로 다른 책임이 만든 근거를 잃지 않고 하나의 업무 흐름으로 연결하는 것이다.
경계는 분명해야 한다. 원장은 회계상의 변화를 기록한다. 가용 자금 관리는 같은 자금을 두 번 약속하지 못하게 막는다. 이체는 자금 이동의 실행 과정을 관리한다. 정산은 외부에서 확인한 최종 결과를 남긴다. 오케스트레이터는 각 경계의 근거를 받아 다음 단계를 결정한다.
‘잔액’이라는 말에 숨은 네 가지 값
금융 시스템에서 잔액이라는 표현은 흔히 다음 값을 뒤섞는다.
원장 잔액
원장은 금액이 왜 변했는지 설명한다. 분개나 원장 항목에서 계산되며, 선택한 회계 모델의 불변식을 지켜야 한다. 최신 합계만 돌려주는 테이블이 아니라 현재 위치가 만들어진 과정을 설명할 수 있어야 한다.
가용 금액
가용 금액은 정책을 적용한 결과다. 원장에 자금이 있어도 승인된 이체를 위해 이미 확보했거나, 처리 중인 청구가 있거나, 위험 검토와 상품 한도가 걸려 있다면 지금 사용할 수 없다.
이체 상태
이체는 자금을 움직이겠다는 의도와 실행 과정을 기록한다. 접수, 대기, 외부 제출, 잠정 응답, 실패, 취소, 역거래는 업무 상태다. 원장 반영이나 외부 정산과 같은 뜻이 아니다.
정산 상태
정산은 은행 내역, 공급자 보고서, 네트워크 원장처럼 권위 있는 외부 근거로 확인한 결과다. 내부 접수와 실행보다 늦게 확정될 수 있다.
네 값은 연결돼 있지만 항상 같아야 하는 것은 아니다. 오히려 서로 다른 동안 그 차이를 설명할 수 있어야 한다.
오케스트레이터는 조정하고, 각 도메인은 판단한다
하나의 업무가 여러 경계를 지날 때 오케스트레이터가 필요하다. 예를 들어 이체 한 건에 한도 확인, 가용 금액 확보, 환율 고정, 외부 제출, 원장 반영, 사후 대사가 필요할 수 있다.
오케스트레이터가 소유할 것은 다음과 같다.
- 업무 식별자와 요청한 최종 결과
- 실행 순서와 현재 단계
- 재시도, 시간 제한, 보상 정책
- 각 참여자가 반환한 근거
- 완료, 실패, 수동 검토 같은 운영상 결론
반대로 워크플로 분기 안에서 회계상의 진실을 직접 계산하거나 잔액을 수정해서는 안 된다. 각 참여자는 더 좁은 계약을 가져야 한다.
ledger.post(journal) -> 원장 반영 참조값
reserve.hold(account, amount, operation) -> 확보 참조값
fx.lock(quote) -> 실행 가능한 환율 참조값
transfer.submit(instruction) -> 외부 제출 참조값
settlement.observe(reference) -> 권위 있는 외부 관측
이렇게 나누면 오케스트레이터를 바꾸더라도 금융 기록의 의미는 유지된다. 또한 프로세스가 이전 응답을 저장하지 못했다는 이유만으로 같은 금융 효과를 다시 실행하는 일을 막을 수 있다.
원장은 의미상 덧붙이기만 한다
구현에서 수정 기능을 제공하더라도 원장 이력의 의미는 불변이어야 한다. 잘못된 기록은 몰래 덮어쓰지 않는다. 취소나 조정을 설명하는 새 원장 항목으로 바로잡는다.
각 분개에는 다음 정보가 필요하다.
- 안정적인 업무 식별자
- 회계 모델에 맞게 균형을 이루는 차변과 대변
- 서로 섞일 수 없는 통화 또는 자산 차원
- 효력 발생 시각과 시스템 기록 시각
- 워크플로와 원천 근거의 참조값
- 같은 요청을 다시 처리해도 중복 반영되지 않는 유일성 규칙
데이터베이스 트랜잭션은 로컬 불변식을 보호한다. PostgreSQL 문서는 Serializable 격리 수준에서 성공적으로 커밋된 동시 트랜잭션이 어떤 순차 실행과 같은 결과를 내도록 보장한다고 설명한다. 대신 직렬화 실패가 발생하면 애플리케이션이 재시도해야 한다. 경쟁하는 확보 요청이나 원장 반영을 보호하는 데 유용하지만, 이 보장은 메시지 브로커나 은행 API, 블록체인 노드까지 이어지지 않는다. 데이터베이스 경계를 명확히 남겨야 한다.
조회 성능을 위해 합계 값을 따로 저장할 수는 있다. 다만 원장 이력에서 다시 만들거나 지속적으로 대조할 수 있어야 한다. 값이 다르면 합계를 몰래 고치지 말고 불일치로 드러내야 한다.
가용 자금 확보는 원장이 아니다
가용 자금 확보는 회계 기록을 하나 더 만드는 일이 아니다. 요청한 금액을 특정 업무에 약속해도 되는지 판단하고, 동시에 진행되는 다른 작업이 같은 여력을 쓰지 못하게 하는 일이다.
상태는 작을수록 좋다.
requested -> active -> consumed
-> released
-> expired
가용성 검사와 확보 생성은 원자적으로 처리해야 한다. 실제 사용으로 전환할 때는 그 결과인 이체나 원장 참조값을 남긴다. 해제와 만료도 안정적인 작업 식별자를 가져야 한다. 시간 초과 뒤 같은 요청이 다시 올 수 있기 때문이다.
부분 실패에서 이 구분이 중요하다. 외부 제출 전에 검증이 실패했다면 정책에 따라 확보를 풀 수 있다. 반면 외부 제출 결과를 모르는 상태에서 바로 풀면 첫 이체가 살아 있는 동안 두 번째 이체가 시작될 수 있다. 이때 상태는 ‘실패’가 아니라 ‘결과 불명’이다. 외부 관측이나 운영자 검토로 결론이 날 때까지 가용성을 통제해야 한다.
이체는 불리언이 아니라 수명 주기다
success: true만으로는 이체를 표현할 수 없다. 내부 접수, 실행 대기, 외부 제출, 잠정 승인, 정산 완료, 거절, 취소, 역거래, 결과 불명은 서로 다른 상태다.
이체 기록에는 세 종류의 식별자를 보존해야 한다.
- 클라이언트나 상위 시스템이 보낸 멱등성 키
- 내부 업무와 이체 식별자
- 은행, 공급자, 체인이 발급한 외부 참조값
세 값은 해결하는 문제가 다르다. 외부 제출 전에는 공급자 참조값이 아직 없다. 반대로 내부 식별자만으로는 외부 정산을 증명할 수 없다.
재시도 여부는 근거에 따라 정한다. 제출 전 검증 실패는 원인을 고친 뒤 같은 업무를 이어갈 수 있다. 지시를 보낸 뒤 시간 초과가 발생했다면 새로 제출하기 전에 기존 상태부터 조회해야 한다. 업무상 최종 거절은 기존 요청의 재사용이 아니라 새로운 의도가 필요할 수 있다.
데이터 저장과 이벤트 발행 사이의 틈을 없앤다
Treasury 워크플로는 상태를 저장한 뒤 다른 서비스에 이벤트를 보내는 경우가 많다. 데이터베이스 기록과 Kafka 발행을 서로 무관한 두 작업으로 실행하면 하나만 성공하는 이중 쓰기 문제가 생긴다.
AWS Prescriptive Guidance는 트랜잭셔널 아웃박스 패턴을 설명한다. 도메인 변경과 이벤트 기록을 하나의 데이터베이스 트랜잭션으로 저장한 뒤, 커밋된 이벤트를 별도 릴레이가 전달하는 방식이다. 전달 중복은 여전히 가능하므로 소비자도 이벤트 식별자로 중복을 제거해야 한다.
운영 규칙은 다음처럼 정리할 수 있다.
- 금융 상태와 그 상태를 알리는 아웃박스 이벤트를 함께 커밋한다.
- 릴레이 전송에는 안정적인 이벤트 식별자와 시도 기록을 둔다.
- 소비자는 이벤트 식별자로 중복을 제거한다.
- 이벤트 소비 성공을 외부 금융 효과의 성공으로 간주하지 않는다.
- 재처리는 새 업무를 만들지 않고 기존 업무를 가리킨다.
Kafka 트랜잭션은 설정된 consume-transform-produce 흐름 안에서 정확히 한 번 처리하는 데 도움을 줄 수 있다. PostgreSQL 변경, 은행 이체, 웹훅까지 하나의 원자적 트랜잭션으로 만들지는 않는다. 도메인 식별자, 아웃박스 경계, 대사는 여전히 필요하다.
정산은 외부 관측과 대사의 결과다
작업자가 정상 종료됐다는 사실만으로 정산 완료를 선언해서는 안 된다. 정산은 권위 있는 외부 근거에서 확인해야 한다.
대사에서는 최소한 다음을 비교한다.
- 내부 이체와 기대 금액
- 외부 참조값과 실제 관측 금액
- 관련 원장 항목
- 가용 금액의 사용 또는 해제 결과
- 처리 시각, 수수료, 환율 차이, 역거래 여부
결과도 명시적으로 남긴다. 일치, 시점 차이, 금액 차이, 내부 누락, 외부 누락, 중복, 수동 검토가 필요하다. 배치 작업이 차이를 발견할 수는 있지만, 조정 분개를 만들지, 조회를 다시 시도할지, 운영자에게 넘길지는 통제된 워크플로가 결정해야 한다.
이 지점에서 오케스트레이션과 분산 트랜잭션을 흉내 내는 방식의 차이가 드러난다. AWS의 Saga 지침은 여러 단계의 업무를 조정하고 계속 진행하거나 보상하는 방식을 설명한다. Treasury에서 보상은 과거 기록을 지우는 일이 아니라 새로운 금융 작업인 경우가 많다. 역거래에도 별도 승인, 원장 반영, 외부 참조값, 근거가 필요하다.
운영 화면에는 성공보다 불확실성을 보여준다
운영 화면이 성공한 이체 목록만 보여줘서는 부족하다. 아직 결론이 나지 않은 상태를 찾을 수 있어야 한다.
업무 한 건에서 다음을 한 흐름으로 확인할 수 있어야 한다.
- 최초 요청과 승인 맥락
- 가용 자금 확보와 원장 참조값
- 아웃박스와 소비자의 처리 시도
- 외부 제출과 후속 관측
- 대사에서 발견한 차이
- 수동 조치의 실행자, 사유, 결과 근거
실제로 중요한 작업 목록은 ‘결과 불명’, ‘정산 지연’, ‘미해제 확보’, ‘원장과 외부 결과 불일치’일 때가 많다. 이런 상태가 있어야 대시보드를 정상으로 만들기 위한 자동 재시도가 두 번째 금융 효과를 만들어내지 않는다.
다시 사용할 수 있는 구조
공개된 BEATOZ Treasury 프로젝트 기록에는 NestJS와 PostgreSQL 기반의 운영 플랫폼, 원장·가용 자금·FX·이체·모니터링 모듈, Kafka ingress/outbox, 대사, 재처리, 운영 도구가 설명돼 있다. 여기서 얻을 교훈은 특정 구현이 Treasury의 정답이라는 것이 아니다. 각 책임이 자기 사실을 설명하고, 오케스트레이터가 그 사실을 근거로 업무를 전진시킬 때 시스템을 운영할 수 있다는 점이다.
Treasury 워크플로를 신뢰할 수 있다고 말하기 전에 다음을 확인한다.
- 원장, 가용 금액, 이체, 정산이 별도 기록으로 존재하는가.
- 모든 금융 효과에 안정적인 업무 식별자가 있는가.
- 로컬 불변식이 하나의 데이터베이스 트랜잭션에서 커밋되는가.
- 서비스 경계를 넘는 이벤트에 아웃박스와 같은 영속 인계가 있는가.
- 외부 효과를 반복하기 전에 기존 근거를 조회하는가.
- 대사 차이를 몰래 고치지 않고 불일치로 기록하는가.
- 운영자가 이력을 지우지 않고 불확실성을 해소할 수 있는가.
Treasury 오케스트레이션의 핵심은 많은 서비스를 연결하는 데 있지 않다. 하나의 금융 업무가 여러 경계를 지나도 각 경계가 가진 의미를 잃지 않게 하는 데 있다.