인증(Auth) 시스템 완벽 가이드

1. 전체 흐름 개요
회원가입 흐름:
- 사용자가 프론트엔드에서 회원가입 폼 입력
- 프론트엔드가 입력값 유효성 검사
- 백엔드로 회원가입 요청 전송
- 백엔드가 비밀번호 해싱 후 DB에 저장
- 성공 응답 반환
로그인 흐름:
- 사용자가 이메일/비밀번호 입력
- 백엔드가 DB에서 사용자 조회
- 비밀번호 검증
- JWT 토큰 생성 및 반환
- 프론트엔드가 토큰 저장
- 이후 요청마다 토큰을 헤더에 포함
2. 핵심 개념
2.1 비밀번호 해싱
비밀번호를 평문으로 저장하면 안 됩니다. bcrypt 같은 라이브러리로 해싱하여 저장합니다.
// bcrypt 예시
const hashedPassword = await bcrypt.hash(plainPassword, 10); // 10은 salt rounds
const isMatch = await bcrypt.compare(plainPassword, hashedPassword);
2.2 JWT (JSON Web Token)
사용자 인증 상태를 유지하는 토큰입니다.
구조: Header.Payload.Signature
- Header: 토큰 타입, 알고리즘
- Payload: 사용자 정보 (user ID, 이메일 등)
- Signature: 위조 방지용 서명
특징:
- Stateless: 서버가 세션을 저장하지 않음
- 자체 포함적: 토큰 안에 필요한 정보가 모두 들어있음
2.3 Access Token & Refresh Token
- Access Token: 짧은 유효기간(15분~1시간), API 요청에 사용
- Refresh Token: 긴 유효기간(7일~30일), Access Token 재발급용
3. 백엔드 구현 (Node.js + Express 예시)
3.1 필요한 패키지
npm install express bcrypt jsonwebtoken mongoose
3.2 사용자 모델 (MongoDB/Mongoose)
// models/User.js
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true
},
password: {
type: String,
required: true,
minlength: 8
},
name: {
type: String,
required: true
},
createdAt: {
type: Date,
default: Date.now
}
});
module.exports = mongoose.model('User', userSchema);
3.3 회원가입 API
// routes/auth.js
const express = require('express');
const bcrypt = require('bcrypt');
const User = require('../models/User');
const router = express.Router();
router.post('/signup', async (req, res) => {
try {
const { email, password, name } = req.body;
// 1. 입력값 검증
if (!email || !password || !name) {
return res.status(400).json({
message: '모든 필드를 입력해주세요.'
});
}
// 2. 이메일 중복 확인
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(409).json({
message: '이미 존재하는 이메일입니다.'
});
}
// 3. 비밀번호 강도 검증 (예시)
if (password.length < 8) {
return res.status(400).json({
message: '비밀번호는 최소 8자 이상이어야 합니다.'
});
}
// 4. 비밀번호 해싱
const hashedPassword = await bcrypt.hash(password, 10);
// 5. 사용자 생성
const user = new User({
email,
password: hashedPassword,
name
});
await user.save();
// 6. 응답 (비밀번호는 제외)
res.status(201).json({
message: '회원가입 성공',
user: {
id: user._id,
email: user.email,
name: user.name
}
});
} catch (error) {
console.error(error);
res.status(500).json({
message: '서버 오류가 발생했습니다.'
});
}
});
module.exports = router;
3.4 로그인 API
// routes/auth.js에 추가
const jwt = require('jsonwebtoken');
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
// 1. 입력값 검증
if (!email || !password) {
return res.status(400).json({
message: '이메일과 비밀번호를 입력해주세요.'
});
}
// 2. 사용자 조회
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({
message: '이메일 또는 비밀번호가 잘못되었습니다.'
});
}
// 3. 비밀번호 검증
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return res.status(401).json({
message: '이메일 또는 비밀번호가 잘못되었습니다.'
});
}
// 4. JWT 토큰 생성
const accessToken = jwt.sign(
{
userId: user._id,
email: user.email
},
process.env.JWT_SECRET, // 환경변수에 저장된 비밀키
{ expiresIn: '1h' } // 1시간 유효
);
const refreshToken = jwt.sign(
{ userId: user._id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' } // 7일 유효
);
// 5. Refresh Token을 DB에 저장 (선택사항)
// user.refreshToken = refreshToken;
// await user.save();
// 6. 응답
res.status(200).json({
message: '로그인 성공',
accessToken,
refreshToken,
user: {
id: user._id,
email: user.email,
name: user.name
}
});
} catch (error) {
console.error(error);
res.status(500).json({
message: '서버 오류가 발생했습니다.'
});
}
});
3.5 인증 미들웨어
// middleware/auth.js
const jwt = require('jsonwebtoken');
const authMiddleware = (req, res, next) => {
try {
// 1. 헤더에서 토큰 추출
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
message: '인증 토큰이 필요합니다.'
});
}
const token = authHeader.split(' ')[1]; // "Bearer TOKEN"에서 TOKEN 부분만
// 2. 토큰 검증
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// 3. 요청 객체에 사용자 정보 추가
req.user = decoded;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
message: '토큰이 만료되었습니다.'
});
}
return res.status(401).json({
message: '유효하지 않은 토큰입니다.'
});
}
};
module.exports = authMiddleware;
3.6 보호된 라우트 예시
// routes/protected.js
const express = require('express');
const authMiddleware = require('../middleware/auth');
const router = express.Router();
router.get('/profile', authMiddleware, async (req, res) => {
try {
// req.user에는 토큰에서 디코딩된 사용자 정보가 있음
const user = await User.findById(req.user.userId).select('-password');
res.status(200).json({ user });
} catch (error) {
res.status(500).json({ message: '서버 오류' });
}
});
module.exports = router;
3.7 Refresh Token API
router.post('/refresh', async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({
message: 'Refresh token이 필요합니다.'
});
}
// Refresh token 검증
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
// 새로운 Access token 발급
const newAccessToken = jwt.sign(
{
userId: decoded.userId,
email: decoded.email
},
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.status(200).json({
accessToken: newAccessToken
});
} catch (error) {
res.status(401).json({
message: '유효하지 않은 refresh token입니다.'
});
}
});
4. 프론트엔드 구현 (React 예시)
4.1 필요한 패키지
npm install axios react-router-dom
4.2 API 서비스 설정
// services/api.js
import axios from 'axios';
const API = axios.create({
baseURL: 'http://localhost:5000/api', // 백엔드 주소
});
// 요청 인터셉터: 모든 요청에 토큰 자동 추가
API.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 응답 인터셉터: 토큰 만료 시 자동 갱신
API.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// 토큰 만료 에러이고, 재시도가 아닌 경우
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
const response = await axios.post(
'http://localhost:5000/api/auth/refresh',
{ refreshToken }
);
const { accessToken } = response.data;
localStorage.setItem('accessToken', accessToken);
// 원래 요청 재시도
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return API(originalRequest);
} catch (refreshError) {
// Refresh token도 만료된 경우 로그아웃
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
export default API;
4.3 회원가입 컴포넌트
// components/Signup.jsx
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import API from '../services/api';
function Signup() {
const navigate = useNavigate();
const [formData, setFormData] = useState({
email: '',
password: '',
confirmPassword: '',
name: ''
});
const [errors, setErrors] = useState({});
const [isLoading, setIsLoading] = useState(false);
// 입력값 변경 핸들러
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// 입력 시 해당 필드 에러 제거
if (errors[name]) {
setErrors(prev => ({
...prev,
[name]: ''
}));
}
};
// 클라이언트 측 유효성 검증
const validateForm = () => {
const newErrors = {};
// 이메일 검증
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!formData.email) {
newErrors.email = '이메일을 입력해주세요.';
} else if (!emailRegex.test(formData.email)) {
newErrors.email = '올바른 이메일 형식이 아닙니다.';
}
// 비밀번호 검증
if (!formData.password) {
newErrors.password = '비밀번호를 입력해주세요.';
} else if (formData.password.length < 8) {
newErrors.password = '비밀번호는 최소 8자 이상이어야 합니다.';
}
// 비밀번호 확인
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = '비밀번호가 일치하지 않습니다.';
}
// 이름 검증
if (!formData.name) {
newErrors.name = '이름을 입력해주세요.';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 제출 핸들러
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setIsLoading(true);
try {
const response = await API.post('/auth/signup', {
email: formData.email,
password: formData.password,
name: formData.name
});
alert('회원가입 성공!');
navigate('/login');
} catch (error) {
if (error.response?.data?.message) {
alert(error.response.data.message);
} else {
alert('회원가입 중 오류가 발생했습니다.');
}
} finally {
setIsLoading(false);
}
};
return (
<div className="signup-container">
<h2>회원가입</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>이메일</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
disabled={isLoading}
/>
{errors.email && <span className="error">{errors.email}</span>}
</div>
<div className="form-group">
<label>이름</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
disabled={isLoading}
/>
{errors.name && <span className="error">{errors.name}</span>}
</div>
<div className="form-group">
<label>비밀번호</label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
disabled={isLoading}
/>
{errors.password && <span className="error">{errors.password}</span>}
</div>
<div className="form-group">
<label>비밀번호 확인</label>
<input
type="password"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleChange}
disabled={isLoading}
/>
{errors.confirmPassword && (
<span className="error">{errors.confirmPassword}</span>
)}
</div>
<button type="submit" disabled={isLoading}>
{isLoading ? '처리중...' : '회원가입'}
</button>
</form>
</div>
);
}
export default Signup;
4.4 로그인 컴포넌트
// components/Login.jsx
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import API from '../services/api';
function Login() {
const navigate = useNavigate();
const [formData, setFormData] = useState({
email: '',
password: ''
});
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
setError(''); // 입력 시 에러 메시지 제거
};
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
const response = await API.post('/auth/login', formData);
const { accessToken, refreshToken, user } = response.data;
// 토큰 저장
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
// 사용자 정보 저장 (선택사항)
localStorage.setItem('user', JSON.stringify(user));
// 홈으로 이동
navigate('/');
} catch (error) {
if (error.response?.data?.message) {
setError(error.response.data.message);
} else {
setError('로그인 중 오류가 발생했습니다.');
}
} finally {
setIsLoading(false);
}
};
return (
<div className="login-container">
<h2>로그인</h2>
<form onSubmit={handleSubmit}>
{error && <div className="error-message">{error}</div>}
<div className="form-group">
<label>이메일</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
required
disabled={isLoading}
/>
</div>
<div className="form-group">
<label>비밀번호</label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
required
disabled={isLoading}
/>
</div>
<button type="submit" disabled={isLoading}>
{isLoading ? '로그인 중...' : '로그인'}
</button>
</form>
</div>
);
}
export default Login;
4.5 인증 컨텍스트 (전역 상태 관리)
// context/AuthContext.jsx
import React, { createContext, useState, useContext, useEffect } from 'react';
import API from '../services/api';
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
// 초기 로드 시 사용자 정보 복원
useEffect(() => {
const storedUser = localStorage.getItem('user');
if (storedUser) {
setUser(JSON.parse(storedUser));
}
setIsLoading(false);
}, []);
const login = async (email, password) => {
const response = await API.post('/auth/login', { email, password });
const { accessToken, refreshToken, user } = response.data;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
localStorage.setItem('user', JSON.stringify(user));
setUser(user);
return user;
};
const logout = () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
setUser(null);
};
const signup = async (email, password, name) => {
await API.post('/auth/signup', { email, password, name });
};
return (
<AuthContext.Provider value={{
user,
login,
logout,
signup,
isLoading
}}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth는 AuthProvider 내에서 사용되어야 합니다.');
}
return context;
};
4.6 보호된 라우트
// components/PrivateRoute.jsx
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
function PrivateRoute({ children }) {
const { user, isLoading } = useAuth();
if (isLoading) {
return <div>로딩 중...</div>;
}
return user ? children : <Navigate to="/login" />;
}
export default PrivateRoute;
4.7 라우터 설정
// App.jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import Login from './components/Login';
import Signup from './components/Signup';
import Home from './components/Home';
import PrivateRoute from './components/PrivateRoute';
function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route
path="/"
element={
<PrivateRoute>
<Home />
</PrivateRoute>
}
/>
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
export default App;
5. 보안 고려사항
5.1 환경변수 설정
// .env 파일
JWT_SECRET=your-super-secret-key-here-make-it-long-and-random
JWT_REFRESH_SECRET=another-different-secret-key
MONGODB_URI=mongodb://localhost:27017/yourdb
5.2 HTTPS 사용
프로덕션에서는 반드시 HTTPS를 사용하여 토큰이 암호화된 채널로 전송되도록 해야 합니다.
5.3 CORS 설정
// server.js
const cors = require('cors');
app.use(cors({
origin: 'http://localhost:3000', // 프론트엔드 주소
credentials: true
}));
5.4 Rate Limiting
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 5, // 최대 5번 시도
message: '너무 많은 로그인 시도입니다. 나중에 다시 시도해주세요.'
});
app.use('/api/auth/login', loginLimiter);
5.5 XSS 방지
사용자 입력을 출력할 때 항상 이스케이프 처리하고, React는 기본적으로 XSS를 방지하지만 dangerouslySetInnerHTML은 피해야 합니다.
5.6 CSRF 방지
토큰 기반 인증을 사용하면 CSRF 공격 위험이 줄어들지만, 쿠키를 사용하는 경우 CSRF 토큰을 추가로 사용해야 합니다.
6. 추가 기능
6.1 이메일 인증
회원가입 시 이메일로 인증 링크를 보내는 기능입니다 (Nodemailer 사용).
6.2 비밀번호 재설정
비밀번호를 잊었을 때 이메일로 재설정 링크를 보내는 기능입니다.
6.3 소셜 로그인
Google, Facebook, GitHub 등의 OAuth를 사용한 로그인 (Passport.js 사용).
6.4 2단계 인증(2FA)
추가 보안 레이어로 OTP를 사용한 인증입니다.
이 가이드는 기본적인 인증 시스템 구현에 필요한 모든 핵심 개념과 코드를 포함하고 있습니다.
실제 프로젝트에서는 요구사항에 따라 추가 기능을 구현하시면 됩니다.
참고할 만한 글
https://www.cloudflare.com/ko-kr/learning/access-management/what-is-authentication/
인증이란?
사이버 보안에서 인증은 엔터티의 ID를 확인하는 프로세스입니다. 액세스 제어 시스템에서 사용하는 다양한 인증 유형에 대해 알아보세요.
www.cloudflare.com
'개발 이모저모' 카테고리의 다른 글
| 대기업에서도 사용하는 VS Code 확장 프로그램 Best 5 (0) | 2026.01.10 |
|---|---|
| 내도메인.한국으로 무료 도메인 등록 (0) | 2025.12.10 |
| Mixed Content 오류란? (0) | 2025.11.05 |
| cors 에러는 무엇인가. (4) | 2025.11.05 |
| 무중단 배포란? (0) | 2025.10.26 |