제공 금액 100,000원을 80,000원으로 정정한다고 하자. 기존 금액을 UPDATE하면 현재 값은 남지만 20,000원이 줄어든 이유는 별도로 찾아야 한다. 차액 원장에서는 +100,000 뒤 -20,000을 추가하고 현재 금액을 합계로 계산한다.

이 글의 질문은 보정과 분납이 반복돼도 현재 금액과 과거 마감의 근거를 어떻게 재현할까다. 예약·제공 내역·결제가 다른 생명주기를 갖는 모델에서 delta INSERT와 SUM을 확장하는 설계를 설명한다.

같은 예약의 금액도 서로 다른 사실이다

예상가 100,000원에 현장 항목 30,000원이 추가되고 카드 80,000원, 현금 50,000원으로 받는다고 하자. 예상가는 100,000원이고 제공·수납 합계는 130,000원이다. 이후 카드 10,000원을 환불해도 실제 제공 금액이 자동으로 120,000원이 되지는 않는다. 미수인지 가격 조정인지 별도 정책이 필요하다.

예약은 일정·방문 의도, 제공 내역은 실제 항목, 결제는 자금 이동, 정산은 파트너와의 계산 기준을 표현한다. 한 예약 아래 여러 제공 내역과 여러 결제 행을 허용하면 분납을 payment_1, payment_2 같은 고정 컬럼에 가두지 않을 수 있다.

원장에는 차액과 효력·기록 시각을 함께 둔다

다음은 제공 내역별 조정을 저장하는 기본 스키마 예제다.

