개발일기

토스페이먼츠로 결제페이지 만들기 본문

devCamp(NextJs)

토스페이먼츠로 결제페이지 만들기

황대성 2024. 6. 1. 20:16

개요

토스페이먼츠에서 제공하는 결제위젯을 사용하여 결제페이지를 만들었다. 근데 결제페이지만 만들기에는 뭔가 부족한거같아서 추가하고, 추가하고, 추가하다보니 처음 생각했던 것보다 커진것 같다. 그래도 최대한 간단하게 구현해보고자 했다. 배포도 완료 했으니 사용하고자 하는 사람은 아래 링크를 통해서 사용해보면 될것같다. 결제하고자 하는 데이터는 의류로 정했고, 어떠한 db를 사용하지 않고, 대부분 목업 데이터를 사용하였으며, 클라이언트쪽에서 해결하고자 했다. 그래서 새로고침시 데이터가 날아가는 부분도 많이 보여진다. 하지만 나의 실력의 최선을 다해 다른 쪽으로 해결 하려고 노력 했다. 또한 토스페이먼츠에서 제공하는 결제기능은 개발서버에서는 테스트 결제가 가능하지만, 배포 이후에는 테스트 결제가 불가능하다. 내가 찾아본 결과로는 그렇다. 하나하나 설명해 보도록 하겠다 !

Site Url

https://closetpayment.vercel.app

사용 기술

1. NextJs - pages Router 방법 사용

2. TypeScript

3. React-hook-form

4. zod

5 .Recoil

 

핵심기능

1. 토스 페이먼츠 연동 - 토스 페이먼츠 API를 사용하여 결제 기능 구현
2. 쿠폰 기능 추가 - 사용자가 쿠폰을 선택하고 할인을 받을 수 있는 기능을 추가
3. 포인트 기능 추가 - 사용자가 보유한 포인트를 사용하여 결제할 수 있는 기능을 추가

 

핵심기능은 위와 같다. 하지만 처음 구상할 때, 2번 3번과 같은 기능은 회원이 있어야 되는데, 회원가입까지 만들어야 되나? 그러면 쿠폰을 어떤 식으로 회원마다 지급해야 되는거지? 이러한 생각들로 많이 고민을 했지만, 최대한 간단하게 만들기 위해서 회원 기능은 구현하지 않고, 쿠폰이나 포인트 기능은 목업데이터를 사용하기로 했다. 자세한 내용은 아래에서 설명하겠다. 

 

폰트 적용

NextJs에서는 두가지 방법의 폰트 적용 방법을 제공한다. 첫번째로는 Google Fonts 방법 이고, 두번째로는 Local Fonts 방법이다. 이번 프로젝트는 Local Fonts 방법을 적용했고, 현재 진행중인 프로젝트에는 Google Fonts를 적용 했다. 두가지 방법의 Fonts 적용 방법은 아래 블로그를 참고하면 된다. 이번 프로젝트에서는 Pretendard 폰트를 적용했다.

NextJs 폰트 적용하기 - https://reactprac.tistory.com/49

 

NextJs 폰트 적용하기(최적화 하기)

개요NextJs에서는 2가지의 폰트적용 방법을 제공한다. 첫번째로는 Google Fonts의 방법이고, 두번째는 Local Fonts의 방법이다. 첫번째 방법인 Google Fonts는 NextJs에 내장되어 있어서 간단하게 Import 하여

reactprac.tistory.com

 

 

1. 먼저 눈누폰트의 프리텐다드로 들어 간 후에 다운로페이지로 이동한다.

2. 이동 후에 페이지를 아래로 조금 내리면 글꼴 다운로드가 나온다. 다운로드 해주면 된다.

 

3. web/static/woff2 폴더의 모든 폰트를 사용하려고 하는 프로젝트에 @/fonts 폴더를 생성 후에 넣는다.

