티스토리 뷰

'use client';

import { useReserveStore } from '@/store/reserveClassStore';
import { PaymentWidgetInstance, loadPaymentWidget } from '@tosspayments/payment-widget-sdk';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useRef } from 'react';
import { useAsync } from 'react-use';

const clientKey = 'test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm' as string;

export default function PaymentPageasync() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const customerKey = searchParams.get('customerKey');
  const price = parseInt(searchParams.get('price') || '', 10) || 0;
  const orderId = searchParams.get('orderId');
  const classId = searchParams.get('classId') || crypto.randomUUID();
  const paymentWidgetRef = useRef<PaymentWidgetInstance | null>(null);
  const paymentMethodsWidgetRef = useRef<ReturnType<PaymentWidgetInstance['renderPaymentMethods']> | null>(null);
  const title = searchParams.get('title');
  const { reserveInfo } = useReserveStore();

  useAsync(async () => {
    if (customerKey === 'null' || !customerKey) {
      alert('유저 정보가 존재하지 않습니다. 로그인 상태를 확인해주세요.');
      router.replace(`/`);
      return;
    }
    //초기화
    const paymentWidget = await loadPaymentWidget(clientKey, customerKey);
    if (!loadPaymentWidget) {
      return console.log('요청실패');
    }

    //결제위젯 렌더링
    const paymentMethodsWidget = paymentWidget.renderPaymentMethods(
      '#payment-widget',
      { value: price },
      { variantKey: 'DEFAULT' }
    );

    //이용약관 렌더링
    paymentWidget.renderAgreement('#agreement');

    paymentWidgetRef.current = paymentWidget;
    paymentMethodsWidgetRef.current = paymentMethodsWidget;
  }, []);

  useEffect(() => {
    const paymentMethodsWidget = paymentMethodsWidgetRef.current;
    if (paymentMethodsWidget == null) {
      return;
    }

    //금액 업데이트
    paymentMethodsWidget.updateAmount(price);
  }, [price]);

  return (
    <div className=" responsiveHeight mx-auto my-20 flex max-w-2xl items-center justify-center px-4">
      <div className="mt-4 flex flex-col  gap-2">
        <h1 className="flex w-full justify-center text-lg font-semibold text-button-default-color md:text-2xl">
          확인 및 결제
        </h1>
        <p className="text-grey-600 mb-4">
          결제 수단을 선택하고 결제를 진행해주세요. 환불금은 예약 취소 후 2-3일 내에 결제한 카드로 입금됩니다. 아래
          버튼을 눌러 예약을 결제하세요.
        </p>

        {/* {(paymentWidget === null || paymentMethodsWidgetRef === null) && ''} */}

        <div id="payment-widget" className="w-full" />
        <div id="agreement" className="w-full" />
        <div className="flex w-full items-center justify-center">
          <p className=" text-red-600">
            모바일의 경우 테스트 결제가 진행되지 않습니다. PC버전에서 시도해주시면 감사하겠습니다!
          </p>
        </div>
        <button
          type="button"
          className="hover:button-hover-color mt-8 rounded-md bg-button-default-color px-5 py-2 text-white"
          onClick={async () => {
            if (customerKey === 'null' || !customerKey) {
              alert('유저 정보가 존재하지 않습니다. 로그인 상태를 확인해주세요.');
              router.replace('/');
            }
            const paymentWidget = paymentWidgetRef.current;
            try {
              await paymentWidget?.requestPayment({
                orderId: orderId as string,
                orderName: title as string,
                // 라우트 핸들러로 예약 정보 전송
                successUrl: `${window.location.origin}/api/payment?classId=${reserveInfo.classId}&reserveQuantity=${reserveInfo.reserveQuantity}&timeId=${reserveInfo.timeId}&userId=${reserveInfo.userId}`,
                //fail 시 보여줄 페이지 만들기
                failUrl: `${window.location.origin}/fail?orderId=${orderId}&classId=${classId}`
              });
            } catch (error: any) {
              console.log('failed to paymentWidget', error);
            }
          }}
        >
          결제하기
        </button>
      </div>
    </div>
  );
}

 

1. 결제 위젯 로딩 및 초기화:
Toss Payments SDK를 사용하여 결제 위젯을 로드하고, 사용자의 고객 키를 기반으로 결제 위젯을 초기화
    const paymentWidget = await loadPaymentWidget(clientKey, customerKey);
    if (!loadPaymentWidget) {
      return console.log('요청실패');
    }
2. 결제 수단 렌더링:
초기화된 결제 위젯을 통해 사용자가 선택할 수 있는 결제 수단을 웹 페이지에 렌더링
    const paymentMethodsWidget = paymentWidget.renderPaymentMethods(
      '#payment-widget',
      { value: price },
      { variantKey: 'DEFAULT' }
    );​
3. 이용 약관 동의 처리
사용자가 결제를 진행하기 전에 이용 약관에 동의할 수 있도록 별도의 약관 동의 섹션을 렌더링
    paymentWidget.renderAgreement('#agreement');

    paymentWidgetRef.current = paymentWidget;
    paymentMethodsWidgetRef.current = paymentMethodsWidget;
  }, []);​
4. 동적 금액 업데이트
사용자가 다른 금액을 선택하거나 변경하는 경우, 결제 위젯의 금액을 실시간으로 업데이트하여 반영
  useEffect(() => {
    const paymentMethodsWidget = paymentMethodsWidgetRef.current;
    if (paymentMethodsWidget == null) {
      return;
    }

    //금액 업데이트
    paymentMethodsWidget.updateAmount(price);
  }, [price]);​
