Guider/Backend/BackendDevGuide0004
Backend#04

BackendDevGuide0004

인증과 보안 완전 정복

🔐

BackendDevGuide0004

인증과 보안 완전 정복 A-Z

비전공자도 OK! 백엔드 보안의 모든 것을 처음부터 끝까지

⏱ 예상 학습: 2~3주 🎯 현업 보안 필수 지식 💼 취업 면접 단골 주제

⚠️ 왜 보안을 꼭 배워야 하나요?

₩4.2조
국내 연간 사이버 피해액
43%
중소기업 해킹 피해 비율
277일
평균 해킹 탐지 소요 시간
#1
개인정보 보호는 법적 의무

📚 학습 목차 — 12가지 핵심 주제

01 인증 vs 인가 — 개념 완벽 구분
02 bcrypt — 비밀번호 암호화 완전 정복
03 JWT — 토큰 기반 인증 완전 정복
04 세션 vs JWT — 상세 비교와 선택 기준
05 OAuth2 — 소셜 로그인(Google/Kakao)
06 HTTPS/TLS — 암호화 통신 원리
07 OWASP Top 10 — 10대 보안 취약점
08 Helmet + CORS — 보안 미들웨어
09 Rate Limiting + Joi 입력 검증
10 실전 인증 시스템 — Redis 블랙리스트
11 이메일 인증 + 비밀번호 재설정 + 2FA
12 면접 Q&A + 실무 체크리스트 + 로드맵

🔑 01. 인증(Authentication) vs 인가(Authorization)

가장 기본이지만 면접에서 반드시 나오는 핵심 개념!

🍽️ 음식점 비유로 완벽 이해

🪪 인증 (Authentication) = 신분 확인

음식점 입장 시 신분증을 보여주는 것.

"당신이 누구인지 증명하세요!"
→ 로그인, 지문인식, OTP

🎫 인가 (Authorization) = 권한 확인

입장 후 VIP룸에 들어갈 수 있는지 확인.

"당신이 이걸 할 권리가 있나요?"
→ 관리자 페이지, 본인 글 수정

📊 인증 방식 4가지 비교표

방식 동작 방식 장점 단점 사용처
세션 서버가 세션 저장, 쿠키로 세션ID 전달 즉시 강제 로그아웃 가능 서버 메모리 사용, 수평확장 어려움 전통 웹사이트
JWT 서버가 토큰 발급, 클라이언트가 저장 stateless, 수평확장 쉬움 토큰 탈취 시 만료까지 유효 SPA, 모바일 API
OAuth2 제3자(Google 등)가 인증 위임 비밀번호 관리 불필요 외부 서비스 의존성 소셜 로그인
API Key 요청마다 헤더에 고정 키 포함 구현 단순 탈취 시 영구 위험, 갱신 어려움 내부 서비스 간 통신

🔐 RBAC (역할 기반 접근 제어) — 인가 패턴

RBAC(Role-Based Access Control)는 현업에서 가장 많이 쓰는 권한 제어 방식입니다. 사용자에게 역할(Role)을 부여하고, 역할에 따라 접근 가능한 리소스를 제한합니다.

// middleware/rbac.js — 역할 기반 접근 제어 미들웨어
const authorizeRoles = (...roles) => {
  return (req, res, next) => {
    // req.user는 authMiddleware에서 설정된 사용자 정보
    if (!req.user) {
      return res.status(401).json({ error: '로그인이 필요합니다.' });
    }
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ 
        error: '접근 권한이 없습니다.',
        required: roles,
        current: req.user.role
      });
    }
    next();
  };
};

// 사용 예시
// 관리자만 접근 가능
router.delete('/users/:id', authMiddleware, authorizeRoles('ADMIN'), deleteUser);

// 관리자 또는 매니저만 접근 가능
router.get('/dashboard/stats', authMiddleware, authorizeRoles('ADMIN', 'MANAGER'), getStats);

// 본인 또는 관리자만 수정 가능
router.put('/users/:id', authMiddleware, async (req, res) => {
  const isOwner = req.user.userId === Number(req.params.id);
  const isAdmin = req.user.role === 'ADMIN';
  if (!isOwner && !isAdmin) {
    return res.status(403).json({ error: '본인 또는 관리자만 수정 가능합니다.' });
  }
  // ... 수정 로직
});

🔒 02. bcrypt — 비밀번호 암호화 완전 정복

비밀번호를 평문으로 저장하면 절대 안 됩니다! bcrypt가 표준인 이유를 파헤칩니다.

💡 왜 bcrypt인가? — 해싱 알고리즘 비교