woff1과 woff2의 차이 
woff2는 woff의 개선된 버전이다. 동일하게 웹 폰트를 압축하고 최적화 하기 위한 웹 폰트 형식이지만, woff2는 Brotli 압축 알고리즘을 사용하여 폰트 데이터를 압축한다. 이를 통해서 기존 woff1 대비 약 30%에서 50% 정도 더 작은 파일 크기를 가질수 있다. 근데 개선된 버전이 나왔는데 왜 woff1은 사라지지 않고, 왜 사용할까? 이유는 바로 호환성 문제이다. woff2는 비교적 낮은 호환성을 가지고 있어 woff1을 폴백 폰트로 같이 사용한다.(woff2를 메인으로 사용하지만 호환되지않는 브라우저를 사용할 시 woff1 폰트를 사용한다.) 하지만 익스플로러가 사라지면서 호환되지 않는 브라우저는 없다.

 

4. _app.tsx에 폰트 적용하기

import localFont from "next/font/local";

const Pretendard = localFont({
  src: [
    {
      path: "../fonts/Pretendard-Black.woff2",
      weight: "900",
      style: "nomal",
    },
    {
      path: "../fonts/Pretendard-ExtraBold.woff2",
      weight: "800",
      style: "nomal",
    },
    //...fonts of different sizes
  ],
});

export default function App({ Component, pageProps }) {
  return (
    <main className={Pretendard.className}>
      <Component {...pageProps} />
    </main>
  );
}

 

목업 데이터

이번 프로젝트를 진행하면서 db를 사용하지 않으면서 최대한 NextJs에서 제공하는 기능을 사용하려고 노력했고, 그렇기 때문에 목업 데이터를 사용 했다. 아래는 사용자가 구매하려고 하는 상품과 사용자에게 주어지는 쿠폰 목업 데이터이다.

 

상품 데이터

export const MERCHANDISES = [
  {
    id: "hX2BTPkNH",
    category: "top",
    image: "/images/BIGTshirt.jpg",
    maker: "소버먼트",
    description: "BIG 트위치 로고 티셔츠 White",
    size: "",
    quantity: 1,
    price: 22900,
  },
  // ... other data
];

 

쿠폰 데이터

export const coupons = [
  {
    id: shortid.generate(),
    label: "쿠폰 적용 안함",
    disCount: 0,
    disCountType: undefined,
  },
  {
    id: shortid.generate(),
    label: "천원 할인 쿠폰",
    disCount: 1000,
    disCountType: "won",
  },
  {
    id: shortid.generate(),
    label: "10% 할인 쿠폰",
    disCount: 10,
    disCountType: "percent",
  },
  // ... other coupons
];

 

메인 페이지

 

상품 목업 데이터를 메인 페이지에서 사용 했고, 해당 상품을 클릭하면 디테일 페이지로 넘어가도록 구현했다.

상품마다 카테고리값을 설정해두었고, 사이드바에서 클릭한 카테고리만 보이도록 했다. 사이드바를 따로 컴포넌트화 해서 클릭한 카테고리 값을 사용하려면 전역으로 관리해야 했다. 그래서 recoil을 사용 하기로 했다.

 

1. recoil 적용 및 사용하기

_app.tsx

import { RecoilRoot } from "recoil";

export default function App({ Component, pageProps }: AppProps) {
  const router = useRouter();

  return (
    <RecoilRoot>
      <main className={Pretendard.className}>
        <Component {...pageProps} />
      </main>
    </RecoilRoot>
  );
}

 

@/Recoil/recoilState.ts

import { atom } from "recoil";

export const categoryState = atom({
    key : 'categoryState',
    default : 'all'
})

 

@/componets/sidebar/Sidebar.tsx

import { useRecoilState } from "recoil";
import { categoryState } from "@/Recoil/recoilState";

