결제 금액 변조 취약점: PG 연동 단계별 서버 검증 설계
결제 금액 변조가 주문 생성·결제 승인·검증 중 어디서 일어나는지 PG 연동 흐름을 따라 살펴봅니다. 서버에서 금액을 대조하는 코드, 멱등성 처리 패턴, 실무 체크리스트를 함께 정리했습니다.
결제 금액 변조는 브라우저나 앱이 보낸 가격, 수량, 할인액, 최종 결제액을 서버가 그대로 믿을 때 생기는 취약점입니다. 공격자는 화면에 보이는 가격을 바꾸지 않습니다. 브라우저 개발자 도구나 프록시로 요청 본문의 price: 59000을 price: 100으로 고쳐 보낼 뿐입니다. 서버가 이 값으로 주문을 만들고 PG 승인까지 진행하면 59,000원짜리 상품이 100원에 결제된 채 "결제 완료" 상태가 됩니다.
이 문제는 서버가 금액의 기준을 직접 정해서 지키면 막을 수 있습니다. 금액은 서버가 DB의 상품 가격과 쿠폰 규칙으로 계산해 주문에 저장하고, 결제 승인 직전과 직후에 그 금액을 PG가 알려준 값과 다시 대조해야 합니다. 그리고 같은 주문이 두 번 승인되거나 쿠폰이 두 번 쓰이지 않도록 상태 전이를 멱등하게 만들어야 합니다.
이 글에서는 국내 PG 결제창 연동의 일반적인 흐름(주문 생성 → 결제창 인증 → 서버 승인 → 결과 검증)을 따라가면서 단계마다 변조가 끼어드는 지점과 막는 코드 패턴을 정리합니다. 코드는 모두 가상의 예시이고, PG별 세부 API는 각 사의 공식 문서를 기준으로 맞추면 됩니다.
왜 클라이언트가 보낸 금액은 믿을 수 없나
OWASP의 Transaction Authorization Cheat Sheet는 거래 승인 로직을 서버에서 강제해야 하고, 클라이언트에서 서버로 가는 데이터를 바꿔 결과에 영향을 줄 수 없어야 한다고 말합니다. 또 중요한 거래 데이터는 서버에서 만들고 저장해야 클라이언트가 손대지 못한다고 권고합니다.
결제에 적용하면 다음과 같습니다.
- 프론트엔드의
disabled입력란, 숨은 필드, 앱 내부 상수는 보안 장치가 아닙니다. 요청은 언제든 다시 만들어 보낼 수 있습니다. - HTTPS는 전송 구간을 지켜줄 뿐 요청을 보내는 사람 본인의 조작은 막지 못합니다.
- 앱 난독화나 요청 서명으로 조작을 어렵게 만들 수는 있지만, 서버 검증을 대신하지는 못합니다.
PG사도 같은 원칙을 안내합니다. 포트원 V2 연동 문서는 인증 결제에서 결제 금액 같은 정보가 브라우저에서 처리되므로, 서버가 결제 조회 API로 결제 건을 확인하고 의도한 결제 내용인지 꼭 확인해야 위변조를 막을 수 있다고 설명합니다. 토스페이먼츠 결제위젯 연동 가이드에도 successUrl로 넘어온 amount가 요청 시 설정한 금액과 같은지 반드시 확인하라는 안내가 있습니다.
PG 결제 흐름에서 변조가 끼어드는 4개 지점
결제창 방식은 PG마다 이름이 조금 달라도 큰 흐름은 비슷합니다.
- 주문 생성: 클라이언트가 장바구니 정보로 주문을 요청하고, 서버가
orderId와 금액을 정합니다. - 결제창 호출과 인증: 클라이언트 SDK가
orderId,amount로 결제창을 띄우고, 구매자가 카드사 인증을 합니다. - 승인 요청: 인증이 끝나면
successUrl로paymentKey,orderId,amount같은 값이 쿼리 파라미터로 넘어옵니다. 서버는 이 값으로 PG 승인 API를 호출합니다. - 결과 반영: 승인 응답이나 웹훅을 받아 주문을 "결제 완료"로 바꾸고, 재고와 쿠폰을 차감합니다.
변조 지점을 정리하면 이렇습니다.
| 단계 | 공격자가 바꾸는 값 | 서버가 놓치기 쉬운 검증 |
|---|---|---|
| 주문 생성 | 단가, 수량(0·음수·소수), 쿠폰 할인액, 배송비 | 금액을 서버에서 다시 계산하지 않음 |
| 결제창 호출 | SDK에 넘기는 amount | 주문에 저장된 금액과 비교하지 않음 |
| 승인 요청 | successUrl 쿼리의 amount, 다른 주문의 orderId | 주문 금액과 대조, 주문 소유자 확인 |
| 결과 반영 | 같은 요청 재전송, 동시 요청 | 중복 승인, 쿠폰 이중 사용 |
orderId를 바꿔 남의 주문을 결제 완료로 만들거나 조회하는 문제는 권한 검증 영역입니다. 이 부분은 IDOR 취약점이란? 주문·예약 API로 보는 원인과 방어법에서 따로 다뤘습니다.
1단계: 주문 생성 시 서버가 금액을 계산한다
가장 흔한 실수는 클라이언트가 계산한 합계를 그대로 저장하는 것입니다.
취약한 코드
// POST /api/orders
app.post('/api/orders', auth, async (req, res) => {
const { items, couponCode, totalAmount } = req.body;
// items: [{ productId, price, quantity }]
const order = await db.order.create({
userId: req.user.id,
items, // 클라이언트가 보낸 price를 그대로 저장
amount: totalAmount, // 클라이언트가 계산한 합계를 신뢰
status: 'PENDING',
});
res.json({ orderId: order.publicId, amount: order.amount });
});
고친 코드
app.post('/api/orders', auth, async (req, res) => {
const { items, couponCode } = req.body; // 가격과 합계는 받지 않는다
// 1) 수량 검증: 정수, 1 이상, 상한 이하
for (const it of items) {
if (!Number.isInteger(it.quantity) || it.quantity < 1 || it.quantity > 100) {
return res.status(400).json({ error: 'INVALID_QUANTITY' });
}
}
// 2) 단가는 DB에서 조회
const products = await db.product.findMany({
where: { id: { in: items.map(i => i.productId) }, onSale: true },
});
if (products.length !== new Set(items.map(i => i.productId)).size) {
return res.status(400).json({ error: 'INVALID_PRODUCT' });
}
const subtotal = items.reduce((sum, it) => {
const p = products.find(p => p.id === it.productId);
return sum + p.price * it.quantity; // 금액은 정수(원 단위)로 다룬다
}, 0);
// 3) 쿠폰은 서버에서 소유·만료·최소금액·중복 규칙을 검사하고 할인액을 계산
const discount = couponCode
? await coupons.calculateDiscount(req.user.id, couponCode, subtotal)
: 0;
const amount = Math.max(subtotal - discount, 0);
const order = await db.order.create({
publicId: crypto.randomUUID(), // 추측하기 어려운 주문번호
userId: req.user.id,
items: items.map(it => ({
productId: it.productId,
quantity: it.quantity,
unitPrice: products.find(p => p.id === it.productId).price,
})),
couponCode: couponCode ?? null,
amount,
status: 'PENDING',
});
res.json({ orderId: order.publicId, amount: order.amount });
});
핵심은 세 가지입니다. 클라이언트에게서는 상품 ID, 수량, 쿠폰 코드만 받습니다. 금액은 부동소수점이 아닌 정수 최소 단위로 계산합니다. 계산 결과는 주문 레코드에 스냅샷으로 저장해서 이후 단계의 기준값으로 씁니다. 결제 도중에 상품 가격이 바뀌어도 기준이 흔들리지 않습니다.
2~3단계: 승인 전에 금액을 대조한다
결제창이 끝나고 successUrl로 돌아온 값도 결국 브라우저를 거친 값입니다. 쿼리 문자열의 amount는 주소창에서 바로 고칠 수 있습니다.
취약한 코드
// GET /payments/success?paymentKey=...&orderId=...&amount=...
app.get('/payments/success', auth, async (req, res) => {
const { paymentKey, orderId, amount } = req.query;
const result = await pg.confirm({ paymentKey, orderId, amount }); // 대조 없이 승인
await db.order.update({ where: { publicId: orderId }, data: { status: 'PAID' } });
res.redirect('/orders/complete');
});
고친 코드
app.get('/payments/success', auth, async (req, res) => {
const { paymentKey, orderId } = req.query;
const queryAmount = Number(req.query.amount);
const order = await db.order.findUnique({ where: { publicId: orderId } });
// 소유자, 상태, 금액을 모두 확인
if (!order || order.userId !== req.user.id) return res.status(404).end();
if (order.status === 'PAID') return res.redirect('/orders/complete'); // 새로고침 대응
if (order.status !== 'PENDING') return res.status(409).end();
if (!Number.isSafeInteger(queryAmount) || queryAmount !== order.amount) {
await audit.log('AMOUNT_MISMATCH', { orderId, queryAmount, expected: order.amount });
return res.redirect('/payments/fail?reason=amount');
}
// PG에는 클라이언트 값이 아니라 DB 금액을 보낸다
const result = await pg.confirm({
paymentKey,
orderId: order.publicId,
amount: order.amount,
idempotencyKey: `confirm-${order.publicId}`,
});
// 승인 응답의 금액·주문번호·상태도 한 번 더 확인
if (result.orderId !== order.publicId || result.totalAmount !== order.amount
|| result.status !== 'DONE') {
await pg.cancel(result.paymentKey, '금액 불일치');
await audit.log('CONFIRM_MISMATCH', { orderId });
return res.redirect('/payments/fail');
}
await markOrderPaid(order, result); // 아래 멱등 처리 참고
res.redirect('/orders/complete');
});
승인 API에 DB 금액을 넘기면 PG가 실제 인증 금액과 다를 때 승인을 거절하므로 한 겹 더 막힙니다. 그래도 승인 응답에 담긴 금액을 다시 확인하는 습관을 들이는 편이 안전합니다. 응답 필드 이름(totalAmount, status 값 등)은 PG마다 다르니 쓰는 PG의 공식 레퍼런스에 맞추세요. 승인 없이 결제가 완료되는 방식이라면, 포트원 가이드처럼 서버가 결제 조회 API로 결제 건을 가져와 주문 금액과 비교하는 구조로 바꾸면 됩니다.
4단계: 중복 승인과 쿠폰 이중 사용을 막는 멱등성 처리
금액이 맞더라도 같은 주문이 두 번 처리되면 문제가 됩니다. 사용자가 결제 완료 페이지를 새로고침하거나, 브라우저 리다이렉트와 웹훅이 거의 동시에 도착하거나, 공격자가 같은 요청을 병렬로 보낼 수 있습니다. 이때 재고와 쿠폰 차감이 두 번 일어나거나, 한 번만 쓸 수 있는 쿠폰이 여러 주문에 적용될 수 있습니다.
PG 호출 멱등성
토스페이먼츠는 모든 POST API에 Idempotency-Key 헤더를 지원합니다. 공식 문서에 따르면 같은 키로 다시 요청하면 실제로 다시 처리하지 않고 첫 응답과 같은 응답을 돌려주고, 키는 처음 사용한 날부터 15일간 유효합니다. 첫 요청이 처리 중일 때 같은 키로 요청하면 409 IDEMPOTENT_REQUEST_PROCESSING이 반환됩니다. 문서는 에러를 받은 뒤 키를 바꿔 재시도하는 것은 위험하니 원인을 먼저 확인하라고도 안내합니다. 주문번호에서 파생한 키를 쓰면 네트워크 타임아웃으로 재시도해도 이중 승인을 피할 수 있습니다.
우리 DB 상태 전이의 원자성
PG 쪽 멱등성만으로는 부족합니다. 우리 서버의 "결제 완료 처리"도 한 번만 실행되어야 합니다. 조회한 다음 갱신하는 코드는 경쟁 상태에 취약하므로, 조건부 UPDATE로 상태 전이를 한 번에 처리합니다.
async function markOrderPaid(order, result) {
await db.$transaction(async (tx) => {
// PENDING → PAID 전이는 단 한 번만 성공한다
const updated = await tx.$executeRaw`
UPDATE orders
SET status = 'PAID', payment_key = ${result.paymentKey}, paid_at = NOW()
WHERE id = ${order.id} AND status = 'PENDING' AND amount = ${result.totalAmount}`;
if (updated === 0) return; // 이미 처리됐거나 조건 불일치: 부수효과 없이 종료
// 쿠폰도 조건부로 사용 처리
if (order.couponCode) {
const used = await tx.$executeRaw`
UPDATE user_coupons SET used_at = NOW(), order_id = ${order.id}
WHERE user_id = ${order.userId} AND code = ${order.couponCode} AND used_at IS NULL`;
if (used === 0) throw new Error('COUPON_ALREADY_USED'); // 롤백 후 결제 취소 플로우로
}
for (const it of order.items) {
const dec = await tx.$executeRaw`
UPDATE products SET stock = stock - ${it.quantity}
WHERE id = ${it.productId} AND stock >= ${it.quantity}`;
if (dec === 0) throw new Error('OUT_OF_STOCK');
}
});
}
payment_key 컬럼에 UNIQUE 제약을 걸어두면 하나의 결제 건이 여러 주문에 연결되는 것도 막을 수 있습니다. 트랜잭션이 실패했는데 PG 승인은 끝난 경우를 대비해 결제 취소(환불) 보상 처리와 알림도 함께 설계해야 합니다.
웹훅도 같은 함수를 탄다
포트원 문서는 결제 결과를 누락 없이 처리하려면 웹훅으로 받은 데이터를 기준으로 DB에 반영하라고 권합니다. 웹훅 본문을 그대로 믿기보다는 웹훅을 "확인해 보라는 신호"로 보고, 서버가 PG 결제 조회 API로 결제 건을 다시 가져와 금액을 대조한 뒤 위의 markOrderPaid를 호출하는 구조가 안전합니다. 리다이렉트와 웹훅이 같은 멱등 함수를 거치면 무엇이 먼저 도착하든 결과가 같습니다.
금액 외에 함께 점검할 비즈니스 로직
금액 대조만으로는 잡히지 않는 우회 경로도 있습니다.
- 음수·0·소수 수량: 음수 수량 항목이 합계를 깎는 경우가 있습니다. 정수와 범위 검증은 필수입니다.
- 쿠폰 중복과 과다 할인: 중복 불가 쿠폰을 배열로 여러 개 보내거나, 정액 할인이 상품가보다 커서 합계가 음수가 되는 경우입니다. 할인 후 금액의 하한과 중복 정책을 서버에 둡니다.
- 포인트·적립금 혼합 결제: 포인트 사용액을 클라이언트가 정하더라도 서버에서 보유 잔액과 사용 한도를 검증하고, 차감도 조건부 UPDATE로 처리합니다.
- 옵션·배송비: 옵션 추가금이나 도서산간 배송비도 서버 테이블에서 계산합니다.
- 부분 취소와 환불: 환불 금액이 원결제액에서 이미 취소된 금액을 뺀 범위를 넘지 않는지 확인합니다.
- 예약 서비스의 인원·기간: 숙박일수나 인원수를 바꿔 요금을 낮추는 경우가 있습니다. 요금은 서버가 날짜와 인원으로 다시 계산합니다.
실무 점검 체크리스트
- 주문 생성 API가 클라이언트의
price,total,discount필드를 무시하는가 - 금액을 정수 최소 단위로 계산하고 주문 레코드에 스냅샷으로 저장하는가
- 수량, 인원, 기간에 정수·하한·상한 검증이 있는가
- 쿠폰 소유, 만료, 최소 주문금액, 중복 규칙을 서버에서 검사하는가
- 승인 전에
successUrl의 금액과 주문 금액을 대조하고, 승인 API에는 DB 금액을 넘기는가 - 승인·조회 응답의 금액, 주문번호, 상태를 다시 확인하는가
- 주문 소유자가 요청한 사용자와 같은지 확인하는가
-
PENDING → PAID전이와 쿠폰·재고·포인트 차감이 조건부 UPDATE와 트랜잭션으로 묶여 있는가 - PG 승인 호출에 멱등키를 쓰고, 타임아웃 재시도 정책이 정의되어 있는가
- 웹훅을 받으면 결제 조회로 재검증하고, 리다이렉트와 같은 처리 함수를 쓰는가
- 금액 불일치 시 결제 취소와 감사 로그, 알림이 남는가
- 정기적으로 PG 정산 데이터와 주문 DB 금액을 대사(reconciliation)하는가
마지막 대사 작업은 코드로 막지 못한 예외를 사후에 찾아내는 안전망입니다. 하루 단위로 PG 거래 내역과 주문 테이블을 비교해 금액이나 건수가 다른 항목을 리포트로 받아보는 것만으로도 효과가 큽니다.
자주 묻는 질문
프론트엔드에서 금액을 암호화하거나 서명해서 보내면 충분하지 않나요?
충분하지 않습니다. 서명 키가 클라이언트에 있으면 결국 추출될 수 있고, 서명은 "누가 보냈는지"를 증명할 뿐 "금액이 맞는지"는 보장하지 않습니다. 금액은 서버가 계산하고 서버가 대조하는 것이 기준입니다.
PG 승인 API에 DB 금액을 넘기면 승인 전 대조는 생략해도 되나요?
PG가 인증 금액과 승인 요청 금액이 다르면 승인을 거절하는 구조라면 피해는 상당 부분 막힙니다. 그래도 승인 전에 대조하면 불필요한 PG 호출을 줄이고 변조 시도를 로그로 남길 수 있습니다. 승인 후 응답 금액 확인까지 세 겹으로 두는 것을 권합니다.
멱등키만 쓰면 중복 처리 문제가 해결되나요?
PG 쪽 이중 승인은 막아주지만, 우리 서버의 쿠폰·재고·포인트 차감이 두 번 실행되는 것은 막지 못합니다. DB 상태 전이를 조건부 UPDATE나 UNIQUE 제약으로 원자적으로 처리해야 완성됩니다.
정리
- 결제 금액 변조는 클라이언트가 보낸 가격·수량·할인·합계를 서버가 믿을 때 생깁니다.
- 금액은 서버가 계산해 주문에 저장하고, 승인 전·승인 응답·웹훅 재조회 단계마다 그 값과 대조합니다.
- PG 멱등키와 DB 조건부 상태 전이를 함께 써서 중복 승인과 쿠폰·재고 이중 차감을 막습니다.
- 불일치 시 취소·로그·알림, 그리고 정기적인 PG 정산 대사로 마무리합니다. 우리 서비스의 결제 흐름을 직접 점검하기 어렵다면 무료 분석 신청으로 함께 살펴볼 수 있습니다.
참고 자료
함께 읽으면 좋은 글
AWS 보안 설정 가이드: 초기 스타트업이 하루에 잠글 5가지
AWS 보안 설정을 루트 계정, IAM, S3, 보안 그룹, 감사 로그 순으로 정리했다. 소규모 팀이 하루 안에 점검하고 고칠 수 있는 명령어와 수정 전후 예시, 판단 기준을 담았다.
취약점 점검 리포트 읽는 법: 위험도 해석부터 조치 우선순위까지
취약점 점검 리포트의 위험도를 서비스 맥락으로 해석하고, 조치 순서 결정, 오탐 확인, 재점검 요청, 수정 완료 근거 정리까지 개발팀의 처리 흐름을 안내합니다.
개인정보 유출 사고 사례: 개인정보위 처분에서 반복된 원인 5가지
개인정보 유출 사고 사례를 개인정보위 처분 기준으로 분석해 반복된 원인 5가지를 정리하고, 개발팀이 바로 쓸 코드 예시와 점검 항목으로 바꿔 줍니다.