해당 글에서는 PortOne의 V2를 기반으로 개발하고 테스트 결제(KG이니시스, 카카오페이 간편결제)만 진행합니다.
현재 진행중인 쇼핑몰 프로젝트의 결제 기능을 구현하기 위해 포트원을 사용하기로 결정했다.
거의 모든 카드사들, 간편 결제 등을 지원하고 있어 포트원을 선택하게 되었다.
포트원의 경우 다음과 같이 한줄로 자신을 소개하고 있다.
PortOne
코드 한 줄로 세상 모든 결제를 한 번에 연동, 통합 결제 솔루션 포트원
좀 더 자세히 설명을 하자면 다음과 같다.
npm i @portone/browser-sdk
yarn add @portone/browser-sdk
pnpm i @portone/browser-sdk
PortOne
객체를 import해서 사용import * as PortOne from '@portone/brower-sdk/v2';
결제 완료에 이르기까지 나의 경우 다음과 같은 과정을 거쳐야 한다.
/api/order
에서 진행/api/payment/complete
에서 진행order_status
, payment_status
의 값을 결제됨으로 업데이트OrderForm.tsx
"use client";
...
import PortOne, { PaymentRequest } from "@portone/browser-sdk/v2";
export default function OrderForm() {
...
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// 주문 생성
const res = await fetch("/api/order", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
const data = await res.json();
...
}
...
}
handleSubmit
에서 fetch('/api/order')
를 통해 Order 테이블에 주문폼의 데이터를 삽입/app/api/order/router.ts
import { createClient } from "@/utils/supabase/server";
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const supabase = createClient();
try {
const data = await req.json();
const { error } = await supabase.from("order").insert({
order_name: data.orderName,
order_status: "PAYMENT_PENDING",
customer_name: data.name,
customer_email: data.email,
customer_phone: data.phone,
customer_address: `${data.postCode}, ${data.defaultAddress} ${data.detailAddress}`,
customer_delivery_message: data.deliveryMessage,
payment_status: "PAYMENT_IN_PROCESS",
payment_method: data.payment,
order_items: data.orderItems,
total_price: data.totalPrice,
});
if (error) {
return NextResponse.json({
status: error.code,
message: "Order created Failed",
error,
});
}
return NextResponse.json({
status: 200,
message: "Order created successfully",
});
} catch (e) {
console.log("order error: ", e);
return e;
}
}
Portone.requestPayment
를 통해 진행할 수 있다.// 예제
const response = await PortOne.requestPayment({
// Store ID 설정
storeId: "store-4ff4af41-85e3-4559-8eb8-0d08a2c6ceec",
// 채널 키 설정
channelKey: "channel-key-893597d6-e62d-410f-83f9-119f530b4b11",
paymentId: `payment-${crypto.randomUUID()}`,
orderName: "나이키 와플 트레이너 2 SD",
totalAmount: 1000,
currency: "CURRENCY_KRW",
payMethod: "CARD",
});
storeId
, channelKey
의 경우 관리자 콘솔의 결제 연동 페이지에서 확인할 수 있다. const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// 주문 생성
...
const paymentId = `${crypto.randomUUID()}`;
const paymentRequest: PaymentRequest = {
storeId: "storeId 입력",
channelKey: "channelKey 입력",
paymentId: paymentId,
orderName: formData.orderName,
totalAmount: formData.totalPrice,
currency: "CURRENCY_KRW",
payMethod: formData.payment as PaymentRequest["payMethod"],
customer: { // customer은 KG이니시스 일반 결제시 필요한 정보
fullName: formData.name,
email: formData.email,
phoneNumber: formData.phone,
},
};
if (data.status == 200) {
// 포트원 결제 요청
const res = await PortOne.requestPayment(paymentRequest);
if (res?.code != null) {
// 오류 발생
return alert(res.message);
}
...
}
};
OrderForm.tsx
"use client";
...
import PortOne from "@portone/browser-sdk/v2";
export default function OrderForm() {
...
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// 주문 생성
...
if (data.status == 200) {
// 결제 요청
...
}
const notified = await fetch(`/api/payment/complete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
// paymentId와 주문 정보를 서버에 전달합니다
body: JSON.stringify({
paymentId: paymentId,
orderData: formData,
}),
});
}
};
...
}
/app/api/payment/complete/router.ts
PORTONE_API_SECRET
: V2 전용 시크릿으로, 포트원 콘솔 내 결제연동 탭에서 발급받아야 함...
import { createClient } from "@/utils/supabase/server";
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
const PORTONE_API_SECRET = process.env.PORTONE_API_SECRET;
export async function POST(req: Request) {
const supabase = createClient();
const data = await req.json();
try {
const { paymentId, order } = data;
// 1. 포트원 결제내역 단건조회 API 호출
const paymentResponse = await fetch(
`https://api.portone.io/payments/${encodeURIComponent(paymentId)}`,
{ headers: { Authorization: `PortOne ${PORTONE_API_SECRET}` } }
);
if (!paymentResponse.ok) {
const errorResponse = await paymentResponse.json();
throw new Error(`paymentResponse: ${JSON.stringify(errorResponse)}`);
}
const payment = await paymentResponse.json();
// 2. 고객사 내부 주문 데이터의 가격과 실제 지불된 금액을 비교
// 2.1 order에서 해당 주문 내역의 total_price 데이터 요청
const { data: orderData, error: orderDataError } = await supabase
.from("order")
.select("total_price")
.eq("order_name", order.orderName)
.single();
if (orderDataError) {
throw new Error(
`주문 데이터를 불러오는데 실패했습니다. ${orderDataError}`
);
}
if (orderData.total_price === payment.amount.total) {
switch (payment.status) {
case "VIRTUAL_ACCOUNT_ISSUED": {
const paymentMethod = payment.paymentMethod;
// 가상 계좌가 발급된 상태입니다.
// 계좌 정보를 이용해 원하는 로직을 구성하세요.
// 가상 계좌 관련 코드 추가 예정
return NextResponse.json({
message: "Virtual account issued",
accountInfo: paymentMethod,
});
}
case "PAID": {
// 모든 금액을 지불한 경우
// Order Table의 order_status, payment_status 업데이트
const { data: updatedData, error: updatedDataError } = await supabase
.from("order")
.update({
order_status: "PAYMENT_COMPLETED",
payment_status: payment.status,
})
.eq("order_name", order.orderName)
.select();
// console.log("주문 테이블의 주문상태와 결제상태 업데이트 성공: ",updatedData);
if (updatedDataError) {
throw new Error(
`주문 테이블의 주문상태와 결제상태 업데이트 실패: ${updatedDataError}`
);
}
// 장바구니에 주문된 아이템 삭제
const cartItemIds = order.orderItems.map(
(item: CartItem) => item.cartItemId
);
const { error: deletedError } = await supabase
.from("cart_item")
.delete()
.in("id", cartItemIds);
if (deletedError) {
throw new Error(
`주문완료된 이후 장바구니에 해당 상품 삭제 실패: ${deletedError}`
);
}
await Promise.all(
order.orderItems.map(async (item: CartItem) => {
// 현재 상품 데이터 가져오기(현재 수량 파악을 위함)
const { data: productData, error: productDataError } =
await supabase
.from("product")
.select("stock")
.eq("id", item.itemId)
.single();
// console.log("주문완료 후 수량 체크를 위한 상품 데이터 가져오기 성공: ",productData);
if (productDataError) {
throw new Error(
`주문완료 후 수량 체크를 위한 상품 데이터 가져오기 실패: ${productDataError}`
);
}
// 수량 업데이트
const newStock = productData?.stock - item.quantity;
const { data: updatedData, error: updatedError } = await supabase
.from("product")
.update({ stock: newStock })
.eq("id", item.itemId)
.select()
.single();
// console.log("주문완료 후 상품 재고 업데이트 성공: ",updatedData);
if (updatedError) {
throw new Error(
`주문완료 후 상품 재고 업데이트 실패: ${updatedError}`
);
}
})
);
revalidatePath("/cart");
return NextResponse.json({
message: "Payment completed successfully",
});
}
}
} else {
// 결제 금액이 불일치하여 위/변조 시도가 의심됩니다.
return NextResponse.json(
{
message: "Payment amount mismatch",
},
{ status: 400 }
);
}
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 400 });
}
}
(결제를 실패하는 등의 예외처리가 제대로 작성되지 않아 나중에 추가할 예정)
결제창 | 결제내역카톡 |
---|---|
핸드폰에서 큐알을 스캔하고 나면 다음과 같은 화면을 볼 수 있다.(아이폰의 경우 일반 카메라로 진행하면 사파리에서 유효하지 않은 주소라고 뜨기 때문에 카카오톡의 큐알을 스캔하는 카메라를 통해 진행했다.) | 해당 결제는 테스트이기 때문에 돈이 지불되지 않으니 걱정 X |
![]() | ![]() |
결제된 내역을 확인하고 싶을 때 관리자 대시보드에서 결제내역>통합결제>필터>테스트결제 체크하면 확인할 수 있다.
혹시, 포트원 결재 api 비용은 요청당 얼마 정도 부과되나요?