Ch 05. JWT 인증 구현하기
데이터베이스에 게시글을 저장했다면, 이제 "누가 쓴 글인지"를 구분해야 합니다. 인증이 필요한 이유입니다. 이 장에서는 JWT(JSON Web Token)와 쿠키를 사용해 로그인·로그아웃 흐름을 구현합니다.
인증 흐름 이해하기
JWT 인증의 흐름은 다음과 같습니다.
- 사용자가 이메일과 비밀번호로 로그인합니다.
- 서버가 자격증명을 확인하고 JWT 토큰을 발급합니다.
- 토큰을 HttpOnly 쿠키에 저장합니다.
- 이후 요청마다 쿠키가 자동으로 전송됩니다.
- 서버 미들웨어에서 토큰을 검증합니다.
HttpOnly 쿠키를 사용하면 JavaScript에서 토큰에 접근할 수 없기 때문에 XSS 공격으로부터 토큰을 보호할 수 있습니다.
필요 패키지 설치
npm install jsonwebtoken bcryptjsnpm install -D @types/jsonwebtoken @types/bcryptjs
bcryptjs는 비밀번호를 단방향 해시로 저장하는 데 사용합니다. 원문을 복원할 수 없으므로 데이터베이스가 탈취되어도 비밀번호가 노출되지 않습니다.
회원 가입 API
// server/api/auth/register.post.tsimport bcrypt from 'bcryptjs'export default defineEventHandler(async (event) => { const { email, password, name } = await readBody(event) const hashed = await bcrypt.hash(password, 10) const user = await usePrisma().user.create({ data: { email, password: hashed, name } }) return { id: user.id, email: user.email }})
bcrypt.hash의 두 번째 인자는 salt 라운드 수입니다. 10이 일반적인 권장값으로, 높을수록 해시 생성이 느려지지만 그만큼 무차별 대입 공격에 강해집니다.
로그인 API
// server/api/auth/login.post.tsimport jwt from 'jsonwebtoken'import bcrypt from 'bcryptjs'export default defineEventHandler(async (event) => { const { email, password } = await readBody(event) const user = await usePrisma().user.findUnique({ where: { email } }) if (!user || !(await bcrypt.compare(password, user.password))) { throw createError({ statusCode: 401, statusMessage: '이메일 또는 비밀번호가 틀렸습니다.' }) } const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET!, { expiresIn: '7d' }) setCookie(event, 'auth_token', token, { httpOnly: true, maxAge: 60 * 60 * 24 * 7 }) return { user: { id: user.id, email: user.email, name: user.name } }})
이메일로 사용자를 찾고, bcrypt.compare로 입력한 비밀번호와 저장된 해시를 비교합니다. 일치하면 7일 유효 기간의 JWT를 발급하고 HttpOnly 쿠키에 담아 응답합니다.
process.env.JWT_SECRET!의 !는 TypeScript에게 이 값이 undefined가 아님을 보장한다는 표시입니다. .env 파일에 반드시 설정해야 합니다.
인증 미들웨어로 라우트 보호하기
// server/middleware/auth.tsimport jwt from 'jsonwebtoken'export default defineEventHandler((event) => { if (event.path.startsWith('/api/protected')) { const token = getCookie(event, 'auth_token') if (!token) throw createError({ statusCode: 401, statusMessage: '로그인이 필요합니다.' }) try { const payload = jwt.verify(token, process.env.JWT_SECRET!) as { userId: number } event.context.userId = payload.userId } catch { throw createError({ statusCode: 401, statusMessage: '유효하지 않은 토큰입니다.' }) } }})
/api/protected/로 시작하는 모든 요청은 이 미들웨어를 통과합니다. 토큰이 없거나 검증에 실패하면 401 오류를 던지고, 성공하면 event.context.userId에 사용자 ID를 저장합니다. 이후 해당 핸들러에서 event.context.userId로 현재 사용자를 확인할 수 있습니다.
로그아웃 API
로그아웃은 쿠키를 삭제하면 됩니다.
// server/api/auth/logout.post.tsexport default defineEventHandler((event) => { deleteCookie(event, 'auth_token') return { success: true }})
JWT 자체는 서버에 저장되지 않으므로, 쿠키를 삭제하면 클라이언트는 이후 요청에 토큰을 보낼 수 없게 됩니다. 이것으로 로그아웃이 완성됩니다.
인증은 처음 구현할 때 복잡해 보이지만, 흐름을 이해하고 나면 패턴이 반복됩니다. 회원 가입에서 비밀번호를 해시하고, 로그인에서 토큰을 발급하며, 미들웨어에서 토큰을 검증하는 세 단계가 전부입니다.