s e o p p o r t . l o g

Today I Learned

[클룸] 예약 => 결제 => 승인 => 예약 완료 로직 이해 및 구현

Seo Ji Won 2024. 4. 12. 21:03

라우트 핸들러를 활용한 서버 사이드에서의 결제 승인 처리

🤔 문제 상황

  • 초기 상태: 사용자가 예약 버튼을 클릭하면 바로 예약 완료 페이지로 넘어가며, 추가 검증 절차가 없었습니다.
  • 변경 후: 결제 기능이 추가되어, 예약 과정에서 결제 성공 여부에 따라 예약을 진행하도록 변경해야 했습니다.

1. 리다이렉트 주소를 받는 페이지 만들기

첫번째로 구현한 방법은 리다이렉트 주소를 받는 페이지 컴포넌트를 만들었습니다.  이 페이지는 결제 완료 후 토스 API로부터 사용자를 리다이렉트하는 URL을 받아서 추가적인 검증 로직을 수행하여 결제의 성공 또는 실패를 판단하려고 했습니다.

  • 사용자가 예약 버튼을 클릭할 때, 오더 ID와 예약 정보를 로컬 스토리지에 저장합니다.
  • 토스 API로부터 받은 오더 ID와 로컬 스토리지의 오더 ID를 비교하여 결제의 성공 또는 실패를 검증합니다.
  • 성공 시, 데이터베이스에 정보를 업로드하고 예약 완료 페이지로 리다이렉트합니다.
// successUrl: `${window.location.origin}/reserve/checkPay`,
// 토스 api에서 사용자가 결제 완료 시 리다이렉트로 보내주는 url
/* ex) http://localhost:3000/reserve/checkPay?paymentType=NORMAL&orderId=7e773321-2610-49fc-807c-b2e08730b0c8&
paymentKey=tviva20240412020401crrz7&amount=55000 */

const CheckPage = () => {
  const searchParams = useSearchParams();
  const queryOrderId = searchParams.get('orderId');
  const storageOrderId = typeof window !== 'undefined' && window.localStorage.getItem('orderId');
  const [reserveId, setReserveId] = useState('');
  const [isLoaidng, setIsLoaidng] = useState(true);

  const [reservationRequest, setReservationRequest] = useState<ReserveInfo>();

  const router = useRouter();

  useEffect(() => {
    if (typeof window !== 'undefined') {
      // 로컬 스토리지에서 예약 정보 가져와서 set
      const storageData = window.localStorage.getItem('reservationInfo');
      const reserveInfo: ReserveInfo = storageData ? JSON.parse(storageData) : null; // null 처리
      setReservationRequest(reserveInfo);
    }
  }, []);

  useEffect(() => {
    if (reservationRequest) {
      const submitReservation = async () => {
        // db에 예약 정보  insert
        const responseReserveId = await insertNewReservation(reservationRequest);
        if (responseReserveId) {
          setReserveId(responseReserveId);
          setIsLoaidng(false);
        }
      };
      submitReservation();
    } else {
      // 요청 인자가 없으면 에러 메세지 출력을 위한 state set
      //  setIsInvalidRequest(true);
    }
  }, [reservationRequest]);

  useEffect(() => {
    if (queryOrderId === storageOrderId) {
      if (reserveId) {
        router.push(`/reserve/success/${reserveId}`);
      }
    }
  }, [reserveId]);

  return <div>CheckPage</div>;
};

export default CheckPage;

 

그러나 이 checkPay 페이지에서 새로고침하면 예약 정보를 데이터베이스에 중복으로 등록할 수 있으며, 사용자가 쿼리스트링을 조작할 수 있다는 문제점도 존재했습니다.

 

❗문제점

  • 사용자 또는 악의적인 제3자가 로컬 스토리지를 조작할 수 있으며 로컬 스토리지는 보안에 취약하다는 단점이 있습니다.
  • 사용자가 결제 대기 페이지 URL을 복사하거나 조작하여 checkPay 페이지에 접속할 경우, 실제로 결제를 거치지 않고도 예약 정보가 계속해서 데이터베이스에 등록될 수 있습니다.
  • 사용자가 브라우저 캐시를 지우거나 다른 브라우저/비공개 모드를 사용하는 경우 로컬 스토리지 데이터가 유실되어 결제가 비정상적으로 처리될 수 있습니다.

 

✅ 해결 방안 - API 라우트 핸들러를 활용한 서버사이드 결제 승인 처리

클라이언트 측에서 결제 로직을 처리하기에는 보안 문제와 결제 처리에 한계가 있다고 판단했고, Next에서 제공하는 라우트 핸들러를 통해 결제 성공 여부를 서버 사이드에서 처리하기로 결정했습니다.

토스 api의 결제 처리 방식은 아래 사진과 같습니다. 사용자가 결제를 완료하면 리다이렉트 되는 주소를 라우트 핸들러로 받고, 라우트 핸들러에서 결제 승인 api를 호출해서 결제 성공 여부를 판단하기로 결정했습니다.

토스 api 결제 과정

  • 토스 requestPayment의 successUrl 설정
    • 토스 결제 성공 시 사용자를 리다이렉트하는 URL을 라우트 핸들러로 설정합니다.
 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}`
});

 

  • 쿼리스트링으로 예약정보 받아오기
    • 예약 정보와 결제 승인 호출에 필요한 orderId, amount, paymentKey를 추출합니다.
// api/payment/route.ts

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');

 

  • 결제 승인 API 호출
    • orderId, amount, paymentKey를 사용하여 토스의 결제 승인 API를 호출합니다.
  if (!orderId || !classId || !amount || !reserveQuantity || !timeId || !userId) {
    // 값이 없으면 실패 페이지로 리다이렉트
    return NextResponse.redirect(new URL(`${process.env.NEXT_PUBLIC_BASE_URL}/reserve/fail`));
  }

  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'
    }
  });

 

  • 결제 승인 시 데이터베이스 처리:
    • 결제가 승인되었다면, 데이터베이스에 사용자의 예약 정보를 저장합니다.
    • 정보 저장이 성공하면, 완료 페이지로 사용자를 리다이렉트하면서 오더 ID를 URL 파라미터로 포함시킵니다.
    • 결제 승인이 거절되거나 정보 저장에 실패할 경우, 사용자는 실패 페이지로 리다이렉트됩니다.
const res = await response.json();

  if (response.ok) {
    try {
    //DB에 예약 정보 insert
      await insertNewReservation({ 
        reserveId: res.orderId,
        classId,
        reservePrice: res.totalAmount,
        reserveQuantity: Number(reserveQuantity),
        timeId,
        userId
      });

      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}`)
    );
  }
  • 완료 페이지에서 데이터 처리:
    • 완료 페이지에서는 URL에서 오더 ID를 추출하여, 해당 오더 ID로 데이터베이스에서 예약 정보를 조회하여 출력합니다.
const ReservationCompletePage = ({ params }: { params: { reservationId: string } }) => {
  const reservationid = params.reservationId;
  const { reservationDetails, isError, isLoading } = useFetchReservationDetail(reservationid);
  // 중략

 

 

😊 결론

라우트 핸들러를 사용한 서버 사이드에서 결제 성공여부를 판단하여 보안을 향상시키고, 사용자의 실수나 악의적인 조작으로 인한 문제를 최소화할 수 있었습니다.