Intro ⏳
배경
프론트엔드 프로젝트의 전면 리뉴얼로 기존 view를 직접 반환하는 mvc 서비스에서 결제 관련 서비스들을 rest-api전환하고 모든 결제 모듈들을 다시 연동해야하는 이슈를 배정받았다.
AS-IS
기존에도 프론트엔드 프로젝트는 따로 next.js로 따로 서비스되고 있었지만 전시영역을 제외한 로그인/마이페이지와 같은 인증관련된 페이지들, 결제/주문 관련된 페이지들은 기존 메인 백엔드 서버가 뷰까지 반환하고 있었는데, 이번 전면 리뉴얼을 통해 해당 페이지들도 신규 프로젝트로 전환 작업이 필요했다.
문제점
은 당연히 시간이었다⏳.
주문/결제쪽이 가장 기획이 늦게 나온 도메인이기도 했고, 일부 파악할 시간을 미리 가지긴 했지만 태스크에 배정받은 시간이 1달 약간 넘는 빠듯한 시간으로 적었고, 프론트엔드 백엔드 작업을 모두 혼자 진행해야 했었다.
당장 연동해야하는 pg사가 네,카,토 페이 + 기존 카드결제 계좌이체 결제 + 사이트 전용 결제와 같이 매우 다양한(…) 모듈을 연동해야 했다.
물론 일정이 마일스톤으로 잡힌건 아니고, 감안해서 버퍼를 주시긴 하셨지만 전체적으로 오픈/QA일정이 촉박해서 일정압박이 거셌다.
해야하는 일 파악하기 🤔
기존에 진행했던 프로젝트에서도 결제를 새로 구현한 적이 있었고, 당시에 결제 모듈을 보고 공부하면서 생긴 노하우와 그 때 봐왔던 코드들이 있어서 일단 업무를 검토하기는 부분은 조금 수월하게 진행되었다.
결제과정의 추상화
결제단계는 크게 두가지로 추상화 할 수 있다.
출처 : https://docs.tosspayments.com/guides/v2/get-started/payment-flow
결제 인증 단계
- 요청한 클라이언트가 pg사를 통해 본인과 요청 그리고 지불 능력이 있는지를 검증하는 결제 인증단계 결제 승인 단계
- 요청의 결과와 각 서버의 결제 로직을 기반으로 실제 결제를 진행하는 결제 승인단계.
사실 이번 기회에 다른 모듈들도 검토해봤을 때 요즘 사용하는 대부분 pg사들은 위의 두가지를 기준으로 단계를 나누고 있는 것 같다.
약간의 차이점이 있어봐야 결제 ‘인증’ 과정에 조금 차이가 있거나 한 정도인 것 같다.
예를 들어 인증 전에 미리 ‘결제 인증’을 위한 식별값을 조금 더 대조한다던지, 클라이언트의 결제 요청이 갈 것 이라고 미리 알려야 한다던지 하는 정도.
이와 같은 관점을 기준으로 하면 구현해야 할 부분을 조금 더 단순하게 구분 할 수 있다.
1. 결제 요청을 위한 준비를 한다.
2. 클라이언트가 실제로 결제 요청을 하면 pg사로 이동
3. 결제 '인증'의 결과를 콜백받는다.
4. 결제 인증 결과를 기반으로 내부로직을 처리하고 '승인' 요청 api를 날린다.
5. 결제 '승인'결과를 분기로 결제를 완료처리한다.
이렇게 단계를 나누고 기존 연동 코드와 연동 문서를 검토했을 때, 해당 흐름을 벗어나는 pg사는 다행히 없었다.
결제 요청을 위한 준비를 하는 부분, pg사 이동
크게는 체크아웃을 위한 서버 내부 로직,
(특정 pg사 만) 결제를 위한 예약(?) 핸드쉐이크(?) 작업,
(pg사에 따라서) 클라이언트 키와 같은 식별값을 클라이언트에 내려주기,
(클라이언트) 결제 모듈 sdk init하기
로 나누어 볼 수 있다.
AS-IS에서는
checkoutViewFile 반환
public String checkout() {
doSomeServerCheckoutLogic(); //결제 예약을 위한 서버 로직
setSomeDataToSession();
return checkoutPage;
}
checkoutPage.hbs
<html lang="ko">
<head>
<!-- 결제 관련 PG 스크립트 -->
<script src="https://pg1.example.com/sdk.js"></script>
<script src="https://pg2.example.com/sdk.js"></script>
</head>
<body>
<button id="payButton">결제하기</button>
<script>
document
.getElementById("payButton")
.addEventListener("click", handleClickPay);
function handleClickPay() {
// 결제 준비 요청을 서버에 보냄
let someData = callPrepareCheckout(); // 결제 모듈 SDK 호출 (someData를 이용)
if (someData.pgType === "KAKAO_PAY") {
callKakaoPaySdk(someData);
} else if (someData.pgType === "PG2") {
callPg2Sdk(someData);
} else {
console.error("지원하지 않는 PG 타입입니다.");
}
}
function callPrepareCheckout() {
return {
pgType: "KAKAO_PAY", // PG사 타입
paymentInfo: {}, // 기타 필요한 데이터
};
}
function callKakaoPaySdk(data) {
console.log("카카오페이 SDK 호출", data); // 실제 카카오페이 SDK 호출 로직
}
function callPg2Sdk(data) {
console.log("PG2 SDK 호출", data); // 실제 PG2 SDK 호출 로직
}
</script>
</body>
</html>
결제 버튼을 누르면
@GetMapping("/prepare")
public ResponseEntity<?> prepareCheckout(PgType pgType) {
...
switch (pgType) {
case KAKAO_PAY:
doingSomeJobs(); // PG사별로 별도의 작업 수행
saveSomeDataAtSession(); // 결제 요청 관련 데이터를 세션에 저장
break;
}
...
return someDataForCallingPaymentModule(); // 결제 모듈 호출을 위한 추가 데이터 반환
}
이런식으로 되어있었다.
이부분을 이런식으로 리팩토링 했다.
public interface PgType {
Map<String, String> reserve(HttpServletRequest request, PaymentInfo paymentInfo);
}
@Component
public class KakaoPayPg implements PgType {
@Override
public Map<String, String> reserve(HttpServletRequest request, PaymentInfo paymentInfo) {
HttpSession session = request.getSession();
session.setAttribute("pg_kakao_orderId", paymentInfo.getOrderId());
session.setAttribute("pg_kakao_amount", paymentInfo.getAmount());
Map<String, String> result = new HashMap<>();
result.put("pg", "kakao");
result.put("orderId", paymentInfo.getOrderId());
result.put("amount", paymentInfo.getAmount());
result.put("redirectUrl", "https://kakaopay.com/init");
return result;
}
}
결제를 승인하는 부분
“그때 그렇게 한 것은 그때의 이유가 있다!”
보통 결제는 인증의 처리한 결과를 미리 pg사와 약속을 해놓는다. 최신 sdk혹은 모듈들은 sdk를 호출하는 시점에 callback받을 endpoint를 넘기고, 그렇지 않은 모듈들은 미리 알려주거나 등록해야 하는 정도가 다르다.
그래서 approval과 같은 하나의 엔드포인트로 그걸 처리한다.
AS-IS에서는
class ApprovalHandler {
private PgService kakaoPayService;
private PgService pg1Service;
// 다른 pg사 서비스들
...
public Boolean approval(HttpServletRequest req);
}
@GetMapping("/approval")
public String approval(HttpServletRequest req, PgType pgType) {
switch (pgType)
// 이후 짐작가능한대로
}
눈여겨 볼점은 승인과 관련한 데이터를 dto로 미리 받아놓지 않고, 그냥 req객체에서 pg사 서비스들이 알아서 뽑아쓰도록 해뒀다는 것이다.
물론 변경에 유연하거나 좋고 예쁜 코드는 아니지만, 그래도 리퀘스트를 직접 넘기는 식으로 구현되었기 때문에 각 결제대행사의 프로토콜대로 직접 구현해야하는 부분의 코드가 확 줄었다.
이 시점에서 정해진 기간 내로 구현할 수 있겠다는 생각이 되었다.
즉 승인 이후의 시점은 우리 프론트엔드 서버가 인증 콜백으로 받은 요청을 백엔드 서버로 동일하게 포워드 해줄수 있으면 내가 신경을 쓸 필요가 없었다.
진짜 해야할 일 정리 ✅
- 체크아웃 준비, 주문서 뷰 반환하는 api에서 체크아웃 준비하는 부분을 별도의 요청으로 분리하고 뷰를 그릴때 필요한 정보 정리해서 api추가하기 (보안상의 이유로 서버컴포넌트로 처리하기위해서 next.js와 백엔드 서버가 직접 통신하도록 구현)
- next.js에서 ‘인증 결과’ 콜백 받을 수 있도록 엔드포인트 추가하기
- 콜백받은 ‘인증 결과’ 포워드하여 우리 백엔드 서버의 승인 로직 호출
- 이걸 위한 수많은 프론트엔드 코드 추가 (…)
Impl & TroubleShootings 🚀
백엔드 부분은 아무리 예시코드로 주요 로직을 수도코드로 작성해도 예민한 부분일 수 있어 로직적인 부분을 이야기하기가 어렵다.
위에 작성한 1번의 내용에서 크게 벗어나지 않는 작업들과 세부사항 처리, 엔드포인트들을 새로운 인터페이스맞게 정리하고 몇가지 리팩토링한게 전부이다.
반면 클라이언트쪽은 예시코드가 가장 잘 공개되어있는 토스 모듈을 기준으로 하고 예민한 부분들을 제거하고 포스팅했다.
이후부터는 일단 결제를 준비하기 위한 주문서 로직을 처리한 이후의 구현이다. 기본 구조를 아래와 같이 정리했다.
(주문서 진입 요청 이전에 서버에는 주문서 데이터의 결제 관련 준비가 되어있음)
1. 결제 버튼 클릭
데스크탑이면 -> 새로운 window open (결제 모듈 페이지)
모바일이면 -> 결제 모듈 페이지로 이동
2. 결제 모듈 페이지에서 결제 준비 api호출, 결제 모듈 준비
3. 인증 이후 콜백을 route.ts로 받은 이후 request데이터와 함께 callback페이지로 리다이렉트
4. callback 페이지에서 승인 api 호출
결제 완료!
TroubleShooting : 결제창 열기
결제창을 데탑에서는 별도의 윈도우로 처리하고 있었다.
그리고 해당 윈도우에서 스크립트를 호출하면 결제사 사이트로 이동하는 구조였고,
해당 창에서 성공/실패한경우 opener 객체에 있는 주렁주렁 함수로 콜백을 처리하기도 했다.
당시에 opener를 직접 참조하는것과 윈도우객체에 꼭 필요한 것들만 정의해서 사용하자는 프론트분들의 룰이 있었다.
그래서 창간의 통신을 postMessage를 이용해서 처리하도록 수정했다.
꼭 필요한 콜백의 경우 명시적으로 postMessage로 부모창에 메세지를 전달하고 해당 메세지를 처리하는 핸들러를 두었다.
const usePaymentPopup = () => {
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin === window.location.origin && event.data === "someId") {
clearPaymentInterval();
setIsWindowOpen(false);
payWindowRef.current = null;
}
};
if (isWindowOpen) {
// handleMessage로 새로 열린 결제페이지에서 보낸 콜백을 받아 처리한다
window.addEventListener("message", handleMessage);
intervalRef.current = setInterval(() => {
if (payWindowRef?.current?.closed) {
if (!payWindowRef.current) return;
clearPaymentInterval();
Alert("결제가 취소되었습니다").then(() => {
setIsWindowOpen(false);
payWindowRef.current = null;
});
}
}, 100);
}
return () => {
clearPaymentInterval();
window.removeEventListener("message", handleMessage);
};
}, [isWindowOpen, clearPaymentInterval]);
return { openPaymentPopup };
};
Impl : 결제페이지 구현하기
프론트엔드 컴포넌트를 개편한 백엔드 api 단계에 맞게 추상화해서 구현
PaymentPage 예시
export default function TossPaymentPage() {
const { data: response, isFetching, isSuccess, isError } = useQueryPaymentPrepare<TossPreparePaymentData>('TOSS');
usePaymentPreparation<PreparePaymentResponse<TossPreparePaymentData>>({
isFetching,
isError,
isSuccess,
response,
});
if (isSuccess && response.data) {
return <>{isSuccess && response && <TossPaymentHandler preparePaymentData={response.data} />}</>;
}
return <div />;
}
“서버의 pg사별 결제 데이터 준비, 세팅”,
“결제 모듈을 호출하기 위한 정보 조회 api”,
“클라이언트 사이드의 결제 모듈 준비”
와 같은 단계로 나누어 컴포넌트를 작성했다.
useQueryPaymentPrepare 에서 useQuery를 이용해서 pg사별 백엔드 결제 준비 api를 호출한다.
백엔드 결제 준비 api호출부
// 제네릭은 pg사를 의미!
interface UsePaymentPreparationHandlerProps<T> {
isFetching: boolean;
isError: boolean;
isSuccess: boolean;
response: { data: T } | undefined;
}
export default function usePaymentPreparation<T>({
isFetching,
isError,
isSuccess,
response,
}: UsePaymentPreparationHandlerProps<T>) {
useOverlayLoadingContext(Boolean(isFetching));
useEffect(() => {
if (!isFetching) {
if (isError) {
alert("결제 준비 성공!");
if (opener) {
self.close();
} else {
gotToCheckoutPage();
}
return;
}
if (isSuccess && !response) {
alert("결제 준비 실패!");
if (opener) {
self.close();
} else {
gotToCheckoutPage();
}
return;
}
}
}, [isSuccess, isError, isFetching, response]);
}
참고로 준비와, sdk 호출을 위한 데이터를 받아오는 api 분리한 이유는 다른 pg사를 고려했을때, 이렇게 단계를 나누는게 유리했고 서버쪽에서 작업하기 이렇게 단계를 나누는게 편리했기 때문이었다.
결제모듈 준비
export default function TossPaymentHandler({ preparePaymentData: data }: TossPaymentHandlerProps) {
const isProcessingRequestRef = useRef(false);
const [scriptLoaded, setScriptLoaded] = useState(false);
useEffect(() => {
if (scriptLoaded && window.TossPayments && !isProcessingRequestRef.current) {
isProcessingRequestRef.current = true;
const tossPrepareData = data.preparePaymentData;
const clientKey = tossPrepareData.clientKey;
const payParams = {
...tossPrepareData?.payParams,
}; const tossPayments = window.TossPayments(clientKey);
tossPayments
.requestPayment('토스결제', {
amount: payParams.amount,
orderId: payParams.orderId,
orderName: payParams.goodsName,
customerName: payParams.buyerName,
successUrl: `${payParams.returnUrl}?pgType=TOSS`,
failUrl: payParams.cancelUrl,
}) .catch(function (error: unknown) {
const newError = error as { code: string };
if (newError.code === 'USER_CANCEL') {
if (opener) {
alert('결제를 취소하셨습니다.');
self.close();
} } }); }
}, [data, scriptLoaded]);
return (
<Script
src="https://js.tosspayments.com/v1"
strategy="afterInteractive"
onLoad={() => setScriptLoaded(true)}
onError={() => {
console.error('토스가 스크립트를 안줬어요 ㅠㅠ');
}} />
);
}
여기까지 구현했을때, 내가 설계하고 개편한 백엔드 인터페이스와 프론트엔드 컴포넌트 단계를 나누어 설계한게 모든 pg사에 적용 가능한 구조라서 너무 행복했었다.
그리고 여기까지가 결제 “인증"의 끝점이다.
Impl : 승인 관련 클라이언트에서 해주는 일들!
일단 콜백을 프론트엔드에서 받아야 하기에 엔드포인트를 next.js의 route.ts를 이용해서 뚫었다.
그리고 상기한 이유로 콜백받은 요청을 그대로 백엔드 서버로 포워드 해줘야 하기 때문에,
요청을 포워드하기위한 util.ts 함수를 만들어 구현했다.
콜백 주소가 여러개인 이유는 결제라인(?)이 다르고 각각 다른 인터페이스를 호출해야하는데 그걸 식별해야 하기 때문에 기존 엔드포인트들이 분기되어 있었기 때문이다.
(예를들어 배송비, 교환관련, 수선관련, 실제 주문 결제 등등)
무튼 pg사로부터 인증결과 콜백을 전달받으면 이 api로 받아서 next.js페이지로 직접 리다이렉트 해주는식으로 처리된다.
apps
└── 모노레포web
├── app
│ ├── (api)
│ │ ├── Pay
│ │ │ ├── authorizeCallback
│ │ │ │ └── route.ts # 요런데로 pg사 콜백 api를 받아서 next.js 페이지로 리다이렉트!
│ │ │ ├── authorizeCallback2
│ │ │ │ └── route.ts
│ │ │ ├── authorizeCallback3
│ │ │ ├── util.ts
│ ├── proxy
│ ├── layout.tsx
리다이렉트 페이지에서 호출하는 콜백 함수
export default function useAfterPaymentCallback(params: {
callbackType: PaymentCallbackType;
}) {
const isProcessingRequestRef = useRef(false);
const payParams = usePayParams();
const callbackType = params?.callbackType ?? "";
// 클라이언트가 요청을 승인 api로 포워드할때 pg사 콜백과 동일하게 만들어주고 몇몇 보안관련 이슈를 해결하는 유틸함수
const params = useMemo(() => {
forwardParams(payParams);
}, [searchParams]);
// 실제 서버 승인 api 호출
const { mutate: mutateFinishCallback } = useMutationPaymentFinishPayment({
// 승인 api 호출 결과에 따라 처리로직은 그냥 따로.
onSuccess: (result) => handleSuccess(callbackType, result),
onError: (error) => handleError(callbackType, error),
});
useEffect(() => {
if (!isProcessingRequestRef.current) {
isProcessingRequestRef.current = true;
if (!callbackType) {
alert("결제 과정 중 문제가 발생하였습니다.");
return;
}
mutateFinishCallback({ callbackType, params });
}
}, [callbackType, queryParams]);
useOverlayLoadingContext();
}
드디어 길고 긴 과정이 끝났다!
결론 💪
- 결제 서비스를 혼자서 백엔드 프론트엔드 전부 리뉴얼했다.
- 의도한대로, 작업이 진행되었고 그 결과가 처음 의도와 딱 맞아 떨어지는게 쾌감이 좋았다.