포인트 원장 합계가 5,000이고 서로 다른 3,000 사용 요청 두 개가 들어온다고 하자. 두 요청이 같은 합계를 읽으면 각각 잔액 검사를 통과하고 -3,000을 INSERT할 수 있다. 행을 수정한 적이 없어도 최종 합계는 -1,000이다.

이 글의 질문은 append-only 원장에서 잔액을 읽고 승인하는 연산을 어떻게 순서대로 처리할까다. 지급·사용·보정을 새 행으로 기록하는 모델과 계정별 Redis 잠금을 출발점으로, TTL 만료와 DB 보호 대안을 설명한다. 잔액과 시간은 아래 실행 순서를 설명하는 예제 값이다.

중복 방지와 잔액 보호는 다른 조건이다

INSERT가 원자적이라는 사실은 합계 조회 → 충분한지 검사 → 사용 행 추가 전체를 원자적으로 만들지 않는다. 두 요청 ID가 다르면 UNIQUE도 충돌하지 않는다. 같은 요청의 재전송인지, 다른 정상 요청이 같은 잔액을 경쟁하는지부터 구분한다.

조사할 때 계정 ID와 요청 ID, 잔액 조회값, 원장 금액, DB 트랜잭션 시작·커밋을 한 시간축에 놓는다. Redis 획득·만료·해제, 소유 토큰과 실행 인스턴스도 연결한다. 잔액을 읽은 DB가 primary인지 replica인지 확인하면 잠금 경합과 복제 지연을 분리할 수 있다.

만료된 잠금의 작업자는 자동으로 멈추지 않는다

단일 Redis의 잠금 획득 핵심은 다음 형태다.

SET point-lock:{accountId} {ownerToken} NX PX {ttlMillis}

NX는 키가 없을 때만 설정하고 PX는 만료 시간을 둔다. 토큰은 획득 시도별로 구분한다. TTL은 작업자가 임계 구역을 독점할 수 있는 시간이지, 시간이 끝나면 DB 작업을 취소하는 장치는 아니다. Redis의 단일 인스턴스 잠금 패턴은 이 유효기간과 소유 확인을 설명한다.

A의 잠금이 만료돼 B도 같은 잔액을 읽으면 두 사용 원장이 커밋되어 합계가 음수가 되는 예제

예를 들어 A가 3초 TTL로 잠금을 얻고 커밋 전에 지연되면 B는 만료 후 키를 다시 얻을 수 있다. 두 작업이 동시에 살아 있는 구간이다. GC 정지, DB lock wait, 네트워크 지연과 외부 호출이 이 구간을 늘릴 수 있다.

해제도 토큰을 비교해야 한다. 늦게 끝난 A가 단순 DEL을 실행하면 B의 새 잠금을 지울 수 있다.

if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
end
return 0

Lua 안에서 비교와 삭제를 함께 실행하면 현재 소유 토큰이 일치할 때만 지운다. 소유 확인 해제는 타인의 잠금 삭제를 막지만, TTL 이후 A의 잔액 쓰기를 막지는 않는다. watchdog을 사용해도 연장 실패와 전체 실행 상한, 중단된 작업의 처리 정책이 필요하다.

보호할 자원은 요청보다 계정이다

다른 요청이 같은 잔액을 사용하므로 계정별 키가 자연스러운 선택이다. 요청별 키는 서로 다른 요청을 직렬화하지 못하고, 전역 키는 무관한 계정까지 기다리게 한다. 계정 이체처럼 두 자원을 함께 잠근다면 획득 순서와 DB 원자성을 별도로 설계한다.

잠금 후 다음 요청이 replica에서 오래된 합계를 읽으면 실행 순서가 맞아도 승인 판단은 틀릴 수 있다. 판정 읽기와 쓰기는 primary의 거래 경계에 둔다. 같은 계정의 모든 쓰기 경로가 이 규칙에 참여하는지도 확인한다.

DB에서는 요청 고유성과 금액 불변식을 각각 지킨다

같은 비즈니스 요청을 한 번만 기록하는 제약부터 둔다.

