디지털 지갑 설계: 이중 장부로 안전한 송금

1. 개요

두 지갑 간의 송금은 간단해 보입니다: 한쪽에서 빼고 다른 쪽에 더합니다. 수천 개의 동시 요청, 클라이언트 재시도, 네트워크 지연, 감시 요구사항이 생기면, “잔액 열을 그냥 업데이트”하는 방식은 곧 잔액 불일치를 야기합니다.

이 글은 이중 장부(double-entry ledger) 기반 디지털 지갑 설계를 다룹니다. 설계에는 요구사항, 데이터 모델, API, 처리 흐름, 그리고 어려운 부분들이 포함됩니다: 잠금 경합, 다른 시스템과의 일관성, 트래픽 급증. 이 글을 읽고 나면 Spring Boot 3과 PostgreSQL로 송금 서비스를 구축할 수 있습니다. 이 서비스는 돈을 잃지 않고, 두 번 차감하지 않으며, 대사가 가능합니다.

2. 요구사항

2.1. 기능

  • 각 사용자는 통화 유형(VND)별로 하나 이상의 지갑을 가집니다.
  • 충전, 출금, 지갑 간 송금.
  • 잔액과 거래 내역 조회.
  • 클라이언트가 요청을 다시 보내더라도 거래는 절대 두 번 기록되지 않습니다.

2.2. 규모 및 비기능 요구사항

  • 1천만 개 지갑, 평균 초당 500건 거래. 플래시 세일이나 급여일에 초당 5,000건.
  • 송금 API의 p99 지연시간 300ms 미만.
  • 잔액은 강한 일관성을 가져야 합니다. 오래된 잔액을 읽어 송금 허용 여부를 결정하는 것은 허용되지 않습니다.

2.3. 제약사항

  • 잔액은 절대 음수가 될 수 없습니다 (시스템 계좌는 음수 허용 가능).
  • 모든 잔액 변화는 회계 항목으로 추적 가능해야 합니다. 항목을 수정하거나 삭제할 수 없습니다. 오류를 수정하려면 역항목을 기록합니다.
  • 돈은 가장 작은 단위(동)의 정수로 저장합니다. double 사용 금지.

3. 이중 장부란 무엇인가

각 거래는 최소 두 개의 **회계 항목(entry)**으로 구성됩니다. 거래의 모든 항목의 합은 항상 0입니다. An에서 Binh로 100,000동을 송금하면 두 개의 항목이 생깁니다:

transaction T1
  account An    : -100000
  account Binh  : +100000
  ----------------------
  sum           :       0

충전도 같은 규칙을 따릅니다. 돈은 “어딘가”에서 오는데, 이는 BANK_CLEARING 같은 시스템 계좌이며 이 계좌는 음수를 가질 수 있습니다. 이 덕분에 우리는 매우 강력한 **불변식(invariant)**을 갖습니다: 시스템의 모든 항목의 합은 항상 0입니다. 합이 0이 아니면 확실히 버그입니다.

4. 데이터 모델

CREATE TABLE account (
    id              BIGINT PRIMARY KEY,
    owner_id        BIGINT      NOT NULL,
    currency        CHAR(3)     NOT NULL,
    balance         BIGINT      NOT NULL DEFAULT 0,
    allow_negative  BOOLEAN     NOT NULL DEFAULT FALSE,
    CHECK (allow_negative OR balance >= 0)
);

