토스 페이먼츠 next.js 연동

짜장킴·2026년 5월 11일

실무

목록 보기
8/8
npm install @tosspayments/tosspayments-sdk
  const clientKey =
    process.env.NEXT_PUBLIC_TOSS_CLIENT_KEY ??
    "test_ck_DpexMgkW36oWNvJzApKEVGbR5ozO";
  • 이 버전은 비회원도 가능
  const handlePayment = async () => {
    if (!selected) {
      alert("충전 금액을 선택해주세요.");
      return;
    }

    // "100,000원" -> 100000 (숫자)로 변환
    const amount = Number(selected.replace(/[^0-9]/g, ""));

    try {
      const tossPayments = await loadTossPayments(clientKey);

      // 결제창 띄우기
      const payment = tossPayments.payment({
        customerKey: "ANONYMOUS", // 비회원 결제 시 필수값 (랜덤값도 가능)
      });

      await payment.requestPayment({
        method: "CARD", // 결제 수단
        amount: {
          currency: "KRW",
          value: amount,
        },
        orderId: `CHARGE-${Math.random().toString(36).slice(2, 11)}`,
        orderName: `캐시 ${selected} 충전`,
        successUrl: `${window.location.origin}/payment/success`,
        failUrl: `${window.location.origin}/payment/fail`,
      });
    } catch (error) {
      console.error("결제 요청 에러:", error);
    }
  };
  • 로그인한 회원만 가능할 때
"use client";

import { useSession } from "next-auth/react"; // 예시: NextAuth 사용 시
// 또는 자신이 사용하는 Auth Hook (zustand, react-query 등)

export default function ChargeModal({ open, onClose }: ModalProps) {
  // 1. 로그인된 유저 정보 가져오기
  const { data: session } = useSession(); 
  const userIdentifier = session?.user?.id || "ANONYMOUS"; // 유저 고유 ID

  const handlePayment = async () => {
    // ... (금액 변환 로직 동일)

    try {
      const tossPayments = await loadTossPayments(clientKey);

      // 2. 고유 식별자를 customerKey에 할당
      const payment = tossPayments.payment({
        customerKey: userIdentifier, 
      });

      await payment.requestPayment({
        method: "CARD",
        amount: {
          currency: "KRW",
          value: amount,
        },
        orderId: `CHARGE-${Date.now()}-${userIdentifier.slice(0, 5)}`, // 주문번호에 유저 정보 살짝 섞기
        orderName: `캐시 ${selected} 충전`,
        // 3. 성공 시 우리 서버로 유저 정보를 같이 넘기기 위해 세팅 (선택사항)
        successUrl: `${window.location.origin}/payment/success?userId=${userIdentifier}`,
        failUrl: `${window.location.origin}/payment/fail`,
        customerEmail: session?.user?.email, // 유저 이메일 (결제창에 자동 입력됨)
        customerName: session?.user?.name,   // 유저 이름
      });
    } catch (error) {
      console.error(error);
    }
  };
  
  // ... (UI 코드)
}

무조건 성공 또는 실패 후 페이지 이동 필수

"use client";

import Modal from "@/shared/ui/Modal";
import { useState } from "react";

const SuccessPage = () => {
  const [successModalOpen, setSuccessModalOpen] = useState(true);
  
  return (
    <Modal
      actions={[{ label: "확인", onClick: () => {}, variant: "green" }]}
      title="결제 성공"
      description="성공적으로 결제가 완료되었습니다."
      open={successModalOpen}
      onClose={() => {
        setSuccessModalOpen(false);
      }}
    />
  );
};

export default SuccessPage;
  • 백엔드에게 정보 보내기
const SuccessPage = () => {
  const [successModalOpen, setSuccessModalOpen] = useState(true);
  const searchParams = useSearchParams();

  useEffect(() => {
    // 1. URL에서 토스가 보내준 파라미터 추출
    const paymentKey = searchParams.get("paymentKey");
    const orderId = searchParams.get("orderId");
    const amount = searchParams.get("amount");

    const confirmPayment = async () => {
      try {
        // 2. 내 백엔드 서버(API)로 결제 승인 요청 보내기
        // BASE_URL은 아까 적어주신 http://123.142.202.212:8081/api/web 등을 활용하세요.
        const response = await axios.post(
          `${process.env.NEXT_PUBLIC_API_BASE_URL}/payments/confirm`,
          {
            paymentKey,
            orderId,
            amount: Number(amount),
          },
        );

        if (response.status === 200) {
          // 서버에서 최종 승인 성공 시 모달 띄우기
          setSuccessModalOpen(true);
        }
      } catch (error: any) {
        console.error("승인 실패:", error.response?.data || error.message);
        // 승인 실패 시 실패 페이지로 강제 이동하거나 에러 모달 처리
        router.push(
          `/billing/payment/fail?code=${error.response?.data?.code}&message=${error.response?.data?.message}`,
        );
      } finally {
        setLoading(false);
      }
    };

    if (paymentKey && orderId && amount) {
      confirmPayment();
    }
  }, [searchParams, router]);

  if (loading) return <div>결제 승인 처리 중입니다...</div>;

  return (
    <Modal
      actions={[{ label: "확인", onClick: () => {}, variant: "green" }]}
      title="결제 성공"
      description="성공적으로 결제가 완료되었습니다."
      open={successModalOpen}
      onClose={() => {
        setSuccessModalOpen(false);
      }}
    />
  );
};

실패

  • 결제 시간 초과

  • 결제 실패

  • 토스에서 제공하는 에러 코드
    PAY_PROCESS_ABORTED 사용자가 결제창을 닫음 "결제가 취소되었습니다"
    REJECT_CARD_PAYMENT 카드사 거절 (잔액부족 등) "결제에 실패했습니다"
    PAY_PROCESS_TIMEOUT 결제 시간 초과 "시간 초과"
    PROVIDER_TIMEOUT 카드사/은행 응답 초과 "시간 초과"

에러코드가 토스페이먼츠의 기본 작동 방식이 "브라우저 주소창(URL)에 에러 정보를 담아서 약속된 페이지로 던져주는 것"이기 때문입니다.
ex)만약 failUrl을 https://mysite.com/payment/fail 로 설정했다면, 실제 에러 발생 시 브라우저 주소창은 다음과 같이 변합니다:

https://mysite.com/payment/fail?code=PAY_PROCESS_TIMEOUT&message=결제시간이+초과되었습니다
"use client";

import Modal from "@/shared/ui/Modal";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";

const FailPage = () => {
  const searchParams = useSearchParams();
  const router = useRouter();
  const [failModalOpen, setFailModalOpen] = useState(true);

  const errorCode = searchParams.get("code");

  const isTimeout =
    errorCode === "PAY_PROCESS_TIMEOUT" || errorCode === "PROVIDER_TIMEOUT";
  const isAborted = errorCode === "PAY_PROCESS_ABORTED"; // 사용자가 직접 닫음

  const handleRetry = () => {
    router.push("/billing");
  };

  return (
    <Modal
      actions={[{ label: "확인", onClick: () => {}, variant: "white" }]}
      title="결제 실패"
      description={
        isTimeout
          ? "결제 가능 시간을 초과하였습니다. 다시 시도해주세요."
          : isAborted
            ? "결제가 취소되었습니다."
            : "결제 요청에 실패했습니다. 다시 시도해주세요."
      }
      open={failModalOpen}
      onClose={() => {
        setFailModalOpen(false);
        handleRetry();
      }}
    />
  );
};

export default FailPage;
profile
프론트엔드 취준생입니다.

0개의 댓글