알고리즘 속도 GPU 공격 Salt 내장 비밀번호용 적합도 결론
MD5 매우 빠름 ⚡ 극도로 취약 ❌ 없음 ❌ 절대 사용 금지 사용 금지
SHA-256 빠름 ⚠️ 취약 ❌ 없음 ⚠️ 비밀번호 단독 불가 데이터 무결성용
bcrypt 의도적으로 느림 ✅ 강함 ✅ 내장 ✅ 완벽 ✅ 표준 권장
Argon2 느림 ✅ 매우 강함 ✅ 내장 ✅ 완벽 최신 표준(2025)
PBKDF2 조절 가능 ⚠️ 보통 ✅ 내장 ⚠️ 보통 FIPS 인증 필요시

⏱ saltRounds 값에 따른 해싱 소요 시간

saltRounds가 높을수록 해킹이 어렵지만, 서버 응답도 느려집니다. 현업 권장: 12

saltRounds 해싱 시간 (서버) 해커 초당 시도 횟수 권장 여부
8 ~1ms ~1,000회 ❌ 너무 낮음
10 ~10ms ~100회 ⚠️ 최소 기준
12 ~250ms ~4회 ✅ 현업 권장
14 ~1초 ~1회 ✅ 고보안 서비스
16 ~4초 ~0.25회 ⚠️ 사용자 경험 저하

💻 bcrypt 실전 구현 — 회원가입 + 로그인 완전 코드

// npm install bcryptjs
const bcrypt = require('bcryptjs');

// ===== 1. 비밀번호 해시화 (회원가입) =====
async function hashPassword(plainText) {
  const saltRounds = 12; // 현업 권장값
  const hash = await bcrypt.hash(plainText, saltRounds);
  return hash;
  // 결과: "$2b$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
  // $2b = bcrypt 버전 | $12 = saltRounds | 나머지 = salt(22자) + hash(31자)
}

// ===== 2. 비밀번호 검증 (로그인) =====
async function verifyPassword(plainText, hashedPassword) {
  const isMatch = await bcrypt.compare(plainText, hashedPassword);
  return isMatch; // true or false
}

// ===== 3. 실전 회원가입 API =====
router.post('/register', async (req, res) => {
  try {
    const { name, email, password } = req.body;

    // ① 입력값 검증
    if (!name || !email || !password) {
      return res.status(400).json({ error: '모든 필드를 입력해주세요.' });
    }
    if (password.length < 8) {
      return res.status(400).json({ error: '비밀번호는 8자 이상이어야 합니다.' });
    }

    // ② 이메일 중복 확인
    const existing = await prisma.user.findUnique({ where: { email } });
    if (existing) {
      return res.status(409).json({ error: '이미 사용 중인 이메일입니다.' });
    }

    // ③ 비밀번호 해시화 (절대 평문 저장 금지!)
    const hashedPassword = await bcrypt.hash(password, 12);

    // ④ DB 저장 (password 필드에 hash 저장)
    const user = await prisma.user.create({
      data: { name, email, password: hashedPassword },
      select: { id: true, name: true, email: true } // password 응답에서 제외!
    });

    res.status(201).json({ message: '회원가입 성공', user });
  } catch (err) {
    next(err);
  }
});

// ===== 4. 실전 로그인 API =====
router.post('/login', async (req, res) => {
  const { email, password } = req.body;

  // ⚠️ 보안 팁: "이메일이 없습니다" vs "비밀번호 틀림" 구분하지 말것
  // → 계정 존재 여부 노출 방지를 위해 동일한 에러 메시지 사용
  const user = await prisma.user.findUnique({ where: { email } });
  if (!user) {
    return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' });
  }

  const isMatch = await bcrypt.compare(password, user.password);
  if (!isMatch) {
    return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' });
  }

  // 로그인 성공 → JWT 발급 (다음 섹션에서 자세히)
  const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { expiresIn: '1h' });
  res.json({ token });
});

🎫 03. JWT(JSON Web Token) 완전 정복

현대 REST API의 표준 인증 방식! 구조부터 실전 구현까지 전부 배웁니다.

🔍 JWT 구조 완전 분석

JWT는 점(.)으로 구분된 3개의 Base64Url 인코딩 문자열입니다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJVU0VSIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

🔴 Header (헤더)

알고리즘 + 토큰 타입 정보

{"alg": "HS256",
"typ": "JWT"}

🟢 Payload (페이로드)

실제 데이터 (클레임) — 민감정보 ❌

{"userId": 1,
"role": "USER",
"exp": 1700003600}

🔵 Signature (서명)

변조 방지 서명 (비밀키로 생성)

HMAC-SHA256(
base64(header)+"."+
base64(payload),
SECRET_KEY)

