3번째 커머스 프로젝트 "푸드버스 스마트오더 어플리케이션"을 개발했을때 겪은 우여곡절에 대한 글이다.
프로젝트 내내 결제 모듈 연동은 가장 중요한 기능이었기 때문에, 시니어 개발자께서 담당하셨다.
하지만, 시니어께서 다른 프로젝트로 끌려간 기간동안,
QA 단계에서 발생한 여러 에러와 성능이슈 때문에 코드 파악 및 기능 개선이 시급했졌다.
결국, 시니어를 기다리기 보다는 스스로 기능 개선을 하며 전체적인 리팩토링을 진행하게 되었다.
요즘 커머스 프로덕트를 개발할때는 아엠포트나 토스 페이먼츠, NHN 결제 시스템 등 조금 더 개발하기 수월한 환경을 제공하는 결제 모듈을 사용하려는 추세지만, 전통의 강자 "나이스페이 결제 모듈"은 저렴한 수수료와 레거시 개발 환경에 익숙한 인터페이스를 제공하고 있어 현재에도 폭넓게 사용되고 있다.
다만, 진행하고 있는 프로젝트는 React
Next.js
기반으로 이루어져 있다보니, JSP
예시 결제 모듈을 그대로 따르기엔 무리가 있었다. 하여, 하단부터는 초심자가 결제모듈을 연동하는데 참조할 수 있도록 차근 차근 단계를 밟아가려 한다.
결제창을 호출하기 위해서는 부모 컴포넌트(Next.js에서는 주로 이 작업을 Page 단에서 처리한다.)의 <Script />
태그에 자바스크립트 코드를 임포트 해주어야 한다.
// src/pages/checkout/index.tsx
const Checkout = () => {
...
return (
<CheckoutLayout>
<Script
src="https://web.nicepay.co.kr/v3/webstd/js/nicepay-3.0.js"
type="text/javascript"
/>
<CheckoutForm />
</CheckoutLayout>
)
};
이렇게 스크립트 코드를 임포트하게 되면 이제 Nicepay에서 제공하는 각종 함수(goPay
, niceSubmit
)를 사용할 수 있게 된다.
goPay()
함수에 전달할 파라미터를 저장할 폼 엘리먼트 작성하기클라이언트에서 goPay()
함수를 호출해 결제창 팝업을 호출하게 될때, goPay()
함수의 파라미터로 결제에 필요한 정보를 form 데이터 형식에 담아 전달하게 된다. 하여 다음 단계에서는 <CheckoutForm />
컴포넌트 최하단에 정보를 담고 전달할 form 엘리먼트를 작성해줄 것이다.
// src/components/checkout/CheckoutForm.tsx
import React, { useState, useRef } from 'react';
const CheckoutForm = () => {
const { form, setForm } = useState({});
const formRef = useRef();
...
return (
<Container>
...
<form
name="payForm"
method="post"
action={ReturnURL}
ref={formRef}
acceptCharset="euc-kr"
>
<input type="hidden" name="GoodsName" value={form.GoodsName} />
<input type="hidden" name="Amt" value={form.Amt} />
<input type="hidden" name="MID" value={form.MID} />
<input type="hidden" name="EdiDate" value={form.EdiDate} />
<input type="hidden" name="Moid" value={form.Moid} />
<input type="hidden" name="PayMethod" value={form.PayMethod} />
<input type="hidden" name="MerchantKey" value={form.MerchantKey} />
<input type="hidden" name="SignData" value={form.SignData} />
<input type="hidden" name="BuyerName" value={form.BuyerName} />
<input type="hidden" name="BuyerTel" value={form.BuyerTel} />
<input type="hidden" name="BuyerEmail" value="" />
<input type="hidden" name="ReturnURL" value={form.ReturnURL} />
<input type="hidden" name="FailURL" value={form.failURL} />
<input type="hidden" name="GoodsCl" value={form.GoodsCl} />
</form>
</Container>
)
}
이제 우리는 useState를 통해 결제 api의 response로 받아온 값을 정형해 그 값을 useRef의 current값에 전달함으로써, 폼 데이터를 저장할 것이다. useRef를 이용해서 htmlElement를 조작하면, 업데이트 할 객체의 값을 자동으로 찾아 업데이트 해주기 때문에 상당히 유용하다.
[React] ref로 HTML 엘리먼트에 접근/제어하기
<input />
엘리먼트의 각 값에 대한 정의는 아래와 같다. 이중 하나도 누락되지 않도록 전달되도록 주의해야 한다.
나이스 페이 결제 모듈을 불러오는데 성공하고, 결제 실패 유무에 따라 페이지를 라우팅하거나 Alert 메시지를 띄워줘야 할 경우를 고려해서 각각 상태에 대응하는 콜백함수를 정의 후 이를 전달할 수 있다.
//src/util/index.ts
export const convertFormToObj = (form) => {
const obj = {};
Object.keys(form).map((k) => {
if (!parseInt(k)) return;
const { name, value } = form[k];
obj[name] = value;
});
return obj;
};
// src/components/checkout/CheckoutForm.tsx
import React, { useState, useEffect, useRef } from 'react';
import { useRouter } from "next/router";
import { convertFormToObj } from "src/util";
const CheckoutForm = () => {
const { form, setForm } = useState({});
const formRef = useRef();
const router = useRouter();
// 결제 후 성공 callback
const nicepaySubmit = async () => {
setAlertType("success");
setAlertMsg("결제에 성공했습니다.");
sendPaymentResult(true);
};
// 결제 후 실패 callback
const nicepayClose = async () => {
// TODO: 결제 실패시 처리
setAlertType("error");
setAlertMsg("결제를 다시 시도해주세요");
sendPaymentResult(false);
};
const sendPaymentResult = async () => {
const body = convertFormToObj(formRef.current);
body.success = success;
...
if (success) {
window.deleteLayer();
router.push("/payment/complete")
}
};
useEffect(() => {
if (Object.keys(form).length === 0) return;
if (isMobile) {
// 모바일 결제창 진입
formRef.current.action = "https://web.nicepay.co.kr/v3/v3Payment.jsp";
formRef.current.acceptCharset = "euc-kr";
formRef.current.submit();
} else {
// PC 결제창 진입
if (typeof window !== "undefined") {
window.nicepaySubmit = nicepaySubmit;
window.nicepayClose = nicepayClose;
window.goPay(formRef.current);
}
}
}, [form]);
return (
<Container>
...
<form
name="payForm"
method="post"
action={ReturnURL}
ref={formRef}
acceptCharset="euc-kr"
>
<input type="hidden" name="GoodsName" value={form.GoodsName} />
<input type="hidden" name="Amt" value={form.Amt} />
<input type="hidden" name="MID" value={form.MID} />
<input type="hidden" name="EdiDate" value={form.EdiDate} />
<input type="hidden" name="Moid" value={form.Moid} />
<input type="hidden" name="PayMethod" value={form.PayMethod} />
<input type="hidden" name="MerchantKey" value={form.MerchantKey} />
<input type="hidden" name="SignData" value={form.SignData} />
<input type="hidden" name="BuyerName" value={form.BuyerName} />
<input type="hidden" name="BuyerTel" value={form.BuyerTel} />
<input type="hidden" name="BuyerEmail" value="" />
<input type="hidden" name="ReturnURL" value={form.ReturnURL} />
<input type="hidden" name="FailURL" value={form.failURL} />
<input type="hidden" name="GoodsCl" value={form.GoodsCl} />
</form>
</Container>
);
};
NICEPAY 결제모듈은 모바일과 PC 결제창 두 가지 타입으로 호출하는 방식을 구분되어 있다. 때문에 상태관리되고 있는 form 객체의 정보가 업데이트 될때 useEffect
로 사이드 이펙트 제어를 분기처리해서 진행할 수 있다.
모바일 결제창 모듈은 formRef
의 current
객체를 참조하는 반면, PC 결제창 모듈은 window의 document 객체에 성공 콜백과 실패 콜백을 체이닝하고, goPay()
함수를 호출하는 방식으로 구동한다.
유틸 함수를 통해서 formRef 객체의 키 값중 success 값에 따라 transaction을 업데이트 하는 등의 추가 api 요청과 라우팅 등은 sendPaymentResult()
함수에서 실행하도록 로직을 분리했다.
// src/hooks/useCheckout.ts
import React, { useState, useEffect, useRef } from 'react';
import { useMutation } from "react-query";
import { useAtom } from "jotai";
import { CartStateAtom, CartAllDataAtom } from "src/atoms/Atoms";
const useCheckout = () => {
const [state, setState] = useAtom(CartStateAtom);
...
const checkoutForMe = () => {
const mutation = useMutation(() => {
// 결제 요청 후 200 status 를 반환받으면 state의 cart 값을 업데이트 시킨다.
const res = await cartApi.checkoutForMe();
return res.data;
});
return mutation
};
return {
state,
setState,
checkoutForMe
};
};
// src/components/checkout/CheckoutForm.tsx
import useCheckout from "src/hooks/useCheckout";
const CheckoutForm = () => {
const { state, setstate, checkoutForMe } = useCheckout();
const { mutate, isLoading, isSuccess, success } = checkoutForMe();
const handleCheckout = () => {
...
// 결제 관련 valiation 로직을 수행한 후 주문을 생성하는 api로 요청한다.
createOrder();
}
const createOrder = () => {
try {
mutate();
// 결제 요청에 성공하면, 결제 유무를 판단하는 atom State의 불리언 값을 변경시킨다.
setState((state) => ({
...state,
isCheckout, !state.isCheckout;
}));
} catch (err) {
console.log(err);
}
};
const createForm = (order) => {
const EdiDate = moment().format("YYYYMMDDHHMMSS");
const MID = process.env.NEXT_PUBLIC_MID;
const Moid = order._id;
const TotalAmount = order.total.amount;
const TotalTaxFree = order.totalTaxFree;
const TotalTaxedAmount = TotalAmount - TotalTaxFree;
const TotalSupplyAmt = Math.ceil(TotalTaxedAmount / 1.1);
const TotalVAT = TotalTaxedAmount - TotalSupplyAmt;
const Amt = TotalAmount + ShippingFee;
const MerchantKey = process.env.NEXT_PUBLIC_MERCHANT_KEY;
const TestData = EdiDate + MID + Amt + MerchantKey;
const SignData = cryptoJs.SHA256(TestData).toString();
const successUrl = encodeURIComponent(
`${window.location.origin}/payment/complete?isMobile=${isMobile}`
);
const failUrl = encodeURIComponent(
`${window.location.origin}/payment/complete?isMobile=${isMobile}`
);
const returnUrl = isMobile ? process.env.NEXT_PUBLIC_RETUN_Mobile_URL : process.env.NEXT_PUBLIC_RETURN_URL
setReturnURL(returnUrl);
// 파라미터로 전달된 값에 따라 전달할 정보를 담아 form 상태를 업데이트 시킨다.
setForm({
// 상품이름
GoodsName,
// 결제 금액
Amt,
// 상점 아이디
MID,
// 전문생성일시
EdiDate,
// 상점 주문번호 상점 고유 식별값
Moid,
MerchantKey,
SignData,
BuyerName: order.address.billing.name.full,
BuyerTel: order.address.billing.name.phone,
BuyerEmail: "",
// 아래 값들중 하나 [CARD, VBANK, SSG_BANK, GIFT_CULT]
PayMethod: "CARD",
ReturnURL: returnUrl,
failURL: failUrl,
// 휴대폰 소액결제 추가 요청 파라미터 (0 = 컨텐츠, 1 = 현물)
GoodsCl: 1,
SupplyAmt: TotalSupplyAmt,
GoodsVat: TotalVAT,
TaxFreeAmt: TotalTaxFree,
});
}
useEffect(() => {
// 결제 성공과 mutation의 상태를 판단하는 지표를 사용해서 폼 데이터를 업데이트 하는 로직을 실행한다.
if (state.order && !isLoading && state.isCheckout) {
createForm(state.order)
}
}, [state.order]);
return (
<Container>
<ButtonWrap>
<Button onClick={() => handleCheckout()} /> 결제하기 </Button>
</ButtonWrap>
<form>
...
</form>
</Container>
);
};
결제하기 버튼을 클릭해서 온클릭 함수handleCheckout()
를 실행하면 다음과 같은 로직이 실행될 것이다.
1. 결제를 위한 validate 로직이 실행된다.
2. validate 완료 후 mutate 함수와 setState 함수를 실행시킨다.
3. mutate 함수 결과에 따라 useEffect로 사이드를 제어해, createForm 함수를 실행시킨다.
4. createForm 함수의 결과로 상태 관리되는 form 객체의 값이 채워진다.
5. useEffect의 디펜던시로 form을 넣었두었기 때문에 아래 로직이 실행되며, 결제 모듈을 불러온다.
// src/components/checkout/CheckoutForm.tsx
...
useEffect(() => {
if (Object.keys(form).length === 0) return;
if (isMobile) {
// 모바일 결제창 진입
formRef.current.action = "https://web.nicepay.co.kr/v3/v3Payment.jsp";
formRef.current.acceptCharset = "euc-kr";
formRef.current.submit();
} else {
// PC 결제창 진입
if (typeof window !== "undefined") {
window.nicepaySubmit = nicepaySubmit;
window.nicepayClose = nicepayClose;
window.goPay(formRef.current);
}
}
}, [form]);
...
이해를 쉽게 하기 위해, 함수를 분리하지 않고 최대한 절차 중심으로 코드를 구성하려고 노력해보았다. 만약 여기서 더 리팩토링할 기회가 있다면, form 상태 또한 jotai의 useAtom을 이용해서 관리할 것이고, 따라서 createForm
함수 등 컴포넌트 뷰 단에 함께 존재하지 않아도 될 함수들은 관심사 분리를 해서 파일을 분리할 것 같다.
이 글을 통해 nicePay 결제 모듈을 연동하는 누군가는 반드시 그렇게 해보길 바란다.
아쉽게도 이 프로젝트는 여기서 끝을 맺어, 수정이 어렵게 되었지만 조금 더 나은 방안이 떠오른다면 언제든지 댓글로 의견을 주면 정말 감사할 것 같다.
더불어, 누군가에게 이 글이 꼭 도움이 되길 바란다.
// src/hooks/useCheckout.ts
import React, { useState, useEffect, useRef } from 'react';
import { useMutation } from "react-query";
import { useAtom } from "jotai";
import { CartStateAtom, CartAllDataAtom } from "src/atoms/Atoms";
const useCheckout = () => {
const [state, setState] = useAtom(CartStateAtom);
...
const checkoutForMe = () => {
const mutation = useMutation(() => {
// 결제 요청 후 200 status 를 반환받으면 state의 cart 값을 업데이트 시킨다.
const res = await cartApi.checkoutForMe();
return res.data;
});
return mutation
};
return {
state,
setState,
checkoutForMe
};
};
//src/util/index.ts
export const convertFormToObj = (form) => {
const obj = {};
Object.keys(form).map((k) => {
if (!parseInt(k)) return;
const { name, value } = form[k];
obj[name] = value;
});
return obj;
};
// src/pages/checkout/index.tsx
const Checkout = () => {
...
return (
<CheckoutLayout>
<Script
src="https://web.nicepay.co.kr/v3/webstd/js/nicepay-3.0.js"
type="text/javascript"
/>
<CheckoutForm />
</CheckoutLayout>
);
};
// src/components/checkout/CheckoutForm.tsx
import React, { useState, useRef } from 'react';
import { useRouter } from "next/router";
import useCheckout from "src/hooks/useCheckout";
import { convertFormToObj } from "src/util";
const CheckoutForm = () => {
const { form, setForm } = useState({});
const formRef = useRef();
const router = useRouter();
const { state, setstate, checkoutForMe } = useCheckout();
// 결제 후 성공 callback
const nicepaySubmit = async () => {
setAlertType("success");
setAlertMsg("결제에 성공했습니다.");
sendPaymentResult(true);
};
// 결제 후 실패 callback
const nicepayClose = async () => {
// TODO: 결제 실패시 처리
setAlertType("error");
setAlertMsg("결제를 다시 시도해주세요");
sendPaymentResult(false);
};
const sendPaymentResult = async () => {
const body = convertFormToObj(formRef.current);
body.success = success;
...
if (success) {
window.deleteLayer();
router.push("/payment/complete")
}
};
const handleCheckout = () => {
...
// 결제 관련 valiation 로직을 수행한 후 주문을 생성하는 api로 요청한다.
createOrder();
}
const createOrder = () => {
try {
mutate();
// 결제 요청에 성공하면, 결제 유무를 판단하는 atom State의 불리언 값을 변경시킨다.
setState((state) => ({
...state,
isCheckout, !state.isCheckout;
}));
} catch (err) {
console.log(err);
}
};
const createForm = (order) => {
const EdiDate = moment().format("YYYYMMDDHHMMSS");
const MID = process.env.NEXT_PUBLIC_MID;
const Moid = order._id;
const TotalAmount = order.total.amount;
const TotalTaxFree = order.totalTaxFree;
const TotalTaxedAmount = TotalAmount - TotalTaxFree;
const TotalSupplyAmt = Math.ceil(TotalTaxedAmount / 1.1);
const TotalVAT = TotalTaxedAmount - TotalSupplyAmt;
const Amt = TotalAmount + ShippingFee;
const MerchantKey = process.env.NEXT_PUBLIC_MERCHANT_KEY;
const TestData = EdiDate + MID + Amt + MerchantKey;
const SignData = cryptoJs.SHA256(TestData).toString();
const successUrl = encodeURIComponent(
`${window.location.origin}/payment/complete?isMobile=${isMobile}`
);
const failUrl = encodeURIComponent(
`${window.location.origin}/payment/complete?isMobile=${isMobile}`
);
const returnUrl = isMobile ? process.env.NEXT_PUBLIC_RETUN_Mobile_URL : process.env.NEXT_PUBLIC_RETURN_URL
setReturnURL(returnUrl);
// 파라미터로 전달된 값에 따라 전달할 정보를 담아 form 상태를 업데이트 시킨다.
setForm({
// 상품이름
GoodsName,
// 결제 금액
Amt,
// 상점 아이디
MID,
// 전문생성일시
EdiDate,
// 상점 주문번호 상점 고유 식별값
Moid,
MerchantKey,
SignData,
BuyerName: order.address.billing.name.full,
BuyerTel: order.address.billing.name.phone,
BuyerEmail: "",
// 아래 값들중 하나 [CARD, VBANK, SSG_BANK, GIFT_CULT]
PayMethod: "CARD",
ReturnURL: returnUrl,
failURL: failUrl,
// 휴대폰 소액결제 추가 요청 파라미터 (0 = 컨텐츠, 1 = 현물)
GoodsCl: 1,
SupplyAmt: TotalSupplyAmt,
GoodsVat: TotalVAT,
TaxFreeAmt: TotalTaxFree,
});
}
useEffect(() => {
// 결제 성공과 mutation의 상태를 판단하는 지표를 사용해서 폼 데이터를 업데이트 하는 로직을 실행한다.
if (state.order && !isLoading && state.isCheckout) {
createForm(state.order)
}
}, [state.order]);
useEffect(() => {
// 폼이 빈 객체일때는 실행하지 않는다.
if (Object.keys(form).length === 0) return;
if (isMobile) {
// 모바일 결제창 진입
formRef.current.action = "https://web.nicepay.co.kr/v3/v3Payment.jsp";
formRef.current.acceptCharset = "euc-kr";
formRef.current.submit();
} else {
// PC 결제창 진입
if (typeof window !== "undefined") {
window.nicepaySubmit = nicepaySubmit;
window.nicepayClose = nicepayClose;
window.goPay(formRef.current);
}
}
}, [form]);
...
return (
<Container>
...
<ButtonWrap>
<Button onClick={() => handleCheckout()} /> 결제하기 </Button>
</ButtonWrap>
...
<form
name="payForm"
method="post"
action={ReturnURL}
ref={formRef}
acceptCharset="euc-kr"
>
<input type="hidden" name="GoodsName" value={form.GoodsName} />
<input type="hidden" name="Amt" value={form.Amt} />
<input type="hidden" name="MID" value={form.MID} />
<input type="hidden" name="EdiDate" value={form.EdiDate} />
<input type="hidden" name="Moid" value={form.Moid} />
<input type="hidden" name="PayMethod" value={form.PayMethod} />
<input type="hidden" name="MerchantKey" value={form.MerchantKey} />
<input type="hidden" name="SignData" value={form.SignData} />
<input type="hidden" name="BuyerName" value={form.BuyerName} />
<input type="hidden" name="BuyerTel" value={form.BuyerTel} />
<input type="hidden" name="BuyerEmail" value="" />
<input type="hidden" name="ReturnURL" value={form.ReturnURL} />
<input type="hidden" name="FailURL" value={form.failURL} />
<input type="hidden" name="GoodsCl" value={form.GoodsCl} />
</form>
</Container>
)
}
공유주신 포스트가 실무에 도움이 많이 되고있습니다! 정말 감사합니다😄