1. 인증(Authentication)과 인가(Authorization)
로그인 기능을 구현하기 전에 가장 혼동하기 쉬운 두 개념을 먼저 정리한다.
인증 (Authentication) — 당신은 누구인가
인증은 사용자가 누구인지 확인하는 과정이다. 아이디와 비밀번호를 입력해 로그인하는 행위가 대표적인 예다. 서버는 이 과정을 통해 "이 요청을 보낸 사람이 실제로 등록된 사용자인가"를 판단한다.
인가 (Authorization) — 무엇을 할 수 있는가
인가는 인증된 사용자가 특정 리소스에 접근하거나 특정 동작을 수행할 권한이 있는지 확인하는 과정이다. 로그인한 사용자가 일반 사용자인지 관리자(Admin)인지를 구분해, 관리자에게만 유저 삭제 기능을 허용하는 것이 인가다.
인가는 인증을 통과한 사용자에게만 의미가 있다. JWT로 "이 사용자가 userId: 1번 유저가 맞는지" 인증하고, 그 다음 "1번 유저가 관리자 권한을 가졌는지" 인가 절차를 거친다.
2. 세션 기반(Stateful) vs JWT 기반(Stateless)
서버가 사용자의 로그인 상태를 유지하는 방식은 크게 두 가지다.
세션 (Session) 방식
서버가 사용자의 로그인 상태를 직접 기억하고 관리하는 Stateful 방식이다. 사용자가 로그인하면 서버는 세션 정보를 메모리 또는 DB에 저장하고, 사용자에게 세션 ID가 담긴 쿠키를 발급한다. 이후 API 요청마다 서버는 쿠키를 보고 세션 저장소를 조회해 사용자를 확인한다.
JWT 방식
서버가 상태를 저장하지 않는 Stateless 방식이다. 서버는 로그인 시 딱 한 번만 신원을 확인하고 JWT를 발급한다. 이후 클라이언트가 JWT를 보관하며, 매 요청마다 토큰을 헤더에 실어 보내면 서버는 서명 검증만으로 사용자를 확인한다.
| 구분 | 세션 (Stateful) | JWT (Stateless) |
|---|---|---|
| 상태 저장 주체 | 서버 (메모리 / DB) | 클라이언트 (localStorage 등) |
| 서버 부담 | 세션 저장소 운영 필요 | 서명 검증만 수행 |
| 분산 환경 | 서버 간 세션 공유 문제 발생 | 어느 서버든 검증 가능 |
| 보안 (탈취 시) | 서버에서 세션 삭제로 즉시 무효화 | 만료 전까지 무효화 어려움 |
| 토큰 강제 만료 | 가능 | 기본적으로 불가 (별도 블랙리스트 필요) |
| 확장성 | 낮음 (세션 동기화 필요) | 높음 |
3. JWT 구조
JWT(JSON Web Token)는 사용자의 정보를 담은 JSON 객체를 암호화 서명한
특별한 토큰이다.
.
으로 구분된 세 부분으로 이루어져 있다.
각 부분의 역할
- Header : 토큰의 타입(JWT)과 서명 알고리즘(예: HS256) 정보를 담는다.
-
Payload
:
userId: 1,email: "..."처럼 사용자를 식별하는 실제 정보(Claim)가 담긴다. 비밀번호 같은 민감한 정보는 절대 넣으면 안 된다. - Signature : 토큰이 위조되지 않았음을 증명하는 비밀 키 기반의 서명이다. 서버는 서명만 검증해도 토큰의 진위를 확인할 수 있다.
Header와 Payload는 Base64Url로 인코딩되어 있어 누구나 디코딩할 수 있다. 암호화가 아니라 인코딩이다. 보안은 Signature가 담당한다. 비밀 키 없이는 서명을 위조할 수 없기 때문에 토큰의 무결성이 보장된다.
4. Access Token과 Refresh Token
JWT 토큰을 하나만 사용하면 딜레마가 발생한다. 수명이 길면 탈취 시 피해가 크고, 수명이 짧으면 사용자가 자주 다시 로그인해야 한다. 이 문제를 해결하기 위해 목적이 다른 두 토큰을 함께 사용한다.
| 구분 | Access Token | Refresh Token |
|---|---|---|
| 역할 | API 요청 시 인증에 사용 | Access Token 재발급 전용 |
| 만료 시간 | 짧음 (1h) | 김 (14d) |
| 보관 위치 | 클라이언트 메모리 / localStorage | 안전한 저장소 (DB, httpOnly 쿠키) |
| 탈취 피해 | 최대 1시간 | 크므로 안전하게 보관 필수 |
| Payload | id, email 등 여러 정보 | id만 (최소한의 정보) |
전체 흐름 (13단계)
5. OAuth 2.0 Authorization Code Flow
OAuth 2.0은 소셜 로그인의 표준 프로토콜이다. "Google로 로그인" 같은 기능이 모두 이 표준을 따른다. 4개의 주체가 등장한다.
- User : 로그인하는 사람
- Client (우리 서버) : Google 로그인을 연동하는 앱 서버
- Authorization Server (Google Auth Server) : 동의 화면을 보여주고 코드를 발급하는 Google 서버
- Resource Server (Google API) : 사용자 프로필 등 실제 데이터를 제공하는 서버
Authorization Code와 Access Token을 굳이 분리하는 이유가 있다. Code는 쿼리 파라미터로 전달되어 브라우저 주소창에 노출된다. 네트워크 로그를 가로챈 해커가 탈취할 수 있다. Code를 일회용으로 만들고 Server-to-Server 통신으로 Token과 교환함으로써 보안을 강화한다.
6. Google Cloud Platform OAuth 설정
우리 서버와 Google 서버가 데이터를 주고받으려면 GCP에 서버를 등록하고 키를 발급받아야 한다.
발급 절차
- Google Cloud Console 에 접속해 프로젝트를 생성한다.
- APIs & Services > Credentials 로 이동한다.
- Create Credentials > OAuth client ID 를 선택한다.
- Application type을 Web application 으로 설정한다.
-
Authorised redirect URIs
에 콜백 URL을 등록한다. (예:
http://localhost:3000/oauth2/callback/google) -
생성 후
Client ID
와
Client Secret
을
.env에 저장한다.
.env
PORT=3000
DATABASE_URL="..."
PASSPORT_GOOGLE_CLIENT_ID="<Client ID>"
PASSPORT_GOOGLE_CLIENT_SECRET="<Client Secret>"
JWT_SECRET="<나만의 비밀 키>"
코드에서 사용하는
callbackURL과 GCP 콘솔의 Authorised redirect URIs 가 정확히 일치해야 한다. 두 값이 다르면 redirect_uri_mismatch 오류가 발생한다.
7. Passport + passport-google-oauth20 구현
패키지 설치
npm install passport passport-google-oauth20 jsonwebtoken passport-jwt
토큰 생성 함수 및 Google Strategy 정의
src/auth.config.js
import dotenv from "dotenv";
import { Strategy as GoogleStrategy } from "passport-google-oauth20";
import { prisma } from "./db.config.js";
import jwt from "jsonwebtoken";
dotenv.config();
const secret = process.env.JWT_SECRET;
export const generateAccessToken = (user) => {
return jwt.sign(
{ id: user.id, email: user.email },
secret,
{ expiresIn: '1h' }
);
};
export const generateRefreshToken = (user) => {
return jwt.sign(
{ id: user.id },
secret,
{ expiresIn: '14d' }
);
};
const googleVerify = async (profile) => {
const email = profile.emails?.[0]?.value;
if (!email) {
throw new Error(`profile.email was not found: ${profile}`);
}
const user = await prisma.user.findFirst({ where: { email } });
if (user !== null) {
return { id: user.id, email: user.email, name: user.name };
}
const created = await prisma.user.create({
data: {
email,
name: profile.displayName,
gender: "추후 수정",
birth: new Date(1970, 0, 1),
address: "추후 수정",
detailAddress: "추후 수정",
phoneNumber: "추후 수정",
},
});
return { id: created.id, email: created.email, name: created.name };
};
export const googleStrategy = new GoogleStrategy(
{
clientID: process.env.PASSPORT_GOOGLE_CLIENT_ID,
clientSecret: process.env.PASSPORT_GOOGLE_CLIENT_SECRET,
callbackURL: "/oauth2/callback/google",
scope: ["email", "profile"],
},
async (accessToken, refreshToken, profile, cb) => {
try {
const user = await googleVerify(profile);
const jwtAccessToken = generateAccessToken(user);
const jwtRefreshToken = generateRefreshToken(user);
return cb(null, { accessToken: jwtAccessToken, refreshToken: jwtRefreshToken });
} catch (err) {
return cb(err);
}
}
);
OAuth 라우트 등록
src/index.js
import passport from "passport";
import { googleStrategy } from "./auth.config.js";
passport.use(googleStrategy);
const app = express();
app.use(passport.initialize());
app.get("/oauth2/login/google",
passport.authenticate("google", { session: false })
);
app.get(
"/oauth2/callback/google",
passport.authenticate("google", {
session: false,
failureRedirect: "/login-failed",
}),
(req, res) => {
const tokens = req.user;
res.status(200).json({
resultType: "SUCCESS",
error: null,
success: {
message: "Google 로그인 성공!",
tokens,
},
});
}
);
-
/oauth2/login/google에 접속하면 Passport가 자동으로 Google 동의 화면으로 리다이렉트한다. -
/oauth2/callback/google은 Google이 Authorization Code를 전달하는 Callback URL이다. Passport가 Code를 Token으로 교환하고googleVerify를 실행한 뒤req.user에 JWT를 담아준다. -
session: false는 Passport의 세션 기능을 비활성화한다. JWT 방식에서는 세션이 필요 없다.
8. passport-jwt로 isLogin 미들웨어
Google 로그인으로 발급받은 Access Token을 검증하는 미들웨어를 구현한다.
passport-jwt
가 Authorization 헤더에서 Bearer 토큰을 자동으로 추출해 검증한다.
JWT Strategy 구현
src/auth.config.js (추가)
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
const jwtOptions = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
};
export const jwtStrategy = new JwtStrategy(jwtOptions, async (payload, done) => {
try {
const user = await prisma.user.findFirst({ where: { id: payload.id } });
if (user) {
return done(null, user);
} else {
return done(null, false);
}
} catch (err) {
return done(err, false);
}
});
jwtStrategy 등록 및 보호된 라우트 적용
src/index.js (추가)
import { googleStrategy, jwtStrategy } from "./auth.config.js";
passport.use(googleStrategy);
passport.use(jwtStrategy);
const isLogin = passport.authenticate('jwt', { session: false });
app.get('/mypage', isLogin, (req, res) => {
res.status(200).json({
resultType: "SUCCESS",
error: null,
success: {
message: `인증 성공! ${req.user.name}님의 마이페이지입니다.`,
user: req.user,
},
});
});
테스트 흐름
-
브라우저에서
http://localhost:3000/oauth2/login/google에 접속해 Google 로그인을 완료한다. -
Callback URL로 리다이렉트되며 JSON으로
accessToken과refreshToken이 출력된다. -
accessToken을 복사한다. -
Postman에서
GET /mypage요청의 Headers에Authorization: Bearer <accessToken>을 추가한다. -
인증에 성공하면 사용자 정보가 담긴 응답이 반환된다. 토큰 없이 요청하면
401 Unauthorized가 반환된다.
ExtractJwt.fromAuthHeaderAsBearerToken()은 요청 헤더의Authorization: Bearer <token>형식에서 토큰 부분만 자동으로 추출한다. 토큰 검증에 성공하면 Payload(payload.id 등)가 전달되고, 이를 이용해 DB에서 사용자를 조회한 뒤req.user에 담아준다.
'Study > Node.JS' 카테고리의 다른 글
| [Express] CORS & Swagger 세팅 (0) | 2026.04.08 |
|---|---|
| [Express] Express 미들웨어 & 에러 핸들링 (1) | 2026.04.08 |
| [Node.js / Express 5] ORM으로 Repository 리팩토링 (0) | 2026.04.01 |
| [Node.js / Express 5] Express API 개발 실습 (0) | 2026.04.01 |
| [Node.js / Express 5] Node.js 핵심 개념과 프로젝트 구조 (0) | 2026.04.01 |