⚠️ 중요: JWT Payload는 Base64로 인코딩되어 있을 뿐, 암호화가 아닙니다! 비밀번호, 카드번호 등 민감 정보를 절대 Payload에 넣지 마세요.

💻 Access Token + Refresh Token 전략 — 실전 구현

// npm install jsonwebtoken
const jwt = require('jsonwebtoken');

// ===== JWT 전략: Access(1시간) + Refresh(7일) =====
// - Access Token: API 인증에 사용, 짧은 수명
// - Refresh Token: Access Token 재발급에만 사용, 긴 수명 (DB 또는 Redis에 저장)

function generateTokens(userId, role) {
  const accessToken = jwt.sign(
    { userId, role },
    process.env.JWT_SECRET,
    { expiresIn: '1h' }   // 1시간 (현업 권장)
  );
  const refreshToken = jwt.sign(
    { userId },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '7d' }  // 7일
  );
  return { accessToken, refreshToken };
}

// ===== 인증 미들웨어 =====
const authMiddleware = (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: '인증 토큰이 없습니다.' });
  }
  const token = authHeader.split(' ')[1];
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded; // { userId, role, iat, exp }
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: '토큰이 만료되었습니다.', code: 'TOKEN_EXPIRED' });
    }
    res.status(401).json({ error: '유효하지 않은 토큰입니다.' });
  }
};

// ===== Refresh Token으로 Access Token 재발급 =====
router.post('/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  if (!refreshToken) return res.status(401).json({ error: 'Refresh Token이 없습니다.' });

  try {
    const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
    
    // DB에서 Refresh Token 유효성 확인 (로그아웃 처리 시 삭제됨)
    const user = await prisma.user.findFirst({
      where: { id: decoded.userId, refreshToken }
    });
    if (!user) return res.status(401).json({ error: '유효하지 않은 Refresh Token' });

    // 새 Access Token 발급
    const newAccessToken = jwt.sign(
      { userId: user.id, role: user.role },
      process.env.JWT_SECRET,
      { expiresIn: '1h' }
    );
    res.json({ accessToken: newAccessToken });
  } catch {
    res.status(401).json({ error: 'Refresh Token이 만료되었습니다. 다시 로그인해주세요.' });
  }
});

⚖️ 04. 세션 vs JWT — 상세 비교와 선택 기준

면접 단골 질문! 각 방식의 특성을 이해하고 상황에 맞게 선택하는 능력이 중요합니다.

비교 항목 🍪 세션 방식 🎫 JWT 방식
상태 관리 Stateful (서버가 상태 기억) Stateless (서버가 기억 안 함)
정보 저장 위치 서버 메모리/Redis 클라이언트 (localStorage/쿠키)
서버 확장(Scale-out) ❌ 어려움 (세션 공유 필요) ✅ 쉬움 (서버 간 공유 불필요)
강제 로그아웃 ✅ 즉시 가능 (세션 삭제) ❌ 어려움 (블랙리스트 필요)
DB 조회 횟수 매 요청마다 DB 조회 ✅ 조회 불필요 (자체 검증)
토큰 크기 ✅ 작음 (세션 ID만) 큼 (헤더에 매번 포함)
CSRF 취약성 ⚠️ 쿠키 사용 시 CSRF 위험 ✅ localStorage 사용 시 안전
주요 사용 예 네이버, 전통 웹사이트 REST API, 모바일, SPA

🍪 세션 방식 선택 시

  • 즉시 로그아웃이 반드시 필요할 때
  • 금융/의료 등 고보안 서비스
  • 서버가 1~2대인 소규모 서비스
  • 사용자 상태를 실시간 추적 필요

🎫 JWT 방식 선택 시

  • 마이크로서비스 아키텍처
  • 모바일 앱 API 서버
  • 서버를 수평 확장해야 할 때
  • 다른 도메인/앱과 인증 공유 필요

🌐 05. OAuth2 소셜 로그인 완전 정복

Google, Kakao, Naver 로그인을 내 서비스에 붙이는 방법! Passport.js로 구현합니다.

🔄 OAuth2 인증 흐름도

👤 사용자
→ "Google로 로그인" 클릭 →
🖥️ 내 서버
→ Google 인증 페이지로 리다이렉트
🔴 Google
← 로그인 후 Authorization Code 전달 ←
👤 사용자
Code를 내 서버로 전달 →
🖥️ 내 서버
→ Code + Client Secret으로 Access Token 요청 →
🔴 Google
← Access Token 발급 ←
🖥️ 내 서버
→ 사용자 정보 요청 →
🔴 Google
← 이름, 이메일, 프로필 사진 반환 ←
✅ 내 서버: DB에서 회원 찾거나 신규 생성 → 내 JWT 발급 → 클라이언트에 반환