const Sidebar = () => {
  const [selectedCategory, setSelectedCategory] = useRecoilState(categoryState);
  const router = useRouter();

  const selectedCategoryButtonHandler = (category: string) => {
    if (router.route === "/") {
      setSelectedCategory(category);
    } else {
      router.push("/");
      setSelectedCategory(category);
    }
  };
  return (
    <aside className="w-[260px] h-[100%] border">
      <ul>
        {CATEGORIES.map((category: CategoryType) => {
          return (
            <li
              className={`px-4 py-3 text-sm font-bold hover:bg-slate-50 ${
                selectedCategory === category.category ? "text-red-300" : ""
              }`}
              key={category.id}
            >
              <button
                onClick={() => selectedCategoryButtonHandler(category.category)}
              >
                {category.label}
                <span className="text-gray-300 ml-2 font-normal">
                  {category.subLabel}
                </span>
              </button>
            </li>
          );
        })}
      </ul>
    </aside>
  );
};

export default Sidebar;

 

index.tsx

import { useRecoilValue } from "recoil";
import { categoryState } from "@/Recoil/recoilState";

const Home = () => {
  
  const category = useRecoilValue(categoryState);

  return (
   //... JSX Code
  );
};

상품 디테일 페이지

메인 페이지에서 상품 클릭시 상품 디테일 페이지로 이동할 수 있도록 했다. pages Router 방법에는 두가지의 방법으로 다이나믹 라우팅이 가능하다. 첫번째로는 useRouter를 사용할 수 있고, 두번쨰로는 useParams를 사용할 수 있다.

나는 useRouter를 사용해서 해당 아이디를 가진 상품으로 이동하도록 했다.

const Merchandise = ({ merchandise }: PropsType) => {
  const router = useRouter();

  const { id, image, maker, description, price } = merchandise;
  
  return (
    <li
      className="border cursor-pointer hover:bg-slate-50"
      onClick={() => router.push(`/detail/${id}`)}
    >
         )
    }

그러면 위와 같이 해당 아이디를 가진 페이지로 이동된다. 우리는 넘어온 아이디를 활용해서 아이디에 일치하는 상품을 찾아서 활용하면 된다.

const Detail = () => {
  const router = useRouter();
  const { id } = router.query;

  const merchandise = MERCHANDISES.find((merchandise: MerchandiseType) => {
    return merchandise.id === id;
  });

  return (
    //...JSX Code
  );
};

export default Detail;

 

상품 사이즈 선택하기

상품 디테일 페이지를 들어가면 사이즈를 선택하게 되어있다. 사이즈는 M, L, XL로만 해두었고, 사이즈 선택시 상품이 추가되고 사이즈에 맞는 상품 수량을 컨트롤 할 수 있도록 구현했다. selectForm과 구매하기 버튼은 Shadcn을 사용했다. useState훅을 사용해서 items의 빈 배열에 선택한 사이즈 값을 추가할 수 있도록 했다.

"use client";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormMessage,
} from "@/components/ui/form";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import SelectedItem from "@/components/detailMerchandise/SelectedItem";
import { cn } from "@/lib/utils";
import { useForm } from "react-hook-form";
import { FormSchema } from "@/valitators/size";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useRouter } from "next/router";
import { MerchandiseType } from "@/types/mockupDataType";
import shortid from "shortid";

interface MerchandiseProps {
  merchandise: MerchandiseType | undefined;
}