5. 결제 요청 처리
최종적으로 사용자가 결제하기 버튼을 클릭할 때, 결제를 요청하고 성공 또는 실패에 따라 적절한 URL로 리디렉션
    <div className=" responsiveHeight mx-auto my-20 flex max-w-2xl items-center justify-center px-4">
      <div className="mt-4 flex flex-col  gap-2">
        <h1 className="flex w-full justify-center text-lg font-semibold text-button-default-color md:text-2xl">
          확인 및 결제
        </h1>
        <p className="text-grey-600 mb-4">
          결제 수단을 선택하고 결제를 진행해주세요. 환불금은 예약 취소 후 2-3일 내에 결제한 카드로 입금됩니다. 아래
          버튼을 눌러 예약을 결제하세요.
        </p>

        {/* {(paymentWidget === null || paymentMethodsWidgetRef === null) && ''} */}

        <div id="payment-widget" className="w-full" />
        <div id="agreement" className="w-full" />
        <div className="flex w-full items-center justify-center">
          <p className=" text-red-600">
            모바일의 경우 테스트 결제가 진행되지 않습니다. PC버전에서 시도해주시면 감사하겠습니다!
          </p>
        </div>
        <button
          type="button"
          className="hover:button-hover-color mt-8 rounded-md bg-button-default-color px-5 py-2 text-white"
          onClick={async () => {
            if (customerKey === 'null' || !customerKey) {
              alert('유저 정보가 존재하지 않습니다. 로그인 상태를 확인해주세요.');
              router.replace('/');
            }
            const paymentWidget = paymentWidgetRef.current;
            try {
              await paymentWidget?.requestPayment({
                orderId: orderId as string,
                orderName: title as string,
                // 라우트 핸들러로 예약 정보 전송
                successUrl: `${window.location.origin}/api/payment?classId=${reserveInfo.classId}&reserveQuantity=${reserveInfo.reserveQuantity}&timeId=${reserveInfo.timeId}&userId=${reserveInfo.userId}`,
                //fail 시 보여줄 페이지 만들기
                failUrl: `${window.location.origin}/fail?orderId=${orderId}&classId=${classId}`
              });
            } catch (error: any) {
              console.log('failed to paymentWidget', error);
            }
          }}
        >
          결제하기
        </button>
      </div>
    </div>​

 

 

api 코드 

아래 코드는 다른 팀원분이 작성해 주셨는데 이해한대로 주석을 달아보았다!

import { redirect } from 'next/dist/server/api-utils';
import { insertNewReservation } from '../reserve/insertNewReservation';
import { NextRequest, NextResponse } from 'next/server';

//http get 요청처리, request 객체를 인자로 받음
export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);

  const orderId = searchParams.get('orderId');
  const paymentKey = searchParams.get('paymentKey');
  const amount = searchParams.get('amount');
  const reserveQuantity = searchParams.get('reserveQuantity');
  const timeId = searchParams.get('timeId');
  const userId = searchParams.get('userId');
  const classId = searchParams.get('classId');

  if (!process.env.NEXT_PUBLIC_BASE_URL) {
    console.log('NEXT_PUBLIC_BASE_URL is undefined');
    return;
  }

  //params로 값을 받을 때 하나라도 없으면 결제 실패페이지로 리다이렉트 오류처리를 위한 필수 검증
  if (!orderId || !classId || !amount || !reserveQuantity || !timeId || !userId) {
    // 값이 없으면 실패 페이지로 리다이렉트
    return NextResponse.redirect(new URL(`${process.env.NEXT_PUBLIC_BASE_URL}/reserve/fail`));
  }

  //tosspayment api에 결제를 확인하는 post 요청.
  //요청 헤더에는 인증, 컨텐츠 유형 설정
  const response = await fetch('https://api.tosspayments.com/v1/payments/confirm', {
    method: 'POST',
    body: JSON.stringify({ orderId: orderId, amount: amount, paymentKey: paymentKey }),
    headers: {
      Authorization: `Basic ${Buffer.from(`${process.env.TOSS_SECRET_KEY}:`).toString('base64')}`,
      'Content-Type': 'application/json'
    }
  });

  //tosspayment api 응답을 json 형태로 파싱
  const res = await response.json();

  //결제 성공 시 예약 데이터를 데이터베이스에 저장
  if (response.ok) {
    try {
      await insertNewReservation({
        reserveId: res.orderId,
        classId,
        reservePrice: res.totalAmount,
        reserveQuantity: Number(reserveQuantity),
        timeId,
        userId
      });

      //성공 시 예약 성공 페이지로 리다이렉트
      //NextResponse.redirect는 서버 측에서 클라이언트를 다른 페이지로 안내할 때 주로 사용
      return NextResponse.redirect(new URL(`${process.env.NEXT_PUBLIC_BASE_URL}/reserve/success/${res.orderId}`));
    } catch (error) {
      console.log('라우트 핸들러의 insertNewReservation 오류 => ', error);
      //실패 시 실패 페이지로 리다이렉트
      return NextResponse.redirect(new URL(`${process.env.NEXT_PUBLIC_BASE_URL}/reserve/fail`));
    }
  } else {
    //실패 시 
    return NextResponse.redirect(
      new URL(`${process.env.NEXT_PUBLIC_BASE_URL}/reserve/fail?code=${res.code}&statusText=${res.message}`)
    );
  }
}

 

 

'프론트앤드 면접 준비' 카테고리의 다른 글

개발자 이력서 작성 방법  (0) 2024.05.07