Node.js 인증과 보안 | JWT, bcrypt, 세션, OAuth

Node.js 인증과 보안 | JWT, bcrypt, 세션, OAuth

이 글의 핵심

Node.js 인증과 보안에 대한 실전 가이드입니다. JWT, bcrypt, 세션, OAuth 등을 예제와 함께 설명합니다.

들어가며

인증 vs 인가

HTTP는 상태가 없어서(stateless) 매 요청마다 “누구인지”를 다시 증명해야 합니다. 그때 세션 쿠키JWT로 “이미 로그인했다”는 정보를 실어 보내고, 비밀번호는 bcrypt 등으로 해시만 저장합니다. 토큰은 출입증에 가깝고, 권한(관리자만 삭제 등)은 그 토큰 안의 역할·스코프로 인가 단계에서 나눕니다.

인증 (Authentication):

  • “당신은 누구인가?” (신원 확인)
  • 로그인, 회원가입

인가 (Authorization):

  • “당신은 무엇을 할 수 있는가?” (권한 확인)
  • 역할 기반 접근 제어 (RBAC)

인증 방식:

  • JWT (JSON Web Token): Stateless, 확장성 좋음
  • 세션 (Session): Stateful, 서버에서 제어
  • OAuth 2.0: 소셜 로그인 (Google, GitHub)
  • API Key: 간단한 API 인증

1. 비밀번호 해싱 (bcrypt)

설치

npm install bcrypt

기본 사용법

const bcrypt = require('bcrypt');

// 비밀번호 해싱
async function hashPassword(password) {
    const saltRounds = 10;  // 해싱 강도 (10~12 권장)
    const hash = await bcrypt.hash(password, saltRounds);
    return hash;
}

// 비밀번호 확인
async function verifyPassword(password, hash) {
    const isValid = await bcrypt.compare(password, hash);
    return isValid;
}

// 사용
async function main() {
    const password = 'mypassword123';
    
    // 해싱
    const hash = await hashPassword(password);
    console.log('해시:', hash);
    // $2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
    
    // 확인
    const isValid = await verifyPassword(password, hash);
    console.log('비밀번호 일치:', isValid);  // true
    
    const isInvalid = await verifyPassword('wrongpassword', hash);
    console.log('잘못된 비밀번호:', isInvalid);  // false
}

main();

회원가입 구현

const express = require('express');
const bcrypt = require('bcrypt');
const User = require('./models/User');

const app = express();
app.use(express.json());