💻 Passport.js Google OAuth2 구현

// npm install passport passport-google-oauth20

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

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 {
    // 기존 회원인지 확인 (Google ID로 조회)
    let user = await prisma.user.findFirst({
      where: { googleId: profile.id }
    });
    
    if (!user) {
      // 신규 회원 자동 생성
      user = await prisma.user.create({
        data: {
          googleId: profile.id,
          name: profile.displayName,
          email: profile.emails[0].value,
          profileImage: profile.photos[0]?.value,
          provider: 'google'
        }
      });
    }
    return done(null, user);
  } catch (err) {
    return done(err, null);
  }
}));

// routes/auth.js — OAuth 라우트
// 1. Google 로그인 시작
router.get('/google', passport.authenticate('google', { 
  scope: ['profile', 'email'] 
}));

// 2. Google 콜백 처리
router.get('/google/callback',
  passport.authenticate('google', { session: false }),
  (req, res) => {
    // Google 인증 성공 → 내 JWT 발급
    const token = jwt.sign(
      { userId: req.user.id, role: req.user.role },
      process.env.JWT_SECRET,
      { expiresIn: '1h' }
    );
    // 클라이언트로 리다이렉트 (토큰 전달)
    res.redirect(`http://localhost:3001/auth/callback?token=${token}`);
  }
);

🌐 06. HTTPS/TLS — 암호화 통신의 모든 것

HTTP는 도청당할 수 있습니다! HTTPS가 어떻게 안전한지 원리부터 이해합니다.

비교 HTTP (불안전) HTTPS (안전)
데이터 전송 평문 (누구나 읽을 수 있음) 암호화 (TLS로 보호)
중간자 공격 ❌ 취약 (비밀번호 도청 가능) ✅ 방어됨
서버 신원 확인 ❌ 불가능 (피싱 위험) ✅ SSL 인증서로 확인
포트 80 443

🤝 TLS Handshake 흐름 (간단 버전)

1 Client Hello: "안녕하세요! 저는 TLS 1.3, AES-256 지원해요"
2 Server Hello: "좋아요! AES-256 씁시다. 여기 제 SSL 인증서 (CA 서명됨)"
3 인증서 검증: 브라우저가 CA(인증기관)의 서명 확인 → 진짜 서버인지 검증
4 키 교환: Diffie-Hellman 방식으로 대칭키 생성 (중간에서 가로채도 알 수 없음)
5 암호화 통신 시작: 이제 모든 데이터가 대칭키로 암호화됩니다!

💻 Let's Encrypt 무료 SSL 설치 (Ubuntu 서버)

# Certbot 설치 (무료 SSL 인증서 발급 도구)
sudo apt update
sudo apt install certbot python3-certbot-nginx

# SSL 인증서 발급 및 Nginx 자동 설정
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

# 자동 갱신 확인 (90일마다 자동 갱신됨)
sudo certbot renew --dry-run

# Nginx HTTPS 설정 (/etc/nginx/sites-available/default)
server {
  listen 443 ssl;
  server_name yourdomain.com;
  
  ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
  ssl_protocols TLSv1.2 TLSv1.3;
  ssl_ciphers HIGH:!aNULL:!MD5;
  
  location / {
    proxy_pass http://localhost:3000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
  }
}

# HTTP → HTTPS 자동 리다이렉트
server {
  listen 80;
  server_name yourdomain.com;
  return 301 https://$server_name$request_uri;
}

🛡️ 07. OWASP Top 10 — 10대 보안 취약점 완전 정복

OWASP(Open Web Application Security Project)는 웹 보안의 표준 단체입니다. 10대 취약점을 알아야 안전한 API를 만들 수 있습니다.

순위 취약점명 간단 설명 Node.js 방어법
#1 Broken Access Control 권한 없는 데이터/기능 접근 RBAC 미들웨어, 소유권 확인
#2 Cryptographic Failures 암호화 실패 (평문 비밀번호) bcrypt, HTTPS, 환경변수
#3 Injection (SQL, NoSQL) 악성 쿼리로 DB 조작 ORM(Prisma), Parameterized Query
#4 Insecure Design 설계 단계부터 보안 결함 Threat Modeling, Security Review
#5 Security Misconfiguration 기본값, 불필요한 기능 노출 Helmet, CORS 설정, .env 관리
#6 Vulnerable Components 취약한 라이브러리 사용 npm audit, 정기적 업데이트
#7 Auth Failures 세션/JWT 관리 실패 Rate Limiting, 강력한 비밀번호
#8 Software Integrity Failures CI/CD 파이프라인 취약점 코드 서명, 신뢰된 저장소
#9 Security Logging Failures 보안 이벤트 미기록 Winston 로깅, 모니터링
#10 SSRF 서버측 요청 위조 URL 허용 목록, 내부망 차단