CREATE TABLE journal_transaction (
    id               UUID PRIMARY KEY,
    idempotency_key  VARCHAR(64) NOT NULL UNIQUE,
    type             VARCHAR(32) NOT NULL,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE ledger_entry (
    id              BIGSERIAL PRIMARY KEY,
    transaction_id  UUID   NOT NULL REFERENCES journal_transaction(id),
    account_id      BIGINT NOT NULL REFERENCES account(id),
    amount          BIGINT NOT NULL,
    balance_after   BIGINT NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_entry_account ON ledger_entry(account_id, id);

CREATE TABLE outbox_event (
    id          BIGSERIAL PRIMARY KEY,
    topic       VARCHAR(64) NOT NULL,
    payload     JSONB       NOT NULL,
    published   BOOLEAN     NOT NULL DEFAULT FALSE
);

위의 테이블들의 역할은 다음과 같습니다:

  • ledger_entry는 진실의 원천입니다. 테이블은 append-only입니다. amount가 음수면 차변, 양수면 대변입니다.
  • account.balance는 회계 항목의 합의 캐시입니다. 수백만 행을 SUM하지 않고도 빠르게 잔액을 확인하기 위해 이 열을 유지합니다.
  • CHECK 제약은 최후의 안전망입니다. 코드에 버그가 있어도 데이터베이스는 여전히 음수 잔액을 거부합니다.
  • idempotency_key UNIQUE는 거래 중복 기록을 방지합니다.
  • outbox_event는 Kafka로 이벤트를 전송하는 데 사용됩니다 (섹션 7.2).

5. API

POST /v1/transfers
Idempotency-Key: 6f1c2a9e-...-client-generated
Content-Type: application/json

{ "fromAccountId": 1001, "toAccountId": 2002, "amount": 100000 }
201 Created
{ "transactionId": "a3f0...", "status": "COMPLETED" }

클라이언트는 각 송금 의도에 대해 Idempotency-Key를 생성하고 재시도할 때 동일한 키를 사용합니다. 서버는 동일한 키에 대해 동일한 결과를 반환해야 합니다.

컨트롤러는 매우 얇습니다:

public record TransferRequest(@NotNull Long fromAccountId,
                              @NotNull Long toAccountId,
                              @Positive long amount) {}

public record TransferResult(UUID transactionId, String status) {}

@RestController
@RequestMapping("/v1/transfers")
class TransferController {
    private final TransferService service;

    TransferController(TransferService service) { this.service = service; }

    @PostMapping
    ResponseEntity<TransferResult> create(@RequestHeader("Idempotency-Key") String key,
                                          @Valid @RequestBody TransferRequest req) {
        return ResponseEntity.status(HttpStatus.CREATED).body(service.transfer(key, req));
    }
}

6. 송금 처리 흐름

우리는 각 SQL 문을 명확하게 보기 위해 spring-boot-starter-jdbc를 사용합니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

전체 흐름은 하나의 데이터베이스 트랜잭션 내에서 실행됩니다:

@Service
public class TransferService {
    private final JdbcTemplate jdbc;

    public TransferService(JdbcTemplate jdbc) { this.jdbc = jdbc; }

    @Transactional
    public TransferResult transfer(String key, TransferRequest req) {
        if (req.fromAccountId().equals(req.toAccountId())) {
            throw new IllegalArgumentException("자신에게 송금할 수 없습니다");
        }
        UUID txId = UUID.randomUUID();
        int inserted = jdbc.update("""
            INSERT INTO journal_transaction(id, idempotency_key, type)
            VALUES (?, ?, 'TRANSFER') ON CONFLICT (idempotency_key) DO NOTHING
            """, txId, key);
        if (inserted == 0) {                                        // (1)
            UUID existing = jdbc.queryForObject(
                "SELECT id FROM journal_transaction WHERE idempotency_key = ?", UUID.class, key);
            return new TransferResult(existing, "COMPLETED");
        }

        long fromBalance = lockAndGet(req.fromAccountId(), req.toAccountId()); // (2)
        if (fromBalance < req.amount()) {
            throw new InsufficientFundsException(req.fromAccountId());          // (3)
        }
        post(txId, req.fromAccountId(), -req.amount());                         // (4)
        post(txId, req.toAccountId(), req.amount());

        jdbc.update("INSERT INTO outbox_event(topic, payload) VALUES ('wallet.transfers', ?::jsonb)",
            """
            {"transactionId":"%s","from":%d,"to":%d,"amount":%d}
            """.formatted(txId, req.fromAccountId(), req.toAccountId(), req.amount()));
        return new TransferResult(txId, "COMPLETED");
    }

    private long lockAndGet(long fromId, long toId) {
        long fromBalance = 0;
        for (long id : new long[]{Math.min(fromId, toId), Math.max(fromId, toId)}) {
            long balance = jdbc.queryForObject(
                "SELECT balance FROM account WHERE id = ? FOR UPDATE", Long.class, id);
            if (id == fromId) fromBalance = balance;
        }
        return fromBalance;
    }

    private void post(UUID txId, long accountId, long amount) {
        Long after = jdbc.queryForObject(
            "UPDATE account SET balance = balance + ? WHERE id = ? RETURNING balance",
            Long.class, amount, accountId);
        jdbc.update("""
            INSERT INTO ledger_entry(transaction_id, account_id, amount, balance_after)
            VALUES (?, ?, ?, ?)""", txId, accountId, amount, after);
    }
}

class InsufficientFundsException extends RuntimeException {
    InsufficientFundsException(long accountId) { super("잔액 부족: " + accountId); }
}

코드의 주요 포인트:

  • (1) 멱등성. ON CONFLICT DO NOTHING은 키가 이미 존재하면 0행을 반환합니다. 같은 키를 가진 두 요청이 병렬로 실행된다고 가정합니다. PostgreSQL은 첫 번째 요청이 커밋될 때까지 두 번째 요청을 고유 인덱스에서 대기하게 합니다. 그 후 두 번째 요청은 0행을 받고 이전 거래를 반환합니다. 두 번째 차감이 없습니다.
  • (2) ID 순서에 따른 잠금. 스레드 A가 1→2로 송금하고 스레드 B가 2→1로 송금합니다. 각 스레드가 출금 계좌를 먼저 잠금하면, 두 스레드는 하나의 잠금을 보유하고 다른 하나를 기다리게 되어 deadlock이 발생합니다. 항상 작은 ID를 먼저 잠금하면 모든 스레드가 같은 순서로 잠금을 요청하므로 대기 사이클이 없습니다.
  • (3) 잠금 후 잔액 확인. 잠금 전에 확인하는 것은 고전적인 check-then-act 오류입니다. 두 요청이 모두 “충분한 잔액”을 읽고 모두 차감합니다.
  • (4) 두 항목과 outbox는 같은 트랜잭션에 있습니다. 어떤 단계가 실패하면 journal_transaction 행을 포함한 모든 것이 롤백됩니다. 따라서 클라이언트는 잔액 부족 오류가 발생했을 때 재시도할 수 있습니다.

주의: private 메서드나 같은 클래스에서 호출되는 메서드(self-invocation)에 @Transactional을 사용하지 마세요. Spring의 프록시가 트랜잭션을 감싸지 않으며 전체 잠금 메커니즘이 손실됩니다.

7. 어려운 부분들

7.1. Hot Account의 경합

큰 가맹점의 지갑은 초당 수천 건의 결제를 받을 수 있습니다. 모든 트랜잭션은 단일 행에서 FOR UPDATE를 기다리므로 처리량은 초당 수백 건으로 제한됩니다.

일반적인 해결책은 hot account를 N개의 sub-account로 분할하는 것입니다. 대변을 기록할 때 임의로 sub-account를 선택합니다. 가맹점의 실제 잔액은 sub-account의 합입니다:

long creditTarget(long merchantAccountId, int shards) {
    int shard = ThreadLocalRandom.current().nextInt(shards);
    return jdbc.queryForObject(
        "SELECT id FROM account WHERE parent_id = ? AND shard_no = ?",
        Long.class, merchantAccountId, shard);
}

16개 shard를 사용하면 경합이 약 16배 감소합니다. 이 접근법은 대변 방향에 잘 맞습니다. 왜냐하면 돈을 더하는 것은 잔액 확인이 필요하지 않기 때문입니다. 차변 방향(예: 가맹점 출금)은 훨씬 덜 자주 발생합니다. 정기적인 작업으로 돈을 주 계좌로 모을 수 있으며, 이 작업도 이중 장부 거래입니다.

일반 개인 지갑을 shard할 필요는 없습니다. 개인 지갑은 거의 경합되지 않으며 sharding은 잔액 확인을 훨씬 복잡하게 만듭니다.

7.2. 다른 시스템과의 일관성: Transactional Outbox

송금 후 알림, 부정행위 방지 및 포인트 서비스가 알아야 합니다. transfer() 내에서 kafkaTemplate.send()를 직접 호출하면 두 가지 오류 중 하나를 만날 수 있습니다:

  • Kafka가 메시지를 받지만 DB가 롤백되어 사용자가 존재하지 않는 거래에 대한 알림을 받습니다.
  • DB는 커밋되지만 Kafka 전송이 실패하여 이벤트가 손실됩니다.

섹션 6에서 우리는 같은 트랜잭션에 outbox_event에 이벤트를 기록했습니다. 별도의 relay가 이 테이블을 읽고 Kafka로 밀어냅니다:

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
@Component
class OutboxRelay {
    private final JdbcTemplate jdbc;
    private final KafkaTemplate<String, String> kafka;

    OutboxRelay(JdbcTemplate jdbc, KafkaTemplate<String, String> kafka) {
        this.jdbc = jdbc;
        this.kafka = kafka;
    }

    @Scheduled(fixedDelay = 200)
    @Transactional
    public void publish() {
        var rows = jdbc.queryForList("""
            SELECT id, topic, payload::text AS payload FROM outbox_event
            WHERE published = FALSE ORDER BY id LIMIT 100
            FOR UPDATE SKIP LOCKED""");
        for (var row : rows) {
            kafka.send((String) row.get("topic"), (String) row.get("payload")).join();
            jdbc.update("UPDATE outbox_event SET published = TRUE WHERE id = ?", row.get("id"));
        }
    }
}

@EnableScheduling을 설정 클래스에서 활성화하여 @Scheduled를 실행하세요. SKIP LOCKED를 통해 여러 relay 인스턴스가 행을 중복 없이 병렬로 실행할 수 있습니다.

이 메커니즘은 at-least-once를 보장합니다. relay가 전송 후 커밋 전에 죽으면 메시지가 다시 전송됩니다. 따라서 consumer는 transactionId에 따라 중복 제거해야 합니다.

7.3. 트래픽 급증

요청 양이 10배 증가하면 CPU가 아니라 데이터베이스 connection pool이 먼저 무너집니다. 세 가지 방어선이 있습니다:

  1. 사용자별 rate limit (Redis 사용).
  2. Connection pool 제한 (HikariCP)으로 DB 과부하 방지, 빠른 실패를 위한 짧은 타임아웃.
  3. 비긴급 작업(예: 알림, 포인트)을 Kafka로 연기합니다. 이는 outbox 덕분입니다.

고정 윈도우 rate limit의 예: 사용자당 초당 10개 요청 (spring-boot-starter-data-redis 사용):

@Component
class TransferRateLimiter {
    private final StringRedisTemplate redis;

    TransferRateLimiter(StringRedisTemplate redis) { this.redis = redis; }

    boolean allow(long userId) {
        String key = "rl:transfer:%d:%d".formatted(userId, Instant.now().getEpochSecond());
        Long count = redis.opsForValue().increment(key);
        if (count != null && count == 1) redis.expire(key, Duration.ofSeconds(2));
        return count != null && count <= 10;
    }
}
spring.datasource.hikari.maximum-pool-size=40
spring.datasource.hikari.connection-timeout=500
spring.jpa.open-in-view=false

allow()이 false를 반환하면 429 Too Many Requests를 반환합니다. 500ms 내에 connection을 얻지 못하면 503을 반환합니다. 클라이언트는 Idempotency-Key이 있으므로 안전하게 재시도할 수 있습니다.

주의: Redis를 잔액 저장이나 “미리 차감한 후 나중에 DB와 동기화”하는 데 사용하지 마세요. Redis는 매우 빠르지만 ledger와 함께 ACID 트랜잭션을 제공하지 않습니다. failover 한 번이면 돈이 손실됩니다.

8. 동시성 행동 테스트

가장 중요한 테스트는 같은 지갑에 많은 요청을 병렬로 보내는 것입니다. 지갑 A는 50,000동을 가지고 있고 1,000동 송금 명령 100개를 받습니다. 올바른 결과는 정확히 50개 명령이 성공하고, 잔액이 0으로 돌아가고, 항목의 합이 0입니다. 다음 테스트에는 실제 PostgreSQL(예: Testcontainers)과 @Sql의 초기화 데이터가 필요합니다.

@SpringBootTest
@Sql(statements = {
    "TRUNCATE ledger_entry, journal_transaction, outbox_event, account",
    "INSERT INTO account(id, owner_id, currency, balance) VALUES (1, 1, 'VND', 50000), (2, 2, 'VND', 0)"
})
class TransferServiceConcurrencyTest {

    @Autowired TransferService service;
    @Autowired JdbcTemplate jdbc;

    @Test
    void neverOverdraftsUnderConcurrency() throws Exception {
        AtomicInteger success = new AtomicInteger();
        try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 100; i++) {
                String key = "k-" + i;
                pool.submit(() -> {
                    try {
                        service.transfer(key, new TransferRequest(1L, 2L, 1_000));
                        success.incrementAndGet();
                    } catch (InsufficientFundsException ignored) { }
                });
            }
        }
        assertThat(success.get()).isEqualTo(50);
        assertThat(jdbc.queryForObject("SELECT balance FROM account WHERE id = 1", Long.class)).isZero();
        assertThat(jdbc.queryForObject("SELECT COALESCE(SUM(amount),0) FROM ledger_entry", Long.class)).isZero();
    }

    @Test
    void sameIdempotencyKeyReturnsSameTransaction() {
        var req = new TransferRequest(1L, 2L, 1_000);
        var first = service.transfer("dup", req);
        var second = service.transfer("dup", req);

        assertThat(second.transactionId()).isEqualTo(first.transactionId());
        assertThat(jdbc.queryForObject("SELECT balance FROM account WHERE id = 1", Long.class))
            .isEqualTo(49_000L);
    }
}