const SelectForm = ({ merchandise }: MerchandiseProps) => {
  const [items, setItems] = useState<any>([]);
  const router = useRouter();
  const { id } = router.query;

  const form = useForm<z.infer<typeof FormSchema>>({
    resolver: zodResolver(FormSchema),
    defaultValues: {
      size: "",
    },
  });

  const addItemList = (value: string) => {
    if (items.find((item: MerchandiseType) => item.size === value)) {
      return;
    }
    const itemToAdd= {
      ...merchandise,
      id: shortid.generate(),
      size: value,
    };

    setItems([...items, itemToAdd]);
  };

  const price = items
    .map((item: MerchandiseType) => {
      return item?.price;
    })
    .reduce((acc: number, curr: number) => {
      return acc + curr;
    }, 0);

  function onSubmit() {
    router.push(
      {
        pathname: "/payment",
        query: { id, items: JSON.stringify(items), price },
      },
      "/payment"
    );
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        <FormField
          control={form.control}
          name="size"
          render={({ field }) => (
            <FormItem
              className={cn(
                "flex justify-center border py-3 bg-gray-50 flex-col"
              )}
            >
              <Select
                onValueChange={(value) => {
                  field.onChange(value);
                  addItemList(value);
                }}
                defaultValue={field.value}
              >
                <FormControl>
                  <SelectTrigger>
                    <SelectValue placeholder="옵션 선택" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="M">M</SelectItem>
                  <SelectItem value="L">L</SelectItem>
                  <SelectItem value="XL">XL</SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />
        <SelectedItem
          items={items}
          setItems={setItems}
          merchandise={merchandise}
        />
        <div>
          <div className="flex items-center mb-3 justify-between border p-3">
            <span className="w-[80px]">총 상품 금액</span>
            <p className="font-bold">{price.toLocaleString()}원</p>
          </div>
        </div>
        <Button type="submit" className={cn("w-[100%] py-7")}>
          구매하기
        </Button>
      </form>
    </Form>
  );
};

export default SelectForm;

 

추가되는 상품들은 따로 컴포넌트를 분리해서 빼두었고, 수량을 컨트롤 할 수 있도록 했다. 수량이 1개라면 그 밑으로 내려가지 않도록 했고, css또한 그에 맞게 설정 했다. x버튼을 클릭하면 items 배열에서 삭제되도록 했다.

 

import { MerchandiseType } from "@/types/mockupDataType";
import React from "react";

interface MerchandiseProps {
  items: MerchandiseType[];
  setItems: React.Dispatch<React.SetStateAction<any>>;
  merchandise: MerchandiseType | undefined;
}

const SelectedItem = ({ items, setItems, merchandise }: MerchandiseProps) => {
  const plusButtonHandler = (itemId: string) => {
    setItems((prevItems: MerchandiseType[]) =>
      prevItems.map((item: MerchandiseType) =>
        item.id === itemId
          ? {
              ...item,
              quantity: item.quantity + 1,
              price: Number(merchandise?.price) * (item.quantity + 1),
            }
          : item
      )
    );
  };

  const minusButtonHandler = (itemId: string) => {
    setItems((prevItems: MerchandiseType[]) =>
      prevItems.map((item: MerchandiseType) =>
        item.id === itemId && item.quantity > 1
          ? {
              ...item,
              quantity: item.quantity - 1,
              price: Number(merchandise?.price) * (item.quantity - 1),
            }
          : item
      )
    );
  };

  return (
    <ul>
      {items.map((item: MerchandiseType) => {
        return (
          <li
            key={item.id}
            className="flex justify-between border px-4 py-2 text-sm items-center"
          >
            <span className="w-[40px]">{item.size}</span>
            <div className="flex w-[110px]">
              <button
                type="button"
                className={`border px-2 bg-gray-100 ${
                  item.quantity <= 1 ? " text-gray-300" : ""
                }`}
                onClick={() => minusButtonHandler(item.id)}
              >
                -
              </button>
              <p className="border w-[38px] text-center">{item.quantity}</p>
              <button
                type="button"
                className="border px-2 bg-gray-100"
                onClick={() => plusButtonHandler(item.id)}
              >
                +
              </button>
            </div>
            <div className="flex text-gray-600">
              <p className="w-[82px]">{item.price.toLocaleString()}원</p>
              <button
                type="button"
                className="px-2"
                onClick={() => {
                  setItems(
                    items.filter((listItem: MerchandiseType) => {
                      return listItem.id !== item.id;
                    })
                  );
                }}
              >
                x
              </button>
            </div>
          </li>
        );
      })}
    </ul>
  );
};

export default SelectedItem;

 

선택한 상품데이터 넘기기

이제 상품 선택 후 구매하기 버튼을 누르면 상품 데이터와 같이 결제 페이지로 넘어가야 한다. useRouter의 push를 사용하여 이동하고자 하는 페이지와 함께 query를 이용하여 데이터를 넘겼다. 이 부분에서 어떻게 데이터를 이전할까 고민을 많이 했던것 같다. 내가 생각했을 때의 최선의 방법을 선택한 것 같고, 그래도 아마 더 좋은 방법이 있을 것도 같다.query로 데이터를 넘길 때에는 string을 넘기든 number를 넘기든 전부 string으로 받아진다. 그러면 배열을 넘기면 어떻게 되는가? 빈 문자열이 나온다. 안넘어가진다. 그래서 생각한 방법이 JSON형식으로 바꿔서 데이터를 보내고, parse한 뒤 사용하는 방법이다. 하지만 넘겨받은 데이터는 새로고침시 undefined가 나오기 때문에 undefined시 메인 페이지로 이동하게끔 구현 했다. 

그리고 query로 데이터를 넘길시 url에 해당 데이터가 드러나는데 그것을 방지하기 위해 router.push 두번째 파라미터에 띄우고자 하는 주소만을 입력하면 데이터가 유출되는 것을 막을 수 있다.

function onSubmit() {
    router.push(
      {
        pathname: "/payment",
        query: { id, items: JSON.stringify(items), price },
      },
      "/payment"
    );
  }
const Payment = () => {
  const [items, setItems] = useState<any[]>([]);
  const router = useRouter();

  useEffect(() => {
    try {
      const queryItems = router.query.items
        ? JSON.parse(router.query.items as string)
        : undefined;
      if (!queryItems) {
        router.push("/");
      } else {
        setItems(queryItems);
      }
    } catch (error) {
      console.error(error);
      router.push("/");
    }
  }, []);
  
  return (
  	//...JSX Code
  )

 

쿠폰 및 포인트 적용하기

쿠폰은 위에서 말했다시피 목업데이터를 활용했고, 포인트도 회원기능을 넣지 않았기때문에 페이지에 들어오면 5000원의 적립금이 있다는 가정하에 기능을 구현했다. 이 부분에서는 할인을 포인트 먼저 적용할지 쿠폰먼저 적용하지에 대한 고민을 했었고, 포인트가 적립금을 넘어갔을 때의 처리, 쿠폰이 퍼센트일 때와 won일 때의 처리를 어떤식으로 해야할지 고민했었다. 아래가 그 결과이다.

const Payment = () => {
  const [point, setPoint] = useState < number > 0;
  const userPoint = 5000;
  
  //쿠폰 선택시 쿠폰 목업데이터에서 해당 쿠폰을 찾음
  const disCount = coupons.find((coupon) => {
    return form.watch().coupon === coupon.id;
  });

  return (
    <FormField
      control={form.control}
      name="coupon"
      render={({ field }) => (
        <FormItem>
          <FormLabel>쿠폰 할인</FormLabel>
          <Select onValueChange={field.onChange} defaultValue={field.value}>
            <FormControl>
              <SelectTrigger>
                <SelectValue placeholder="쿠폰 선택" />
              </SelectTrigger>
            </FormControl>
            <SelectContent>
              {coupons.map((coupon) => {
                return (
                  <SelectItem key={coupon.id} value={coupon.id}>
                    {coupon.label}
                  </SelectItem>
                );
              })}
            </SelectContent>
          </Select>
          <FormMessage />
        </FormItem>
      )}
    />
    <div className="flex items-center justify-between mt-2">
      <label htmlFor="point" className="text-[14px] font-medium">적립금 사용</label>
      <input
        className="flex h-10 w-[90%] ..."
        id="point"
        name="point"
        value={point}
        onChange={(e:React.ChangeEvent<HTMLInputElement>) =>
          // ... 사용할 포인트가 적립금을 초과시 실행되는 로직
          {
                        const newPoint = parseInt(e.target.value, 10);
                        if (isNaN(newPoint) || newPoint <= 0) {
                          setPoint(0);
                          return;
                        }
                        if (newPoint > userPoint) {
                          alert("보유 적립금을 초과하였습니다.");
                          setPoint(0);
                          return;
                        }
                        setPoint(newPoint);
                      }
          }
      />
      <p className="text-[14px]">
        보유 적립금
        <span className="text-orange-500 font-bold">
          {userPoint.toLocaleString()}
        </span>
        원
      </p>
    </div>
    // 쿠폰 적용시 처리 방식
      <TableRow>
        <TableCell className="font-medium">쿠폰 할인</TableCell>
        <TableCell className="text-right">
          {disCount === undefined || disCount.disCountType === undefined ? (
            "쿠폰 적용 안함"
          ) : (
            <p>
              {disCount?.disCount}
              {disCount?.disCountType === "won" ? "원" : "%"}
            </p>
          )}
        </TableCell>
      </TableRow>
  );
};
export const totalPay = (disCount:DisCountType | undefined,price:number,point:number) => {
    if (disCount === undefined && point === 0) {
      return price - 2500;
    }
    if (disCount === undefined || disCount.disCountType === undefined) {
      return price - point;
    } else if (point === undefined) {
      // 쿠폰만 정의된 경우
      if (disCount.disCountType === "won") {
        return price - disCount?.disCount;
      } else if (disCount.disCountType === "percent") {
        return price - (price * disCount?.disCount) / 100;
      }
    }
    // 할인과 포인트가 모두 정의된 경우
    if (disCount.disCountType === "won") {
      return price - point - disCount?.disCount;
    } else if (disCount.disCountType === "percent") {
      return price - point - price / disCount?.disCount ;
    }
  };

 

토스페이먼츠 결제위젯 연동하기

여기서도 정말 많이 헤맨부분이다. 찾고 찾아서 나온 결과는 테스트키를 연동해야 되는 것이다. 처음에는 토스페이먼츠에서 제공하는 가이드를 따라하면 되겠지 했지만 가이드와 내가 만져야할 부분이 달랐다.

가이드 링크 - https://docs.tosspayments.com/reference/using-api/api-keys

 

API 키 | 토스페이먼츠 개발자센터

토스페이먼츠 클라이언트 키 및 시크릿 키를 발급받고 사용하는 방법을 알아봅니다. 클라이언트 키는 SDK를 초기화할 때 사용하고 시크릿 키는 API를 호출할 때 사용합니다.

docs.tosspayments.com

위의 가이드를 보면 상점아이디를 선택할 수 있는데 내 개발정보에 들어가보면 그런건 나와있지 않다. 아래의 버전이 다른건가 해서 바꿔봐도 달라지는 건 없었다.

그렇다면 이 부분에서 결제위젯 연동 키는 실제로 결제위젯을 연결할 때 사용하는 것이라면 우리가 테스트로 이용해야 할 건 API 개별 연동 키 부분인데 이 부분의 클라이언트 키와 시크릿 키를 연동하면 될까? 그렇지 않다. 그러면 결제위젯과 연결하려면 어떻게 해야하는가. 많은 시도 끝에 연결한 방법은 토스페이먼츠에서 제공하는 테스트 키 뿐이였다. 토스페이먼츠에서 지원을 안한다는 글을 본거 같은데 확실하지 않을 수도 있다. 내가 찾은 방법은 이것 뿐이다,, 클라이언트 키와 시크릿 키는 아래 링크에서 얻을 수 있다.

링크 - https://github.com/tosspayments/payment-widget-sample

 

GitHub - tosspayments/payment-widget-sample: 토스페이먼츠 결제위젯 샘플 프로젝트입니다.

토스페이먼츠 결제위젯 샘플 프로젝트입니다. . Contribute to tosspayments/payment-widget-sample development by creating an account on GitHub.

github.com

위의 링크에서 클라이언트 키와 시크릿 키를 확인 했다면 아래의 링크를 따라서 하면 쉽게 결제위젯을 연동할 수 있을 것 이다.

링크 - https://docs.tosspayments.com/reference/widget-sdk

 

결제위젯 JavaScript SDK | 토스페이먼츠 개발자센터

결제위젯 JavaScript SDK를 추가하고 메서드를 사용하는 방법을 알아봅니다.

docs.tosspayments.com

가이드나 코드를 살펴보면 customerkey가 필요한데 이것은 결제하는 고객을 특정하는 키라고 생각하면 될 것 같다. 그러므로 아마 가이드에도 나와 있을 텐데 랜덤키를 지정해 주면 된다. 나는 shortid를 사용했다.

import shortid from "shortid";

const widgetClientKey = "test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm";
const customerKey = shortid.generate();

 

const Payment = () => {
  const [paymentWidget, setPaymentWidget] = useState<any>(null);
  const paymentMethodsWidgetRef = useRef<any>(null);
  const [items, setItems] = useState<any[]>([]);
  const [point, setPoint] = useState<number>(0);
  const userPoint = 5000;

  const router = useRouter();

  useEffect(() => {
    try {
      const queryItems = router.query.items
        ? JSON.parse(router.query.items as string)
        : undefined;
      if (!queryItems) {
        router.push("/");
      } else {
        setItems(queryItems);
      }
    } catch (error) {
      console.error(error);
      router.push("/");
    }
  }, []);

  const price = items
    .map((item: MerchandiseType) => {
      return item.price;
    })
    .reduce((acc: number, curr: number) => {
      return acc + curr;
    }, 0);

  useLayoutEffect(() => {
    const fetchPaymentWidget = async () => {
      try {
        const loadedWidget = await loadPaymentWidget(
          widgetClientKey,
          customerKey
        );
        setPaymentWidget(loadedWidget);
      } catch (error) {
        console.error("Error fetching payment widget:", error);
      }
    };

    fetchPaymentWidget();
  }, []);

  useEffect(() => {
    if (paymentWidget == null) {
      return;
    }

    const paymentMethodsWidget = paymentWidget.renderPaymentMethods(
      "#payment-widget",
      { value: price },
      { variantKey: "DEFAULT" }
    );

    paymentWidget.renderAgreement("#agreement", { variantKey: "AGREEMENT" }); //약관 동의 부분

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

  useEffect(() => {
    const paymentMethodsWidget = paymentMethodsWidgetRef.current;

    if (paymentMethodsWidget == null) {
      return;
    }

    paymentMethodsWidget.updateAmount(price);
  }, [price]);

  const form = useForm<z.infer<typeof registerSchema>>({
    resolver: zodResolver(registerSchema),
    defaultValues: {
      name: "",
      email: "",
      phone: "",
      recipient: "",
      orderPhone: "",
      landLinePhone: "",
      address: "",
      detailedAddress: "",
      coupon: "",
    },
  });

  const disCount = coupons.find((coupon) => {
    return form.watch().coupon === coupon.id;
  });

  async function onSubmit(values: z.infer<typeof registerSchema>) {
    const { name, email, phone } = values;

    try {
      await paymentWidget?.requestPayment({
        orderId: shortid.generate(),
        orderName:
          items.length === 1
            ? items[0].description
            : `${items[0].description} 외 ${items.length}`,
        customerName: name,
        customerEmail: email,
        customerMobilePhone: phone,
        successUrl: `${window.location.origin}/payment/success`,
        failUrl: `${window.location.origin}/payment/fail`,
      });
    } catch (error) {
      console.error(error);
    }
  }
}

return (
     //...JSX Code
            <section>
              <div className="border mb-5">
                <div id="payment-widget" />
                <div id="agreement" />
              </div>
            </section>
)

 

연동 후에 자신의 프로젝트에 맞게 커스텀 해주면 원하는 가격과 구매하고자 하는 정보를 결제완료 페이지에 띄울 수 있다. 하지만 테스트 키를 연동 하면 개발 사이트에서는 가능하지만 배포한 사이트에서는 테스트 결제가 되지 않는다.

 

마무리 

이렇게 토스페이먼츠 결제위젯을 사용해서 간단한 의류사이트를 만들어 보았다. 역시 익숙하지 않은 NextJs와 Shadcn, Zod 등을 사용하면서 시간이 꽤 걸렸지만, 좋은 정보들을 알아가는 것 같아 좋다. 아마 더 최적화 하고, 정리해야 겠지만 이 프로젝트는 여기서 마무리 해야할 것 같다.