💻 SQL Injection 공격 vs 방어

// ❌ 위험한 코드 — SQL Injection 취약점
app.get('/users', async (req, res) => {
  const { email } = req.query;
  // 해커가 email=' OR '1'='1 입력 시 모든 사용자 조회됨!
  const query = `SELECT * FROM users WHERE email = '${email}'`;
  const [rows] = await db.execute(query); // 💀 절대 이렇게 하지 마세요
});

// ✅ 안전한 코드 방법 1 — Parameterized Query
app.get('/users', async (req, res) => {
  const { email } = req.query;
  // ? 자리표시자 사용 → DB 드라이버가 자동으로 이스케이프
  const [rows] = await db.execute('SELECT * FROM users WHERE email = ?', [email]);
  res.json(rows);
});

// ✅ 안전한 코드 방법 2 — Prisma ORM (자동으로 이스케이프)
app.get('/users', async (req, res) => {
  const { email } = req.query;
  const user = await prisma.user.findFirst({ where: { email } }); // ✅ 안전
  res.json(user);
});

💻 XSS 공격 vs 방어

// XSS (Cross-Site Scripting): 악성 스크립트를 페이지에 삽입
// 해커가 게시글에 <script>document.cookie를 훔쳐가는 코드</script> 입력

// ✅ 방어 1: DOMPurify로 HTML 태그 제거 (프론트엔드)
const DOMPurify = require('dompurify');
const clean = DOMPurify.sanitize(userInput); // 스크립트 태그 제거

// ✅ 방어 2: xss 라이브러리 (백엔드)
// npm install xss
const xss = require('xss');
router.post('/posts', async (req, res) => {
  const safeContent = xss(req.body.content); // XSS 필터링
  await prisma.post.create({ data: { content: safeContent, ... } });
});

// ✅ 방어 3: Helmet CSP 헤더 (Content-Security-Policy)
app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],          // 자신의 도메인만 허용
    scriptSrc: ["'self'"],            // 외부 스크립트 차단
    styleSrc: ["'self'", "'unsafe-inline'"],
    imgSrc: ["'self'", 'data:', 'https:'],
  }
}));

💻 CSRF 공격 vs 방어

// CSRF(Cross-Site Request Forgery): 사용자 모르게 요청 위조
// 피해자가 해커 사이트 방문 시, 피해자의 쿠키로 은행 송금 요청 발송됨

// npm install csurf (세션/쿠키 기반 인증 사용 시)
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });

app.get('/form', csrfProtection, (req, res) => {
  res.json({ csrfToken: req.csrfToken() }); // 폼에 CSRF 토큰 포함
});
app.post('/transfer', csrfProtection, (req, res) => {
  // CSRF 토큰 자동 검증됨
});

// ✅ JWT + Authorization 헤더 방식은 CSRF 자동 방어됨
// (쿠키 대신 헤더로 인증하므로 다른 사이트에서 자동 포함 불가)

// ✅ SameSite 쿠키 설정으로도 방어 가능
res.cookie('session', sessionId, {
  httpOnly: true,       // JS에서 접근 불가 (XSS 방어)
  secure: true,         // HTTPS에서만 전송
  sameSite: 'strict',  // 동일 출처에서만 쿠키 전송 (CSRF 방어)
  maxAge: 60 * 60 * 1000 // 1시간
});

🪖 08. Helmet + CORS — 보안 미들웨어 완전 설정

Express 앱에 필수 보안 헤더를 자동으로 추가하고, CORS를 안전하게 설정하는 방법입니다.

Helmet이 설정하는 헤더 역할 기본값 예시
Content-Security-Policy 허용된 리소스 출처 제한 (XSS 방어) default-src 'self'
X-Content-Type-Options MIME 타입 스니핑 방지 nosniff
X-Frame-Options iframe 삽입 차단 (클릭재킹 방어) SAMEORIGIN
Strict-Transport-Security HTTPS 강제 (HSTS) max-age=15552000
X-Powered-By 제거 서버 기술 스택 숨김 "Express" 헤더 제거

💻 Helmet + CORS 실전 설정

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

// ===== CORS 화이트리스트 설정 =====
const allowedOrigins = [
  'http://localhost:3001',       // 개발 환경
  'https://myapp.com',           // 프로덕션
  'https://www.myapp.com'        // www 서브도메인
];

