AXliftLaunch

5단계 · 결제 · 1시간

토스페이먼츠 붙이기

결제창을 띄우는 건 쉽습니다. 어려운 건 돈이 실제로 맞게 들어오게 하는 일입니다. 실제로 돌아가는 코드로 설명합니다.

목차8절

여기서 막힙니다

클라이언트가 보낸 금액을 믿으면 안 됩니다. 브라우저에서 보낸 값은 누구나 바꿀 수 있습니다.

결제창을 띄울 때 금액을 1,000원으로 바꾸면 10만원짜리 상품을 그 값에 사갑니다. 승인 단계에서 서버가 저장해둔 주문 금액과 비교하지 않으면 그대로 통과됩니다.

이건 무료 URL 진단으로는 절대 알 수 없습니다. 밖에서 보이지 않기 때문입니다. 저희가 “코드를 봐야 압니다”라고 표시하는 항목이 이런 것들입니다.

순서: 서류가 먼저입니다

  1. 1사업자등록 · 통신판매업신고 : PG 계약의 전제입니다. 순서는 여기
  2. 2토스페이먼츠 상점 신청 : 사업자 정보로 가입합니다.
  3. 3테스트 키로 먼저 붙입니다. 심사를 기다리는 동안 개발을 끝낼 수 있습니다. 테스트 키는 바로 발급됩니다.
  4. 4심사 : 이용약관·개인정보처리방침·환불정책과 사업자 정보 표시를 봅니다. 문서 템플릿
  5. 5운영 키로 바꿉니다. 테스트 결제로 전 구간을 확인한 뒤에 바꿉니다. 순서를 바꾸지 마세요.

흐름은 세 단계입니다

  1. 1서버가 주문을 만듭니다. 주문번호와 금액을 서버에서 정하고 저장합니다. 이 금액이 나중에 비교 기준이 됩니다.
  2. 2브라우저가 결제창을 띄웁니다. 사용자가 결제를 마치면 토스가 paymentKey·orderId·amount를 돌려줍니다.
  3. 3서버가 승인합니다. 이때 저장해둔 금액과 비교합니다. 여기서 안 막으면 어디서도 안 막힙니다.

승인 코드: 여기가 전부입니다

아래는 이 서비스가 실제로 쓰는 코드입니다 (src/lib/payments.ts). 주석까지 그대로 옮겼습니다.

// 승인 요청. 반드시 서버에서 한다
// clientAmount: 브라우저가 보낸 금액 (믿지 않는다)
// expectedAmount: 우리가 주문을 만들 때 DB에 저장한 금액 (기준)

if (clientAmount !== expectedAmount) {
  // 조작 시도일 수 있다. 금액을 로그에 남겨 추적 가능하게 한다.
  console.error(
    `결제 금액 불일치. 승인하지 않음. 주문 ${orderId} ` +
    `· DB ${expectedAmount}원 · 요청 ${clientAmount}원`,
  );
  throw new Error("결제 금액이 주문 금액과 다릅니다.");
}

const res = await fetch("https://api.tosspayments.com/v1/payments/confirm", {
  method: "POST",
  headers: {
    authorization: "Basic " + btoa(SECRET_KEY + ":"),
    "content-type": "application/json",
    // 같은 주문이 두 번 승인되지 않게 (네트워크 재시도·새로고침 대비)
    "idempotency-key": `confirm-${orderId}`,
  },
  // ⚠️ 승인은 **DB 금액으로** 요청한다. 브라우저가 보낸 값을 그대로 넘기지 않는다.
  body: JSON.stringify({ paymentKey, orderId, amount: expectedAmount }),
});

const payload = await res.json();

// 승인 응답의 금액까지 한 번 더 확인한다 (방어를 두 겹으로)
if (Number(payload.totalAmount) !== expectedAmount) {
  throw new Error("승인된 금액이 주문 금액과 다릅니다.");
}

