본문 바로가기
개발 팁

JWT 인증 구현 — Node.js, Python, Go 비교

by 루까(Luka) 2026. 2. 25.
반응형

JWT(JSON Web Token)는 서버 세션 없이 인증 상태를 유지하는 방법이다. 구조와 흐름은 언어에 상관없이 동일하고, 구현 방식만 조금씩 다르다. Node.js를 메인으로, Python(FastAPI)과 Go도 함께 정리한다.


JWT 구조

JWT는 .으로 구분된 세 부분으로 이루어진다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9   ← Header  (알고리즘, 토큰 타입)
.eyJ1c2VySWQiOiIxMjMiLCJpYXQiOjE2OTk...  ← Payload (실제 데이터)
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV...   ← Signature (변조 검증)

Header와 Payload는 Base64로 인코딩되어 있어 누구나 디코딩할 수 있다. 민감한 데이터(비밀번호, 카드번호 등)는 절대 Payload에 넣으면 안 된다. Signature는 서버의 secret key로 서명되어 있어서 변조 여부를 검증할 수 있다.


전체 흐름

1. 클라이언트 → 서버: 아이디 + 비밀번호 전송
2. 서버: 비밀번호 검증 → 일치하면 JWT 발급
3. 서버 → 클라이언트: JWT 반환
4. 클라이언트: JWT를 localStorage 또는 메모리에 저장
5. 이후 요청: Authorization: Bearer <token> 헤더에 첨부
6. 서버: JWT 검증 → 유효하면 요청 처리

Node.js (Express)

패키지 설치

npm install jsonwebtoken bcryptjs

토큰 발급 — 로그인

// routes/auth.js
import jwt from 'jsonwebtoken'
import bcrypt from 'bcryptjs'
import User from '../models/User.js'

export async function login(req, res) {
  const { email, password } = req.body

  // 1. 유저 조회
  const user = await User.findOne({ email })
  if (!user) {
    return res.status(401).json({ message: '이메일 또는 비밀번호가 틀렸습니다' })
  }

  // 2. 비밀번호 검증
  const isMatch = await bcrypt.compare(password, user.password)
  if (!isMatch) {
    return res.status(401).json({ message: '이메일 또는 비밀번호가 틀렸습니다' })
  }

  // 3. JWT 발급
  const token = jwt.sign(
    { userId: user._id, email: user.email },  // Payload
    process.env.JWT_SECRET,                    // Secret key
    { expiresIn: '7d' }                        // 만료 시간
  )

  res.json({ token })
}

회원가입 시 비밀번호 해싱

import bcrypt from 'bcryptjs'

export async function register(req, res) {
  const { email, password } = req.body

  // 비밀번호 해싱 (salt rounds: 10)
  const hashedPassword = await bcrypt.hash(password, 10)

  const user = await User.create({
    email,
    password: hashedPassword,  // 원본 비밀번호는 절대 저장하지 않는다
  })

  res.status(201).json({ message: '회원가입 완료' })
}

인증 미들웨어

// middleware/auth.js
import jwt from 'jsonwebtoken'

export function authenticate(req, res, next) {
  const authHeader = req.headers.authorization

  // "Bearer " 형식 확인
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ message: '토큰이 없습니다' })
  }

  const token = authHeader.split(' ')[1]

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET)
    req.user = decoded  // 이후 핸들러에서 req.user.userId로 접근
    next()
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ message: '토큰이 만료됐습니다' })
    }
    return res.status(401).json({ message: '유효하지 않은 토큰입니다' })
  }
}

라우트에 미들웨어 적용

// routes/plans.js
import { authenticate } from '../middleware/auth.js'

router.get('/plans', authenticate, async (req, res) => {
  // req.user.userId 사용 가능
  const plans = await Plan.find({ userId: req.user.userId })
  res.json(plans)
})

Python (FastAPI)

패키지 설치

pip install python-jose[cryptography] passlib[bcrypt] fastapi

설정 및 유틸

# auth/utils.py
from datetime import datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext

SECRET_KEY = "your-secret-key"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7  # 7일

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def verify_password(plain_password: str, hashed_password: str) -> bool:
    return pwd_context.verify(plain_password, hashed_password)

def get_password_hash(password: str) -> str:
    return pwd_context.hash(password)

def create_access_token(data: dict) -> str:
    to_encode = data.copy()
    expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    to_encode["exp"] = expire
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

def decode_token(token: str) -> dict:
    return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])

토큰 발급 — 로그인 엔드포인트

# routes/auth.py
from fastapi import APIRouter, HTTPException, Depends
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm

router = APIRouter()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")