app.post('/auth/register', async (req, res) => {
    try {
        const { email, password, name } = req.body;
        
        // 유효성 검사
        if (!email || !password || !name) {
            return res.status(400).json({ error: '모든 필드가 필요합니다' });
        }
        
        if (password.length < 8) {
            return res.status(400).json({ error: '비밀번호는 8자 이상이어야 합니다' });
        }
        
        // 중복 확인
        const existingUser = await User.findOne({ email });
        if (existingUser) {
            return res.status(400).json({ error: '이미 존재하는 이메일입니다' });
        }
        
        // 비밀번호 해싱
        const hashedPassword = await bcrypt.hash(password, 10);
        
        // 사용자 생성
        const user = await User.create({
            email,
            password: hashedPassword,
            name
        });
        
        // 비밀번호 제외하고 응답
        const { password: _, ...userWithoutPassword } = user.toObject();
        
        res.status(201).json({
            message: '회원가입 성공',
            user: userWithoutPassword
        });
        
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});

2. JWT (JSON Web Token)

설치

npm install jsonwebtoken

JWT 생성 및 검증

const jwt = require('jsonwebtoken');

const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';

// 토큰 생성
function generateToken(payload) {
    return jwt.sign(
        payload,
        JWT_SECRET,
        { expiresIn: '1h' }  // 1시간 후 만료
    );
}

// 토큰 검증
function verifyToken(token) {
    try {
        const decoded = jwt.verify(token, JWT_SECRET);
        return decoded;
    } catch (err) {
        if (err.name === 'TokenExpiredError') {
            throw new Error('토큰이 만료되었습니다');
        }
        throw new Error('유효하지 않은 토큰입니다');
    }
}

// 사용
const token = generateToken({ id: 123, email: '[email protected]' });
console.log('토큰:', token);

const decoded = verifyToken(token);
console.log('디코딩:', decoded);
// { id: 123, email: '[email protected]', iat: 1234567890, exp: 1234571490 }

로그인 구현

const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const User = require('./models/User');

const app = express();
app.use(express.json());

const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';

app.post('/auth/login', async (req, res) => {
    try {
        const { email, password } = req.body;
        
        // 사용자 찾기
        const user = await User.findOne({ email });
        
        if (!user) {
            return res.status(401).json({ error: '이메일 또는 비밀번호가 잘못되었습니다' });
        }
        
        // 비밀번호 확인
        const isValid = await bcrypt.compare(password, user.password);
        
        if (!isValid) {
            return res.status(401).json({ error: '이메일 또는 비밀번호가 잘못되었습니다' });
        }
        
        // JWT 토큰 생성
        const token = jwt.sign(
            { id: user._id, email: user.email, role: user.role },
            JWT_SECRET,
            { expiresIn: '1h' }
        );
        
        res.json({
            message: '로그인 성공',
            token,
            user: {
                id: user._id,
                email: user.email,
                name: user.name,
                role: user.role
            }
        });
        
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});

인증 미들웨어

// middlewares/auth.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');

const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';

async function authenticate(req, res, next) {
    try {
        // 헤더에서 토큰 추출
        const authHeader = req.headers.authorization;
        
        if (!authHeader || !authHeader.startsWith('Bearer ')) {
            return res.status(401).json({ error: '인증 토큰이 필요합니다' });
        }
        
        const token = authHeader.substring(7);
        
        // 토큰 검증
        const decoded = jwt.verify(token, JWT_SECRET);
        
        // 사용자 조회
        const user = await User.findById(decoded.id).select('-password');
        
        if (!user) {
            return res.status(401).json({ error: '사용자를 찾을 수 없습니다' });
        }
        
        // req에 사용자 정보 추가
        req.user = user;
        next();
        
    } catch (err) {
        if (err.name === 'TokenExpiredError') {
            return res.status(401).json({ error: '토큰이 만료되었습니다' });
        }
        res.status(401).json({ error: '유효하지 않은 토큰입니다' });
    }
}

module.exports = { authenticate };
// 사용
const { authenticate } = require('./middlewares/auth');

app.get('/api/profile', authenticate, (req, res) => {
    res.json({ user: req.user });
});

권한 확인 미들웨어

// middlewares/authorize.js
function authorize(...roles) {
    return (req, res, next) => {
        if (!req.user) {
            return res.status(401).json({ error: '인증이 필요합니다' });
        }
        
        if (!roles.includes(req.user.role)) {
            return res.status(403).json({ error: '권한이 없습니다' });
        }
        
        next();
    };
}

module.exports = { authorize };
// 사용
const { authenticate } = require('./middlewares/auth');
const { authorize } = require('./middlewares/authorize');

// 관리자만 접근 가능
app.delete('/api/users/:id',
    authenticate,
    authorize('admin'),
    async (req, res) => {
        await User.findByIdAndDelete(req.params.id);
        res.status(204).send();
    }
);

// 관리자 또는 매니저
app.get('/api/reports',
    authenticate,
    authorize('admin', 'manager'),
    (req, res) => {
        res.json({ reports: [] });
    }
);

3. Refresh Token

구현

// models/RefreshToken.js
const mongoose = require('mongoose');

const refreshTokenSchema = new mongoose.Schema({
    token: {
        type: String,
        required: true,
        unique: true
    },
    user: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'User',
        required: true
    },
    expiresAt: {
        type: Date,
        required: true
    },
    createdAt: {
        type: Date,
        default: Date.now
    }
});

// 만료된 토큰 자동 삭제
refreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });

