로그인한 사용자를 "기억"하는 방법은 여러 가지인데, 세션과 JWT 중 어떤 걸 선택해야 할까요?

HTTP는 본질적으로 상태가 없습니다(stateless). 매 요청이 독립적이기 때문에, 서버가 "이 사용자는 로그인했다"는 사실을 기억하려면 별도의 메커니즘이 필요합니다. 이 선택이 아키텍처 전체에 영향을 미칩니다.

개념 정의

인증(Authentication) 은 "이 사용자가 누구인지" 확인하는 과정입니다. 인가(Authorization) 는 "이 사용자가 이 자원에 접근할 수 있는지" 판단하는 과정입니다. 이 글에서는 인증에 집중합니다.

세션 vs JWT — 두 가지 전략

기준세션 기반JWT 기반
상태 저장서버에 세션 저장 (메모리/Redis)클라이언트에 토큰 저장
확장성서버 간 세션 공유 필요상태 없음, 수평 확장 쉬움
로그아웃서버에서 세션 삭제하면 즉시 만료토큰 무효화가 어려움
보안세션 ID만 전송 (데이터는 서버)페이로드가 클라이언트에 노출
용량쿠키에 세션 ID만 (~32bytes)토큰 자체가 큼 (500bytes)
적합한 경우단일 서버, 즉시 만료 필요마이크로서비스, 모바일 앱

JWT 동작 원리

JWT(JSON Web Token)는 세 부분으로 구성됩니다.

PLAINTEXT
헤더.페이로드.서명
eyJhbGci...  .  eyJ1c2Vy...  .  SflKxwRJ...
  1. **헤더 **: 알고리즘과 토큰 타입 ({"alg": "HS256", "typ": "JWT"})
  2. ** 페이로드 **: 사용자 정보 (claims). Base64 인코딩이지 ** 암호화가 아닙니다 **.
  3. ** 서명 **: 헤더 + 페이로드를 서버의 비밀키로 서명한 값

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

순서클라이언트방향서버비고
1이메일 + 비밀번호-->검증로그인 요청
2<--JWT 발급Access + Refresh 토큰
3Authorization: Bearer {token}-->서명 검증API 요청마다
4<--응답 데이터검증 성공 시

Express에서 JWT를 구현하면 다음과 같습니다.

JS
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;

// 로그인 — 토큰 발급
app.post('/login', async (req, res) => {
  const user = await User.findByEmail(req.body.email);
  if (!user || !await bcrypt.compare(req.body.password, user.password)) {
    return res.status(401).json({ error: '인증 실패' });
  }
  const token = jwt.sign({ id: user.id }, SECRET, { expiresIn: '1h' });
  res.json({ token });
});
JS
// 인증 미들웨어 — 토큰 검증
function authenticate(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: '토큰 없음' });
  try {
    req.user = jwt.verify(token, SECRET); // ← 서명 검증 + 만료 체크
    next();
  } catch {
    return res.status(401).json({ error: '유효하지 않은 토큰' });
  }
}

jwt.verify()는 서명 검증과 만료 시간 체크를 동시에 수행합니다. 위변조된 토큰이나 만료된 토큰은 여기서 걸립니다.

Passport.js — 인증 전략 추상화

Passport.js는 인증 로직을 Strategy 패턴 으로 추상화합니다. 로컬 로그인, JWT, OAuth 등을 동일한 인터페이스로 처리합니다.

JS
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const JwtStrategy = require('passport-jwt').Strategy;

// Local 전략 — 이메일/비밀번호 로그인
passport.use(new LocalStrategy(
  { usernameField: 'email' },
  async (email, password, done) => {
    const user = await User.findByEmail(email);
    if (!user) return done(null, false);
    if (!await bcrypt.compare(password, user.password)) return done(null, false);
    return done(null, user);
  }
));

// JWT 전략 — 토큰 검증
passport.use(new JwtStrategy(
  { jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKey: SECRET },
  async (payload, done) => {
    const user = await User.findById(payload.id);
    return done(null, user || false);
  }
));
JS
// 라우트에서 사용
app.post('/login', passport.authenticate('local', { session: false }), (req, res) => {
  const token = jwt.sign({ id: req.user.id }, SECRET, { expiresIn: '1h' });
  res.json({ token });
});

app.get('/profile', passport.authenticate('jwt', { session: false }), (req, res) => {
  res.json(req.user);
});

{ session: false }를 지정하면 Passport가 세션을 사용하지 않습니다. JWT 기반 인증에서는 항상 이 옵션을 켜야 합니다.

보안 미들웨어

인증만으로는 보안이 완성되지 않습니다. Express 애플리케이션에 필수적인 보안 미들웨어를 살펴보겠습니다.

helmet — HTTP 헤더 보안

JS
const helmet = require('helmet');
app.use(helmet()); // ← 한 줄로 11개 보안 헤더 설정

helmet은 X-Content-Type-Options, Strict-Transport-Security, X-Frame-Options 등의 보안 헤더를 자동으로 설정합니다. 기본 설정만으로도 XSS, 클릭재킹 등의 공격 표면을 줄여줍니다.

cors — Cross-Origin Resource Sharing

JS
const cors = require('cors');
app.use(cors({
  origin: 'https://myapp.com',       // ← 허용할 출처
  methods: ['GET', 'POST', 'PUT'],
  credentials: true,                  // ← 쿠키 전송 허용
}));

cors()를 인자 없이 호출하면 모든 출처를 허용합니다. 프로덕션에서는 반드시 origin을 지정해야 합니다.

express-rate-limit — 요청 제한

JS
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15분
  max: 100,                   // IP당 최대 100회
  message: { error: 'Too many requests' },
});

app.use('/api', limiter);

브루트포스 공격이나 API 남용을 방지합니다. 로그인 엔드포인트에는 더 엄격한 제한을 걸어야 합니다.

주의할 점

JWT 페이로드에 민감한 정보 넣지 않기

JWT의 페이로드는 Base64 인코딩 이지 암호화 가 아닙니다. 누구나 디코딩할 수 있습니다.

JS
// 위험 — 비밀번호 해시가 토큰에 노출
jwt.sign({ id: user.id, passwordHash: user.password }, SECRET);

// 안전 — 최소한의 식별 정보만
jwt.sign({ id: user.id, role: user.role }, SECRET);

JWT 무효화(로그아웃)가 어렵다

JWT는 서버에 상태가 없기 때문에, 발급된 토큰을 "무효화"할 방법이 기본적으로 없습니다. 만료 시간 전까지 유효합니다.

해결 방법은 세 가지입니다.

  • Access Token 수명을 짧게 (15분) + Refresh Token으로 갱신
  • ** 블랙리스트 **: Redis에 로그아웃된 토큰 ID를 저장하고 검증 시 체크
  • ** 토큰 버전 **: DB에 사용자별 토큰 버전을 두고, 버전 불일치 시 거부

비밀키를 코드에 하드코딩하지 않기

JS
// 절대 금지
const SECRET = 'my-super-secret-key';

// 환경 변수로 분리
const SECRET = process.env.JWT_SECRET;

정리

항목설명
세션 vs JWT서버 상태 저장 vs 클라이언트 상태, 트레이드오프
JWT 구조헤더.페이로드.서명, 페이로드는 암호화가 아닌 인코딩
Passport.jsStrategy 패턴으로 인증 전략 추상화
helmetHTTP 보안 헤더 자동 설정
cors프로덕션에서 반드시 origin 지정
rate-limit브루트포스/남용 방지, 엔드포인트별 차등 적용
JWT 무효화짧은 만료 + Refresh Token 또는 블랙리스트
댓글 로딩 중...