결제는 "되는 것"과 "안전한 것"이 완전히 다르다
AI 코딩 도구에게 "결제 기능 넣어줘"라고 하면 PG사 SDK 연동부터 결제 완료 페이지까지 한 번에 만들어준다. 테스트 결제도 잘 된다. 하지만 결제 보안은 "잘 되는 것"이 아니라 "조작이 불가능한 것"이다. 정상 흐름으로 눌렀을 때 성공하는 것과, 악의적인 사용자가 요청을 뜯어고쳐도 손해가 나지 않는 것은 전혀 다른 문제다.
결제 로직의 보안 결함은 곧 직접적인 금전 피해로 이어진다. 정보 유출은 사후에 수습이라도 하지만, 결제 조작은 공격자가 실행하는 순간 돈이 빠져나간다. SENTRIX 레드팀이 실제 커머스 서비스를 점검할 때 가장 먼저 두드리는 곳도 결제 흐름이다. 취약점 하나가 매출 전체를 무너뜨릴 수 있기 때문이다. 아래 5가지는 AI 코딩으로 만든 결제 시스템에서 가장 많이 발견되는 취약점이며, 대부분 한두 줄의 서버 검증만 추가하면 막을 수 있다.
1. 클라이언트에서 결제 금액 결정
가장 치명적인 실수다. AI 코딩 도구는 화면에 표시된 가격을 그대로 결제 요청에 넘기는 코드를 자연스럽게 만든다.
// 취약: 클라이언트가 금액을 정한다
const amount = document.getElementById('price').value;
IMP.request_pay({ amount: amount, name: '프리미엄 플랜', ... });
공격 시나리오. 공격자는 결제 버튼을 누르기 전에 브라우저 개발자 도구(F12) 콘솔에서 document.getElementById('price').value = 100을 실행하거나, 요청을 가로채는 프록시로 amount 파라미터를 100으로 바꾼다. 그러면 10만원짜리 상품이 100원에 결제된다. PG 창은 정직하게 "100원 결제"를 띄우고 정상 승인된다. 서버가 금액을 다시 확인하지 않으면 100원을 받고 10만원짜리 상품을 내주게 된다.
안전한 구조. 결제 금액은 클라이언트가 아니라 서버가 상품 ID로 DB를 조회해 결정한다. 클라이언트는 "무엇을 사겠다"는 상품 식별자만 보내고, "얼마"는 절대 신뢰하지 않는다.
// 안전: 서버가 상품 ID로 실제 가격을 조회
const product = await db.getProduct(req.body.productId);
const amount = product.price; // DB가 진실의 원천
const order = await createPendingOrder({ productId: product.id, amount });
// 이 order.amount 를 PG 결제창에 전달
직접 확인하는 법. 결제 페이지에서 F12 → Network 탭을 켠 뒤 결제를 진행해보라. PG로 넘어가는 요청 본문에 amount, price 같은 값이 그대로 들어 있고, 그 값을 바꿔도 결제가 진행된다면 취약하다. 콘솔에서 히든 필드나 전역 변수의 금액을 바꿔 저가 결제가 성공하는지 테스트 환경에서 시도해보라.
2. 결제 완료 후 서버 검증 누락
PG사에서 결제 성공 응답이 오면 그 응답만 믿고 바로 "결제 완료" 처리하는 코드다.
// 취약: 클라이언트가 전달한 결과를 그대로 신뢰
if (response.success) {
await createOrder(response); // 서버 재검증 없음
}
공격 시나리오. 클라이언트가 받는 콜백 응답은 사용자 손안에 있으므로 위조할 수 있다. 공격자는 실제로 결제하지 않고도 { success: true, paymentId: 'fake', amount: 100000 } 형태의 요청을 서버의 주문 완료 엔드포인트로 직접 던진다. 서버가 PG사에 "이 결제가 진짜 승인됐고 금액이 맞는지"를 되묻지 않으면, 돈 한 푼 내지 않은 주문이 완료 처리된다. 1번(금액 조작)과 결합하면 피해가 배가된다.
안전한 구조. 결제 완료 신호를 받으면 서버가 PG사 조회 API를 직접 호출해 (1) 실제 승인 상태 (2) 결제 금액 (3) 통화를, 서버가 만들어둔 주문(1번의 pending order)과 대조한다. 하나라도 어긋나면 주문을 확정하지 않고 즉시 취소·환불한다.
// 안전: 서버가 PG API로 원장을 재확인
const paid = await pg.getPayment(paymentId); // 서버-서버 통신
const order = await db.getPendingOrder(paid.orderId);
if (paid.status !== 'PAID' || paid.amount !== order.amount) {
await pg.cancel(paymentId); // 위변조 → 즉시 취소
return reject();
}
await confirmOrder(order.id);
직접 확인하는 법. 결제 완료를 처리하는 서버 URL로, 승인되지 않은 가짜 paymentId와 임의 금액을 담아 요청을 보내보라(Postman 등). 서버가 PG 조회 없이 주문을 완료시킨다면 검증이 빠진 것이다. 정상 서버라면 존재하지 않는 결제로 판단해 거절해야 한다.
3. 주문 ID 중복 처리 미흡
같은 결제 한 건으로 주문 완료가 여러 번 일어나는 경우다. 네트워크 지연으로 콜백이 재전송되거나, 사용자가 완료 페이지에서 새로고침을 하면 처리 로직이 두 번 돈다.
공격 시나리오. 공격자는 이를 의도적으로 악용한다. 결제 성공 콜백을 캡처해 완료 엔드포인트로 여러 번 반복 전송(리플레이)하면, 한 번 결제하고 상품이 여러 번 지급되거나 포인트·캐시가 여러 번 충전된다. 충전형 서비스에서는 이 하나로 무한 재화 복제가 된다.
안전한 구조. 결제 ID(paymentId)를 멱등성 키로 삼는다. 애플리케이션 로직의 중복 체크만으로는 동시 요청(race condition)을 막지 못하므로, DB 컬럼에 UNIQUE 제약을 걸어 물리적으로 두 번째 삽입을 막는 것이 핵심이다.
-- 안전: DB가 물리적으로 중복을 거부
ALTER TABLE orders ADD CONSTRAINT uq_payment UNIQUE (payment_id);
-- 두 번째 INSERT 는 unique 위반으로 실패 → 재지급 차단
직접 확인하는 법. 테스트 결제 완료 직후 완료 페이지에서 새로고침을 여러 번 하거나, 같은 콜백 요청을 두 번 보내보라. 주문 내역이 두 건 생기거나 재화가 두 번 들어오면 멱등 처리가 없는 것이다.
4. 환불 API 권한 미검증
환불 API가 로그인만 되어 있으면 누구나 호출할 수 있게 열려 있는 경우다. AI 코딩 도구는 "환불 기능"을 요청받으면 요청자 본인 여부까지는 자동으로 챙기지 않는다.
// 취약: 넘어온 orderId 를 그대로 환불
app.post('/refund', auth, async (req) => {
await pg.cancel(req.body.orderId); // 소유자 확인 없음
});
공격 시나리오. 로그인한 공격자가 자기 주문번호 대신 남의 주문번호(orderId)를 넣어 환불을 호출한다. 순차 증가하는 주문번호라면 값만 바꿔가며 타인의 결제를 환불시킬 수 있다(전형적인 IDOR, 접근제어 취약점). 이미 환불된 건을 다시 환불하게 만들어 이중 환불로 돈을 빼내는 변형도 있다.
안전한 구조. 환불 API는 서버에서 (1) 해당 주문이 요청자 본인의 것인지 (2) 아직 환불되지 않은 상태인지 (3) 환불 가능 기간·조건 내인지를 모두 검증한다. 상태 변경은 트랜잭션으로 원자적으로 처리해 동시 이중 환불을 막는다.
// 안전: 소유권·상태·기간을 서버가 검증
const order = await db.getOrder(req.body.orderId);
if (!order || order.userId !== req.user.id) return forbidden();
if (order.status === 'REFUNDED') return conflict();
if (!withinRefundWindow(order)) return reject();
await pg.cancel(order.paymentId);
직접 확인하는 법. 계정 두 개로 각각 결제한 뒤, A 계정으로 로그인한 채 환불 요청의 주문번호만 B의 것으로 바꿔 보내보라. 서버가 "본인 주문 아님"으로 거절하지 않고 환불이 진행되면 접근제어가 뚫린 것이다.
5. 할인 코드 다중 적용
같은 할인 코드를 여러 번 적용하거나, 여러 코드를 동시에 쌓아 0원 또는 마이너스 결제가 되는 경우다. AI 코딩 도구는 할인 로직을 단순 뺄셈으로 구현해 하한선과 중복 사용 같은 엣지 케이스를 놓친다.
// 취약: 하한선·중복 검증 없는 단순 차감
let total = product.price;
for (const code of req.body.coupons) {
total -= getDiscount(code); // 음수로 내려갈 수 있음
}
공격 시나리오. 공격자는 결제 요청에 쿠폰 코드를 배열로 여러 개 실어 보내거나 같은 코드를 반복해 넣는다. 합산 할인액이 상품가를 넘어서면 최종 금액이 0원 이하가 되어 공짜 결제, 혹은 마이너스 결제로 포인트가 되레 적립되는 사고가 난다. 사용 횟수 관리가 없으면 1회용 코드를 무한 재사용한다.
안전한 구조. 할인 적용 후 최종 금액이 0원 미만으로 내려가지 않도록 서버에서 하한을 강제하고, 중복 적용 정책(코드 1개만, 또는 병용 불가)을 명시적으로 검증한다. 쿠폰 사용 횟수는 DB에서 원자적으로 차감해 재사용을 막는다.
// 안전: 하한 강제 + 사용 횟수 원자적 차감
const total = Math.max(0, product.price - discount);
const used = await db.consumeCoupon(code, req.user.id); // 1회 제한 원자적 처리
if (!used) return reject('이미 사용된 코드');
직접 확인하는 법. 결제 요청에서 쿠폰 필드에 같은 코드를 두 번 이상 넣거나 여러 코드를 동시에 넣어 보내보라. 최종 결제 금액이 음수로 계산되거나 이미 쓴 코드가 또 먹히면 검증이 없는 것이다.
결제 시스템, 취약점 자동 점검
위 5가지는 코드 레벨에서 서버 검증을 잡아야 하는 항목이다. 그런데 결제 사고는 결제 로직 밖에서도 터진다. PG 시크릿 키가 프론트엔드 번들이나 API 응답에 노출되거나, HTTPS 설정이 허술하거나, 보안 헤더가 빠져 있으면 위의 위조 공격이 훨씬 쉬워진다. 그래서 순서는 먼저 외부에서 보이는 취약점(API 키 노출, 보안 헤더 누락, SSL 설정)을 자동으로 걷어내고, 그다음 결제 로직을 코드 리뷰하는 것이 효율적이다.
CodeScan은 URL 하나만 넣으면 21개 항목을 30초 안에 점검해 키 노출·헤더·SSL 문제를 등급으로 보여준다. 결제 코드에 손대기 전, 밖에서 새는 곳부터 막아라. 매출을 다루는 사이트라면 지속 모니터링으로 배포할 때마다 재점검하는 것을 권한다.