CREATE TABLE point_ledger (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    account_id bigint NOT NULL,
    business_request_id uuid NOT NULL,
    amount bigint NOT NULL,
    reason text NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT uq_point_ledger_request
        UNIQUE (account_id, business_request_id)
);

이 제약은 사전 SELECT가 놓친 동시 삽입도 저장 시점에 판정한다. PostgreSQL은 미커밋 충돌 행의 종료를 기다리고 고유성을 다시 확인한다. 유일성 검사. 그러나 이 스키마만으로 계정 전체 SUM이 음수가 되지 않는 조건을 보장하지는 않는다.

잔액 스냅샷 행을 함께 유지하는 대안에서는 다음 순서를 사용한다.

BEGIN;
SELECT balance FROM point_account WHERE id = :account_id FOR UPDATE;
UPDATE point_account
SET balance = balance - :use_amount
WHERE id = :account_id AND balance >= :use_amount;
-- UPDATE가 1행일 때만 실행한다.
INSERT INTO point_ledger(account_id, business_request_id, amount, reason)
VALUES (:account_id, :request_id, -:use_amount, 'USE');
COMMIT;

금액은 양수로 검증하고 갱신이 0행이면 원장을 넣지 않는다. 요청 UNIQUE가 충돌하면 잔액 변경도 함께 롤백한다. 계정 행은 DB 경합의 기준이 되고 원장과 잔액이 같이 확정된다. 대신 파생 잔액과 원장 합계의 일치 검사가 필요하다. 이 SQL은 분기를 구현할 저장소 코드를 생략한 설계 예제다.

잔액 스냅샷 대안에서 계정 잠금 뒤 DB 조건부 차감·원장 추가를 함께 커밋하고 소유자 잠금을 해제하는 흐름

그림은 잔액 스냅샷 행을 추가하는 대안이다. INSERT-only 원장에 이미 이 구조가 있다고 해석하지 않는다. 성공·거절 모두 실제 트랜잭션 종료 뒤 해제한다. 프록시 기반 @Transactional 메서드 안의 finally는 커밋보다 먼저 실행될 수 있어 외부 래퍼나 종료 콜백의 순서를 확인한다.

스냅샷 행을 피하려면 합계 판정을 Serializable 트랜잭션으로 수행하는 대안이 있다. 성공한 커밋은 직렬 실행과 같은 결과를 갖지만 40001 실패 시 전체 트랜잭션을 재시도해야 한다. 외부 효과를 재시도 구간에 그대로 넣지 않는다. Serializable의 보장·재시도.

계정별 큐도 순서를 만들 수 있다. burst를 흡수하는 대신 처리 지연, 소비자 재할당과 재전송을 관리한다. DB 잔액 행, Serializable, 큐의 선택은 경합률과 동기 응답 요구, 운영 비용으로 비교한다. 요청 UNIQUE는 어느 선택에서도 별도 역할을 갖는다.

TTL을 넘는 테스트에서 저장 결과를 확인한다

같은 요청 ID 반복은 원장 한 행과 중복 응답 계약을 검사한다. 다른 ID로 잔액을 경쟁하는 테스트는 잔액 불변식을 검사한다. 잔액 100에 금액 30의 서로 다른 요청 10개를 보낸다면, 처리 가능한 사용이 모두 완료되는 조건에서 성공은 세 개이고 잔액은 10이어야 한다.

강제 pause로 TTL을 넘기고, 커밋 전 프로세스를 종료하고, Redis 지연과 replica 읽기를 주입한다. 성공 건수만 세지 않고 잠금 토큰·트랜잭션·원장을 연결한다. 실패 요청이 뒤늦은 비동기 재시도로 다시 승인되는지도 본다.

TTL은 평균보다 꼬리 지연과 DB 대기를 고려해 정하되 너무 긴 만료는 장애 후 대기를 늘린다. 획득 대기, TTL 초과, 연장 실패와 소유 불일치 해제를 함께 관측한다. 금액 오류를 허용할 수 없는 조건에서는 Redis 잠금이 사라져도 DB에서 잔액 보호가 유지되는 설계를 먼저 선택한다.