JWT로 Node.js 토큰 기반 로그인 구현하기
Node.js에서 JWT를 이용한 토큰 기반 로그인 흐름과 구현 핵심을 단계별로 정리한 설명. Express와 jsonwebtoken, bcrypt 활용법과 보안 고려사항을 포괄한 설명
목차
소개
토큰 기반 인증은 서버가 세션을 직접 보관하지 않고 인증 정보를 클라이언트에 위임하는 방식이다. JWT(JSON Web Token)는 서명된 토큰으로 안전하게 사용자 신원을 전달할 수 있어서 RESTful API에서 널리 쓰인다. 이 글은 JWT 개념부터 Express 기반 구현, 인증 미들웨어와 보안 고려사항까지 처음 접하는 사람도 따라할 수 있도록 단계별로 설명한다.
JWT 개요
JWT 구성
JWT는 세 부분으로 구성된다: 헤더(header), 페이로드(payload), 서명(signature). 헤더와 페이로드는 Base64Url로 인코딩되고, 비밀키로 서명해 위변조를 방지한다. 페이로드에는 사용자 식별자와 만료시간(exp) 같은 클레임(claim)이 들어간다.
장단점
- 장점: 무상태(stateless)로 확장성이 좋고, 다양한 클라이언트와 쉽게 호환된다.
- 단점: 토큰 유출 시 재발급 전까지 접근 제어가 어렵고, 토큰 크기 때문에 네트워크 비용이 존재한다.
환경 준비
다음 패키지를 사용한다: Express, jsonwebtoken, bcrypt(또는 bcryptjs), dotenv. 예시 프로젝트 초기화와 설치는 다음과 같다.
npm init -y
npm install express jsonwebtoken bcrypt dotenv body-parser
간단한 구현 흐름
- 회원가입: 비밀번호를 bcrypt로 해싱해 저장
- 로그인: 아이디/비밀번호 검증 후 JWT 발급
- 인증된 요청: Authorization 헤더의 Bearer 토큰을 검사하는 미들웨어 적용
서버 코드 예제
아래 예제는 메모리 사용자 저장 방식으로 핵심 로직만 보여준다. 실제 사용 시 데이터베이스와 환경변수 관리가 필요하다.
const express = require('express')
const jwt = require('jsonwebtoken')
const bcrypt = require('bcrypt')
const bodyParser = require('body-parser')
require('dotenv').config()
const app = express()
app.use(bodyParser.json())
const USERS = [] // 예시용 메모리 저장소
const JWT_SECRET = process.env.JWT_SECRET || 'change_this_secret'
app.post('/register', async (req, res) => {
const { username, password } = req.body
if (!username || !password) return res.status(400).json({ error: 'invalid' })
const hashed = await bcrypt.hash(password, 10)
USERS.push({ username, password: hashed })
res.status(201).json({ message: 'registered' })
})
app.post('/login', async (req, res) => {
const { username, password } = req.body
const user = USERS.find(u => u.username === username)
if (!user) return res.status(401).json({ error: 'invalid' })
const match = await bcrypt.compare(password, user.password)
if (!match) return res.status(401).json({ error: 'invalid' })
const token = jwt.sign({ sub: username }, JWT_SECRET, { expiresIn: '1h' })
res.json({ token })
})
// 인증 미들웨어 예시
function authenticate(req, res, next) {
const header = req.headers['authorization']
if (!header) return res.status(401).json({ error: 'no_token' })
const parts = header.split(' ')
if (parts.length !== 2 || parts[0] !== 'Bearer') return res.status(401).json({ error: 'invalid_token' })
const token = parts[1]
try {
const payload = jwt.verify(token, JWT_SECRET)
req.user = payload
next()
} catch (err) {
return res.status(401).json({ error: 'invalid_or_expired' })
}
}
app.get('/protected', authenticate, (req, res) => {
res.json({ data: 'protected data', user: req.user.sub })
})
app.listen(3000, () => console.log('Server running on 3000'))
보안 고려사항
- 비밀키 관리는 환경변수로 하고, 로테이션 전략을 마련한다.
- 토큰 긴 수명을 피하고 짧은 만료시간과 리프레시 토큰 조합을 사용한다.
- HTTPS 적용으로 전송 중 탈취 위험을 줄인다.
- 중요한 권한 변경 시 서버에서의 토큰 무효화 전략(블랙리스트 등)을 고려한다.
토큰 갱신과 로그아웃
토큰 갱신은 보통 액세스 토큰(짧은 만료)과 리프레시 토큰(서버 저장 또는 안전히 관리)을 함께 사용한다. 로그아웃은 클라이언트에서 토큰 삭제를 수행하고, 서버가 토큰을 강제 무효화해야 하는 경우 블랙리스트를 사용한다.
테스트와 디버깅 팁
- Postman이나 curl로 Authorization 헤더를 확인한다.
- jwt.io 같은 사이트로 토큰 페이로드와 만료시간을 확인한다.
- 서명 비밀이 변경되면 기존 토큰이 모두 무효화되는 점을 염두에 둔다.
마무리
JWT는 확장성과 클라이언트 분리 환경에서 유용한 인증 수단이다. 본문 예제는 핵심 흐름을 이해하기 위한 최소 구현에 초점을 맞췄다. 실서비스 적용 시에는 데이터베이스 연동, HTTPS, 비밀키 관리, 토큰 갱신 전략을 추가로 설계해야 한다.