이사 완료 후 고객이 견적 금액을 결제하는 기능을 만들었다. 결제는 토스 결제위젯으로 진행하고, 성공하면 토스가 successUrl로 paymentKey, orderId, amount를 쿼리로 붙여 리다이렉트한다. FE는 이 값을 받아 POST /estimates/:id/pay로 BE에 넘기고, BE가 토스 승인 API(/v1/payments/confirm)를 호출한 뒤 견적을 PAID로 바꾸는 구조다.
성공 URL의 쿼리는 사용자가 주소창에서 얼마든지 바꿀 수 있다. 그대로 믿고 승인하면 다음 문제가 생긴다.
amount로 승인 요청을 보낼 수 있다.estimateId로 요청을 보낼 수 있다.서버가 기준 금액과 주문 정보를 직접 계산해서 검증하고, 승인과 상태 갱신 사이의 경쟁을 막는 것이 목표였다.
pay()는 토스를 호출하기 전에 다음 순서로 거른다.
expectedAmount)을 서버가 직접 계산한다. 클라이언트가 보낸 amount는 이 값과 비교만 한다.orderId가 서버가 만든 규칙(moving-{type}-{estimateId})과 같은지 확인한다.// 금액·주문 번호는 클라이언트가 보낸 값이라 서버 기준과 맞는지 먼저 확인한다
// (승인 전에 걸러야 청구가 안 된다)
if (
input.amount !== expectedAmount ||
input.orderId !== toPaymentOrderId(estimateId, input.type)
) {
throw AppError.badRequest(ERROR_CODES.PAYMENT_FAILED, "결제 금액 또는 주문 정보가 견적과 일치하지 않습니다");
}
검증을 승인 앞에 둔 이유는 순서다. 승인이 한 번 나가면 돈이 청구되므로, 틀린 요청은 토스에 닿기 전에 서버에서 끝나야 한다.
Idempotency-Key에 orderId를 넣어, 같은 요청이 재전송돼도 토스가 중복 승인하지 않게 했다.paymentKey / orderId / amount 셋만으로 제한했다. 우리 쪽 필드(type 등)가 외부로 나가지 않게 하려는 목적이다.