결제 시스템 설계: 멱등성, 대사 및 환불
1. 개요
결제 시스템은 일반적인 CRUD 작업과 다른 한 가지 점이 있습니다: 한 번 실수하면 실제 돈을 잃게 됩니다. 클라이언트의 재시도(retry)로 인해 고객이 두 번 청구될 수 있습니다. 결제 게이트웨이(PSP)는 거래가 이미 성공했는데도 타임아웃이 발생할 수 있습니다. 두 개의 환불 요청이 병렬로 실행되면 수령한 금액을 초과하여 환불할 수 있습니다.
이 글은 요구사항, 데이터 모델, API 및 처리 흐름을 포함한 구체적인 설계를 다룹니다. 그 다음 세 가지 어려운 부분을 해결합니다: 멱등성(idempotency), 대사(reconciliation), 그리고 환불(refund). 읽고 나면, Spring Boot 3와 PostgreSQL로 핵심 부분을 직접 구축할 수 있으며, 동시성 상황에서 환불이 한도를 초과하지 않음을 증명하는 테스트도 함께 제공됩니다.
2. 요구사항
2.1. 기능 요구사항
- 판매자가
Order에 대한 결제를 생성합니다. 시스템이 PSP를 호출하여 카드 또는 지갑에서 금액을 청구합니다. - 판매자는 전액 환불하거나 부분 환불할 수 있습니다. 환불 총액은 수령한 금액을 초과할 수 없습니다.
- 매일, 시스템은 내부 데이터를 PSP의 settlement 파일과 대사합니다.
- 시스템은 Order 및 Accounting과 같은 다른 서비스에 이벤트(
PaymentCaptured,RefundSucceeded등)를 발행합니다.
2.2. 규모 및 제약 조건
- 평균 초당 200건의 거래. 플래시 세일 피크 시 몇 분간 초당 약 5,000건의 거래.
- 같은 결제 의도에 대해 절대 두 번 청구하지 않습니다. 이것은 hard constraint입니다.
- 가용성보다 일관성을 우선시합니다: 클라이언트에 오류를 반환하는 것이 잘못된 금액을 기록하는 것보다 낫습니다.
- PSP는 외부 시스템입니다. 느릴 수 있고, 타임아웃이 발생할 수 있으며, 웹훅이 늦거나 중복으로 전송될 수 있습니다.
3. 데이터 모델
돈은 항상 가장 작은 단위(VND는 동(đồng), USD는 센트)로 BIGINT 타입으로 저장됩니다. double을 사용하지 않습니다.
CREATE TABLE payments (
id UUID PRIMARY KEY,
merchant_id TEXT NOT NULL,
order_id TEXT NOT NULL,
amount BIGINT NOT NULL CHECK (amount > 0),
currency CHAR(3) NOT NULL,
status TEXT NOT NULL, -- CREATED, PROCESSING, CAPTURED, FAILED, UNKNOWN
refunded_amount BIGINT NOT NULL DEFAULT 0,
psp_reference TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (refunded_amount <= amount)
);
CREATE TABLE refunds (
id UUID PRIMARY KEY,
payment_id UUID NOT NULL REFERENCES payments(id),
amount BIGINT NOT NULL CHECK (amount > 0),
status TEXT NOT NULL, -- PENDING, SUCCEEDED, FAILED
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE idempotency_keys (
merchant_id TEXT NOT NULL,
idem_key TEXT NOT NULL,
request_hash TEXT NOT NULL,
status TEXT NOT NULL, -- IN_PROGRESS, COMPLETED
response_code INT,
response_body TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (merchant_id, idem_key)
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
topic TEXT NOT NULL,
payload JSONB NOT NULL,
sent_at TIMESTAMPTZ
);
주목할 몇 가지 사항:
CHECK (refunded_amount <= amount)는 데이터베이스 계층의 최후의 안전망입니다. 코드에 버그가 있어도 데이터베이스가 차단합니다.idempotency_keys테이블의 기본 키(merchant_id, idem_key)는 중복 방지 메커니즘의 핵심입니다.outbox테이블은 분산 트랜잭션 없이 Kafka로 이벤트를 발행하는 데 도움이 됩니다 (섹션 6 참조).
4. API 설계
POST /v1/payments
Headers: Idempotency-Key: 7f3c1e2a-...
Body: { "orderId": "ORD-1001", "amount": 250000, "currency": "VND", "source": "tok_visa" }
→ 201 { "id": "...", "status": "CAPTURED" }
POST /v1/payments/{id}/refunds
Headers: Idempotency-Key: 9a8b...
Body: { "amount": 100000 }
→ 202 { "refundId": "...", "status": "PENDING" }
GET /v1/payments/{id}
상태를 변경하는 모든 API는 클라이언트가 생성한 Idempotency-Key를 반드시 포함해야 합니다. 클라이언트가 재시도할 때 동일한 키를 다시 전송합니다. PSP가 환불을 비동기로 처리하기 때문에 환불은 202 Accepted를 반환합니다.
5. 멱등성: 중복 청구 방지
5.1. 아이디어
요청을 받으면, 아무것도 하기 전에 먼저 데이터베이스에 키를 기록합니다. 세 가지 경우가 있습니다:
- 키가 아직 존재하지 않음: 성공적으로 기록하고 계속 처리합니다.
- 키가
COMPLETED상태: 저장된 응답을 정확히 반환하고 PSP를 다시 호출하지 않습니다. - 키가
IN_PROGRESS상태: 다른 요청이 같은 키로 실행 중입니다. 클라이언트가 나중에 다시 시도하도록409 Conflict를 반환합니다.
또한 본문의 해시를 저장합니다. 클라이언트가 다른 본문(예: 다른 금액)에 대해 키를 재사용하면, 클라이언트 오류이고 422를 반환합니다.
5.2. 구현
필요한 의존성:
<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 IdempotencyService {
private final JdbcTemplate jdbc;
public IdempotencyService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public record StoredResponse(int code, String body) {}
public Optional<StoredResponse> begin(String merchantId, String key, String requestHash) {
int inserted = jdbc.update("""
INSERT INTO idempotency_keys (merchant_id, idem_key, request_hash, status)
VALUES (?, ?, ?, 'IN_PROGRESS')
ON CONFLICT (merchant_id, idem_key) DO NOTHING
""", merchantId, key, requestHash);
if (inserted == 1) {
return Optional.empty(); // 새로운 요청이므로 처리 진행
}
Map<String, Object> row = jdbc.queryForMap("""
SELECT request_hash, status, response_code, response_body
FROM idempotency_keys WHERE merchant_id = ? AND idem_key = ?
""", merchantId, key);
if (!requestHash.equals(row.get("request_hash"))) {
throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Key reused with different body");
}
if ("IN_PROGRESS".equals(row.get("status"))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Request is being processed");
}
return Optional.of(new StoredResponse((Integer) row.get("response_code"), (String) row.get("response_body")));
}
public void complete(String merchantId, String key, int code, String body) {
jdbc.update("""
UPDATE idempotency_keys SET status = 'COMPLETED', response_code = ?, response_body = ?
WHERE merchant_id = ? AND idem_key = ?
""", code, body, merchantId, key);
}
}
ON CONFLICT DO NOTHING은 “확인 후 기록” 작업을 하나의 원자 문으로 바꿉니다. 같은 키를 가진 두 요청이 동시에 도착하면 inserted == 1인 요청은 하나뿐입니다. 다른 요청은 409를 받습니다. Race condition이 없습니다.
주의: Redis
SETNX만을 결제 멱등성의 소스 오브 트루스로 사용하지 마세요. Redis 페일오버나 키 제거로 인해 추적이 손실되고 돈이 두 번 청구될 수 있습니다. Redis는 캐시 레이어일 뿐이고 데이터베이스만이 최종 판정자여야 합니다.
5.3. PSP 측 멱등성도 함께
당신의 요청은 보호되지만, PSP로의 호출도 재시도될 수 있습니다. 대부분의 PSP는 자체 idempotency key를 지원합니다. 이를 위해 payments.id를 키로 전달하세요:
PspResult result = pspClient.charge(new ChargeRequest(
payment.id().toString(), // PSP에 전송할 idempotency key
payment.amount(),
payment.currency(),
sourceToken));
PSP 호출이 타임아웃되면, 돈이 청구되었는지 아닌지 알 수 없습니다. FAILED로 표시하지 마세요. 상태를 UNKNOWN으로 설정한 다음, 작업을 통해 payments.id를 사용하여 PSP에 다시 확인하세요. FAILED로 표시한 후 클라이언트가 새 키로 재시도하도록 하는 것은 중복 청구로 이어지는 전형적인 방법입니다.
6. 데이터베이스와 Kafka 일관성: Transactional Outbox
돈을 수령한 후, payments를 업데이트하고 PaymentCaptured 이벤트를 발행해야 합니다. 데이터베이스 기록은 성공했지만 Kafka 전송이 실패하면 (또는 그 반대) Order 서비스의 상태가 벗어날 것입니다. 해결 방법은 이벤트를 같은 트랜잭션 내에서 outbox 테이블에 기록하는 것입니다:
@Transactional
public void markCaptured(UUID paymentId, String pspReference) {
jdbc.update("UPDATE payments SET status = 'CAPTURED', psp_reference = ? WHERE id = ? AND status IN ('PROCESSING','UNKNOWN')",
pspReference, paymentId);
jdbc.update("INSERT INTO outbox (topic, payload) VALUES ('payment-events', ?::jsonb)",
"""
{"type":"PaymentCaptured","paymentId":"%s"}""".formatted(paymentId));
}
Relay (정기적인 작업 또는 Debezium CDC)는 sent_at IS NULL인 행을 읽어 Kafka로 전송한 후 sent_at을 업데이트합니다. Relay는 중복으로 전송할 수 있으므로 consumer는 멱등성이 있어야 합니다. 예를 들어 처리된 paymentId를 저장하세요. UPDATE 문의 status IN (...) 조건은 PSP의 중복 웹훅이 다른 최종 상태를 덮어쓰는 것을 방지합니다.
7. 환불 및 동시성 문제
7.1. 문제
결제 금액 100,000동. 고객 서비스 담당자와 자동 작업이 동시에 60,000동 환불 요청을 보냅니다. 코드가 “refunded_amount를 읽고, 확인한 후, 기록하는” 방식으로 작성되면, 둘 다 0 + 60,000 ≤ 100,000을 보고 승인됩니다. 결과적으로 120,000동을 환불하게 됩니다.
7.2. 해결책: 조건부 업데이트
확인과 업데이트를 하나의 조건부 UPDATE 문으로 결합합니다:
@Service
public class RefundService {
private final JdbcTemplate jdbc;
public RefundService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public UUID requestRefund(UUID paymentId, long amount) {
int reserved = jdbc.update("""
UPDATE payments SET refunded_amount = refunded_amount + ?
WHERE id = ? AND status = 'CAPTURED' AND refunded_amount + ? <= amount
""", amount, paymentId, amount);
if (reserved == 0) {
throw new IllegalStateException("Refund exceeds refundable amount or payment not captured");
}
UUID refundId = UUID.randomUUID();
jdbc.update("INSERT INTO refunds (id, payment_id, amount, status) VALUES (?, ?, ?, 'PENDING')",
refundId, paymentId, amount);
jdbc.update("INSERT INTO outbox (topic, payload) VALUES ('refund-commands', ?::jsonb)",
"""
{"refundId":"%s","paymentId":"%s","amount":%d}""".formatted(refundId, paymentId, amount));
return refundId;
}
}
PostgreSQL은 UPDATE 시 payments 행을 잠급니다. 두 번째 트랜잭션은 첫 번째 트랜잭션이 커밋될 때까지 기다려야 합니다. 그 후 새로운 데이터에서 WHERE 조건을 재평가하여 60,000 + 60,000 > 100,000을 보고 reserved == 0을 받습니다. 이것은 READ COMMITTED 수준에서의 기본 동작이며 SERIALIZABLE이 필요하지 않습니다.
먼저 금액을 ’예약’한 다음 outbox를 통해 PSP를 비동기적으로 호출합니다. PSP가 환불을 거부하면, consumer가 예약된 부분을 반환합니다:
UPDATE payments SET refunded_amount = refunded_amount - :amount WHERE id = :paymentId;
UPDATE refunds SET status = 'FAILED' WHERE id = :refundId AND status = 'PENDING';
이 두 문은 같은 트랜잭션 내에 있어야 합니다. status = 'PENDING' 조건은 실패한 웹훅이 중복으로 전송될 때 예약 금액이 두 번 반환되지 않도록 보장합니다. 더 엄격하게 하려면, 두 번째 문이 정확히 한 행을 업데이트할 때만 첫 번째 문을 실행하세요.
7.3. 동시성 테스트
필요한 테스트 의존성:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class RefundServiceTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired RefundService refundService;
@Autowired JdbcTemplate jdbc;
@Test
void concurrentRefunds_neverExceedCapturedAmount() throws Exception {
UUID paymentId = UUID.randomUUID();
jdbc.update("""
INSERT INTO payments (id, merchant_id, order_id, amount, currency, status)
VALUES (?, 'shop-1', 'ORD-1001', 100000, 'VND', 'CAPTURED')
""", paymentId);
Callable<Boolean> refund60k = () -> {
try {
refundService.requestRefund(paymentId, 60_000);
return true;
} catch (IllegalStateException e) {
return false;
}
};
List<Boolean> results;
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
results = executor.invokeAll(List.of(refund60k, refund60k))
.stream().map(Future::resultNow).toList();
}
assertThat(results).containsExactlyInAnyOrder(true, false);
Long refunded = jdbc.queryForObject(
"SELECT refunded_amount FROM payments WHERE id = ?", Long.class, paymentId);
assertThat(refunded).isEqualTo(60_000L);
}
}
테스트는 섹션 3의 스키마가 src/test/resources/schema.sql을 통해 로드된다고 가정합니다 (spring.sql.init.mode=always와 함께) 또는 Flyway를 통해 로드된다고 가정합니다. 두 개의 virtual thread가 동시에 환불을 보냅니다. 예상되는 결과는 항상 안정적입니다:
results = [true, false] (순서는 임의)
refunded_amount = 60000
UPDATE 문을 Java의 read-then-compare 방식으로 바꾸면, 테스트가 가끔 실패할 것입니다. 이것이 이 설계가 제거하는 race condition입니다.
8. 대사(Reconciliation)
아무리 주의해도 내부 데이터와 PSP 데이터는 여전히 벗어날 수 있습니다. 웹훅이 손실될 수 있고, UNKNOWN 거래가 미해결 상태일 수 있으며, PSP가 차징백으로 인해 독자적으로 환불할 수 있습니다. 대사는 최후의 방어선입니다.
일일 프로세스:
- PSP의 settlement 파일을 로드하여 임시 테이블
psp_settlements(psp_reference, amount, type)에 로드합니다. FULL OUTER JOIN을 사용하여 내부 데이터와 매칭합니다.- 차이점을
recon_breaks테이블에 기록하여 자동 또는 수동으로 처리합니다.
INSERT INTO recon_breaks (psp_reference, internal_amount, psp_amount, reason)
SELECT COALESCE(p.psp_reference, s.psp_reference),
p.amount,
s.amount,
CASE
WHEN p.psp_reference IS NULL THEN 'MISSING_INTERNAL'
WHEN s.psp_reference IS NULL THEN 'MISSING_AT_PSP'
ELSE 'AMOUNT_MISMATCH'
END
FROM (SELECT * FROM payments
WHERE status = 'CAPTURED' AND created_at >= :day AND created_at < :day + INTERVAL '1 day') p
FULL OUTER JOIN (SELECT * FROM psp_settlements WHERE type = 'CHARGE') s
ON p.psp_reference = s.psp_reference
WHERE p.psp_reference IS NULL
OR s.psp_reference IS NULL
OR p.amount <> s.amount;
각 불일치 유형의 의미:
MISSING_INTERNAL: PSP가 돈을 수집했지만 당신 측에는CAPTURED레코드가 없습니다. 일반적으로UNKNOWN거래이거나 웹훅이 손실된 경우입니다. 상태를 업데이트하거나 주문이 이미 취소된 경우 환불로 처리합니다.MISSING_AT_PSP: 당신 측에서는 수령했다고 기록했지만 PSP에는 없습니다. 배송했지만 돈을 받지 못했을 수 있으므로 이것은 심각한 경우입니다.AMOUNT_MISMATCH: 일반적으로 수수료, 환율 또는 반올림 버그로 인한 경우입니다.
주의: 시간 창에 주의하세요. 23:59의 거래는 다음 날 settlement 파일에 포함될 수 있습니다. T+1 또는 T+2 지연으로 대사를 수행하고, 연속된 두 번 실행에서 존재한 후에만 사람에게 불일치를 전달하세요.
9. 급격한 트래픽 증가
플래시 세일 중 트래픽이 25배 증가합니다. 시스템을 견디게 하는 몇 가지 기법:
- Merchant별 Rate 제한 Redis를 사용하여 (
INCR+ 1초 윈도우의EXPIRE). 이것은 시스템을 ‘시끄러운’ merchant로부터 보호합니다. 여기서 Redis는 제한에만 사용되며 거래의 정확성을 결정하지 않습니다. - 동기 부분과 비동기 부분을 분리합니다. 동기 경로는 idempotency key 기록, 결제 생성 및 PSP 인증 호출만 포함합니다. 환불, 이메일 발송 및 회계 업데이트는 모두 outbox → Kafka를 거칩니다.
paymentId로 Kafka를 partition하여 각 결제의 이벤트 순서를 유지합니다. - Circuit breaker 및 짧은 타임아웃 PSP 호출 시 (예: Resilience4j). PSP가 느릴 때 빠르게 실패하고
Retry-After와 함께503을 반환하여 thread pool이 고갈되지 않도록 합니다. 클라이언트가 같은Idempotency-Key로 재시도하는 것은 섹션 5 덕분에 안전합니다. idempotency_keys테이블 정리 정기적으로 (24-72시간 유지) 및 날짜별 partition하여 테이블이 커져서INSERT작업을 느리게 하지 않도록 합니다.
10. 흔한 실수
- 돈에
double/float을 사용합니다. 가장 작은 단위로long을 사용하거나 환율이 필요할 때BigDecimal을 사용하세요. - 타임아웃을 실패로 간주합니다. 타임아웃은 아직 모름을 의미하므로
UNKNOWN으로 설정한 후 PSP에 다시 쿼리해야 합니다. - 서버에서 idempotency key를 생성합니다. 키는 클라이언트가 생성하고 재시도를 통해 동일하게 유지되어야 합니다.
- Java에서 환불 잔액을 확인한 후
UPDATE합니다. 데이터베이스에서 한 문에서 그 작업을 수행하도록 하세요. @Transactional내에서 Kafka로 보내고 데이터베이스와 함께 롤백되기를 바랍니다. 그것은 롤백되지 않습니다. Outbox를 사용하세요.- “코드가 이미 올바르다”고 해서 대사를 건너뜁니다. 외부 시스템은 당신의 통제 범위 내에 있지 않습니다.
11. 결론
이 글은 세 가지 원칙에 따른 결제 시스템의 핵심을 설계했습니다. 첫째, 멱등성은 데이터베이스의 unique 제약 조건으로 보장되며, API와 PSP 호출 모두에 적용됩니다. 둘째, 환불 시 동시성은 조건부 업데이트와 CHECK 제약 조건으로 해결되며, 동시성 테스트가 이를 증명합니다. 셋째, 외부 세계와의 일관성은 transactional outbox와 일일 대사에 기반합니다.
세 가지 모두의 공통점은 중요한 불변식을 데이터베이스 계층으로 푸시하여 원자적으로 시행하는 것입니다. 동시에 네트워크, PSP 및 당신의 코드 자체도 실수할 수 있다고 항상 가정하고, 오류를 감지하고 수정할 수 있는 메커니즘을 미리 준비하세요.