오늘은 프로젝트를 하다가 React와 Firebase를 사용하여 안전하고 신뢰할 수 있는 📞 전화번호 인증 시스템을 구현하는 방법을 알게 되었고 이에 대해 정리하고자 한다!!
reCAPTCHA 인증을 포함하여 보안성을 강화하고, 환경 변수를 통한 중요 정보 관리도 함께 다룰 예정이다.
(예시코드와 함께 간단하게 설명하고자 하고 기본적으로 리액트 프로젝트가 세팅되어있다고 가정하겠다.)
1. Firebase Console 설정
먼저 Firebase Console에서 프로젝트를 설정해야한다.
1-1. Firebase 프로젝트 설정
- Firebase Console에 접속
- "새 프로젝트 만들기" 클릭하기
- 프로젝트 이름 입력 및 약관 동의
1-2. 전화번호 인증 활성화하기
- 좌측 메뉴에서 "Authentication" 선택
- "시작하기" 클릭
- "Sign-in-method" 탭에서 "전화번호" 제공업체 활성화하기
‼️ 무료 요금제인 Spark 요금제는 하루에 10개의 SMS로만 테스트할 수 있지만 Blaze 요금제는 하루에 1000개의 SMS 전송이 허용된다! 필자도 Blaze 요금제로 돈을 내지 않고 인증을 사용중이다! ‼️
1-3. 앱 등록
- 프로젝트 개요 페이지에서 웹 앱 아이콘(</>)` 클릭
- 앱 닉네임 입력
- Firebase SDK 설정 정보 저장 (이후 .env 파일에 사용)
나중에라도 확인하고 싶다면 위 프로젝트 설정에서 Firebase 설정 정보를 확인할 수 있었다.
2. 프로젝트 설정
먼저 필요한 패키지들을 설치해야한다.
npm install @firebase/app @firebase/auth styled-components
필자는 CSS 대신 styled-components를 사용하고자 한다.
3. 환경 변수 설정
이제 Firebase 설정 정보를 환경 변수로 관리해야 하는데 왜 그래야만 할까? 🤔
API 키와 같은 민감한 정보가 소스 코드에 직접 노출되는 것을 방지하기 때문이다.
만약 이러한 정보가 GitHub와 같은 공개 저장소에 노출되면, 악의적인 사용자가 이를 악용할 수 있다.
(다른 이유들도 존재하지만 필자는 API 키를 보호 목적으로 환경 변수로 관리한다.)
아래와 같이 프로젝트 루트에 .env 파일을 생성하고 Firebase 설정 정보를 추가한다.
REACT_APP_FIREBASE_API_KEY=your_api_key
REACT_APP_FIREBASE_AUTH_DOMAIN=your_auth_domain
REACT_APP_FIREBASE_PROJECT_ID=your_project_id
REACT_APP_FIREBASE_STORAGE_BUCKET=your_storage_bucket
REACT_APP_FIREBASE_MESSAGING_SENDER_ID=your_sender_id
REACT_APP_FIREBASE_APP_ID=your_app_id
⚠️ 중요: .gitignore 파일에 .env를 추가하여 환경 변수 파일이 Git에 추가되지 않도록 한다.
대신, 아래와 같이 .env.example 파일을 만들어 필요한 환경 변수의 형식을 공유하는 것이 좋은 방법이다
# .env.example
REACT_APP_FIREBASE_API_KEY=your_api_key_here
REACT_APP_FIREBASE_AUTH_DOMAIN=your_auth_domain_here
REACT_APP_FIREBASE_PROJECT_ID=your_project_id_here
4. PhoneAuthService.js 파일 생성하고 Firebase 인증 관련 로직 구현하기
import { initializeApp } from '@firebase/app';
import {
getAuth,
RecaptchaVerifier,
signInWithPhoneNumber
} from '@firebase/auth';
const firebaseConfig = {
apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.REACT_APP_FIREBASE_APP_ID,
};
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
auth.languageCode = 'ko';
let recaptchaVerifier = null;
export const setupRecaptcha = () => {
try {
if (!recaptchaVerifier) {
recaptchaVerifier = new RecaptchaVerifier(auth, 'recaptcha-container', {
size: 'invisible',
callback: () => {
console.log('reCAPTCHA verified');
},
'expired-callback': () => {
console.log('reCAPTCHA expired');
recaptchaVerifier = null;
},
});
}
} catch (error) {
console.error('Error setting up reCAPTCHA:', error);
recaptchaVerifier = null;
}
};
export const requestPhoneVerification = async (phoneNumber) => {
try {
const cleaned = phoneNumber.replace(/[^0-9]/g, '');
if (!cleaned.startsWith('010') || cleaned.length !== 11) {
throw new Error('올바른 전화번호 형식이 아닙니다.');
}
const formattedPhone = `+82${cleaned.slice(1)}`;
console.log('Sending verification to:', formattedPhone);
if (!recaptchaVerifier) {
setupRecaptcha();
}
// reCAPTCHA를 먼저 렌더링
await recaptchaVerifier.render();
const confirmationResult = await firebaseSignInWithPhoneNumber(auth, formattedPhone, recaptchaVerifier);
window.confirmationResult = confirmationResult;
return { success: true };
} catch (error) {
console.error('SMS failed:', error);
if (recaptchaVerifier) {
recaptchaVerifier.clear();
recaptchaVerifier = null;
}
return {
success: false,
error: error.code === 'auth/invalid-phone-number' ? '올바른 전화번호 형식이 아닙니다.' : error.message,
};
}
};
export const verifyPhoneNumber = async (verificationCode) => {
try {
if (!window.confirmationResult) {
throw new Error('먼저 인증번호를 요청해주세요.');
}
const result = await window.confirmationResult.confirm(verificationCode);
return { success: true, user: result.user };
} catch (error) {
console.error('Verification failed:', error);
return {
success: false,
error: error.code === 'auth/invalid-verification-code' ? '잘못된 인증번호입니다.' : error.message,
};
}
};
5. 회원가입 페이지(아래는 필자가 예시로 만든 코드)에서 전화번호 인증 구현
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import { setupRecaptcha, requestPhoneVerification, verifyPhoneNumber } from '../services/PhoneAuthService';
const SignUpPage = () => {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [verificationCode, setVerificationCode] = useState('');
const [showVerification, setShowVerification] = useState(false);
const [isPhoneVerified, setIsPhoneVerified] = useState(false);
useEffect(() => {
setupRecaptcha();
}, []);
const formatPhoneNumber = (value) => {
if (!value) return value;
const phoneNumber = value.replace(/[^\d]/g, '');
if (phoneNumber.length <= 3) {
return phoneNumber;
}
if (phoneNumber.length <= 7) {
return `${phoneNumber.slice(0, 3)}-${phoneNumber.slice(3)}`;
}
return `${phoneNumber.slice(0, 3)}-${phoneNumber.slice(3, 7)}-${phoneNumber.slice(7, 11)}`;
};
const handlePhoneChange = (e) => {
const formattedPhone = formatPhoneNumber(e.target.value);
if (formattedPhone.replace(/-/g, '').length <= 11) {
setPhone(formattedPhone);
}
};
const handleVerificationRequest = async () => {
const response = await requestPhoneVerification(phone);
if (response.success) {
setShowVerification(true);
alert('인증번호가 발송되었습니다.');
} else {
alert('인증번호 발송에 실패했습니다.');
}
};
const handleVerificationSubmit = async () => {
const response = await verifyPhoneNumber(verificationCode);
if (response.success) {
setIsPhoneVerified(true);
alert('인증이 완료되었습니다.');
} else {
alert('인증에 실패했습니다.');
}
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!isPhoneVerified) {
alert('전화번호 인증이 필요합니다.');
return;
}
// 회원가입 처리 로직
console.log('회원가입 처리:', { name, email, phone });
};
return (
<Container>
<FormWrapper>
<Title>회원가입</Title>
<Form onSubmit={handleSubmit}>
<FormGroup>
<Label>이름</Label>
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="이름을 입력하세요"
required
/>
</FormGroup>
<FormGroup>
<Label>이메일</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="이메일을 입력하세요"
required
/>
</FormGroup>
<FormGroup>
<Label>전화번호</Label>
<PhoneInputGroup>
{isPhoneVerified ? (
<DisabledInput value={phone} disabled />
) : (
<PhoneInput
id="recaptcha-container"
type="tel"
value={phone}
onChange={handlePhoneChange}
placeholder="전화번호를 입력하세요"
required
/>
)}
<VerificationButton
type="button"
disabled={phone.replace(/-/g, '').length !== 11 || isPhoneVerified}
onClick={handleVerificationRequest}
>
{isPhoneVerified ? '인증완료' : '인증번호 받기'}
</VerificationButton>
</PhoneInputGroup>
{showVerification && (
<VerificationInputGroup>
{isPhoneVerified ? (
<DisabledInput value={verificationCode} disabled />
) : (
<Input
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="인증번호 6자리를 입력하세요"
maxLength="6"
/>
)}
<VerificationButton
type="button"
onClick={handleVerificationSubmit}
disabled={isPhoneVerified}
>
확인
</VerificationButton>
</VerificationInputGroup>
)}
</FormGroup>
<SubmitButton type="submit">가입 완료</SubmitButton>
</Form>
</FormWrapper>
</Container>
);
};
const Container = styled.div`
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f5f5f5;
padding: 20px;
`;
const FormWrapper = styled.div`
background: white;
padding: 48px;
border-radius: 16px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.05);
width: 100%;
max-width: 500px;
`;
const Title = styled.h1`
font-size: 2.5rem;
color: #333;
margin-bottom: 2rem;
font-weight: 600;
`;
const Form = styled.form`
display: flex;
flex-direction: column;
gap: 24px;
`;
const FormGroup = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const Label = styled.label`
color: #333;
font-weight: 500;
font-size: 0.9rem;
`;
const Input = styled.input`
width: 100%;
padding: 12px 16px;
border: 2px solid #eee;
border-radius: 8px;
font-size: 1rem;
transition: all 0.2s ease;
&:focus {
outline: none;
border-color: #4f3296;
}
`;
const PhoneInput = styled(Input)`
flex: 1;
`;
const DisabledInput = styled(Input)`
background-color: #f5f5f5;
color: #666;
`;
const PhoneInputGroup = styled.div`
display: flex;
gap: 8px;
width: 100%;
`;
const VerificationInputGroup = styled(PhoneInputGroup)`
margin-top: 8px;
`;
const VerificationButton = styled.button`
padding: 12px 20px;
background: ${(props) => {
if (props.disabled) {
return props.children === '인증완료' ? '#4CAF50' : '#cccccc';
}
return '#4f3296';
}};
color: white;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: ${(props) => (props.disabled ? 'not-allowed' : 'pointer')};
transition: all 0.2s ease;
white-space: nowrap;
&:hover:not(:disabled) {
background: #3a2570;
}
`;
const SubmitButton = styled.button`
width: 100%;
padding: 16px;
background: #4f3296;
color: white;
border: none;
border-radius: 8px;
font-size: 1.1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #3a2570;
}
`;
export default SignUpPage;
주의사항은 같은 휴대폰 번호로 너무 많은 요청을 보내면 장시간동안 해당 번호로 SMS 인증 번호를 보내는 것이 불가할것이다!!
'React' 카테고리의 다른 글
[React] Error: can't resolve './reportWebVitals' 문제 해결 (0) | 2025.01.17 |
---|---|
[React] useState로 상태 관리하기 (1) | 2025.01.16 |
[React] Quill 에디터로 나만의 텍스트 에디터 만들기 🚀 (2) | 2025.01.15 |
[React] React 19 출시 : 무엇이 해결되었을까? (0) | 2025.01.11 |
[React] React Suspense란? (2) | 2024.12.05 |