Next.js에서 NICE 본인인증 GET/POST 동시 수용 콜백 구현
프로젝트에서 나이스평가정보의 본인인증 API를 붙이게 됐다. 붙이고 보니 PC와 모바일이 결과값을 돌려주는 방식이 달랐다. 같은 콜백 URL인데 한쪽은 POST 바디로, 한쪽은 GET 쿼리로 들어왔다.
같은 게이트웨이인데 채널마다 결과 전달 방식이 갈렸다
나이스의 인증 게이트웨이는 채널(PC 웹/모바일 웹·앱)에 따라 결과를 다르게 보낸다. 인증이 진행된 경로(브라우저인지 인앱인지, 외부 앱 전환을 거쳤는지)에 따라 결과 송신 방식이 form auto-submit(POST)이 되기도 하고 302 Location 기반 GET 리다이렉트가 되기도 한다.
form auto-submit(POST)은 화면에 보이지 않는 폼을 자바스크립트로 자동 전송해서 콜백 URL로 데이터를 POST로 보내는 방식이다. 302 Location 기반 GET 리다이렉트는 서버가 "이 주소로 가세요"라고 302 응답을 주면 브라우저가 그 주소로 다시 GET 요청을 던지는 방식이고, 데이터는 URL 쿼리에 붙어 따라온다.
모바일에서 POST 바디가 사라지는 이유
본인인증을 진행하면 보안번호가 문자로 오기 때문에 메시지 앱으로 전환해야 한다. PASS 앱을 들락날락하기도 한다. 모바일에서는 이렇게 브라우저 → 앱 → 브라우저로 컨텍스트가 전환되면서 POST request body 값이 유실됐다.
POST request body는 리다이렉트나 컨텍스트 전환 과정에서 쉽게 사라진다. 그래서 모바일/인앱 환경에선 결과가 GET으로 전환되는 일이 잦았다. 모바일은 GET 쿼리 리다이렉트가 호환성이 좋았다.
PC는 다르다. 인증 시작(팝업 열기) → 인증 진행 → 콜백 수신까지가 모두 같은 브라우저의 탭/팝업 안에서 끝난다. 컨텍스트 전환이 없으니 POST body가 유실될 일도 없다. URL에 민감 데이터가 남지 않고, 큰 데이터도 안정적으로 전달되는 form auto-submit(POST) 방식이 PC에선 더 유리했다.
결국 같은 콜백을 두 가지 방식 모두로 받을 수 있게 짜야 했다.
클라이언트가 폼을 던지면 콜백이 받아 내부 경로로 넘긴다
전체 흐름은 이렇다. 클라이언트가 팝업을 열고 히든 폼을 나이스로 전송하면, 나이스가 인증을 처리한 뒤 우리 Next.js API 콜백으로 결과를 돌려준다. 콜백은 결과를 받아 내부 인증 경로로 리다이렉트하고, 거기서 서버-서버 검증이 돌아간다.
next.config.js에서 콜백 경로를 API로 돌렸다
/nice/callback으로 들어온 요청을 /api/nice_callback이 처리하도록 리다이렉트를 걸어뒀다.
async redirects() {
return [
{
source: '/nice/callback',
destination: '/api/nice_callback',
permanent: false,
},
];
},콜백 한 곳에서 GET과 POST를 동시에 받기
콜백 핸들러는 메서드에 따라 req.body 또는 req.query에서 값을 꺼낸다. 필요한 값은 enc_data, integrity_value, token_version_id 셋이다. 값이 없으면 404로, 있으면 내부 인증 경로로 쿼리를 붙여 리다이렉트했다.
const conditionalRedirect = (
redirect: (status: number, path: string) => void,
data?: Record<
'enc_data' | 'integrity_value' | 'token_version_id',
string
> | null,
) => {
if (!data) {
redirect(302, ROUTES[404]);
} else {
const params = new URLSearchParams(data);
redirect(302, `${ROUTES.NICE_AUTH}?${params.toString()}`);
}
};
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const { redirect } = res;
const data =
req.method === 'POST'
? {
enc_data: req.body['enc_data'],
integrity_value: req.body['integrity_value'],
token_version_id: req.body['token_version_id'],
}
: {
enc_data: req.query['enc_data'],
integrity_value: req.query['integrity_value'],
token_version_id: req.query['token_version_id'],
};
conditionalRedirect(redirect, data);
}인앱브라우저에서 폼 데이터가 통째로 사라지는 문제
프로젝트의 본인인증은 3단계로 나뉜 폼 안에서 일어난다. 그런데 카카오·인스타그램 같은 인앱브라우저에서는 인증 팝업이 새 창으로 열리지 않고 같은 페이지가 인증 페이지로 전환됐다. 인증이 끝나도 이전 페이지로 돌아오지 않았고, local state로 들고 있던 폼 데이터도 같이 날아갔다.
세션 스토리지(niceStorage)를 끼워넣어 두 값을 별도로 저장했다.
returnPath: 본인인증이 끝난 뒤 돌아갈 경로를 저장한다.capturedFormData: 폼에 입력된 값을 통째로 저장해뒀다가,returnPath로 돌아왔을 때 폼에 다시 채워 넣는다.
팝업을 열고 히든 폼으로 나이스에 데이터를 던지는 훅
window.open으로 빈 팝업을 먼저 띄워두고, 히든 폼의 target을 그 팝업 이름으로 맞춰 제출했다. 팝업을 열기 직전에 niceStorage에 복귀 경로와 폼 데이터를 박아뒀다.
const authTrigger = async () => {
setIsValidAuth(true);
window.open(
'',
'niceAuth',
`width=${WIDTH}, height=${HEIGHT}, top=${TOP}, left=${LEFT}`,
);
niceStorage?.set({
returnPath: router.pathname,
capturedFormData: methods?.getValues(),
});
};
const authPopup = useMemo(
() =>
value ? (
<Portal>
<Box
display={'none'}
as={'form'}
name={'auth-form'}
id={'auth-form'}
target={'niceAuth'}
method={'get'}
action={
'https://nice.checkplus.co.kr/CheckPlusSafeModel/service.cb'
}
>
<Input type="hidden" id="m" name="m" value="service" />
<Input
type="hidden"
id="token_version_id"
name="token_version_id"
value={value?.token_version_id || ''}
/>
<Input
type="hidden"
id="enc_data"
name="enc_data"
value={value?.enc_data || ''}
/>
<Input
type="hidden"
id="integrity_value"
name="integrity_value"
value={value?.integrity_value || ''}
/>
</Box>
</Portal>
) : (
<></>
),
[value],
);PC는 window message, 모바일은 query string으로 결과를 받았다
서버-서버 검증은 useUserGetEncDataRetrieveQuery로 돌렸다. 이 쿼리에 넣을 파라미터를 어디서 꺼내느냐가 PC와 모바일에서 갈렸다. PC는 팝업에서 부모 창으로 보내는 window.postMessage 이벤트로 받았고, 모바일은 콜백이 내부 경로로 리다이렉트해주면서 붙여준 query string에서 꺼냈다.
const {
data: authResult,
refetch: refetchAuthResult,
isSuccess,
isError,
error,
} = useUserGetEncDataRetrieveQuery({
variables: {
query: authQuery as AuthQueryParamType,
},
options: {
enabled: !!nice?.isSettled && !!authQuery && isValidAuth,
refetchOnMount: false,
refetchOnReconnect: false,
retryOnMount: false,
},
});
useEffect(function receivePCResponseData() {
const handleSetAuthQuery = (e: MessageEvent) => {
if (e) {
const sender = e.source;
if (sender !== self) {
if (typeof e.data === 'string') {
const parsed = JSON.parse(e.data);
setAuthQuery(parsed);
}
}
}
};
window.addEventListener('message', handleSetAuthQuery, false);
return () => {
window.removeEventListener('message', handleSetAuthQuery, false);
};
}, []);
useEffect(function receiveMobileResponseData() {
if (!router.query['enc_data']) return;
setAuthQuery({
enc_data: router.query['enc_data'] as string,
integrity_value: router.query['integrity_value'] as string,
token_version_id: router.query['token_version_id'] as string,
});
}, [router.query]);
useEffect(() => {
if (!authQuery) return;
refetchAuthResult();
}, [authQuery, refetchAuthResult]);authQuery가 어디서 들어왔든 채워지기만 하면 refetchAuthResult를 돌려 검증을 트리거하도록 묶었다. PC 경로와 모바일 경로를 둘 다 살려둔 채 마지막 단계만 공통으로 합쳤다.
콜백을 두 방식 다 받게 둔 이유
나이스API 게이트웨이를 실제로 붙여보니 PC와 모바일의 환경 차이가 HTTP 통신 설계와 그대로 맞물렸다. PC는 동일 브라우저 컨텍스트가 유지되니 form auto-submit 기반 POST가 안정적으로 돌았고, 모바일은 컨텍스트 전환이 잦아 GET이 호환성이 좋았다. 콜백 단계에서 한쪽만 받게 짜면 다른 쪽이 깨졌다. 그래서 둘 다 수용하는 구조가 안전했다.