이미지 로딩 중...
AI Generated
2025. 11. 19. · 2 Views
JWT 인증 시스템 완벽 구현 가이드
JWT를 활용한 안전한 인증 시스템을 처음부터 끝까지 구현하는 방법을 배웁니다. 토큰 생성부터 검증, 회원가입/로그인 API, Refresh Token 전략, 그리고 비밀번호 보안까지 실무에 바로 적용 가능한 완전한 인증 시스템을 만들어봅니다.
목차
1. JWT 토큰 생성 로직
시작하며
여러분이 웹 애플리케이션을 만들 때 "이 사용자가 정말 로그인한 사람이 맞나?"를 어떻게 확인하시나요? 옛날에는 서버에 세션을 저장해서 매번 확인했지만, 요즘은 더 똑똑한 방법이 있습니다.
마치 놀이공원 입장권처럼, 한 번 발급받으면 그 티켓만 보여주면 되는 시스템이 있다면 어떨까요? 서버는 매번 "이 사람 회원 맞아?"라고 데이터베이스를 뒤질 필요 없이, 티켓만 확인하면 됩니다.
이런 마법 같은 티켓이 바로 JWT(JSON Web Token)입니다. 사용자 정보를 암호화해서 토큰으로 만들고, 이 토큰만 있으면 누구인지 바로 알 수 있죠.
개요
간단히 말해서, JWT는 사용자 정보를 담은 암호화된 문자열입니다. 마치 신분증처럼 "나는 누구이고, 언제까지 유효해"라는 정보가 들어있습니다.
왜 이게 필요할까요? 서버가 매번 데이터베이스를 조회하지 않아도 되니까 훨씬 빠릅니다.
사용자가 1000명이든 10만 명이든, 토큰만 확인하면 되니까 서버 부담이 확 줄어들죠. 또한 모바일 앱이나 여러 서비스에서 같은 토큰을 사용할 수 있어서 확장성도 뛰어납니다.
기존 세션 방식에서는 서버 메모리에 모든 사용자 정보를 저장해야 했습니다. 하지만 JWT를 사용하면 토큰 자체에 정보가 들어있어서 서버는 가볍게 유지할 수 있습니다.
JWT의 핵심 특징은 세 가지입니다. 첫째, stateless(상태 비저장)라서 서버가 세션을 관리할 필요가 없습니다.
둘째, 자체 포함(self-contained)이라 토큰 안에 필요한 모든 정보가 들어있습니다. 셋째, 암호화된 서명으로 위조를 방지합니다.
이 특징들 덕분에 현대적인 API 인증의 표준이 되었습니다.
코드 예제
const jwt = require('jsonwebtoken');
// JWT 토큰 생성 함수
function generateToken(userId, email) {
// payload: 토큰에 담을 사용자 정보
const payload = {
userId: userId,
email: email,
role: 'user' // 사용자 권한 정보
};
// 비밀키로 서명하여 토큰 생성 (15분 유효)
const accessToken = jwt.sign(
payload,
process.env.JWT_SECRET, // 환경변수에 저장된 비밀키
{ expiresIn: '15m' } // 토큰 만료 시간
);
// Refresh Token은 더 긴 유효기간 (7일)
const refreshToken = jwt.sign(
{ userId: userId },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
return { accessToken, refreshToken };
}
설명
이 코드가 하는 일을 차근차근 살펴볼게요. JWT 토큰을 만들어서 사용자에게 발급하는 과정입니다.
첫 번째로, payload 객체를 만듭니다. 이건 토큰 안에 담을 정보인데요, 마치 신분증에 적힌 이름, 생년월일처럼 사용자를 식별할 수 있는 정보를 넣습니다.
userId와 email, 그리고 role(역할)을 넣었어요. 여기서 중요한 건, 비밀번호 같은 민감한 정보는 절대 넣으면 안 된다는 점입니다!
JWT는 암호화가 아니라 인코딩이기 때문에 누구나 디코딩해서 볼 수 있거든요. 그 다음으로, jwt.sign() 함수가 실제 마법을 부립니다.
이 함수는 세 가지를 받아요. 첫째 payload(담을 정보), 둘째 비밀키(JWT_SECRET), 셋째 옵션(만료 시간 등).
비밀키는 마치 도장처럼 작동합니다. 이 도장으로 찍은 토큰만 진짜로 인정하는 거죠.
그래서 비밀키는 절대 외부에 노출되면 안 되고, 환경변수에 안전하게 보관해야 합니다. accessToken은 15분짜리 단기 토큰이고, refreshToken은 7일짜리 장기 토큰입니다.
왜 두 개를 만들까요? 보안 때문입니다.
accessToken은 자주 사용되니까 탈취 위험이 있어요. 그래서 짧게 만들어서 피해를 최소화하고, 만료되면 refreshToken으로 새로 발급받는 방식입니다.
마치 현금과 신용카드를 나눠 쓰는 것과 비슷하죠. 여러분이 이 코드를 사용하면 서버는 더 이상 세션 저장소를 관리할 필요가 없고, 사용자는 토큰 하나만 가지고 다니면 됩니다.
특히 마이크로서비스 아키텍처나 모바일 앱에서 정말 유용해요. 서버를 여러 대 운영해도 토큰만 검증하면 되니까 확장성이 뛰어나죠.
실전 팁
💡 비밀키(JWT_SECRET)는 최소 32자 이상의 복잡한 문자열로 만들고, 절대 코드에 직접 적지 마세요. .env 파일에 저장하고 .gitignore에 추가해야 합니다.
💡 accessToken 만료 시간은 15분~1시간 사이가 적당합니다. 너무 길면 보안에 취약하고, 너무 짧으면 사용자 경험이 나빠져요.
💡 payload에는 최소한의 정보만 넣으세요. 토큰이 커지면 네트워크 비용이 증가하고, 모든 요청마다 전송되니까 부담이 됩니다.
💡 개발 환경과 프로덕션 환경의 비밀키를 반드시 다르게 설정하세요. 개발 중 실수로 노출되더라도 실제 서비스는 안전합니다.
💡 jwt.sign() 옵션에 issuer(발급자)와 audience(대상)를 추가하면 토큰의 출처와 용도를 더 명확히 할 수 있어서 보안이 강화됩니다.
2. 토큰 검증 미들웨어
시작하며
토큰을 발급했으니 이제 사용해야겠죠? 그런데 사용자가 보내온 토큰이 진짜인지, 만료되지 않았는지 어떻게 확인할까요?
마치 놀이공원에서 입장권을 검사하는 직원처럼, 누군가 토큰을 확인해야 합니다. 모든 API 요청마다 이 검증 코드를 복사-붙여넣기 한다면?
끔찍하겠죠. 코드가 중복되고, 나중에 수정할 때 모든 곳을 다 고쳐야 합니다.
이럴 때 필요한 게 미들웨어입니다. 미들웨어는 요청이 실제 API에 도달하기 전에 거치는 관문이에요.
여기서 토큰을 한 번만 검증하면, 모든 보호된 API가 자동으로 안전해집니다.
개요
간단히 말해서, 미들웨어는 요청과 응답 사이에서 동작하는 함수입니다. 마치 보안 검색대처럼 모든 요청을 검사하고, 문제가 없으면 통과시키는 역할이죠.
왜 미들웨어가 필요할까요? 인증 로직을 한 곳에 모아두면 관리가 정말 쉬워집니다.
100개의 API가 있어도 미들웨어 하나만 수정하면 모든 API의 인증 방식이 바뀌죠. 또한 API 로직과 인증 로직을 분리해서 코드가 깔끔해지고, 각 기능에 집중할 수 있습니다.
기존 방식에서는 각 API 함수 안에서 토큰을 검증했습니다. 하지만 미들웨어를 사용하면 Express의 app.use()나 라우터에 한 번만 등록하면 자동으로 모든 요청을 검증합니다.
미들웨어의 핵심 특징은 세 가지입니다. 첫째, 재사용성이 뛰어나서 여러 라우트에 적용할 수 있습니다.
둘째, 체이닝이 가능해서 여러 미들웨어를 순서대로 실행할 수 있습니다. 셋째, next() 함수로 다음 단계로 넘어갈지 결정할 수 있어서 세밀한 제어가 가능합니다.
이런 특징들이 Express.js의 강력함을 만들어냅니다.
코드 예제
const jwt = require('jsonwebtoken');
// JWT 검증 미들웨어
function authenticateToken(req, res, next) {
// Authorization 헤더에서 토큰 추출
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN" 형식
// 토큰이 없으면 401 에러
if (!token) {
return res.status(401).json({ error: '토큰이 필요합니다' });
}
// 토큰 검증
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
// 토큰이 유효하지 않거나 만료됨
return res.status(403).json({ error: '유효하지 않은 토큰입니다' });
}
// 검증 성공 시 사용자 정보를 req에 저장
req.user = user; // 이후 라우트에서 req.user로 접근 가능
next(); // 다음 미들웨어/라우트로 진행
});
}
// 사용 예시
app.get('/api/profile', authenticateToken, (req, res) => {
// req.user에 토큰의 payload가 들어있음
res.json({ userId: req.user.userId, email: req.user.email });
});
설명
이 미들웨어가 하는 일을 단계별로 살펴볼게요. 사용자가 보낸 토큰이 진짜인지 확인하는 보안 검색대 역할입니다.
첫 번째로, Authorization 헤더에서 토큰을 꺼냅니다. 일반적으로 클라이언트는 "Bearer eyJhbGc..." 형식으로 토큰을 보내요.
"Bearer"는 "이 토큰을 가진 사람"이라는 뜻입니다. split(' ')[1]로 "Bearer" 다음의 실제 토큰만 추출하죠.
만약 토큰이 없다면? 바로 401 Unauthorized 에러를 보냅니다.
"인증이 필요해요"라는 의미죠. 그 다음으로, jwt.verify() 함수가 토큰의 진위를 확인합니다.
이 함수는 토큰을 비밀키로 검증해요. 마치 도장을 확인하는 것처럼, "이 토큰이 우리 서버에서 발급한 게 맞나?"를 체크합니다.
내부적으로는 서명을 다시 계산해서 토큰의 서명과 비교합니다. 만약 누군가 토큰을 위조했다면 서명이 맞지 않아서 바로 걸리죠.
또한 만료 시간도 자동으로 확인합니다. 검증이 성공하면, 토큰의 payload(사용자 정보)를 req.user에 저장합니다.
이게 정말 중요한 포인트예요! 이후 모든 라우트 핸들러에서 req.user로 "지금 요청한 사람이 누구인지" 알 수 있습니다.
데이터베이스를 조회할 필요 없이 바로 사용자 ID를 알 수 있는 거죠. 그리고 next()를 호출해서 다음 미들웨어나 실제 API 함수로 넘어갑니다.
검증이 실패하면 어떻게 될까요? 403 Forbidden 에러를 보냅니다.
401은 "인증이 필요해"이고, 403은 "인증은 했지만 유효하지 않아"라는 차이가 있어요. 토큰이 만료되었거나, 비밀키가 다르거나, 위조된 경우 모두 여기서 걸립니다.
여러분이 이 미들웨어를 사용하면 보호가 필요한 API마다 간단히 추가할 수 있습니다. app.get('/api/profile', authenticateToken, ...)처럼 미들웨어를 끼워넣기만 하면 자동으로 인증됩니다.
100개의 보호된 API가 있어도 코드 중복 없이 깔끔하게 관리할 수 있죠.
실전 팁
💡 Authorization 헤더 외에 쿠키에서 토큰을 읽는 방식도 고려하세요. XSS 공격을 방지하려면 httpOnly 쿠키가 더 안전합니다.
💡 에러 메시지를 너무 자세히 보내지 마세요. "토큰이 만료되었습니다" 대신 "유효하지 않은 토큰"으로 통일하면 공격자에게 힌트를 주지 않습니다.
💡 미들웨어를 전역으로 적용하려면 app.use(authenticateToken)을 사용하되, 로그인/회원가입 같은 공개 API는 제외해야 합니다.
💡 토큰 검증 실패 시 로그를 남기세요. 반복적인 실패는 공격 시도일 수 있으니 모니터링이 필요합니다.
💡 req.user에 추가 정보를 붙여서 권한 체크에 활용할 수 있습니다. 예를 들어 관리자 여부를 확인하는 추가 미들웨어를 체이닝할 수 있죠.
3. 회원가입 API 구현
시작하며
새로운 사용자가 여러분의 서비스에 가입하려고 합니다. 이메일과 비밀번호를 입력하고 "가입하기" 버튼을 누르죠.
그런데 뒤에서는 어떤 일이 벌어질까요? 단순히 데이터베이스에 저장하기만 하면 될까요?
절대 그렇지 않습니다! 같은 이메일로 중복 가입을 막아야 하고, 비밀번호는 암호화해서 저장해야 하고, 입력값이 올바른지 검증도 해야 합니다.
하나라도 빠뜨리면 보안 사고로 이어질 수 있어요. 회원가입 API는 단순해 보이지만, 실제로는 여러 단계의 검증과 보안 처리가 필요한 중요한 엔드포인트입니다.
제대로 만들어봅시다.
개요
간단히 말해서, 회원가입 API는 새로운 사용자 정보를 안전하게 저장하는 엔드포인트입니다. 하지만 그냥 저장만 하는 게 아니라, 여러 보안 검증을 거쳐야 합니다.
왜 이렇게 신경 써야 할까요? 첫째, 중복 가입을 허용하면 시스템이 혼란스러워집니다.
같은 이메일로 여러 계정이 생기면 어떤 계정이 진짜인지 알 수 없죠. 둘째, 비밀번호를 평문으로 저장하면 데이터베이스가 해킹당했을 때 모든 사용자 비밀번호가 그대로 노출됩니다.
셋째, 입력값 검증을 안 하면 이상한 데이터가 들어와서 나중에 오류를 일으킬 수 있습니다. 전통적인 방식에서는 서버 사이드 세션을 만들고 쿠키를 발급했습니다.
하지만 JWT 방식에서는 회원가입 후 바로 토큰을 발급해서 즉시 로그인 상태로 만들 수 있어요. 사용자 경험이 훨씬 부드럽죠.
회원가입 API의 핵심 단계는 다섯 가지입니다. 입력값 검증 → 중복 체크 → 비밀번호 해싱 → 데이터베이스 저장 → 토큰 발급.
이 순서를 하나라도 빠뜨리면 보안 구멍이 생깁니다. 각 단계가 방패의 층을 이루어서 다층 방어를 구성하는 거죠.
코드 예제
const bcrypt = require('bcrypt');
const { body, validationResult } = require('express-validator');
// 회원가입 API
app.post('/api/auth/register',
// 입력값 검증 미들웨어
body('email').isEmail().normalizeEmail(),
body('password').isLength({ min: 8 }),
async (req, res) => {
// 검증 결과 확인
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { email, password, name } = req.body;
try {
// 중복 이메일 체크
const existingUser = await db.query('SELECT * FROM users WHERE email = $1', [email]);
if (existingUser.rows.length > 0) {
return res.status(409).json({ error: '이미 존재하는 이메일입니다' });
}
// 비밀번호 해싱 (salt rounds: 10)
const hashedPassword = await bcrypt.hash(password, 10);
// 사용자 정보 저장
const result = await db.query(
'INSERT INTO users (email, password, name) VALUES ($1, $2, $3) RETURNING id, email',
[email, hashedPassword, name]
);
const newUser = result.rows[0];
// JWT 토큰 발급
const { accessToken, refreshToken } = generateToken(newUser.id, newUser.email);
res.status(201).json({
message: '회원가입 성공',
accessToken,
refreshToken
});
} catch (error) {
console.error(error);
res.status(500).json({ error: '서버 오류가 발생했습니다' });
}
});
설명
이 회원가입 API가 어떻게 동작하는지 차근차근 살펴볼게요. 새로운 사용자를 안전하게 등록하는 전체 과정입니다.
첫 번째 단계는 입력값 검증입니다. express-validator를 사용해서 이메일이 올바른 형식인지, 비밀번호가 최소 8자 이상인지 확인해요.
normalizeEmail()은 이메일을 표준 형식으로 바꿔줍니다. 예를 들어 "User@Example.COM"을 "user@example.com"으로 통일하죠.
검증에 실패하면 바로 400 에러를 보내서 잘못된 데이터가 들어오는 걸 원천 차단합니다. 두 번째는 중복 체크입니다.
데이터베이스에서 같은 이메일이 있는지 조회합니다. 만약 이미 존재한다면 409 Conflict 에러를 보냅니다.
"이 이메일은 이미 사용 중이에요"라고 알려주는 거죠. 이 단계를 빠뜨리면 같은 이메일로 여러 계정이 생겨서 나중에 로그인할 때 문제가 생깁니다.
세 번째는 비밀번호 해싱입니다. bcrypt.hash()를 사용해서 평문 비밀번호를 암호화합니다.
salt rounds를 10으로 설정했는데, 이건 해싱을 10번 반복한다는 의미예요. 많이 반복할수록 안전하지만 느려지니까, 10이 적당한 균형점입니다.
절대로 비밀번호를 평문으로 저장하면 안 됩니다! 해커가 데이터베이스를 탈취하면 모든 사용자 비밀번호가 노출되거든요.
네 번째는 데이터베이스 저장입니다. INSERT 쿼리로 사용자 정보를 저장하고, RETURNING으로 방금 생성된 사용자 정보를 바로 받아옵니다.
이렇게 하면 추가 쿼리 없이 새 사용자의 ID를 알 수 있어서 효율적입니다. 마지막으로 JWT 토큰을 발급합니다.
회원가입이 완료되면 바로 로그인 상태로 만들어주는 거죠. 사용자는 가입 후 다시 로그인할 필요 없이 바로 서비스를 사용할 수 있습니다.
이게 바로 좋은 사용자 경험입니다. 여러분이 이 코드를 사용하면 안전하고 편리한 회원가입 시스템을 만들 수 있습니다.
각 단계의 방어막이 겹겹이 쌓여서 보안이 강화되고, 사용자는 간편하게 가입부터 로그인까지 한 번에 완료할 수 있죠.
실전 팁
💡 이메일 중복 체크 시 대소문자를 구분하지 않으려면 데이터베이스에 저장할 때 소문자로 변환하고, UNIQUE 제약조건을 거세요.
💡 비밀번호 정책을 강화하려면 express-validator의 matches()로 정규식을 사용하세요. 대문자, 소문자, 숫자, 특수문자 포함을 강제할 수 있습니다.
💡 회원가입 시 이메일 인증을 추가하면 더 안전합니다. 토큰과 함께 verified: false를 저장하고, 이메일 링크 클릭 시 true로 변경하세요.
💡 rate limiting을 적용해서 같은 IP에서 짧은 시간에 여러 번 가입하는 걸 막으세요. express-rate-limit 라이브러리가 유용합니다.
💡 try-catch에서 에러를 그대로 클라이언트에 보내지 마세요. 데이터베이스 구조가 노출될 수 있으니 일반적인 메시지만 보내고, 자세한 내용은 서버 로그에 남기세요.
4. 로그인 API 구현
시작하며
사용자가 이메일과 비밀번호를 입력하고 로그인 버튼을 눌렀습니다. 이제 우리는 "이 사람이 진짜 회원이 맞나?"를 확인해야 해요.
어떻게 확인할까요? 단순히 데이터베이스에서 이메일을 찾아서 비밀번호가 일치하는지 보면 될까요?
하지만 비밀번호는 해싱되어 있어서 직접 비교할 수 없습니다. 또한 보안을 위해 로그인 시도 횟수도 제한해야 하고, 실패했을 때 왜 실패했는지 너무 자세히 알려주면 안 됩니다.
로그인 API는 보안과 사용자 경험의 균형을 맞춰야 하는 섬세한 작업입니다. 제대로 구현해봅시다.
개요
간단히 말해서, 로그인 API는 사용자 자격 증명을 확인하고 JWT 토큰을 발급하는 엔드포인트입니다. 하지만 단순 확인 이상의 보안 고려사항이 많습니다.
왜 로그인 API가 까다로울까요? 첫째, brute force 공격(무차별 대입 공격)을 막아야 합니다.
누군가 자동화 프로그램으로 계속 비밀번호를 시도하면 언젠가 맞출 수 있거든요. 둘째, 타이밍 공격을 방지해야 합니다.
"이메일이 존재하지 않습니다"와 "비밀번호가 틀렸습니다"를 구분해서 알려주면 공격자가 유효한 이메일을 수집할 수 있어요. 셋째, 해싱된 비밀번호를 안전하게 비교해야 합니다.
기존 세션 방식에서는 서버 메모리에 로그인 상태를 저장했습니다. 하지만 JWT 방식에서는 토큰을 발급하고, 그 이후로는 서버가 로그인 상태를 기억할 필요가 없습니다.
stateless의 장점이죠. 로그인 API의 핵심은 세 가지입니다.
첫째, 사용자 존재 확인과 비밀번호 검증을 하나의 에러 메시지로 통일합니다. 둘째, bcrypt.compare()로 안전하게 비밀번호를 비교합니다.
셋째, 성공 시 새로운 토큰을 발급해서 보안을 강화합니다. 매번 새 토큰을 주는 게 같은 토큰을 계속 쓰는 것보다 안전하거든요.
코드 예제
const bcrypt = require('bcrypt');
// 로그인 API
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
// 입력값 검증
if (!email || !password) {
return res.status(400).json({ error: '이메일과 비밀번호를 입력해주세요' });
}
try {
// 사용자 조회
const result = await db.query('SELECT * FROM users WHERE email = $1', [email]);
const user = result.rows[0];
// 사용자가 없거나 비밀번호가 틀린 경우
// 보안을 위해 같은 에러 메시지 사용
if (!user) {
return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다' });
}
// 비밀번호 비교 (해싱된 비밀번호와 비교)
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다' });
}
// JWT 토큰 발급
const { accessToken, refreshToken } = generateToken(user.id, user.email);
// Refresh Token을 데이터베이스에 저장 (나중에 갱신할 때 사용)
await db.query('UPDATE users SET refresh_token = $1 WHERE id = $2', [refreshToken, user.id]);
res.json({
message: '로그인 성공',
accessToken,
refreshToken,
user: { id: user.id, email: user.email, name: user.name }
});
} catch (error) {
console.error(error);
res.status(500).json({ error: '서버 오류가 발생했습니다' });
}
});
설명
이 로그인 API가 어떻게 작동하는지 단계별로 살펴볼게요. 사용자를 인증하고 토큰을 발급하는 전체 과정입니다.
첫 번째로, 기본적인 입력값 검증을 합니다. 이메일이나 비밀번호가 비어있으면 400 에러를 보냅니다.
이건 클라이언트 측 문제니까 "잘못된 요청"이라고 알려주는 거죠. 두 번째로, 데이터베이스에서 이메일로 사용자를 조회합니다.
여기서 중요한 포인트! 사용자가 없어도 일단 다음 단계로 넘어갑니다.
왜냐하면 "이메일이 존재하지 않습니다"라고 바로 알려주면 공격자가 "아하, 이 이메일은 가입되어 있지 않구나"를 알 수 있거든요. 그럼 유효한 이메일 목록을 수집할 수 있어서 위험합니다.
세 번째로, bcrypt.compare()로 비밀번호를 비교합니다. 이 함수는 마법 같아요.
사용자가 입력한 평문 비밀번호를 같은 방식으로 해싱해서, 데이터베이스에 저장된 해시값과 비교합니다. 같으면 true, 다르면 false를 반환하죠.
직접 해시를 비교하면 안 됩니다! bcrypt는 내부에 salt(무작위 값)가 있어서 같은 비밀번호도 매번 다른 해시가 나오거든요.
bcrypt.compare()만이 이걸 올바르게 비교할 수 있습니다. 사용자가 없거나 비밀번호가 틀렸을 때, 똑같은 에러 메시지를 보냅니다.
"이메일 또는 비밀번호가 올바르지 않습니다." 어느 쪽이 틀렸는지 알려주지 않는 게 보안의 핵심입니다. 또한 응답 시간도 비슷하게 유지해야 타이밍 공격을 막을 수 있어요.
검증이 성공하면, 새로운 JWT 토큰을 발급합니다. 여기서 refreshToken을 데이터베이스에 저장하는데, 이게 중요합니다!
나중에 accessToken이 만료되면 refreshToken으로 새 토큰을 받는데, 그때 데이터베이스의 토큰과 비교해서 유효성을 확인하거든요. 이렇게 하면 로그아웃 기능도 구현할 수 있습니다.
refreshToken을 데이터베이스에서 삭제하면 더 이상 갱신할 수 없으니까요. 여러분이 이 코드를 사용하면 안전한 로그인 시스템을 만들 수 있습니다.
공격자는 어떤 정보도 수집할 수 없고, 사용자는 편리하게 로그인해서 토큰을 받아 서비스를 이용할 수 있습니다.
실전 팁
💡 로그인 실패 횟수를 추적하고, 5회 실패 시 계정을 일시 잠그세요. Redis에 카운터를 저장하면 빠르고 자동으로 만료시킬 수 있습니다.
💡 bcrypt.compare()는 느린 함수입니다(보안상 의도적으로). 사용자가 없을 때도 가짜 해시와 비교해서 응답 시간을 동일하게 만들면 타이밍 공격을 완벽히 방어할 수 있습니다.
💡 로그인 성공 시 마지막 로그인 시간과 IP를 기록하세요. 사용자가 의심스러운 활동을 감지할 수 있습니다.
💡 2FA(이중 인증)를 추가하면 보안이 크게 강화됩니다. speakeasy 라이브러리로 TOTP를 구현할 수 있어요.
💡 로그인 후 민감한 정보(비밀번호 해시)는 응답에 포함하지 마세요. user 객체를 보낼 때 password 필드를 명시적으로 제외하세요.
5. Refresh Token 전략
시작하며
accessToken을 15분짜리로 만들었는데, 그럼 사용자는 15분마다 다시 로그인해야 할까요? 그건 너무 불편하죠.
하지만 토큰을 일주일, 한 달짜리로 만들면 보안이 취약해집니다. 어떻게 하면 좋을까요?
이때 필요한 게 Refresh Token 전략입니다. 마치 신분증과 갱신 쿠폰을 따로 가지고 다니는 것처럼, 두 개의 토큰을 사용하는 거예요.
하나는 짧은 수명의 일상용(accessToken), 다른 하나는 긴 수명의 갱신용(refreshToken)입니다. 이 전략을 사용하면 보안과 편의성을 동시에 잡을 수 있습니다.
accessToken이 탈취되어도 15분 후에는 쓸모없어지고, 사용자는 refreshToken으로 자동 갱신하니까 계속 로그인 상태를 유지할 수 있죠.
개요
간단히 말해서, Refresh Token 전략은 단기 토큰과 장기 토큰을 조합해서 보안과 편의성을 모두 잡는 방법입니다. 실제 서비스에서 거의 필수로 사용하는 패턴이에요.
왜 이 전략이 필요할까요? accessToken은 모든 API 요청에 사용되니까 노출 위험이 높습니다.
네트워크 중간에 가로채이거나, 브라우저 메모리에서 XSS 공격으로 탈취될 수 있죠. 그래서 짧게 만들어서 피해를 최소화합니다.
반면 refreshToken은 오직 토큰 갱신할 때만 사용하니까 노출 위험이 훨씬 적어요. 그래서 길게 만들 수 있습니다.
또한 데이터베이스에 저장해서 언제든 무효화할 수 있어서 로그아웃도 구현 가능합니다. 전통적인 JWT 방식에서는 accessToken 하나만 사용했습니다.
하지만 그러면 보안(짧은 수명)과 편의성(긴 수명) 중 하나를 포기해야 했죠. Refresh Token 전략은 이 딜레마를 해결합니다.
이 전략의 핵심 흐름은 네 단계입니다. 첫째, 로그인 시 두 토큰을 모두 발급합니다.
둘째, 평소에는 accessToken으로 API를 사용합니다. 셋째, accessToken이 만료되면 클라이언트가 refreshToken으로 갱신을 요청합니다.
넷째, 서버는 refreshToken을 검증하고 새로운 accessToken을 발급합니다. 이 사이클이 반복되면서 사용자는 자동으로 로그인 상태를 유지하게 됩니다.
코드 예제
// Refresh Token으로 새 Access Token 발급
app.post('/api/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh Token이 필요합니다' });
}
try {
// Refresh Token 검증
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
// 데이터베이스에서 사용자와 저장된 Refresh Token 조회
const result = await db.query(
'SELECT * FROM users WHERE id = $1 AND refresh_token = $2',
[decoded.userId, refreshToken]
);
const user = result.rows[0];
if (!user) {
// Refresh Token이 데이터베이스에 없거나 일치하지 않음
return res.status(403).json({ error: '유효하지 않은 Refresh Token입니다' });
}
// 새로운 Access Token 발급
const payload = { userId: user.id, email: user.email, role: user.role };
const newAccessToken = jwt.sign(
payload,
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
// 필요시 Refresh Token도 갱신 (Refresh Token Rotation)
const newRefreshToken = jwt.sign(
{ userId: user.id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
// 새 Refresh Token을 데이터베이스에 저장
await db.query('UPDATE users SET refresh_token = $1 WHERE id = $2', [newRefreshToken, user.id]);
res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Refresh Token이 만료되었습니다. 다시 로그인해주세요.' });
}
console.error(error);
res.status(403).json({ error: '유효하지 않은 토큰입니다' });
}
});
설명
이 Refresh Token API가 어떻게 작동하는지 살펴볼게요. 만료된 accessToken을 새로 발급받는 자동 갱신 시스템입니다.
첫 번째로, 클라이언트가 보낸 refreshToken을 받습니다. 보통 accessToken이 만료되어 401 에러를 받으면, 클라이언트는 자동으로 이 API를 호출해요.
사용자는 이 과정을 전혀 느끼지 못합니다. 마치 스마트폰이 백그라운드에서 앱을 업데이트하는 것처럼 자동으로 처리되죠.
두 번째로, jwt.verify()로 refreshToken의 서명과 만료 시간을 검증합니다. 이건 첫 번째 방어선이에요.
위조된 토큰이나 만료된 토큰은 여기서 걸립니다. 만료되었다면 더 이상 갱신할 수 없으니 다시 로그인해야 합니다.
세 번째로, 데이터베이스에서 이중 검증을 합니다. 이게 핵심입니다!
토큰의 서명이 맞더라도, 데이터베이스에 저장된 refreshToken과 일치하는지 확인해요. 왜 이렇게 할까요?
첫째, 로그아웃 기능을 구현하기 위해서입니다. 로그아웃하면 DB에서 토큰을 삭제하니까 갱신이 안 되죠.
둘째, 토큰이 탈취되었을 때 관리자가 DB에서 직접 삭제해서 무효화할 수 있습니다. JWT는 원래 무효화할 수 없는데, 이 방법으로 가능하게 만든 거예요.
네 번째로, 새로운 accessToken을 발급합니다. 똑같은 payload로 15분짜리 토큰을 만듭니다.
여기서 중요한 건 Refresh Token Rotation입니다. refreshToken도 새로 발급해서 매번 바꿔주는 거예요.
이렇게 하면 refreshToken이 탈취되어도 다음 갱신 때 기존 토큰은 무효화되니까 공격자는 더 이상 사용할 수 없습니다. 이게 최신 보안 권장 사항입니다.
마지막으로, 새 refreshToken을 데이터베이스에 업데이트합니다. 이제 이전 refreshToken은 쓸모없어지고, 새 토큰만 유효합니다.
클라이언트는 두 토큰을 모두 받아서 저장하고, 계속 서비스를 사용할 수 있습니다. 여러분이 이 전략을 사용하면 사용자는 한 번 로그인하면 일주일 동안 자동으로 로그인 상태를 유지할 수 있습니다.
동시에 accessToken은 15분마다 바뀌니까 보안도 강력하죠. 이게 바로 현대적인 인증 시스템의 표준입니다.
실전 팁
💡 Refresh Token Rotation을 사용하면 보안이 크게 강화됩니다. 기존 토큰으로 갱신 시도가 감지되면 모든 토큰을 무효화하는 "탈취 감지" 기능도 구현할 수 있어요.
💡 refreshToken은 httpOnly 쿠키에 저장하고, accessToken은 메모리에 저장하세요. XSS 공격으로부터 refreshToken을 보호할 수 있습니다.
💡 refreshToken의 만료 시간을 activity-based로 만들 수 있습니다. 사용할 때마다 만료 시간을 연장해서 활발한 사용자는 계속 로그인 상태를 유지하게 하세요.
💡 로그아웃 API에서는 DB의 refreshToken을 NULL로 설정하세요. 그러면 사용자가 가진 토큰은 더 이상 갱신할 수 없게 됩니다.
💡 여러 기기에서 로그인을 지원하려면 users 테이블 대신 별도의 refresh_tokens 테이블을 만들어서 기기별로 토큰을 관리하세요.
6. 비밀번호 해싱 (bcrypt)
시작하며
여러분의 데이터베이스가 해킹당했다고 상상해보세요. 공격자가 users 테이블을 다 가져갔습니다.
만약 비밀번호가 평문으로 저장되어 있다면? 모든 사용자의 비밀번호가 그대로 노출되고, 다른 서비스에서도 같은 비밀번호를 쓰는 사용자들까지 위험에 빠집니다.
"그럼 비밀번호를 암호화하면 되잖아요?"라고 생각할 수 있습니다. 하지만 암호화는 복호화할 수 있어요.
암호화 키가 있으면 원래 비밀번호를 알아낼 수 있다는 뜻입니다. 우리는 원래 비밀번호를 복구할 필요가 없어요.
그냥 "입력한 비밀번호가 맞는지"만 확인하면 됩니다. 이때 필요한 게 단방향 해싱(hashing)입니다.
특히 bcrypt는 비밀번호 해싱에 특화된 알고리즘으로, 레인보우 테이블 공격도 막아주고 브루트포스 공격도 느리게 만들어서 사실상 불가능하게 만듭니다.
개요
간단히 말해서, bcrypt는 비밀번호를 복구 불가능한 문자열로 변환하는 해싱 알고리즘입니다. "password123"을 입력하면 "$2b$10$abcd..."같은 긴 문자열로 바뀌는데, 이걸 다시 "password123"으로 되돌릴 수 없습니다.
왜 bcrypt가 특별할까요? 첫째, salt를 자동으로 생성합니다.
salt는 무작위 값인데, 같은 비밀번호라도 매번 다른 해시가 나오게 만들어요. 그래서 레인보우 테이블(미리 계산된 해시 목록) 공격이 통하지 않습니다.
둘째, 의도적으로 느립니다. 일반 해시는 초당 수십억 번 계산할 수 있지만, bcrypt는 조절 가능하게 느리게 만들어서 브루트포스 공격을 비현실적으로 만듭니다.
셋째, 미래 대비가 가능합니다. salt rounds를 높여서 컴퓨터가 빨라져도 여전히 안전하게 유지할 수 있어요.
일반 해싱(MD5, SHA-256)은 비밀번호 저장에 적합하지 않습니다. 너무 빨라서 공격자가 초당 수억 번 시도할 수 있거든요.
하지만 bcrypt는 비밀번호 전용으로 설계되어서 이런 공격을 효과적으로 방어합니다. bcrypt의 핵심 특징 세 가지입니다.
첫째, adaptive function이라서 시간이 지나도 안전합니다. work factor를 조정해서 항상 적절한 보안 수준을 유지할 수 있어요.
둘째, salt가 해시에 포함되어 있어서 따로 저장할 필요가 없습니다. 셋째, 검증할 때는 bcrypt.compare()만 쓰면 되니까 사용이 간단합니다.
이런 특징들이 bcrypt를 업계 표준으로 만들었습니다.
코드 예제
const bcrypt = require('bcrypt');
// 비밀번호 해싱
async function hashPassword(plainPassword) {
// salt rounds: 10 (2^10번 해싱 반복)
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(plainPassword, saltRounds);
return hashedPassword;
// 결과 예: "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
}
// 비밀번호 검증
async function verifyPassword(plainPassword, hashedPassword) {
const isMatch = await bcrypt.compare(plainPassword, hashedPassword);
return isMatch; // true 또는 false
}
// 실제 사용 예시
async function example() {
const userPassword = "mySecurePassword123!";
// 회원가입 시: 비밀번호 해싱
const hashed = await hashPassword(userPassword);
console.log("해시된 비밀번호:", hashed);
// DB에 hashed를 저장
// 로그인 시: 비밀번호 검증
const loginAttempt = "mySecurePassword123!";
const isCorrect = await verifyPassword(loginAttempt, hashed);
console.log("비밀번호 일치:", isCorrect); // true
const wrongAttempt = "wrongPassword";
const isWrong = await verifyPassword(wrongAttempt, hashed);
console.log("비밀번호 불일치:", isWrong); // false
}
설명
bcrypt가 어떻게 비밀번호를 보호하는지 자세히 살펴볼게요. 단순해 보이지만 내부에는 강력한 보안 메커니즘이 숨어있습니다.
첫 번째로, bcrypt.hash()를 호출하면 내부에서 여러 단계를 거칩니다. 먼저 무작위 salt를 생성해요.
이 salt는 16바이트 길이의 완전히 랜덤한 값입니다. 그 다음 사용자의 비밀번호와 salt를 결합해서 해싱을 시작합니다.
saltRounds가 10이면 2^10 = 1024번 반복해서 해싱합니다. 이게 bcrypt를 느리게 만드는 핵심이에요.
똑같은 연산을 1024번 반복하니까 시간이 걸리죠. 두 번째로, 결과 문자열의 구조를 이해해봅시다.
"$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"처럼 생겼는데, 이건 세 부분으로 나뉩니다. "$2b$"는 bcrypt 버전, "10$"은 salt rounds, 그 다음은 salt와 실제 해시가 합쳐진 값입니다.
놀라운 점은 salt를 따로 저장할 필요가 없다는 거예요! 해시 문자열에 이미 포함되어 있거든요.
bcrypt.compare()는 이걸 자동으로 추출해서 검증합니다. 세 번째로, bcrypt.compare()의 작동 원리를 볼게요.
사용자가 로그인할 때 입력한 평문 비밀번호와 DB에 저장된 해시를 넘겨줍니다. bcrypt는 해시에서 salt를 추출하고, 입력한 비밀번호를 같은 salt와 같은 rounds로 해싱합니다.
그 결과를 저장된 해시와 비교해서 일치하면 true, 다르면 false를 반환하죠. 이 과정이 안전한 이유는 공격자가 원본 비밀번호를 알아낼 방법이 없기 때문입니다.
saltRounds를 10으로 설정한 이유도 중요합니다. 숫자가 높을수록 안전하지만 느려져요.
10은 보통 0.1초 정도 걸리는데, 이게 적당한 균형점입니다. 사용자는 0.1초 정도는 느끼지 못하지만, 공격자가 수백만 번 시도하려면 며칠이 걸리죠.
만약 12로 올리면 4배 느려지고(2^12 / 2^10 = 4), 15로 올리면 32배 느려집니다. 서버 성능과 보안 요구사항에 따라 조절하면 됩니다.
여러분이 bcrypt를 사용하면 데이터베이스가 유출되어도 사용자 비밀번호는 안전합니다. 공격자는 해시를 보고 원본 비밀번호를 알아낼 수 없고, 무차별 대입으로 찾으려 해도 bcrypt가 너무 느려서 비현실적입니다.
이게 바로 비밀번호 보안의 골드 스탠다드입니다.
실전 팁
💡 saltRounds는 1012 사이가 적당합니다. 서버에서 실제로 테스트해보고 0.10.3초 정도 걸리는 값을 선택하세요. 너무 높으면 서버에 부담이 됩니다.
💡 bcrypt.compare()를 직접 구현하지 마세요. 해시에서 salt를 추출하고 비교하는 복잡한 로직이 있어서 라이브러리를 쓰는 게 안전합니다.
💡 비밀번호 변경 시 이전 비밀번호와 같은지 확인하려면 새 비밀번호를 해싱하기 전에 compare()로 비교하세요. 해시는 매번 달라서 직접 비교할 수 없습니다.
💡 비동기 버전(bcrypt.hash, bcrypt.compare)을 사용하세요. 동기 버전은 Node.js 이벤트 루프를 블록해서 서버 전체가 느려질 수 있습니다.
💡 정말 민감한 서비스라면 Argon2나 scrypt 같은 더 최신 알고리즘도 고려하세요. 하지만 bcrypt도 여전히 충분히 안전하고 검증되었습니다.