CREATE TABLE treatment (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    reservation_id bigint NOT NULL,
    occurred_at timestamptz NOT NULL,
    status varchar(24) NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE settlement_entry (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    treatment_id bigint NOT NULL REFERENCES treatment(id),
    entry_type varchar(32) NOT NULL,
    amount_delta numeric(18, 2) NOT NULL,
    currency char(3) NOT NULL,
    business_request_id uuid NOT NULL,
    source_entry_id bigint REFERENCES settlement_entry(id),
    reason_code varchar(32) NOT NULL,
    reason_text text,
    effective_at timestamptz NOT NULL,
    recorded_at timestamptz NOT NULL DEFAULT now(),
    recorded_by varchar(128) NOT NULL,
    UNIQUE (business_request_id),
    CHECK (amount_delta <> 0)
);

어제 발생한 항목이 오늘 입력될 수 있어 effective_at과 recorded_at을 나눈다. 보정은 원행 참조, 이유와 행위자를 남긴다. 여러 원행을 한 번에 조정한다면 연결 테이블을 선택할 수 있다. 금액은 정확한 계산이 필요한 값이므로 numeric이나 최소 화폐 단위 정수를 사용한다. PostgreSQL 숫자 타입. 통화·반올림 정책은 별도로 정한다.

SELECT treatment_id, SUM(amount_delta) AS settlement_amount
FROM settlement_entry
WHERE treatment_id = :treatment_id
GROUP BY treatment_id;

이 합계는 같은 의미의 delta를 모을 때 유효하다. 제공·고객 수납·파트너 지급·수수료를 모두 섞으면 합계가 어떤 사실인지 사라진다. 여러 의미를 한 원장에 담으려면 account_code를 추가하고 (business_request_id, account_code) 고유성을 선택할 수 있다. SERVICE_GROSS, CUSTOMER_PAYMENT, PROVIDER_PAYABLE, PLATFORM_FEE, REFUND는 보고서에 필요한 축부터 도입한다. 차액 기록만으로 복식부기나 전체 Event Sourcing을 구현했다고 보지 않는다.

최종 금액 정정도 동시성 제어가 필요하다

두 운영자가 합계 100,000원을 보고 목표 80,000원과 90,000원을 각각 보내면 -20,000과 -10,000이 함께 추가돼 70,000원이 된다. 행을 덮어쓰지 않았어도 판단 시점의 충돌은 남는다.

정정 명령에 기대 금액이나 버전을 포함한다.

{
  "treatmentId": 42,
  "expectedCurrentAmount": 100000,
  "newAmount": 80000,
  "reasonCode": "INPUT_CORRECTION",
  "requestId": "a2f6..."
}

서버가 최신 합계와 기대값을 비교하는 동안에도 다른 보정이 들어오지 않도록 상태 행 잠금 또는 optimistic version을 사용한다. 버전 방식의 핵심은 다음과 같다.

UPDATE treatment_settlement_state
SET version = version + 1
WHERE treatment_id = :treatment_id AND version = :expected_version
RETURNING version;

반환 행이 있을 때만 delta를 계산·추가하고 버전 변경과 원장을 같은 트랜잭션으로 커밋한다. 없다면 최신 내역을 다시 보여준다. 버전 행 생성과 최초 합계 계산은 생략된 구현 조건이다. READ COMMITTED의 문장별 스냅샷 차이를 고려해 기대값 비교만 따로 실행하지 않는다. 격리 수준의 읽기·갱신.

PG 거래는 정산 조정의 참조로 연결한다

승인·부분 취소도 별도의 거래 행으로 보존할 수 있다.

CREATE TABLE payment_transaction (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    treatment_id bigint NOT NULL,
    provider varchar(32) NOT NULL,
    provider_transaction_id varchar(128) NOT NULL,
    transaction_type varchar(24) NOT NULL,
    amount numeric(18, 2) NOT NULL,
    occurred_at timestamptz NOT NULL,
    original_transaction_id bigint REFERENCES payment_transaction(id),
    UNIQUE (provider, provider_transaction_id)
);

승인 80,000원과 50,000원, 원 승인을 참조하는 부분 취소는 서로 다른 행이다. 승인·취소의 부호 규칙은 명령 종류와 함께 정한다. 고객 수납 delta와 PG 거래를 연결하면 양쪽 합계를 대사할 수 있다. 한 거래가 여러 제공 내역에 배분된다면 다대다 연결이 필요하다.

PG 승인은 외부 자금 이동이고 수수료율 보정은 내부 정산 정책일 수 있다. 둘을 같은 행으로 합치지 않는다. 이 테이블은 플랫폼에서 거래 이력을 표현하는 예제이며 PG 내부 저장 모델을 설명하는 것이 아니다.

마감은 cutoff보다 포함 집합을 고정한다

마감 뒤 현재 테이블을 다시 SUM하면 늦게 입력된 과거 항목이 보고서를 바꿀 수 있다. recorded_at <= cutoff_at도 충분하지 않다. cutoff 이전 timestamp를 가진 트랜잭션이 나중에 커밋하면 다음 조회에 새로 보인다. sequence 최댓값 역시 커밋 순서를 뜻하지 않는다.

일관된 스냅샷의 포함 원장 ID를 고정하고 늦은 커밋은 다음 기간 조정 또는 새 마감 revision으로 처리하는 흐름

마감 시점의 일관된 DB 스냅샷에서 원장 ID 집합을 고르고 그 명세와 합계를 함께 저장한다. 재현은 현재 테이블의 cutoff 재조회가 아니라 고정 명세를 기준으로 한다. 이후 과거 효력일의 항목은 다음 기간 조정 또는 승인된 재마감으로 처리한다.

CREATE TABLE settlement_close (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    partner_id bigint NOT NULL,
    period_start date NOT NULL,
    period_end date NOT NULL,
    revision integer NOT NULL,
    cutoff_at timestamptz NOT NULL,
    total_amount numeric(18, 2) NOT NULL,
    entry_count bigint NOT NULL,
    status varchar(24) NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now(),
    UNIQUE (partner_id, period_start, period_end, revision)
);

이 예제는 새 마감본을 만들 수 있도록 revision을 포함한다. 포함 ID를 저장할 명세 테이블과 승인·기존본 보존 규칙은 별도로 구현한다. 어느 기간에 늦은 항목을 반영하는지는 계약·회계 정책에서 정하고 시스템은 그 선택을 재현해야 한다.

파생 합계는 다시 만들 수 있게 둔다

행이 늘면 매번 전체 SUM을 읽는 비용이 커진다. 대상·효력일 인덱스, 기간 파티션, current balance와 일·월 집계를 사용하되 필요한 조회부터 측정한다. 파생 잔액을 원장과 같은 트랜잭션에서 갱신하거나 재처리 가능한 방식으로 만들고 주기적으로 대사한다.

원장 권한과 정정 API도 모델을 뒷받침해야 한다. append-only가 개인정보를 영원히 보존한다는 뜻은 아니다. 재무 근거와 개인 식별 연결을 분리하고 보존 정책에 맞춘다. CHECK는 행의 비영 delta를 검사하는 데 쓸 수 있지만 전체 행의 누적 합계를 안전하게 검사하는 장치가 아니다. PostgreSQL 제약의 범위.

검증은 최초 제공·추가·보정, 분납·부분 취소, 마감 뒤 과거 항목 입력을 연결한다. 계정별 합계, 요청 중복, 동시 정정 Conflict, 고정 명세 재계산과 늦은 커밋을 확인한다. PG 저장과 원장, 파생 잔액 사이의 중간 실패도 다룬다. 외부 PG 호출의 실패는 로컬 롤백만으로 끝나지 않아 대사가 필요하다.

현재 값만 필요하고 과거 근거가 중요하지 않다면 UPDATE가 더 단순하다. 차액 원장은 변경 이유·효력 시각과 마감 재현이 필요한 값에 적합하다. 운영 UI에서는 부호 있는 delta를 직접 입력하게 하기보다 추가·부분 취소·최종값 정정이라는 명령을 제공하고 서버가 동시성 조건에 맞춰 변환한다.