@router.post("/auth/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
    user = await get_user_by_email(form_data.username)
    if not user or not verify_password(form_data.password, user.hashed_password):
        raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 틀렸습니다")

    token = create_access_token({"sub": str(user.id), "email": user.email})
    return {"access_token": token, "token_type": "bearer"}

인증 의존성 (미들웨어 대신 Depends 사용)

# auth/dependencies.py
from fastapi import Depends, HTTPException
from jose import JWTError

async def get_current_user(token: str = Depends(oauth2_scheme)):
    try:
        payload = decode_token(token)
        user_id = payload.get("sub")
        if not user_id:
            raise HTTPException(status_code=401, detail="유효하지 않은 토큰")
    except JWTError:
        raise HTTPException(status_code=401, detail="토큰 검증 실패")

    user = await get_user_by_id(user_id)
    if not user:
        raise HTTPException(status_code=401, detail="유저를 찾을 수 없습니다")
    return user

# 라우트에서 사용
@router.get("/plans")
async def get_plans(current_user = Depends(get_current_user)):
    return await Plan.find({"user_id": current_user.id})

Go (net/http + golang-jwt)

패키지 설치

go get github.com/golang-jwt/jwt/v5
go get golang.org/x/crypto/bcrypt

토큰 발급

// auth/jwt.go
package auth

import (
    "time"
    "github.com/golang-jwt/jwt/v5"
)

var secretKey = []byte("your-secret-key")

type Claims struct {
    UserID string `json:"userId"`
    Email  string `json:"email"`
    jwt.RegisteredClaims
}

func GenerateToken(userID, email string) (string, error) {
    claims := &Claims{
        UserID: userID,
        Email:  email,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
        },
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(secretKey)
}

func ValidateToken(tokenStr string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
        return secretKey, nil
    })
    if err != nil {
        return nil, err
    }

    claims, ok := token.Claims.(*Claims)
    if !ok || !token.Valid {
        return nil, fmt.Errorf("invalid token")
    }
    return claims, nil
}

인증 미들웨어

// middleware/auth.go
package middleware

import (
    "net/http"
    "strings"
    "myapp/auth"
)

func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        authHeader := r.Header.Get("Authorization")
        if !strings.HasPrefix(authHeader, "Bearer ") {
            http.Error(w, "토큰이 없습니다", http.StatusUnauthorized)
            return
        }

        tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
        claims, err := auth.ValidateToken(tokenStr)
        if err != nil {
            http.Error(w, "유효하지 않은 토큰", http.StatusUnauthorized)
            return
        }

        // Context에 유저 정보 저장
        ctx := context.WithValue(r.Context(), "userId", claims.UserID)
        next(w, r.WithContext(ctx))
    }
}

언어별 비교

항목 Node.js (Express) Python (FastAPI) Go
JWT 라이브러리 jsonwebtoken python-jose golang-jwt
bcrypt 라이브러리 bcryptjs passlib[bcrypt] golang.org/x/crypto/bcrypt
미들웨어 방식 middleware function Depends() 의존성 주입 HandlerFunc 래핑
에러 처리 try/catch + 상태코드 HTTPException error 반환값
토큰 파싱 jwt.verify() jwt.decode() jwt.ParseWithClaims()

세 언어 모두 흐름은 동일하다. 발급 → 헤더 첨부 → 검증 → 페이로드 추출. 라이브러리 이름과 에러 처리 방식만 다르다.


프론트엔드 (React) — 토큰 저장 및 첨부

로그인 후 토큰 저장

async function login(email, password) {
  const res = await axios.post('/api/auth/login', { email, password })
  localStorage.setItem('token', res.data.token)
}

Axios 인터셉터로 자동 첨부

// api/axios.js
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('token')
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

// 401 응답 시 자동 로그아웃
api.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      localStorage.removeItem('token')
      window.location.href = '/login'
    }
    return Promise.reject(error)
  }
)

인터셉터를 한 번만 설정해두면 모든 API 요청에 토큰이 자동으로 붙는다. 각 컴포넌트에서 토큰을 신경 쓸 필요가 없다.


보안 주의사항

localStorage vs HttpOnly Cookie

localStorage는 구현이 간단하지만 XSS 공격에 취약하다. 공격자가 스크립트를 심으면 토큰을 탈취할 수 있다. 보안이 중요한 서비스라면 HttpOnly Cookie를 쓰는 게 좋다. HttpOnly Cookie는 JavaScript에서 접근 자체가 불가능하다.

// 서버에서 HttpOnly Cookie로 토큰 전달
res.cookie('token', token, {
  httpOnly: true,   // JS 접근 불가
  secure: true,     // HTTPS에서만 전송
  sameSite: 'strict',
  maxAge: 7 * 24 * 60 * 60 * 1000  // 7일
})

토큰 만료 시간

용도 권장 만료 시간
일반 웹앱 1일 ~ 7일
금융/보안 민감 15분 ~ 1시간
모바일 앱 30일 (Refresh Token 병행)

짧게 설정할수록 보안은 높아지지만 사용자가 자주 재로그인해야 한다. Refresh Token을 함께 쓰면 보안과 편의성을 둘 다 잡을 수 있다.

절대 하지 말 것

// ❌ Payload에 민감 정보 넣기 — 누구나 디코딩 가능
jwt.sign({ password: '1234', cardNumber: '1234-...' }, secret)

// ❌ secret key를 코드에 하드코딩
const token = jwt.sign(payload, 'my-secret-key')

// ✅ 환경 변수로 관리
const token = jwt.sign(payload, process.env.JWT_SECRET)

정리

단계 Node.js Python Go
비밀번호 해싱 bcrypt.hash() pwd_context.hash() bcrypt.GenerateFromPassword()
토큰 발급 jwt.sign() jwt.encode() token.SignedString()
토큰 검증 jwt.verify() jwt.decode() jwt.ParseWithClaims()
미들웨어 (req, res, next) Depends() HandlerFunc 래핑

구조를 한 번 이해하면 언어가 바뀌어도 라이브러리 이름만 달라질 뿐 흐름은 그대로다.

반응형