app.use(cors({
  origin: (origin, callback) => {
    // origin이 없는 경우 (Postman, 서버간 요청) 허용
    if (!origin) return callback(null, true);
    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('CORS 정책에 의해 차단되었습니다.'));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,  // 쿠키/인증정보 포함 요청 허용
  maxAge: 86400        // Preflight 결과 캐시: 24시간
}));

// ===== Helmet 설정 =====
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      styleSrc: ["'self'", 'https://fonts.googleapis.com'],
      fontSrc: ["'self'", 'https://fonts.gstatic.com'],
      imgSrc: ["'self'", 'data:', 'https:'],
      scriptSrc: ["'self'"],
    }
  },
  hsts: {
    maxAge: 31536000,  // 1년간 HTTPS 강제
    includeSubDomains: true,
    preload: true
  }
}));

🚦 09. Rate Limiting + Joi 입력 검증

무제한 요청을 막고, 잘못된 입력을 사전에 차단하는 두 가지 필수 방어 기법입니다.

💻 Rate Limiting — 엔드포인트별 제한 설정

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

// ① 일반 API: 15분에 100회
const generalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,  // RateLimit 헤더 포함
  legacyHeaders: false,
  message: { error: '요청이 너무 많습니다. 15분 후 다시 시도해주세요.' }
});

// ② 로그인 API: 1분에 5회 (Brute Force 방지)
const loginLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 5,
  message: { error: '너무 많은 로그인 시도. 1분 후 다시 시도해주세요.' },
  skipSuccessfulRequests: true  // 성공한 요청은 카운트 제외
});

// ③ 비밀번호 재설정: 1시간에 3회
const passwordResetLimiter = rateLimit({
  windowMs: 60 * 60 * 1000,
  max: 3,
  message: { error: '비밀번호 재설정은 1시간에 3회만 가능합니다.' }
});

// 적용
app.use('/api', generalLimiter);
app.use('/api/auth/login', loginLimiter);
app.use('/api/auth/reset-password', passwordResetLimiter);

💻 Joi 입력 검증 — 스키마 기반 유효성 검사

// npm install joi
const Joi = require('joi');

// ===== 검증 스키마 정의 =====
const schemas = {
  register: Joi.object({
    name: Joi.string().min(2).max(50).required()
      .messages({ 'string.min': '이름은 2자 이상이어야 합니다.' }),
    email: Joi.string().email().required()
      .messages({ 'string.email': '올바른 이메일 형식이 아닙니다.' }),
    password: Joi.string()
      .min(8).max(128)
      .pattern(new RegExp('^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])'))
      .required()
      .messages({ 
        'string.pattern.base': '비밀번호는 대소문자, 숫자, 특수문자를 포함해야 합니다.'
      })
  }),
  login: Joi.object({
    email: Joi.string().email().required(),
    password: Joi.string().required()
  })
};

// ===== 검증 미들웨어 팩토리 =====
const validate = (schema) => (req, res, next) => {
  const { error } = schema.validate(req.body, { abortEarly: false });
  if (error) {
    const details = error.details.map(d => d.message);
    return res.status(400).json({ errors: details });
  }
  next();
};

// 사용 예시
router.post('/register', validate(schemas.register), registerHandler);
router.post('/login', validate(schemas.login), loginHandler);

⚡ 10. 실전 인증 시스템 — Redis 블랙리스트 + 안전한 로그아웃

JWT의 치명적 단점인 "로그아웃 후에도 토큰이 유효한 문제"를 Redis로 해결합니다.

🤔 왜 문제가 되나요?

❌ 일반 JWT 로그아웃 문제

로그아웃 버튼 클릭 → 클라이언트에서 토큰 삭제 → 끝!

하지만 해커가 토큰을 미리 복사해뒀다면? 만료까지 계속 사용 가능!

✅ Redis 블랙리스트 해결책

로그아웃 시 → 서버가 Redis에 해당 토큰 저장 → 이후 요청 시 블랙리스트 확인 → 거부!

💻 Redis 블랙리스트 구현

// npm install ioredis
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

// ===== 로그아웃 — 토큰 블랙리스트 등록 =====
router.post('/logout', authMiddleware, async (req, res) => {
  const token = req.headers.authorization.split(' ')[1];
  const decoded = jwt.decode(token);
  
  // 토큰 만료까지 남은 시간(초) 계산
  const ttl = decoded.exp - Math.floor(Date.now() / 1000);
  
  // Redis에 블랙리스트 등록 (TTL: 남은 만료 시간)
  await redis.setex(`blacklist:${token}`, ttl, 'logout');
  
  res.json({ message: '로그아웃 되었습니다.' });
});