세 가지가 들어 있습니다. 승인 전 비교, 승인 요청에 DB 금액 사용, 승인 응답 재확인. 하나만 해도 대부분 막히지만, 셋 다 하는 게 맞습니다.

비밀 키는 서버에만

토스 시크릿 키(live_sk_ · test_sk_)가 브라우저로 내려가면 남이 결제를 승인하고 취소할 수 있습니다. 클라이언트에서 읽히는 환경변수 접두어를 붙이지 마세요 (NEXT_PUBLIC_ · VITE_ · REACT_APP_).

결제창을 띄울 때 쓰는 클라이언트 키는 공개돼도 괜찮습니다. 둘을 헷갈리지 않는 것이 중요합니다. 저희 진단은 시크릿 키가 번들에 있으면 잡아냅니다.

웹훅이 진짜 PG 가 보낸 것인지

저희가 코드에서 보는 것: 결제됐다고 알려주는 주소가 서명이나 재조회로 확인되나.

웹훅은 PG 가 「결제됐다」고 알려주는 주소입니다. 그런데 그 주소는 그냥 열려 있는 API 입니다. 주소만 알면 누구나 같은 요청을 보낼 수 있습니다. 금액을 바꾸는 것보다 쉽습니다. 주소를 길고 어렵게 짓는 것은 방어가 아닙니다.

믿을 수 있는 방법은 하나입니다. PG 에게 다시 물어보는 것입니다. 웹훅 본문은 «한번 보세요» 라는 알림일 뿐이고, 사실은 시크릿 키로 조회한 응답에 있습니다. 서명 헤더를 주는 PG 라면 서명도 같이 확인하시면 됩니다.

// 웹훅 본문은 믿지 않는다. 무엇을 볼지만 받아온다.
export async function POST(req) {
  const { paymentKey, orderId } = await req.json();

  // 시크릿 키로 조회한다. 이 응답이 사실이다.
  const res = await fetch(
    "https://api.tosspayments.com/v1/payments/" + paymentKey,
    { headers: { authorization: "Basic " + btoa(SECRET_KEY + ":") } },
  );

  // 조회가 안 되면 우리가 모르는 결제다. 아무것도 하지 않는다.
  if (!res.ok) return new Response("ignored", { status: 200 });
  const payment = await res.json();

  if (payment.status !== "DONE") return new Response("ignored", { status: 200 });
  if (payment.orderId !== orderId) return new Response("ignored", { status: 200 });

  // 금액도 여기서 한 번 더 본다. 웹훅 경로에도 같은 방어가 필요하다.
  const order = await getOrder(orderId);
  if (Number(payment.totalAmount) !== order.amount) {
    console.error("웹훅 금액 불일치. 처리하지 않음. 주문 " + orderId);
    return new Response("ignored", { status: 200 });
  }

  await markPaid(orderId, paymentKey);
  return new Response("ok", { status: 200 });
}

가짜 요청에도 200 을 돌려주는 것이 맞습니다. 400 을 내면 상대가 «무엇이 걸렸는지» 를 알게 되고, 진짜 PG 가 보낸 재시도까지 같이 막힙니다. 걸러낸 것은 화면이 아니라 로그에 남깁니다.

같은 결제가 두 번 처리되지 않게

저희가 코드에서 보는 것: 확인과 기록이 한 번에 일어나나, 아니면 나뉘어 있나.

웹훅은 원래 여러 번 옵니다. PG 가 재시도하고, 사용자가 완료 화면에서 새로고침하고, 네트워크가 끊겨도 다시 옵니다. 막아두지 않으면 한 번 결제에 상품이 두 번 나갑니다.

여기서 거의 모두가 같은 코드를 씁니다. 그리고 그 코드가 틀렸습니다.

