React에서 NICE 본인인증 구현하기 (웹/앱 환경 대응)

junhyung kwon·2025년 7월 8일
0

React에서 NICE 본인인증 구현하기 (웹/앱 환경 대응)

📌 목차

  1. 배경 및 요구사항
  2. NICE 본인인증이란?
  3. 웹 vs 앱 환경의 차이점
  4. 구현 아키텍처
  5. 핵심 코드 구현
  6. 사용법
  7. 트러블슈팅
  8. 마무리

🎯 배경 및 요구사항

최근 프로젝트에서 다음과 같은 요구사항을 만족하는 본인인증 기능을 구현해야 했습니다:

  • 크로스 플랫폼: 웹브라우저와 React Native WebView에서 모두 동작
  • 재사용성: 회원가입, 비밀번호 찾기, 개인정보 수정 등 다양한 상황에서 활용
  • 사용자 경험: 환경에 맞는 최적화된 인증 흐름 제공
  • 유지보수성: Hook 패턴을 활용한 깔끔한 코드 구조

📱 NICE 본인인증이란?

NICE 본인인증은 한국에서 가장 널리 사용되는 본인인증 서비스로, 휴대폰 본인인증을 통해 사용자의 실명과 연락처를 검증할 수 있습니다.

인증 흐름


🔄 웹 vs 앱 환경의 차이점

환경별로 다른 접근 방식이 필요했습니다:

구분웹 환경앱 환경
인증 방식팝업 창 (window.open)페이지 리다이렉트
데이터 전달postMessage APIlocalStorage
창 관리팝업 생성/닫기 제어브라우저 기본 네비게이션
사용자 경험기존 페이지 유지자연스러운 페이지 전환

🏗️ 구현 아키텍처

📁 hooks/
└── useIdentityVerification.js # 본인인증 로직
📁 pages/Auth/Join/
└── JoinVerification.js # 회원가입용 본인인증 페이지
📁 services/
└── services.js # API 통신
📁 utils/
└── urls.js # API 엔드포인트

🎨 설계 원칙

  1. 단일 책임 원칙: Hook은 본인인증 로직만 담당
  2. 환경 추상화: 사용하는 컴포넌트는 환경을 신경 쓰지 않음
  3. 확장성: 새로운 인증 상황을 쉽게 추가 가능
  4. 에러 핸들링: 각 단계별 적절한 에러 처리

🔧 기술 스택

  • React Hooks: 상태 관리 및 로직 캡슐화
  • Axios: HTTP 통신
  • localStorage: 앱 환경에서의 상태 저장
  • NICE CheckPlus: 본인인증 서비스

💻 핵심 코드 구현

1. 🔍 앱 환경 감지 함수

앱과 웹을 정확히 구분하는 것이 가장 중요합니다:

// 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;
};

2. 📡 메인 Hook 구현

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;

3. 🎨 컴포넌트 구현

// 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' });

🚨 트러블슈팅

1. 🚫 팝업 차단 문제

증상: 웹 환경에서 본인인증 창이 열리지 않음

// 해결 방법: 팝업 차단 감지 및 안내
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;
};

2. 🔍 앱 환경 감지 실패

증상: 잘못된 환경으로 인식하여 인증 방식이 맞지 않음

// 해결 방법: 다중 검증 로직 강화
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;
};

3. 💾 localStorage 데이터 유실

증상: 앱 환경에서 인증 후 돌아왔을 때 상태 정보가 없음

// 해결 방법: 데이터 유효성 검증 및 복구 로직
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;
};

4. 🔧 receivedata 필드 누락

증상: 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('폼 필드 설정이 완료되지 않았습니다.');
    }
};

5. ⏱️ 타임아웃 및 네트워크 에러

증상: 네트워크 불안정으로 인증 과정 중단

// 해결 방법: 재시도 로직 및 타임아웃 처리
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% 향상

📚 배운 점

  • 환경별 차이 이해: 동일한 기능도 플랫폼에 따라 완전히 다른 구현이 필요할 수 있음
  • 추상화의 힘: 복잡한 로직을 Hook으로 캡슐화하면 사용하는 쪽에서는 매우 간단해짐
  • 점진적 개선: 처음부터 완벽한 코드보다는 동작하는 코드를 만들고 개선해나가는 것이 중요
  • 사용자 경험 우선: 기술적 제약을 사용자가 느끼지 않도록 하는 것이 핵심

🔧 개선 가능한 부분

  1. TypeScript 적용
interface IdentityVerificationOptions {
    context: 'join' | 'passwordFind' | 'profileEdit' | 'adultVerification';
    extraData?: Record<string, any>;
}

interface AuthTokens {
    enc_data: string;
    integrity_value: string;
    token_version_id: string;
}
  1. 테스트 코드 작성
describe('useIdentityVerification', () => {
    test('웹 환경에서 팝업 방식 동작', () => {
        // 테스트 로직
    });
    
    test('앱 환경에서 리다이렉트 방식 동작', () => {
        // 테스트 로직
    });
});
  1. 에러 모니터링 연동
const logError = (error, context) => {
    // Sentry, LogRocket 등 모니터링 도구 연동
    console.error('본인인증 오류:', { error, context, userAgent: navigator.userAgent });
};
  1. 성능 최적화
// 메모이제이션으로 불필요한 재계산 방지
const memoizedIsInApp = useMemo(() => isInApp(), []);

🚀 다음 단계

  1. 다른 본인인증 서비스 지원: PASS, KakaoTalk 인증 등
  2. 국제화 대응: 해외 사용자를 위한 다른 인증 방식
  3. 보안 강화: 추가적인 검증 로직 및 암호화
  4. 모니터링 대시보드: 인증 성공률, 실패 원인 분석

💡 참고 자료

profile
“Everything comes to him who hustles while he waits” 항상 최고가 되기 위해 꾸준히 노력하며 성장해 나아가는 Front-End 개발자 권준형입니다.

0개의 댓글