// ===== 강화된 인증 미들웨어 (블랙리스트 확인) =====
const authMiddleware = async (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: '토큰이 없습니다.' });
  }
  
  const token = authHeader.split(' ')[1];
  
  // ① 블랙리스트 확인 (로그아웃된 토큰 거부)
  const isBlacklisted = await redis.get(`blacklist:${token}`);
  if (isBlacklisted) {
    return res.status(401).json({ error: '로그아웃된 토큰입니다.' });
  }
  
  // ② 서명 검증
  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch (err) {
    res.status(401).json({ error: '유효하지 않은 토큰입니다.' });
  }
};

📧 11. 이메일 인증 + 비밀번호 재설정 + 2FA

실제 서비스에 반드시 필요한 고급 인증 기능들! 단계별로 구현합니다.

💻 이메일 인증 구현 (Nodemailer + Redis)

// npm install nodemailer
const nodemailer = require('nodemailer');

// ===== 이메일 전송 설정 =====
const transporter = nodemailer.createTransport({
  service: 'gmail',
  auth: {
    user: process.env.EMAIL_USER,
    pass: process.env.EMAIL_APP_PASSWORD  // Gmail 앱 비밀번호
  }
});

// ===== 회원가입 시 이메일 인증 요청 =====
router.post('/register', async (req, res) => {
  // ... 기본 회원가입 처리 후 ...
  
  // 6자리 인증 코드 생성
  const verifyCode = Math.random().toString().slice(2, 8); // "384920"
  
  // Redis에 10분간 저장
  await redis.setex(`email_verify:${email}`, 600, verifyCode);
  
  // 이메일 발송
  await transporter.sendMail({
    from: '"MyApp" <noreply@myapp.com>',
    to: email,
    subject: '[MyApp] 이메일 인증 코드',
    html: `
      <div style="background:#f3f4f6;padding:40px;">
        <h2>이메일 인증</h2>
        <p>인증 코드: <strong style="font-size:2em;color:#6366f1;">${verifyCode}</strong></p>
        <p>10분 내에 입력해주세요.</p>
      </div>
    `
  });
  
  res.status(201).json({ message: '이메일로 인증 코드를 전송했습니다.' });
});

// ===== 인증 코드 검증 =====
router.post('/verify-email', async (req, res) => {
  const { email, code } = req.body;
  const savedCode = await redis.get(`email_verify:${email}`);
  
  if (!savedCode || savedCode !== code) {
    return res.status(400).json({ error: '인증 코드가 올바르지 않거나 만료되었습니다.' });
  }
  
  // 인증 완료 처리
  await prisma.user.update({ 
    where: { email }, 
    data: { emailVerified: true } 
  });
  await redis.del(`email_verify:${email}`); // 사용한 코드 삭제
  
  res.json({ message: '이메일 인증이 완료되었습니다!' });
});

💻 2FA (이중 인증) — TOTP 방식 (Google Authenticator)

// npm install speakeasy qrcode
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');

// ① 2FA 설정 시작 — QR코드 생성
router.post('/2fa/setup', authMiddleware, async (req, res) => {
  // 시크릿 키 생성
  const secret = speakeasy.generateSecret({ 
    name: `MyApp (${req.user.email})` 
  });
  
  // DB에 임시 저장 (검증 전)
  await prisma.user.update({
    where: { id: req.user.userId },
    data: { twoFactorSecret: secret.base32 }
  });
  
  // QR 코드 이미지 생성 (Google Authenticator로 스캔)
  const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
  
  res.json({ secret: secret.base32, qrCode: qrCodeUrl });
});

// ② 2FA 인증 코드 검증
router.post('/2fa/verify', authMiddleware, async (req, res) => {
  const { token } = req.body; // Google Authenticator에서 생성된 6자리 코드
  const user = await prisma.user.findUnique({ where: { id: req.user.userId } });
  
  const verified = speakeasy.totp.verify({
    secret: user.twoFactorSecret,
    encoding: 'base32',
    token,
    window: 2  // ±2 타임스텝 허용 (시계 오차 보정)
  });
  
  if (!verified) {
    return res.status(400).json({ error: '인증 코드가 올바르지 않습니다.' });
  }
  
  res.json({ message: '2FA 인증 성공!' });
});

🎤 12. 면접 Q&A + 실무 체크리스트 + 로드맵

취업 면접에서 자주 나오는 질문들과, 현업에서 바로 쓸 수 있는 체크리스트입니다.

Q1. 인증(Authentication)과 인가(Authorization)의 차이는?

인증은 "누구인지 확인"하는 것(신분증 검사), 인가는 "무엇을 할 수 있는지 확인"하는 것(VIP 입장권)입니다. 인증이 먼저 이루어진 후, 인가가 결정됩니다. 예를 들어 로그인(인증) 후 관리자 페이지 접근 권한이 있는지 확인(인가)하는 것입니다.

