S (Situation)

이사 완료 후 고객이 견적 금액을 결제하는 기능을 만들었다. 결제는 토스 결제위젯으로 진행하고, 성공하면 토스가 successUrl로 paymentKey, orderId, amount를 쿼리로 붙여 리다이렉트한다. FE는 이 값을 받아 POST /estimates/:id/pay로 BE에 넘기고, BE가 토스 승인 API(/v1/payments/confirm)를 호출한 뒤 견적을 PAID로 바꾸는 구조다.

T (Task)

성공 URL의 쿼리는 사용자가 주소창에서 얼마든지 바꿀 수 있다. 그대로 믿고 승인하면 다음 문제가 생긴다.

  1. 금액 위변조: 견적가보다 작은 amount로 승인 요청을 보낼 수 있다.
  2. 남의 견적 결제: 다른 사람의 estimateId로 요청을 보낼 수 있다.
  3. 이중 결제: 같은 견적에 요청이 동시에 들어오면 두 번 승인될 수 있다.

서버가 기준 금액과 주문 정보를 직접 계산해서 검증하고, 승인과 상태 갱신 사이의 경쟁을 막는 것이 목표였다.

A (Action)

1. 승인 전에 서버 기준으로 검증

pay()는 토스를 호출하기 전에 다음 순서로 거른다.

  1. 견적이 존재하는지, 요청한 사용자가 이 견적의 고객 본인인지 확인한다 (아니면 403).
  2. 견적 상태와 결제 단계를 확인한다. 이사가 완료되지 않았거나, 이미 결제했거나, 추가 금액 요청에 응답하지 않은 견적은 거절한다.
  3. 기준 금액(expectedAmount)을 서버가 직접 계산한다. 클라이언트가 보낸 amount는 이 값과 비교만 한다.
  4. orderId가 서버가 만든 규칙(moving-{type}-{estimateId})과 같은지 확인한다.
// 금액·주문 번호는 클라이언트가 보낸 값이라 서버 기준과 맞는지 먼저 확인한다
// (승인 전에 걸러야 청구가 안 된다)
if (
  input.amount !== expectedAmount ||
  input.orderId !== toPaymentOrderId(estimateId, input.type)
) {
  throw AppError.badRequest(ERROR_CODES.PAYMENT_FAILED, "결제 금액 또는 주문 정보가 견적과 일치하지 않습니다");
}

검증을 승인 앞에 둔 이유는 순서다. 승인이 한 번 나가면 돈이 청구되므로, 틀린 요청은 토스에 닿기 전에 서버에서 끝나야 한다.

2. 승인 요청 설계