iBetter Books
수정

Ch 05. JWT 인증 구현하기

데이터베이스에 게시글을 저장했다면, 이제 "누가 쓴 글인지"를 구분해야 합니다. 인증이 필요한 이유입니다. 이 장에서는 JWT(JSON Web Token)와 쿠키를 사용해 로그인·로그아웃 흐름을 구현합니다.

인증 흐름 이해하기

JWT 인증의 흐름은 다음과 같습니다.

  1. 사용자가 이메일과 비밀번호로 로그인합니다.
  2. 서버가 자격증명을 확인하고 JWT 토큰을 발급합니다.
  3. 토큰을 HttpOnly 쿠키에 저장합니다.
  4. 이후 요청마다 쿠키가 자동으로 전송됩니다.
  5. 서버 미들웨어에서 토큰을 검증합니다.

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 자체는 서버에 저장되지 않으므로, 쿠키를 삭제하면 클라이언트는 이후 요청에 토큰을 보낼 수 없게 됩니다. 이것으로 로그아웃이 완성됩니다.

인증은 처음 구현할 때 복잡해 보이지만, 흐름을 이해하고 나면 패턴이 반복됩니다. 회원 가입에서 비밀번호를 해시하고, 로그인에서 토큰을 발급하며, 미들웨어에서 토큰을 검증하는 세 단계가 전부입니다.