lockAndGet에서 FOR UPDATE를 제거하면 첫 번째 테스트가 임의로 실패합니다. 때때로 success는 50보다 크고, 때때로 CHECK 제약이 오류를 throw합니다. ON CONFLICT가 없으면 두 번째 테스트는 2,000동을 차감합니다.

테스트 외에도 매일 밤 대사 작업을 실행합니다. 이 작업은 두 가지를 확인합니다: 항목의 합이 0이고 account.balance가 각 계정의 ledger_entry 합과 같은지 확인합니다:

SELECT a.id, a.balance, COALESCE(SUM(e.amount), 0) AS ledger_sum
FROM account a LEFT JOIN ledger_entry e ON e.account_id = a.id
GROUP BY a.id, a.balance
HAVING a.balance <> COALESCE(SUM(e.amount), 0);

쿼리가 0행을 반환하면 장부가 일치합니다.

9. 흔한 오류와 언제 사용하지 말아야 할지

  • 돈에 double/float 사용. 0.1 + 0.2 계산은 0.3이 되지 않습니다. 가장 작은 단위의 long 또는 환율이 필요한 경우 BigDecimal을 사용하세요.
  • 항목 없이 balance를 직접 업데이트. 초기에는 빠르지만 고객이 불만을 제기하면 “돈이 어디로 갔는가”에 답할 수 없습니다.
  • 오류 항목 수정 또는 삭제. 역항목(reversal)을 새 거래로 기록하세요.
  • 순서 없는 잠금으로 인해 부하 하에 deadlock이 발생합니다. PostgreSQL은 트랜잭션을 취소하여 deadlock을 해제하고 사용자는 임의의 오류를 봅니다.
  • 잠금을 보유하는 동안 외부 API 호출(예: 은행 결제 게이트웨이). 잠금이 수초 동안 유지되고 처리량이 무너집니다. 임시 홀드 계정을 포함한 상태 머신(PENDING → COMPLETED/FAILED)으로 은행 관련 흐름을 분리합니다.
  • 이 설계를 돈이나 자산이 아닌 데이터(예: 조회수 카운터)에 사용하지 마세요. 작은 불일치는 허용되고 이중 장부는 단지 오버헤드입니다.

10. 결론

안전한 디지털 지갑의 핵심은 새로운 기술이 아니라 몇 가지 엄격한 원칙에 있습니다:

  • Append-only 이중 장부, 항목의 합은 항상 0입니다.
  • 모든 쓰기 명령에 대한 멱등성 키.
  • 고정된 순서로 비관적 잠금, 잠금 후 잔액 확인.
  • CHECK 제약은 최후의 안전망입니다.

그 위에서 sub-account를 사용하여 hot account를 처리하고, Transactional Outbox와 Kafka를 사용하여 다른 시스템과 동기화하며, Redis rate limit과 제한된 connection pool을 사용하여 트래픽 급증을 방지합니다.

더 확장할 때 임시 홀드 계정을 포함한 상태 머신으로 은행 흐름을 분리할 수 있습니다. 테이블이 수십억 행에 도달하면 ledger_entry를 시간별로 분할할 수 있습니다. 어떻게 확장하든 동시성 테스트와 대사 작업을 계속 실행하세요.

system-designspring-bootpostgresqlkafkafintech