module.exports = mongoose.model('RefreshToken', refreshTokenSchema);
// auth.js
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const RefreshToken = require('./models/RefreshToken');

const JWT_SECRET = process.env.JWT_SECRET;
const REFRESH_SECRET = process.env.REFRESH_SECRET;

// Access Token 생성 (짧은 유효기간)
function generateAccessToken(user) {
    return jwt.sign(
        { id: user._id, email: user.email, role: user.role },
        JWT_SECRET,
        { expiresIn: '15m' }  // 15분
    );
}

// Refresh Token 생성 (긴 유효기간)
async function generateRefreshToken(user) {
    const token = crypto.randomBytes(40).toString('hex');
    
    await RefreshToken.create({
        token,
        user: user._id,
        expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)  // 7일
    });
    
    return token;
}

// 로그인
app.post('/auth/login', async (req, res) => {
    try {
        const { email, password } = req.body;
        
        const user = await User.findOne({ email });
        if (!user || !(await bcrypt.compare(password, user.password))) {
            return res.status(401).json({ error: '이메일 또는 비밀번호가 잘못되었습니다' });
        }
        
        const accessToken = generateAccessToken(user);
        const refreshToken = await generateRefreshToken(user);
        
        res.json({
            accessToken,
            refreshToken,
            expiresIn: 900  // 15분 (초 단위)
        });
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});

// 토큰 갱신
app.post('/auth/refresh', async (req, res) => {
    try {
        const { refreshToken } = req.body;
        
        if (!refreshToken) {
            return res.status(400).json({ error: 'Refresh Token이 필요합니다' });
        }
        
        // Refresh Token 확인
        const tokenDoc = await RefreshToken.findOne({ token: refreshToken })
            .populate('user');
        
        if (!tokenDoc) {
            return res.status(401).json({ error: '유효하지 않은 Refresh Token' });
        }
        
        if (tokenDoc.expiresAt < new Date()) {
            await RefreshToken.deleteOne({ token: refreshToken });
            return res.status(401).json({ error: 'Refresh Token이 만료되었습니다' });
        }
        
        // 새 Access Token 발급
        const accessToken = generateAccessToken(tokenDoc.user);
        
        res.json({
            accessToken,
            expiresIn: 900
        });
        
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});

// 로그아웃
app.post('/auth/logout', async (req, res) => {
    try {
        const { refreshToken } = req.body;
        
        if (refreshToken) {
            await RefreshToken.deleteOne({ token: refreshToken });
        }
        
        res.json({ message: '로그아웃 성공' });
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});

4. 세션 (Session)

설치

npm install express-session connect-mongo

기본 설정

const express = require('express');
const session = require('express-session');
const MongoStore = require('connect-mongo');

const app = express();

app.use(session({
    secret: process.env.SESSION_SECRET || 'your-secret-key',
    resave: false,
    saveUninitialized: false,
    store: MongoStore.create({
        mongoUrl: 'mongodb://localhost:27017/mydb',
        ttl: 24 * 60 * 60  // 1일 (초 단위)
    }),
    cookie: {
        maxAge: 24 * 60 * 60 * 1000,  // 1일 (밀리초)
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',  // HTTPS에서만
        sameSite: 'strict'
    }
}));

세션 사용

// 로그인
app.post('/auth/login', async (req, res) => {
    try {
        const { email, password } = req.body;
        
        const user = await User.findOne({ email });
        if (!user || !(await bcrypt.compare(password, user.password))) {
            return res.status(401).json({ error: '이메일 또는 비밀번호가 잘못되었습니다' });
        }
        
        // 세션에 사용자 정보 저장
        req.session.userId = user._id;
        req.session.email = user.email;
        req.session.role = user.role;
        
        res.json({
            message: '로그인 성공',
            user: {
                id: user._id,
                email: user.email,
                name: user.name
            }
        });
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});

// 로그아웃
app.post('/auth/logout', (req, res) => {
    req.session.destroy((err) => {
        if (err) {
            return res.status(500).json({ error: '로그아웃 실패' });
        }
        
        res.clearCookie('connect.sid');
        res.json({ message: '로그아웃 성공' });
    });
});

// 세션 확인 미들웨어
function requireAuth(req, res, next) {
    if (!req.session.userId) {
        return res.status(401).json({ error: '로그인이 필요합니다' });
    }
    next();
}

// 보호된 라우트
app.get('/api/profile', requireAuth, async (req, res) => {
    try {
        const user = await User.findById(req.session.userId).select('-password');
        res.json({ user });
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});

5. OAuth 2.0 (소셜 로그인)

Passport.js 설치

npm install passport passport-google-oauth20 passport-github2

Google OAuth

// config/passport.js
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const User = require('../models/User');

passport.use(new GoogleStrategy({
    clientID: process.env.GOOGLE_CLIENT_ID,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    callbackURL: '/auth/google/callback'
}, async (accessToken, refreshToken, profile, done) => {
    try {
        // 기존 사용자 찾기
        let user = await User.findOne({ googleId: profile.id });
        
        if (!user) {
            // 새 사용자 생성
            user = await User.create({
                googleId: profile.id,
                email: profile.emails[0].value,
                name: profile.displayName,
                avatar: profile.photos[0].value
            });
        }
        
        done(null, user);
    } catch (err) {
        done(err, null);
    }
}));

passport.serializeUser((user, done) => {
    done(null, user._id);
});

passport.deserializeUser(async (id, done) => {
    try {
        const user = await User.findById(id);
        done(null, user);
    } catch (err) {
        done(err, null);
    }
});

module.exports = passport;
// app.js
const express = require('express');
const session = require('express-session');
const passport = require('./config/passport');

const app = express();

app.use(session({
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false
}));

app.use(passport.initialize());
app.use(passport.session());

// Google 로그인 시작
app.get('/auth/google',
    passport.authenticate('google', { scope: ['profile', 'email'] })
);

// Google 콜백
app.get('/auth/google/callback',
    passport.authenticate('google', { failureRedirect: '/login' }),
    (req, res) => {
        res.redirect('/dashboard');
    }
);

// 로그아웃
app.get('/auth/logout', (req, res) => {
    req.logout((err) => {
        if (err) {
            return res.status(500).json({ error: '로그아웃 실패' });
        }
        res.redirect('/');
    });
});

// 인증 확인
function ensureAuthenticated(req, res, next) {
    if (req.isAuthenticated()) {
        return next();
    }
    res.redirect('/login');
}

app.get('/dashboard', ensureAuthenticated, (req, res) => {
    res.json({ user: req.user });
});

6. 보안 베스트 프랙티스

Helmet (보안 헤더)

npm install helmet
const helmet = require('helmet');

app.use(helmet());

// 커스텀 설정
app.use(helmet({
    contentSecurityPolicy: {
        directives: {
            defaultSrc: ["'self'"],
            styleSrc: ["'self'", "'unsafe-inline'"],
            scriptSrc: ["'self'"]
        }
    },
    hsts: {
        maxAge: 31536000,
        includeSubDomains: true,
        preload: true
    }
}));

Rate Limiting

npm install express-rate-limit
const rateLimit = require('express-rate-limit');

// 일반 요청 제한
const limiter = rateLimit({
    windowMs: 15 * 60 * 1000,  // 15분
    max: 100,  // 최대 100개 요청
    message: '너무 많은 요청이 발생했습니다. 나중에 다시 시도하세요.'
});

app.use('/api/', limiter);

// 로그인 요청 제한 (더 엄격)
const loginLimiter = rateLimit({
    windowMs: 15 * 60 * 1000,
    max: 5,  // 15분에 5번만
    skipSuccessfulRequests: true  // 성공한 요청은 카운트 안 함
});

app.post('/auth/login', loginLimiter, async (req, res) => {
    // ...
});

CORS 설정

npm install cors
const cors = require('cors');

// 모든 도메인 허용 (개발 환경)
app.use(cors());

// 특정 도메인만 허용 (프로덕션)
app.use(cors({
    origin: 'https://yourdomain.com',
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization']
}));

// 동적 origin
app.use(cors({
    origin: (origin, callback) => {
        const allowedOrigins = ['https://yourdomain.com', 'https://admin.yourdomain.com'];
        
        if (!origin || allowedOrigins.includes(origin)) {
            callback(null, true);
        } else {
            callback(new Error('CORS 정책 위반'));
        }
    },
    credentials: true
}));

입력 검증

npm install express-validator
const { body, validationResult } = require('express-validator');

app.post('/auth/register',
    // 검증 규칙
    body('email')
        .isEmail().withMessage('유효한 이메일이 아닙니다')
        .normalizeEmail(),
    body('password')
        .isLength({ min: 8 }).withMessage('비밀번호는 8자 이상이어야 합니다')
        .matches(/[A-Z]/).withMessage('대문자가 포함되어야 합니다')
        .matches(/[a-z]/).withMessage('소문자가 포함되어야 합니다')
        .matches(/[0-9]/).withMessage('숫자가 포함되어야 합니다')
        .matches(/[@$!%*?&#]/).withMessage('특수문자가 포함되어야 합니다'),
    body('name')
        .trim()
        .notEmpty().withMessage('이름이 필요합니다')
        .isLength({ min: 2, max: 50 }).withMessage('이름은 2-50자여야 합니다'),
    
    // 핸들러
    async (req, res) => {
        const errors = validationResult(req);
        
        if (!errors.isEmpty()) {
            return res.status(400).json({ errors: errors.array() });
        }
        
        // 회원가입 로직
        // ...
    }
);

SQL Injection 방지

// ❌ SQL Injection 취약
async function vulnerable(email) {
    const query = `SELECT * FROM users WHERE email = '${email}'`;
    const [rows] = await pool.query(query);
    return rows;
}
// 공격: email = "' OR '1'='1"

// ✅ Prepared Statement 사용
async function safe(email) {
    const [rows] = await pool.query(
        'SELECT * FROM users WHERE email = ?',
        [email]
    );
    return rows;
}

// ✅ ORM 사용
async function safest(email) {
    return await User.findOne({ email });
}

XSS 방지

npm install xss
const xss = require('xss');

app.post('/api/posts', async (req, res) => {
    const { title, content } = req.body;
    
    // XSS 필터링
    const sanitizedTitle = xss(title);
    const sanitizedContent = xss(content);
    
    const post = await Post.create({
        title: sanitizedTitle,
        content: sanitizedContent
    });
    
    res.status(201).json(post);
});

7. 실전 프로젝트: 완전한 인증 시스템

// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');

const userSchema = new mongoose.Schema({
    email: {
        type: String,
        required: true,
        unique: true,
        lowercase: true
    },
    password: {
        type: String,
        required: true,
        minlength: 8
    },
    name: {
        type: String,
        required: true
    },
    role: {
        type: String,
        enum: ['user', 'admin'],
        default: 'user'
    },
    isVerified: {
        type: Boolean,
        default: false
    },
    verificationToken: String,
    resetPasswordToken: String,
    resetPasswordExpires: Date,
    lastLogin: Date
}, {
    timestamps: true
});

// 비밀번호 해싱 (저장 전)
userSchema.pre('save', async function(next) {
    if (!this.isModified('password')) {
        return next();
    }
    
    this.password = await bcrypt.hash(this.password, 10);
    next();
});

// 비밀번호 확인 메서드
userSchema.methods.comparePassword = async function(candidatePassword) {
    return await bcrypt.compare(candidatePassword, this.password);
};

module.exports = mongoose.model('User', userSchema);
// routes/auth.js
const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const User = require('../models/User');
const { sendEmail } = require('../utils/email');

const JWT_SECRET = process.env.JWT_SECRET;

// 회원가입
router.post('/register', async (req, res) => {
    try {
        const { email, password, name } = req.body;
        
        // 중복 확인
        const existingUser = await User.findOne({ email });
        if (existingUser) {
            return res.status(400).json({ error: '이미 존재하는 이메일입니다' });
        }
        
        // 이메일 인증 토큰 생성
        const verificationToken = crypto.randomBytes(32).toString('hex');
        
        // 사용자 생성
        const user = await User.create({
            email,
            password,
            name,
            verificationToken
        });
        
        // 인증 이메일 발송
        const verificationUrl = `${req.protocol}://${req.get('host')}/auth/verify/${verificationToken}`;
        await sendEmail({
            to: email,
            subject: '이메일 인증',
            text: `다음 링크를 클릭하여 이메일을 인증하세요: ${verificationUrl}`
        });
        
        res.status(201).json({
            message: '회원가입 성공. 이메일을 확인하세요.',
            user: {
                id: user._id,
                email: user.email,
                name: user.name
            }
        });
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});

// 이메일 인증
router.get('/verify/:token', async (req, res) => {
    try {
        const user = await User.findOne({ verificationToken: req.params.token });
        
        if (!user) {
            return res.status(400).json({ error: '유효하지 않은 토큰입니다' });
        }
        
        user.isVerified = true;
        user.verificationToken = undefined;
        await user.save();
        
        res.json({ message: '이메일 인증 완료' });
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});

// 비밀번호 재설정 요청
router.post('/forgot-password', async (req, res) => {
    try {
        const { email } = req.body;
        
        const user = await User.findOne({ email });
        if (!user) {
            // 보안상 사용자 존재 여부를 알려주지 않음
            return res.json({ message: '이메일을 확인하세요' });
        }
        
        // 재설정 토큰 생성
        const resetToken = crypto.randomBytes(32).toString('hex');
        user.resetPasswordToken = resetToken;
        user.resetPasswordExpires = Date.now() + 3600000;  // 1시간
        await user.save();
        
        // 이메일 발송
        const resetUrl = `${req.protocol}://${req.get('host')}/auth/reset-password/${resetToken}`;
        await sendEmail({
            to: email,
            subject: '비밀번호 재설정',
            text: `다음 링크를 클릭하여 비밀번호를 재설정하세요: ${resetUrl}`
        });
        
        res.json({ message: '이메일을 확인하세요' });
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});

// 비밀번호 재설정
router.post('/reset-password/:token', async (req, res) => {
    try {
        const { password } = req.body;
        
        const user = await User.findOne({
            resetPasswordToken: req.params.token,
            resetPasswordExpires: { $gt: Date.now() }
        });
        
        if (!user) {
            return res.status(400).json({ error: '유효하지 않거나 만료된 토큰입니다' });
        }
        
        user.password = password;
        user.resetPasswordToken = undefined;
        user.resetPasswordExpires = undefined;
        await user.save();
        
        res.json({ message: '비밀번호가 재설정되었습니다' });
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});

module.exports = router;

8. 자주 발생하는 문제

문제 1: JWT 토큰 무효화

문제: JWT는 서버에서 무효화할 수 없음

해결:

// 블랙리스트 방식
const tokenBlacklist = new Set();

app.post('/auth/logout', authenticate, (req, res) => {
    const token = req.headers.authorization.substring(7);
    tokenBlacklist.add(token);
    
    res.json({ message: '로그아웃 성공' });
});

// 미들웨어에서 확인
function authenticate(req, res, next) {
    const token = req.headers.authorization?.substring(7);
    
    if (tokenBlacklist.has(token)) {
        return res.status(401).json({ error: '무효화된 토큰입니다' });
    }
    
    // 토큰 검증...
}

문제 2: 세션 스토어 메모리 누수

// ❌ 메모리 스토어 (프로덕션 부적합)
app.use(session({
    secret: 'secret',
    resave: false,
    saveUninitialized: false
    // store 설정 없음 → 메모리에 저장
}));

// ✅ 영구 스토어 사용
const MongoStore = require('connect-mongo');

app.use(session({
    secret: 'secret',
    resave: false,
    saveUninitialized: false,
    store: MongoStore.create({
        mongoUrl: 'mongodb://localhost:27017/mydb'
    })
}));

문제 3: 비밀번호 평문 저장

// ❌ 절대 안 됨!
const user = await User.create({
    email: '[email protected]',
    password: 'mypassword123'  // 평문 저장
});

// ✅ 해싱하여 저장
const hashedPassword = await bcrypt.hash('mypassword123', 10);
const user = await User.create({
    email: '[email protected]',
    password: hashedPassword
});

9. 실전 팁

JWT vs 세션 선택

특징JWT세션
저장 위치클라이언트서버
확장성✅ 좋음⭕ 세션 스토어 필요
무효화❌ 어려움✅ 쉬움
크기큼 (모든 요청)작음 (세션 ID만)
사용 사례API, 마이크로서비스웹 앱, 단일 서버

비밀번호 정책

function validatePassword(password) {
    const errors = [];
    
    if (password.length < 8) {
        errors.push('8자 이상이어야 합니다');
    }
    
    if (!/[A-Z]/.test(password)) {
        errors.push('대문자가 포함되어야 합니다');
    }
    
    if (!/[a-z]/.test(password)) {
        errors.push('소문자가 포함되어야 합니다');
    }
    
    if (!/[0-9]/.test(password)) {
        errors.push('숫자가 포함되어야 합니다');
    }
    
    if (!/[@$!%*?&#]/.test(password)) {
        errors.push('특수문자가 포함되어야 합니다');
    }
    
    return errors;
}

// 사용
app.post('/auth/register', async (req, res) => {
    const { password } = req.body;
    
    const errors = validatePassword(password);
    if (errors.length > 0) {
        return res.status(400).json({ errors });
    }
    
    // 회원가입 계속...
});

환경 변수 관리

# .env
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
REFRESH_SECRET=your-refresh-token-secret
SESSION_SECRET=your-session-secret
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
require('dotenv').config();

const config = {
    jwtSecret: process.env.JWT_SECRET,
    refreshSecret: process.env.REFRESH_SECRET,
    sessionSecret: process.env.SESSION_SECRET
};

if (!config.jwtSecret || !config.refreshSecret) {
    throw new Error('JWT_SECRET과 REFRESH_SECRET이 필요합니다');
}

module.exports = config;

정리

핵심 요약

  1. 비밀번호 해싱: bcrypt 사용, 절대 평문 저장 금지
  2. JWT: Stateless, Access Token + Refresh Token
  3. 세션: Stateful, 서버에서 제어 가능
  4. OAuth 2.0: 소셜 로그인, Passport.js
  5. 보안: Helmet, Rate Limiting, CORS, 입력 검증
  6. 에러 처리: 명확한 에러 메시지, 로깅

인증 방식 비교

특징JWT세션OAuth
복잡도중간낮음높음
확장성
무효화
사용 사례API웹 앱소셜 로그인

보안 체크리스트

  • 비밀번호 해싱 (bcrypt, argon2)
  • HTTPS 사용
  • JWT Secret 안전하게 관리
  • Rate Limiting 적용
  • CORS 설정
  • Helmet 사용
  • 입력 검증
  • SQL Injection 방지
  • XSS 방지
  • CSRF 방지 (웹 앱)

다음 단계

  • Node.js 테스트
  • Node.js 배포
  • Node.js 성능 최적화

추천 학습 자료

공식 문서:

패키지:


관련 글

  • C++ REST API 클라이언트 완벽 가이드 | CRUD·인증·에러 처리·프로덕션 패턴 [#21-3]
  • C++ SSL/TLS 보안 통신 | OpenSSL과 Asio 연동 완벽 가이드 [#30-2]
  • C++ 채팅 서버 만들기 | 다중 클라이언트와 메시지 브로드캐스트 완벽 가이드 [#31-1]
  • C++ 보안 코딩 가이드: 오버플로우 방지와 암호화 라이브러리(OpenSSL) 실전 연동 [#43-2]
  • C++ 채팅 서버 완성하기 | 인증·방 관리·메시지 히스토리 구현 [#50-1]