API 멱등성(Idempotency)은 왜 결제 시스템에서 중요할까? 직접 구현해보기
결제 버튼을 눌렀는데 응답이 없어서 한 번 더 눌렀다가 돈이 두 번 빠져나간 경험, 혹은 그런 버그를 수습해본 경험이 있다면 이 글이 도움이 될 것입니다. 이런 문제의 핵심에는 API 멱등성(Idempotency)이 있습니다.
멱등성이란 무엇인가
멱등성은 수학과 컴퓨터 과학에서 빌려온 개념입니다. 어떤 연산을 한 번 수행한 결과와 여러 번 반복 수행한 결과가 동일할 때, 그 연산을 멱등하다고 말합니다.
HTTP 메서드로 예를 들면 이해하기 쉽습니다. GET 요청은 서버 상태를 변경하지 않으므로 몇 번을 호출해도 결과가 같습니다. DELETE /users/123도 마찬가지입니다. 처음 호출하면 사용자가 삭제되고, 두 번째 호출에서는 이미 없는 사용자를 삭제하려 하지만 최종 상태는 동일합니다(사용자가 존재하지 않음). 반면 POST /payments는 기본적으로 멱등하지 않습니다. 호출할 때마다 새로운 결제 건이 생성될 수 있기 때문입니다.
결제 시스템에서 멱등성이 중요한 이유
네트워크는 본질적으로 불안정합니다. 클라이언트가 결제 요청을 보냈을 때 다음과 같은 상황이 발생할 수 있습니다.
- 클라이언트가 요청을 보냈지만 서버에 도달하기 전에 타임아웃이 발생한 경우
- 서버가 요청을 처리하고 결제까지 완료했지만 응답을 전송하는 도중 연결이 끊긴 경우
- 클라이언트가 응답을 받지 못해 재시도 로직이 동작하는 경우
두 번째 시나리오가 특히 위험합니다. 서버 입장에서는 결제가 이미 완료된 상태이고, 클라이언트 입장에서는 실패한 것처럼 보이기 때문입니다. 이 상태에서 클라이언트가 재시도하면 동일한 결제가 두 번 처리될 수 있습니다.
멱등성이 보장된 API라면 재시도가 와도 결제는 단 한 번만 이루어집니다. 이것이 결제 시스템에서 멱등성이 선택이 아닌 필수인 이유입니다.
멱등성 키(Idempotency Key) 개념
멱등성을 구현하는 가장 일반적인 방법은 멱등성 키를 사용하는 것입니다. 클라이언트가 요청을 보낼 때 고유한 식별자를 함께 전송하고, 서버는 이 키를 기준으로 동일한 요청의 중복 처리를 막습니다.
Stripe, PayPal 같은 글로벌 결제 플랫폼들도 이 방식을 표준으로 사용합니다. Stripe의 경우 HTTP 헤더에 Idempotency-Key 값을 포함하도록 API를 설계했습니다.
동작 원리는 다음과 같습니다.
- 클라이언트가 UUID 등 고유한 멱등성 키를 생성합니다.
- 해당 키를 요청 헤더나 본문에 포함해 서버로 전송합니다.
- 서버는 키가 처음 들어온 경우 요청을 처리하고, 처리 결과를 키와 함께 저장합니다.
- 동일한 키로 요청이 다시 들어오면 저장된 결과를 그대로 반환합니다.
- 클라이언트는 재시도 응답과 최초 응답을 구분할 필요 없이 동일한 결과를 받게 됩니다.
직접 구현해보기: Node.js + Redis 예시
간단한 Express 기반 결제 API에 멱등성을 적용하는 예시를 살펴보겠습니다. 멱등성 키의 저장소로는 Redis를 사용합니다. 빠른 읽기/쓰기와 TTL(만료 시간) 설정이 용이하기 때문입니다.
기본 미들웨어 구성
const express = require('express');
const redis = require('redis');
const { v4: uuidv4 } = require('uuid');
const app = express();
app.use(express.json());
const client = redis.createClient();
client.connect();
async function idempotencyMiddleware(req, res, next) {
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) {
return res.status(400).json({ error: 'Idempotency-Key 헤더가 필요합니다.' });
}
const cached = await client.get(`idem:${idempotencyKey}`);
if (cached) {
const cachedResponse = JSON.parse(cached);
return res.status(cachedResponse.status).json(cachedResponse.body);
}
// 결과를 저장하는 함수를 req에 붙여 컨트롤러에서 사용
req.saveIdempotentResponse = async (status, body) => {
await client.setEx(
`idem:${idempotencyKey}`,
86400, // 24시간 TTL
JSON.stringify({ status, body })
);
};
next();
}
결제 처리 엔드포인트 적용
app.post('/payments', idempotencyMiddleware, async (req, res) => {
const { amount, currency, paymentMethod } = req.body;
try {
// 실제 결제 처리 로직 (PG사 API 호출 등)
const paymentResult = await processPayment({ amount, currency, paymentMethod });
const responseBody = {
paymentId: paymentResult.id,
status: 'succeeded',
amount,
currency,
};
// 멱등성 결과 저장
await req.saveIdempotentResponse(200, responseBody);
return res.status(200).json(responseBody);
} catch (error) {
const errorBody = { error: error.message };
await req.saveIdempotentResponse(500, errorBody);
return res.status(500).json(errorBody);
}
});
이 구조에서 클라이언트가 동일한 Idempotency-Key로 재시도를 보내면 Redis에서 캐시된 응답을 즉시 반환합니다. 실제 결제 로직은 단 한 번만 실행됩니다.
구현 시 주의해야 할 세부 사항
처리 중 상태(In-flight) 처리
위 예시에는 한 가지 취약점이 있습니다. 첫 번째 요청이 아직 처리 중인 상태에서 두 번째 동일 키 요청이 들어오면, Redis에 아직 결과가 저장되지 않았으므로 두 요청 모두 결제 처리 로직으로 진입할 수 있습니다.
이를 방지하려면 처리 시작 시점에 PROCESSING 같은 임시 상태를 먼저 저장해 락(lock)처럼 활용해야 합니다.
async function idempotencyMiddleware(req, res, next) {
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) {
return res.status(400).json({ error: 'Idempotency-Key 헤더가 필요합니다.' });
}
const redisKey = `idem:${idempotencyKey}`;
// NX 옵션: 키가 없을 때만 저장 (원자적 연산)
const acquired = await client.set(redisKey, JSON.stringify({ status: 'processing' }), {
NX: true,
EX: 30, // 처리 중 상태는 30초만 유지
});
if (!acquired) {
const cached = await client.get(redisKey);
const parsed = JSON.parse(cached);
if (parsed.status === 'processing') {
return res.status(409).json({ error: '동일한 요청이 처리 중입니다. 잠시 후 다시 시도해주세요.' });
}
return res.status(parsed.status).json(parsed.body);
}
req.saveIdempotentResponse = async (status, body) => {
await client.setEx(redisKey, 86400, JSON.stringify({ status, body }));
};
next();
}
Redis의 SET ... NX 명령은 원자적(atomic)으로 동작하므로, 동시에 여러 요청이 들어와도 하나의 요청만 락을 획득할 수 있습니다.
멱등성 키의 범위와 유효기간
멱등성 키는 무한정 보관할 필요가 없습니다. 일반적으로 24시간에서 7일 사이의 TTL을 설정합니다. 기간이 지나면 동일한 키로 요청이 와도 새로운 결제로 처리됩니다. 클라이언트 측에서 재시도 로직은 보통 수 초 내에 이루어지므로, 합리적인 TTL을 설정하면 충분합니다.
또한 멱등성 키의 유효 범위를 명확히 해야 합니다. 같은 키라도 다른 사용자나 다른 API 엔드포인트에서 사용하면 별개로 취급해야 할 수 있습니다. Redis 키를 idem:{userId}:{endpoint}:{key} 형태로 네임스페이스를 구성하는 것이 좋습니다.
오류 응답도 캐시해야 하는가
이 부분은 설계 결정이 필요합니다. 일시적인 오류(503 Service Unavailable 등)는 캐시하지 않고 클라이언트가 재시도할 수 있도록 열어두는 것이 바람직합니다. 반면 비즈니스 로직 오류(잔액 부족, 유효하지 않은 카드 등)는 캐시해도 무방합니다. 같은 조건에서 재시도해도 결과가 달라지지 않기 때문입니다.
데이터베이스 레벨에서의 멱등성 보완
Redis 기반 멱등성 키만으로 모든 상황을 커버하기 어렵습니다. Redis 장애 시 멱등성 보호가 무력화될 수 있기 때문입니다. 이를 보완하기 위해 데이터베이스 레벨에서도 중복 방지 장치를 마련하는 것이 권장됩니다.
가장 간단한 방법은 결제 테이블에 idempotency_key 컬럼을 추가하고 유니크 제약(unique constraint)을 설정하는 것입니다. 이렇게 하면 Redis를 우회하더라도 동일한 키로 두 건의 결제가 DB에 저장되는 것을 막을 수 있습니다.
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
idempotency_key VARCHAR(255) UNIQUE NOT NULL,
amount INTEGER NOT NULL,
currency VARCHAR(10) NOT NULL,
status VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
DB의 유니크 제약과 Redis의 멱등성 키 캐시를 함께 사용하면 방어 레이어가 이중으로 갖춰집니다.
클라이언트 측 구현 원칙
서버가 멱등성을 지원하더라도 클라이언트가 올바르게 사용하지 않으면 의미가 없습니다.
- 재시도할 때는 반드시 동일한 멱등성 키를 사용해야 합니다.
- 새로운 결제 의도가 생길 때마다 새로운 키를 생성해야 합니다.
- 키는 UUID v4처럼 충분히 고유성이 보장되는 방식으로 생성해야 합니다.
- 재시도 간격은 지수 백오프(exponential backoff) 방식을 적용하는 것이 좋습니다.
정리
API 멱등성은 분산 환경에서 안정적인 결제 시스템을 만들기 위한 핵심 설계 원칙입니다. 네트워크 불안정, 타임아웃, 클라이언트 재시도는 피할 수 없는 현실이고, 이에 대비하지 않으면 중복 결제라는 치명적인 버그로 이어질 수 있습니다.
구현 자체는 복잡하지 않습니다. 멱등성 키를 헤더로 받아 Redis에 처리 결과를 캐시하고, 데이터베이스에 유니크 제약을 추가하는 것만으로 기본적인 보호가 가능합니다. 처리 중 상태 관리와 TTL 설계까지 더하면 실제 프로덕션 환경에서도 충분히 사용할 수 있는 구조가 됩니다.
결제 시스템을 새로 설계하거나 기존 API에 재시도 로직을 추가할 계획이라면, 멱등성 지원을 초기 설계 단계에서 반드시 고려하시기 바랍니다.