프로젝트를 하던 중 성인인증을 구현해야 할 일이 생겼는데,
이 구현과정이 꽤나 험난했어서 정보공유차 적어놓기로 한다.
우선 성인인증이 필요한 경우는 두가지
둘 다 로그인이 되어있는 상태를 전제로 하고 로그인이 되어있지 않으면 로그인 창으로 리다이렉션 한다.
두가지의 경우에 "최초 1회 성인인증이 필요해요!" 모달창을 띄우고,
확인 버튼을 누르면 성인인증을 진행, 취소 버튼을 누르면 모달창을 닫는다.
본인인증 API의 흐름은 이렇다.
- 본인인증 완료
- 인증 완료 후 포트원 서버로부터 고객의 imp_uid 값을 얻음
- 포트원 서버에 access-token을 요청해 그 값과 imp_uid로 고객의 정보를 조회
- 리턴된 고객 정보에서 생년 추출 후 나이를 계산
4-1. 성인일 경우 우리 서버에 저장돼있는 고객 정보의 extra.isAdult에 true값을 저장, 이후 결제 페이지로 이동
4-2. 미성년자일 경우 "성인만 구매할 수 있습니다" 모달 출력 후 홈으로 이동
본인인증 PG사는 KG이니시스를 이용했다. 다날은 따로 가입해서 키를 받아야 하는 것 같음...
그리고 원래 모바일에선 본인인증 창을 팝업으로 띄우지 않고 진행한 다음 원래 페이지로 리다이렉션 하는 모양인데, 나는 그냥 pc에서처럼 팝업을 띄우게 했다.
이 방식이 웹뷰 환경에선 작동하지 않을 수도 있다고 하는데, 카톡 내부 브라우저에선 잘 돼서...우선은...두기로...
KG이니시스의 본인인증 모듈은 v1밖에 지원이 되지 않는데 이건 SDK를 js라이브러리로 불러와야 한다. 이와중에 jquery도 사용함.
처음엔 _document.tsx에 넣으면 되는 줄 알았는데 이건 pages라우터 버전에서 그렇고 app라우터 버전은 layout.tsx에 넣으면 된다.
// layout.tsx
<head>
<script src="https://cdn.iamport.kr/v1/iamport.js" defer />
</head>
SDK는 타입을 따로 제공해주지 않아서 직접 만들거나 가져와야 하는데...
어느 고마운 분들이 타입 라이브러리를 만들어주시긴 했으나 결제 모듈 관련 타입만 있어서 확장해서 사용해야 했다.
iamport-typings Github
API 사용시 window에서 IMP 객체를 가져와야 하는데 window에 해당 객체의 타입이 지정돼있지 않아 확장해줘야 하는 문제도 처리되어있다.
240825 기준 포트원 API 공식 문서에 타입 정의 문서가 따로 생겼다...!!
https://developers.portone.io/api/rest-v1/type-def?v=v1#CertificationAnnotation
며칠 사이에 문서가 전체적으로 업데이트 돼버렸다제이쿼리도 사용하지 않게 바뀌었고...허헣 우선 기존 코드 참고해서 적어놔야지
// 본인인증 창 호출 함수
const onCertification = () => {
if (!session) return;
if (!window.IMP) return;
// 가맹점 식별하기
const IMP = window.IMP as unknown as ExtendedIamport;
IMP.init(V1_IMP_KEY);
// 본인인증 데이터
IMP.certification(
{
// m_redirect_url: "/" + pathname,
popup: true,
pg: "inicis_unified",
},
certificationCallback,
);
};
본인인증 창을 호출하려면 윈도우의 IMP객체에서 certification메서드를 호출해야 한다.
참고로 window에서 뽑아오는거라 서버 컴포넌트에선 실행할 수 없으니 컴포넌트 최상단에 "use client"를 넣어주어야 함.
위에 있던 타입 라이브러리를 사용하면 IMP는 window에서 확장되어 오류가 없지만 certification 메서드가 없다고 나온다.
때문에 실행은 되지만 vscode 내에선 오류가 나고 배포때도 오류가 날 가능성이 생겨버림.
해서 따로 타입 확장을 해줘야 한다.
// iamportExtends.ts
import Iamport, { Pg } from "iamport-typings";
export interface RequestCertificationParams {
pg?: Pg | `${Pg}.${string}`; //본인인증 설정이 2개이상 되어 있는 경우 필수
merchant_uid?: string; // 주문 번호
m_redirect_url?: string; // 모바일환경에서 popup:false(기본값) 인 경우 필수, 예: https://www.myservice.com/payments/complete/mobile
popup?: boolean;
}
export interface RequestCertificationResponse {
success: boolean;
imp_uid: string | null;
merchant_uid: string;
pg_provider: string;
pg_type: string;
error_code: string | null;
error_msg: string | null;
}
export type RequestCertificationResponseCallback = (response: RequestCertificationResponse) => void;
// Iamport를 확장한 새로운 인터페이스 정의
export interface ExtendedIamport extends Iamport {
certification: (
params: RequestCertificationParams,
callback?: RequestCertificationResponseCallback,
) => void;
}
처음엔 export interface Iamport로 인터페이스 확장을 하려고 했는데, 아무리 해도 확장이 되지 않는 것이다.
이것때문에 한참 헤매다가 용쌤께 도움을 요청했는데, 애초에 라이브러리에서 Iamport의 인터페이스가 export default Iamport로 내보내기 되어있어서 확장이 되지 않았던것...
그래서 아예 Iamport를 확장한 새로운 인터페이스를 만들어 적용해야했다.
이제 다시 본인인증 호출 함수를 보면 certification에 빨간줄이 없어졌다.
그리고 이 함수를 버튼에 onClick 이벤트 핸들러로 전달하면 팝업이 아주 잘 뜸!
위의 타입 확장에도 있지만 certification 메서드엔 두가지 매개변수를 전달해주어야 한다.
첫번째 매개변수는 pg사 정보, 주문 번호, popup창 여부, 모바일 환경에서 리다이렉션 될 url의 정보가 담긴 객체,
두번째 매개변수는 본인인증이 완료 된 후 실행 될 콜백함수.
이 콜백 함수에는 많은 로직이 담기게 되는데...
- 본인인증이 완료되었을 경우엔 반환되는 imp_uid로 고객의 정보를 아임포트 서버에 요청
1-1. response에서 출생년 추출 후 성인인지 계산하여 우리 서버의 고객 정보에isAdult: true추가 후 인증 전 페이지로 이동
1-2. 성인이 아니라면 안내 후 홈으로 이동- 본인인증이 실패되었을 경우 에러메세지 출력
위에도 적은 내용이지만 이런 로직이다.
그렇게 만든 콜백함수.
"use server";
import { auth } from "@/auth";
import { RequestCertificationResponseCallback } from "@/types/iamportExtends";
import { redirect } from "next/navigation";
const IMP_API_KEY = process.env.NEXT_PUBLIC_API_V1_REST_API_KEY;
const IMP_API_SECRET = process.env.NEXT_PUBLIC_API_V1_REST_API_SECRET;
const API_SERVER = process.env.NEXT_PUBLIC_API_SERVER;
const CLIENT_ID = process.env.NEXT_PUBLIC_CLIENT_ID;
// 유저의 id와 access-token을 가져오는 함수
export const getUserIdToken = async () => {
const session = await auth();
const userId = session?.user?.id!;
const userAccessToken = session?.accessToken;
return { userId, userAccessToken };
};
// 서버에 저장된 유저 정보를 가져오는 함수
export const getUserInfo = async (userId: string) => {
const res = await fetch(`${API_SERVER}/users/${userId}`, {
headers: {
"client-id": `${CLIENT_ID}`,
},
});
return res.json();
};
// 유저 정보의 extra값에 {isAdult: true}를 업데이트하는 함수
const updateUserIsAdult = async (userId: string, extraInfo: object, accessToken: string) => {
const res = await fetch(`${API_SERVER}/users/${userId}`, {
method: "PATCH",
headers: {
"client-id": `${CLIENT_ID}`,
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
extra: { ...extraInfo, isAdult: true },
}),
});
return res.json();
};
// 포트원 서버로부터 access-token을 받아오는 함수
export const getToken = async () => {
try {
const data = await fetch("https://api.iamport.kr/users/getToken", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
imp_key: IMP_API_KEY,
imp_secret: IMP_API_SECRET,
}),
});
return data.json();
} catch (error) {
console.error(error);
}
};
// 본인인증 완료 후 실행될 콜백함수
export const certificationCallback: RequestCertificationResponseCallback = async (response) => {
const { success, error_msg } = response;
if (success) {
const userImpUid = response.imp_uid;
const tokenData = await getToken();
const { access_token } = tokenData.response;
// imp_uid를 바탕으로 서버에 고객 정보 조회 요청하는 함수
const getCertifications = await fetch(`https://api.iamport.kr/certifications/${userImpUid}`, {
method: "GET",
headers: { Authorization: access_token },
});
const userInfoJsonData = await getCertifications.json();
// 고객의 생일 정보로부터 출생년 추출
const userBirthYear = +userInfoJsonData.response.birthday.slice(0, 4);
// 사용자가 성인일 경우 -> 서버에 저장된 고객정보에 isAdult 추가 후 상품페이지로 이동
if (new Date().getFullYear() - userBirthYear >= 19) {
const { userId, userAccessToken } = await getUserIdToken();
const userInfo = await getUserInfo(userId);
const userExtraInfo = userInfo.item.extra;
updateUserIsAdult(userId, userExtraInfo, userAccessToken!);
// stateless 모달 출력
redirect("adult/?confirmSuccess=true");
} else {
// 사용자가 성인이 아닐 경우 -> 성인만 구매할 수 있는 상품입니다 모달 출력
redirect("adult/?confirmFailed=true");
}
} else {
console.error(`본인인증 실패! : ${error_msg}`);
}
};
각 함수의 설명은 주석을 참고.
서버단에서 실행할 함수들이라 상태관리가 불가능해 인증 후엔 stateless modal을 띄우도록 했다.
stateless modal
https://medium.com/@dtulpa16/next-js-modals-made-easy-7bdce15b2a5e
위의 글을 참고해서 만들었는데, url의 파라미터를 불러와 해당 값으로 조건부 랜더링 하는 방법이다.
전역 상태관리가 필요하지 않다고 생각해 이 방법으로 모달을 구현했다.
function Adult({ searchParams }: SearchParamProps) {
// url의 request값을 받아와 변수에 저장
const request = searchParams?.request;
const confirmSuccess = searchParams?.confirmSuccess;
const confirmFailed = searchParams?.confirmFailed;
const router = useRouter();
const { data: session, status } = useSession();
const userId = session?.user?.id;
const checkLoggedIn = async () => {
// 로그인 유무 확인
if (status === "authenticated") {
// 회원 정보 불러와서 성인인증 유무 확인
const userInfo = await getUserInfo(userId!);
if (userInfo.item.extra.isAdult) {
// 로그인이 되어있고 성인인증도 돼있을 때
router.push("/pay");
} else {
// 로그인은 되어있지만 성인인증이 되지 않았을 때
// url의 request값이 true면 모달 띄우기(stateless modal)
router.push("/adult?request=true");
}
} else {
// 로그인이 안 돼있을 때
alert("로그인 후 이용하실 수 있습니다.");
router.push("/login");
}
};
// 본인인증 창 호출 함수
const onCertification = () => {
if (!session) return;
if (!window.IMP) return;
// 가맹점 식별하기
const IMP = window.IMP as unknown as ExtendedIamport;
IMP.init(V1_IMP_KEY);
// 본인인증 데이터
IMP.certification(
{
// m_redirect_url: "/" + pathname,
popup: true,
pg: "inicis_unified",
},
certificationCallback,
);
};
return (
<>
<h1>성인인증 테스트 페이지</h1>
<Button onClick={checkLoggedIn}>구매하기</Button>
<Button onClick={onCertification}>성인인증하기</Button>
{request && (
<div
className={`fixed w-screen h-screen flex justify-center items-center ${request ? "opacity-100" : "opacity-0"} bg-black bg-opacity-50`}
>
<div className="flex flex-col justify-center items-center w-4/5 px-5 py-8 rounded-2xl bg-white text-center text-black">
<p className="text-primary font-bold">잠시만요!</p>
구매 전 최초 1회
<br />
성인인증이 필요해요.
<div className="flex justify-center align-top mt-3 gap-2 w-full">
<Button
onClick={() => {
router.back();
onCertification();
}}
className="grow"
>
인증하기
</Button>
<Button onClick={() => router.back()} className="grow" color="disabled">
취소
</Button>
</div>
</div>
</div>
)}
{confirmSuccess && (
<div
className={`fixed w-screen h-screen flex justify-center items-center ${confirmSuccess ? "opacity-100" : "opacity-0"} bg-black bg-opacity-50`}
>
<div className="flex flex-col justify-center items-center w-4/5 px-5 py-8 rounded-2xl bg-white text-center text-black">
성인인증이 완료되었습니다
<br />
상품을 구매해주세요.
<Button onClick={() => router.back()} className="w-full mt-3">
구매하러 가기
</Button>
</div>
</div>
)}
{confirmFailed && (
<div
className={`fixed w-screen h-screen flex justify-center items-center ${confirmFailed ? "opacity-100" : "opacity-0"} bg-black bg-opacity-50`}
>
<div className="flex flex-col justify-center items-center w-4/5 px-5 py-8 rounded-2xl bg-white text-center text-black">
성인부터 구매할 수 있는 상품입니다.
<br />
다음 기회에 봬요!
<LinkButton href="/" className="w-full mt-3">
홈으로
</LinkButton>
</div>
</div>
)}
</>
);
}
각 함수들의 역할은 주석 참고
배포 후 실행해보니 잘 작동된다 굿