최근 프로젝트에서 다음과 같은 요구사항을 만족하는 본인인증 기능을 구현해야 했습니다:
NICE 본인인증은 한국에서 가장 널리 사용되는 본인인증 서비스로, 휴대폰 본인인증을 통해 사용자의 실명과 연락처를 검증할 수 있습니다.
환경별로 다른 접근 방식이 필요했습니다:
구분 | 웹 환경 | 앱 환경 |
---|---|---|
인증 방식 | 팝업 창 (window.open ) | 페이지 리다이렉트 |
데이터 전달 | postMessage API | localStorage |
창 관리 | 팝업 생성/닫기 제어 | 브라우저 기본 네비게이션 |
사용자 경험 | 기존 페이지 유지 | 자연스러운 페이지 전환 |
📁 hooks/
└── useIdentityVerification.js # 본인인증 로직
📁 pages/Auth/Join/
└── JoinVerification.js # 회원가입용 본인인증 페이지
📁 services/
└── services.js # API 통신
📁 utils/
└── urls.js # API 엔드포인트
앱과 웹을 정확히 구분하는 것이 가장 중요합니다:
// hooks/useIdentityVerification.js
/**
* 앱 환경 여부를 감지하는 함수
* 여러 방법을 조합하여 정확도를 높임
*/
const isInApp = () => {
// 1. User-Agent 문자열 검사
const userAgent = navigator.userAgent || '';
const isAppByUserAgent = /yourAppName|AppWebView|wv/i.test(userAgent);
// 2. React Native WebView 전용 객체 확인
const hasReactNativeWebView = typeof window.ReactNativeWebView !== 'undefined';
// 3. 앱에서 주입하는 커스텀 인터페이스 확인
const hasAppBridge = typeof window.AppInterface !== 'undefined' ||
typeof window.webkit?.messageHandlers?.AppInterface !== 'undefined';
// 4. URL 파라미터를 통한 명시적 앱 표시
const isAppByUrl = window.location.href.includes('app=true');
// 5. 뷰포트 크기로 모바일 앱 추정 (보조 수단)
const isMobileViewport = window.innerWidth <= 768;
return isAppByUserAgent || hasReactNativeWebView || hasAppBridge || isAppByUrl;
};
import instance from "../services/services";
import { IDENTITY_VERIFICATION_URL } from "../utils/urls";
const useIdentityVerification = ({ context, extraData }) => {
/**
* 본인인증 프로세스를 시작하는 메인 함수
* @param {string} context - 인증 컨텍스트 (join, passwordFind 등)
* @param {object} extraData - 추가 데이터
*/
const handleIdentity = async () => {
try {
// 📡 1단계: 서버에서 NICE 인증 토큰 획득
const authTokens = await fetchAuthTokens(context, extraData);
// 🔀 2단계: 환경에 따른 인증 방식 분기
if (isInApp()) {
await handleAppAuthentication(authTokens, context);
} else {
await handleWebAuthentication(authTokens);
}
} catch (error) {
handleAuthError(error);
}
};
/**
* 서버로부터 NICE 인증 토큰을 받아오는 함수
*/
const fetchAuthTokens = async (context, extraData) => {
const baseUrl = process.env.REACT_APP_API_URL || window.location.origin;
const returnUrl = buildReturnUrl(baseUrl, context, extraData);
const response = await instance.post(IDENTITY_VERIFICATION_URL, {
returnUrl
});
const { enc_data, integrity_value, token_version_id } = response?.data?.data || {};
// 필수 데이터 검증
if (!enc_data || !integrity_value || !token_version_id) {
throw new Error("서버로부터 유효하지 않은 인증 데이터를 받았습니다.");
}
return { enc_data, integrity_value, token_version_id };
};
/**
* 콜백 URL 생성 함수
*/
const buildReturnUrl = (baseUrl, context, extraData) => {
let returnUrl = `${baseUrl}/auth/verification/result?context=${context || 'default'}`;
// 비밀번호 찾기의 경우 사용자 ID 추가
if (context === 'PasswordFind' && extraData?.userId) {
returnUrl += `&userId=${extraData.userId}`;
}
return returnUrl;
};
/**
* 📱 앱 환경에서의 인증 처리
*/
const handleAppAuthentication = async (tokens, context) => {
const { enc_data, integrity_value, token_version_id } = tokens;
// 현재 페이지 정보 저장 (인증 후 복원용)
const currentState = {
path: window.location.pathname,
search: window.location.search,
context: context,
timestamp: Date.now()
};
localStorage.setItem('auth_return_state', JSON.stringify(currentState));
// NICE 인증 페이지로 리다이렉트하는 폼 생성
const form = createNiceForm(enc_data, integrity_value, token_version_id);
document.body.appendChild(form);
form.submit();
// 폼 제출 후 정리
document.body.removeChild(form);
};
/**
* 💻 웹 환경에서의 인증 처리
*/
const handleWebAuthentication = async (tokens) => {
const { enc_data, integrity_value, token_version_id } = tokens;
// 팝업 창 생성
const popup = createAuthPopup();
if (!popup) return;
// 기존 폼 활용하여 인증 진행
const form = document.getElementById('niceAuthForm');
if (!form) {
console.error('웹 인증용 폼이 존재하지 않습니다.');
popup.close();
return;
}
setupFormForPopup(form, tokens, popup.name);
form.submit();
// 팝업 상태 모니터링
monitorPopupStatus(popup);
};
/**
* NICE 인증용 동적 폼 생성
*/
const createNiceForm = (enc_data, integrity_value, token_version_id) => {
const form = document.createElement('form');
form.method = 'POST';
form.action = 'https://nice.checkplus.co.kr/CheckPlusSafeModel/checkplus.cb';
form.style.display = 'none';
const fields = {
m: 'service',
enc_data,
integrity_value,
token_version_id,
receivedata: enc_data
};
Object.entries(fields).forEach(([name, value]) => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = value;
form.appendChild(input);
});
return form;
};
/**
* 인증 팝업 창 생성
*/
const createAuthPopup = () => {
const width = 500;
const height = 600;
const left = (window.screen.width / 2) - (width / 2);
const top = (window.screen.height / 2) - (height / 2);
const options = [
`width=${width}`,
`height=${height}`,
`left=${left}`,
`top=${top}`,
'status=no',
'menubar=no',
'toolbar=no',
'resizable=no',
'scrollbars=yes'
].join(',');
const popup = window.open('', 'niceAuthPopup', options);
if (!popup || popup.closed) {
alert('팝업이 차단되었습니다. 브라우저 설정에서 팝업을 허용해주세요.');
return null;
}
return popup;
};
/**
* 웹 환경 폼 설정
*/
const setupFormForPopup = (form, tokens, popupName) => {
const { enc_data, integrity_value, token_version_id } = tokens;
form.target = popupName;
form.m.value = 'service';
form.enc_data.value = enc_data;
form.integrity_value.value = integrity_value;
form.token_version_id.value = token_version_id;
// receivedata 필드 동적 추가/수정
let receiveDataInput = form.receivedata;
if (!receiveDataInput) {
receiveDataInput = document.createElement('input');
receiveDataInput.type = 'hidden';
receiveDataInput.name = 'receivedata';
form.appendChild(receiveDataInput);
}
receiveDataInput.value = enc_data;
};
/**
* 팝업 상태 모니터링
*/
const monitorPopupStatus = (popup) => {
const checkClosed = setInterval(() => {
if (popup.closed) {
clearInterval(checkClosed);
console.log('인증 팝업이 닫혔습니다.');
// 필요시 추가 처리 로직
}
}, 1000);
// 30분 후 자동 정리 (타임아웃 방지)
setTimeout(() => {
clearInterval(checkClosed);
if (!popup.closed) {
popup.close();
}
}, 30 * 60 * 1000);
};
/**
* 인증 에러 처리
*/
const handleAuthError = (error) => {
console.error('본인인증 오류:', error);
const errorMessages = {
'NETWORK_ERROR': '네트워크 연결을 확인해주세요.',
'INVALID_TOKEN': '인증 토큰이 유효하지 않습니다.',
'SERVER_ERROR': '서버에서 문제가 발생했습니다.',
'POPUP_BLOCKED': '팝업이 차단되었습니다. 브라우저 설정을 확인해주세요.'
};
const message = errorMessages[error.code] ||
`본인인증 중 오류가 발생했습니다: ${error.message}`;
alert(message);
};
return {
handleIdentity,
isInApp: isInApp() // 디버깅용으로 환경 정보 노출
};
};
export default useIdentityVerification;
// pages/Auth/Join/JoinVerification.js
import React from "react";
import { useNavigate } from "react-router-dom";
import useIdentityVerification from "../../../hooks/useIdentityVerification";
import useUserStore from "../../../store/userStore";
import { useLanguage } from "../../../hooks/useLanguage";
function JoinVerification() {
const { t } = useLanguage();
const navigate = useNavigate();
const { updateJoinVerificationInfo } = useUserStore();
// 🎯 Hook 사용 - 매우 간단!
const { handleIdentity, isInApp } = useIdentityVerification({
context: 'join'
});
// 🧪 개발 환경에서만 사용하는 테스트 함수
const handleTest = () => {
updateJoinVerificationInfo({
ci: 'test_ci',
di: 'test_di',
username: '테스트',
mem_phone: '01012345678'
});
navigate('/joinForm');
};
return (
<div className="verification-container">
<section className="auth_section">
<div className="inner gap_32">
{/* 헤더 영역 */}
<div className="section_title">
<h3>{t('sign_up')}</h3>
{/* 환경 표시 (개발용) */}
{process.env.NODE_ENV === 'development' && (
<small style={{ color: '#666' }}>
환경: {isInApp ? '앱' : '웹'}
</small>
)}
</div>
{/* 안내 텍스트 */}
<div className="form_wrap join_con">
<h6 className="sub_title">{t('phone_verification')}</h6>
<p className="text_center info_text">
{t('safe_transaction')}<br/>
{t('verify_your_identity')}
</p>
</div>
{/* 개발 환경 테스트 버튼 */}
{process.env.NODE_ENV === 'development' && (
<button
type="button"
className="default_btn gray lg"
onClick={handleTest}
style={{ marginBottom: '10px' }}
>
🧪 테스트 (개발용)
</button>
)}
{/* 메인 인증 버튼 */}
<button
type="button"
className="default_btn black lg"
onClick={handleIdentity}
>
📱 {t('verify_identity')}
</button>
</div>
</section>
{/* 웹 환경용 숨겨진 폼 */}
<form
id="niceAuthForm"
name="niceAuthForm"
action="https://nice.checkplus.co.kr/CheckPlusSafeModel/checkplus.cb"
method="post"
style={{ display: 'none' }}
>
<input type="hidden" id="m" name="m" value="service" />
<input type="hidden" id="token_version_id" name="token_version_id" value="" />
<input type="hidden" id="enc_data" name="enc_data" value="" />
<input type="hidden" id="integrity_value" name="integrity_value" value="" />
<input type="hidden" id="receivedata" name="receivedata" value="" />
</form>
</div>
);
}
export default JoinVerification;
// 1. Hook import
import useIdentityVerification from '../hooks/useIdentityVerification';
// 2. 컴포넌트에서 사용
function MyComponent() {
const { handleIdentity } = useIdentityVerification({
context: 'join' // 'join', 'passwordFind', 'profileEdit' 등
});
return (
<button onClick={handleIdentity}>
본인인증하기
</button>
);
}
// 추가 데이터가 필요한 경우
const { handleIdentity } = useIdentityVerification({
context: 'passwordFind',
extraData: {
userId: 'user123',
requestId: 'req-456'
}
});
// 환경 정보 확인
const { handleIdentity, isInApp } = useIdentityVerification({
context: 'profileEdit'
});
console.log('현재 환경:', isInApp ? '앱' : '웹');
// 1. 회원가입
const joinAuth = useIdentityVerification({ context: 'join' });
// 2. 비밀번호 찾기
const passwordAuth = useIdentityVerification({
context: 'passwordFind',
extraData: { userId: inputUserId }
});
// 3. 개인정보 수정
const profileAuth = useIdentityVerification({ context: 'profileEdit' });
// 4. 성인 인증
const adultAuth = useIdentityVerification({ context: 'adultVerification' });
증상: 웹 환경에서 본인인증 창이 열리지 않음
// 해결 방법: 팝업 차단 감지 및 안내
const createAuthPopup = () => {
const popup = window.open('', 'niceAuthPopup', options);
if (!popup || popup.closed) {
// 사용자 친화적 안내
const shouldRetry = confirm(
'팝업이 차단되었습니다.\n' +
'브라우저 설정에서 팝업을 허용한 후 다시 시도하시겠습니까?'
);
if (shouldRetry) {
// 재시도 로직
setTimeout(() => createAuthPopup(), 100);
}
return null;
}
return popup;
};
증상: 잘못된 환경으로 인식하여 인증 방식이 맞지 않음
// 해결 방법: 다중 검증 로직 강화
const isInApp = () => {
const checks = [
// User-Agent 검사
/yourAppName|AppWebView|wv/i.test(navigator.userAgent),
// WebView 객체 확인
typeof window.ReactNativeWebView !== 'undefined',
// 앱 브릿지 확인
typeof window.AppInterface !== 'undefined',
// URL 파라미터 확인
window.location.href.includes('app=true'),
// 뷰포트 크기 (보조 수단)
window.innerWidth <= 768 && /Mobi|Android/i.test(navigator.userAgent)
];
// 여러 조건 중 하나라도 만족하면 앱으로 판단
const isApp = checks.some(check => check);
// 디버깅 정보 출력 (개발 환경)
if (process.env.NODE_ENV === 'development') {
console.log('환경 감지 결과:', {
isApp,
userAgent: navigator.userAgent,
hasWebView: typeof window.ReactNativeWebView !== 'undefined',
hasAppBridge: typeof window.AppInterface !== 'undefined',
urlParam: window.location.href.includes('app=true')
});
}
return isApp;
};
증상: 앱 환경에서 인증 후 돌아왔을 때 상태 정보가 없음
// 해결 방법: 데이터 유효성 검증 및 복구 로직
const saveAuthState = (context) => {
const authState = {
context,
timestamp: Date.now(),
path: window.location.pathname,
search: window.location.search,
version: '1.0' // 버전 정보로 호환성 체크
};
try {
localStorage.setItem('auth_return_state', JSON.stringify(authState));
// 백업 저장소도 활용
sessionStorage.setItem('auth_backup_state', JSON.stringify(authState));
} catch (error) {
console.error('상태 저장 실패:', error);
// 쿠키 등 대안 방법 사용
document.cookie = `auth_state=${encodeURIComponent(JSON.stringify(authState))}; path=/`;
}
};
const loadAuthState = () => {
try {
// 1차: localStorage 시도
let stateData = localStorage.getItem('auth_return_state');
// 2차: sessionStorage 백업 시도
if (!stateData) {
stateData = sessionStorage.getItem('auth_backup_state');
}
// 3차: 쿠키에서 시도
if (!stateData) {
const cookieMatch = document.cookie.match(/auth_state=([^;]+)/);
if (cookieMatch) {
stateData = decodeURIComponent(cookieMatch[1]);
}
}
if (stateData) {
const state = JSON.parse(stateData);
// 데이터 유효성 검증 (1시간 내 데이터만 유효)
const isValid = Date.now() - state.timestamp < 60 * 60 * 1000;
if (!isValid) {
console.warn('만료된 인증 상태 데이터');
return null;
}
return state;
}
} catch (error) {
console.error('상태 로드 실패:', error);
}
return null;
};
증상: NICE 폼 제출 시 필수 필드가 없어 인증 실패
// 해결 방법: 동적 필드 생성 및 검증
const ensureFormFields = (form, tokens) => {
const requiredFields = {
m: 'service',
enc_data: tokens.enc_data,
integrity_value: tokens.integrity_value,
token_version_id: tokens.token_version_id,
receivedata: tokens.enc_data
};
Object.entries(requiredFields).forEach(([name, value]) => {
let field = form.elements[name];
if (!field) {
// 필드가 없으면 생성
field = document.createElement('input');
field.type = 'hidden';
field.name = name;
form.appendChild(field);
console.log(`필드 생성: ${name}`);
}
field.value = value;
});
// 폼 유효성 최종 검증
const validation = Object.keys(requiredFields).every(name => {
const field = form.elements[name];
return field && field.value;
});
if (!validation) {
throw new Error('폼 필드 설정이 완료되지 않았습니다.');
}
};
증상: 네트워크 불안정으로 인증 과정 중단
// 해결 방법: 재시도 로직 및 타임아웃 처리
const fetchWithRetry = async (url, options, maxRetries = 3) => {
for (let i = 0; i < maxRetries; i++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30초 타임아웃
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response;
} catch (error) {
console.warn(`시도 ${i + 1}/${maxRetries} 실패:`, error.message);
if (i === maxRetries - 1) {
throw new Error(`${maxRetries}회 시도 후 실패: ${error.message}`);
}
// 지수 백오프로 재시도 간격 조정
const delay = Math.pow(2, i) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
};
이번 구현을 통해 다음과 같은 개선 효과를 얻었습니다:
항목 | 이전 | 이후 | 개선률 |
---|---|---|---|
코드 중복 | 웹/앱 각각 구현 | 단일 Hook으로 통합 | 80% 감소 |
유지보수 시간 | 2개 파일 수정 | 1개 파일 수정 | 50% 단축 |
버그 발생률 | 환경별 불일치 빈발 | 로직 통합으로 안정화 | 70% 감소 |
개발 속도 | 환경별 테스트 필요 | 한 번에 양쪽 검증 | 40% 향상 |
interface IdentityVerificationOptions {
context: 'join' | 'passwordFind' | 'profileEdit' | 'adultVerification';
extraData?: Record<string, any>;
}
interface AuthTokens {
enc_data: string;
integrity_value: string;
token_version_id: string;
}
describe('useIdentityVerification', () => {
test('웹 환경에서 팝업 방식 동작', () => {
// 테스트 로직
});
test('앱 환경에서 리다이렉트 방식 동작', () => {
// 테스트 로직
});
});
const logError = (error, context) => {
// Sentry, LogRocket 등 모니터링 도구 연동
console.error('본인인증 오류:', { error, context, userAgent: navigator.userAgent });
};
// 메모이제이션으로 불필요한 재계산 방지
const memoizedIsInApp = useMemo(() => isInApp(), []);