Q2. JWT의 장단점은? 세션과 비교해서 설명해주세요.

JWT 장점: Stateless라서 서버 확장이 쉽고, DB 조회 없이 자체 검증이 가능합니다. 마이크로서비스/모바일 API에 적합합니다. JWT 단점: 발급된 토큰 즉시 무효화가 어렵습니다(Redis 블랙리스트로 해결). 세션은 서버에 상태를 저장해 즉시 무효화가 가능하지만, 수평확장 시 세션 공유가 필요합니다.

Q3. 비밀번호를 DB에 어떻게 저장해야 하나요?

절대 평문으로 저장하면 안 되고, bcrypt 등 단방향 해시 함수로 해시화하여 저장합니다. bcrypt는 내부적으로 salt를 생성해 같은 비밀번호도 다른 해시값이 나와 레인보우 테이블 공격을 방어합니다. saltRounds는 현업에서 12를 권장합니다.

Q4. SQL Injection이란? 어떻게 방어하나요?

SQL Injection은 사용자 입력값에 SQL 쿼리를 삽입해 DB를 조작하는 공격입니다. 예를 들어 이메일 입력란에 "' OR '1'='1"을 입력해 모든 사용자 정보를 빼낼 수 있습니다. 방어 방법은 ① Parameterized Query(준비된 쿼리), ② ORM(Prisma) 사용, ③ 입력값 길이/형식 검증입니다.

Q5. CORS란 무엇인가요? 왜 필요한가요?

CORS(Cross-Origin Resource Sharing)는 다른 출처(도메인/포트)에서 온 요청을 허용할지 제어하는 브라우저 보안 정책입니다. 예를 들어 프론트(localhost:3001)에서 API 서버(localhost:3000)에 요청 시 CORS 설정이 없으면 브라우저가 차단합니다. 서버에서 허용할 출처를 명시적으로 설정해야 합니다.

Q6. Access Token과 Refresh Token을 나누는 이유는?

보안과 편의성의 균형을 위해서입니다. Access Token은 수명을 짧게(1시간) 유지해 탈취 피해를 최소화하고, Refresh Token은 수명을 길게(7일~30일) 유지해 사용자가 자주 로그인하지 않아도 됩니다. Refresh Token은 DB에 저장해 로그아웃 시 삭제함으로써 세션 관리가 가능합니다.

📋 현업 보안 체크리스트

✅ 기초 (반드시)

  • 비밀번호 bcrypt 해시화 (saltRounds≥12)
  • HTTPS 강제 (HTTP 리다이렉트)
  • 환경변수(.env)로 시크릿 관리
  • .gitignore에 .env 추가
  • Helmet 미들웨어 적용
  • CORS 화이트리스트 설정
  • SQL Injection 방지 (ORM 사용)

🚀 심화 (현업 수준)

  • Rate Limiting (로그인/API 분리)
  • Joi/Zod 입력 검증
  • XSS 필터링 (xss 라이브러리)
  • JWT 블랙리스트 (Redis)
  • 이메일 인증 구현
  • npm audit 정기 실행
  • 보안 로그 기록 (Winston)

🏆 고급 (대규모 서비스)

  • 2FA (TOTP/SMS)
  • 소셜 로그인 (OAuth2)
  • WAF (Web Application Firewall)
  • SIEM (보안 이벤트 모니터링)
  • Penetration Testing 정기 실시
  • OWASP ZAP 자동 스캔
  • 보안 코드 리뷰 프로세스

🗺️ 인증/보안 학습 로드맵 (6단계)

단계 학습 내용 기간 결과물
1단계 bcrypt + JWT 기본 로그인/로그아웃 1주 회원가입/로그인 API
2단계 RBAC 권한 제어 + Helmet/CORS 설정 3~4일 관리자 페이지 접근 제어
3단계 Rate Limiting + Joi 입력 검증 2~3일 Brute Force 방어 API
4단계 Redis 블랙리스트 + 이메일 인증 1주 완전한 인증 시스템
5단계 OAuth2 소셜 로그인 + 2FA 1주 Google/Kakao 로그인
6단계 OWASP 취약점 점검 + 보안 테스트 1주 보안 감사 완료 프로젝트

🎉 BackendDevGuide0004 완료!

인증과 보안의 모든 핵심을 마스터했습니다. 이제 bcrypt, JWT, OAuth2, HTTPS, OWASP, Rate Limiting까지 현업에서 바로 사용할 수 있습니다!

다음: BackendDevGuide0005 — TypeScript + NestJS 완전 정복

반응형