// ❌ 읽고, 보고, 쓰기. 동시에 온 요청 둘이 같은 값을 읽고 둘 다 통과한다.
const order = await getOrder(orderId);
if (order.status === "paid") return;      // 둘 다 여기서 "아직 아니다"
await updateOrder(orderId, { status: "paid" });
await grantProduct(orderId);              // 두 번 나간다

확인과 기록 사이에 틈이 있으면 그 틈으로 두 번째 요청이 들어옵니다. 평소에는 안 보이고 결제가 몰릴 때만 납니다. 둘을 한 번에 일어나게 만들어야 합니다.

// ✅ 조건을 걸고 바꾼다. 바뀐 행이 0개면 이미 누가 처리한 것이다.
const { rowCount } = await db.query(
  `update orders
      set status = 'paid', payment_key = $2, paid_at = now()
    where id = $1 and status = 'pending'`,
  [orderId, paymentKey],
);

if (rowCount === 0) return;   // 두 번째 요청. 여기서 멈춘다.
await grantProduct(orderId);  // 지급은 이 아래에서 딱 한 번

// 그리고 DB 가 한 겹 더 막게 해둔다
// alter table orders add constraint orders_payment_key_key unique (payment_key);

승인을 요청할 때 넣는 idempotency-key 헤더는 PG 쪽에서 중복 승인을 막아줍니다. 그건 우리 DB 를 지켜주지 않습니다. 둘 다 필요합니다.

승인 응답을 보고 지급하나

저희가 코드에서 보는 것: 승인 요청을 보낸 다음 줄에서 바로 지급하나.

fetch 는 결제가 거절돼도 예외를 던지지 않습니다. 400 이든 402 든 «응답을 받았다» 는 성공입니다. 네트워크가 죽었을 때만 던집니다. 그래서 아래 코드가 그대로 돌아갑니다.

// ❌ 승인이 거절돼도 상품이 나간다
await fetch("https://api.tosspayments.com/v1/payments/confirm", { ... });
await grantProduct(orderId);

응답을 읽고, 상태를 보고, 금액까지 확인한 뒤에 지급합니다. 그리고 서버에 기록한 다음에 완료 화면을 보여주세요. 순서가 뒤집히면 돈은 받았는데 주문이 없는 상태가 생깁니다.

// ✅ 응답을 읽는다
const res = await fetch("https://api.tosspayments.com/v1/payments/confirm", { ... });
const payload = await res.json();

if (!res.ok) {
  // payload.code · payload.message 에 거절 사유가 있다. 그대로 남긴다.
  console.error("승인 거절. 주문 " + orderId, payload.code, payload.message);
  throw new Error("결제가 승인되지 않았습니다.");
}
if (payload.status !== "DONE") {
  throw new Error("결제 상태가 완료가 아닙니다: " + payload.status);
}
if (Number(payload.totalAmount) !== expectedAmount) {
  throw new Error("승인된 금액이 주문 금액과 다릅니다.");
}

await markPaid(orderId, payload.paymentKey);  // 먼저 기록하고
await grantProduct(orderId);                  // 그 다음 지급한다

거절 사유를 로그에 남기는 것이 중요합니다. 「결제가 안 돼요」 라는 문의가 왔을 때 남은 것이 없으면 확인할 방법이 없습니다.

붙이기 전에 이걸 시키세요

끝났으면 다시 진단해보세요

결제 코드를 붙이고 배포하셨으면 주소를 다시 넣어보세요. 그러면 5단계가 완료로 바뀝니다. 저희 진단이 검사기 역할을 합니다. 통과되면 PG 심사에서 보는 것도 통과됩니다.

다시 진단하기 →

금액 검증이 제대로 됐는지는 밖에서 확인할 수 없습니다

저희 무료 진단은 결제 모듈이 붙어 있는지까지만 봅니다. 서버가 금액을 다시 확인하는지는 코드를 봐야 압니다. 직접 붙이신 뒤 확인이 필요하시면 코드리뷰로 봐드리고, 처음부터 맡기고 싶으시면 붙여드리는 것도 됩니다.