JWT와 세션 인증, 어떤 상황에서 뭘 써야 하나

신규 서비스를 설계하다 보면 꼭 한 번은 마주치는 갈림길이 있다. 인증 방식을 JWT로 갈지, 세션 기반으로 갈지. 검색하면 “JWT가 무상태(stateless)라서 확장성이 좋다”는 말이 넘쳐나지만, 막상 실무에서 둘 다 써본 입장에서는 이 말이 절반만 맞다고 느낀다. JWT가 만능이었다면 세션 방식은 진작에 사라졌을 것이다.

이 글은 개념 설명보다는 어떤 상황에서 어떤 선택이 더 나은지, 그리고 각각의 방식에서 실제로 자주 발생하는 보안 실수들을 다룬다.

JWT와 세션 기반 인증 요청 흐름 비교

두 방식이 실제로 어떻게 다른가

세션 방식은 서버가 상태를 들고 있다. 로그인하면 서버 메모리(또는 Redis 같은 스토어)에 세션 데이터가 생기고, 클라이언트는 세션 ID만 쿠키로 보관한다. 요청이 들어올 때마다 서버는 “이 세션 ID 진짜야?” 하고 저장소를 조회한다.

JWT는 반대다. 서버가 아무것도 기억하지 않는다. 토큰 자체에 사용자 정보(클레임)가 담겨 있고, 서버는 서명(signature)만 검증해서 유효 여부를 판단한다. 조회 비용이 없는 대신, 한 번 발급된 토큰은 만료 전까지 강제로 무효화하기 어렵다.

이 구조적 차이가 아래의 모든 선택 기준으로 이어진다.


선택 기준 한눈에 보기

상황추천 방식이유
단일 서버, 소규모 서비스세션구현 단순, 즉각적인 세션 무효화 가능
마이크로서비스 / 분산 환경JWT서비스 간 상태 공유 불필요
즉각적인 강제 로그아웃 필요세션서버에서 직접 세션 삭제 가능
모바일 앱 / SPAJWT (단, 리프레시 토큰 포함)쿠키 없이 인증 가능, 유연성 높음
서드파티 API 인증JWT무상태 구조가 적합
높은 보안 요구 (금융, 의료)세션 또는 단기 JWT + 로테이션즉각 무효화 제어권 확보
관리자 패널 / 내부 도구세션단순하고 통제가 쉬움

JWT에서 자주 발생하는 보안 실수

alg를 “none”으로 허용하는 문제

JWT 헤더의 alg 필드를 서버가 신뢰하면 치명적이다. 공격자가 "alg": "none"으로 설정한 토큰을 만들어 서명 없이 통과시킬 수 있다.

js

// ❌ 잘못된 예: 토큰 헤더의 alg를 그대로 사용
const decoded = jwt.decode(token, { complete: true });
const algorithm = decoded.header.alg; // 공격자가 조작 가능
jwt.verify(token, secret, { algorithms: [algorithm] });

// ✅ 올바른 예: 서버에서 알고리즘을 명시적으로 고정
jwt.verify(token, secret, { algorithms: ['HS256'] });

만료 시간을 너무 길게 설정하는 문제

토큰이 탈취됐을 때 피해를 줄이려면 만료 시간을 짧게 유지해야 한다. 경험상 액세스 토큰은 15분~1시간, 리프레시 토큰은 7~30일 정도가 현실적인 균형점이다.

js

// ❌ 잘못된 예: 만료 없이 발급
const token = jwt.sign({ userId: 123 }, secret);

// ✅ 올바른 예: 짧은 만료 + 리프레시 토큰 분리
const accessToken = jwt.sign({ userId: 123 }, secret, { expiresIn: '15m' });
const refreshToken = jwt.sign({ userId: 123 }, refreshSecret, { expiresIn: '7d' });

토큰을 localStorage에 저장하는 문제

localStorage에 JWT를 저장하면 XSS 공격으로 그대로 탈취된다. httpOnly 쿠키로 저장하거나, 메모리(변수)에만 보관하고 리프레시는 쿠키를 사용하는 방식이 낫다.

주의: httpOnly 쿠키로 저장하면 CSRF 취약점이 생기므로, SameSite=Strict 또는 CSRF 토큰을 함께 사용해야 한다.


세션에서 자주 발생하는 보안 실수

세션 고정(Session Fixation) 공격

로그인 전에 발급된 세션 ID를 로그인 후에도 그대로 사용하면 문제가 된다. 공격자가 미리 알고 있는 세션 ID로 인증 후 상태를 가로챌 수 있다.

js

// ❌ 잘못된 예: 로그인 후 세션 ID 재발급 없음
app.post('/login', (req, res) => {
  req.session.userId = user.id; // 기존 세션 ID 그대로 유지
  res.send('logged in');
});

// ✅ 올바른 예: 로그인 성공 시 세션 재생성
app.post('/login', (req, res) => {
  req.session.regenerate((err) => { // 새로운 세션 ID 발급
    req.session.userId = user.id;
    res.send('logged in');
  });
});

세션 스토어 없이 다중 서버 운영

세션을 서버 메모리에만 저장하면 로드밸런서 환경에서 특정 서버에만 세션이 있어 로그아웃 현상이 생긴다. Redis 같은 외부 스토어를 사용해야 한다.

js

// ✅ Redis 기반 세션 스토어 사용 (express-session + connect-redis)
const session = require('express-session');
const RedisStore = require('connect-redis')(session);
const client = require('redis').createClient();

app.use(session({
  store: new RedisStore({ client }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { secure: true, httpOnly: true, maxAge: 1000 * 60 * 30 } // 30분
}));

팁: saveUninitialized: false 설정은 로그인 전 빈 세션이 스토어에 쌓이는 걸 막아줘서 메모리 낭비를 줄인다.


JWT 강제 무효화, 정말 불가능한가

“JWT는 무효화가 안 된다”는 말은 기본 구현에 한정된 얘기다. 몇 가지 방법으로 우회할 수 있다.

블랙리스트 방식: 무효화할 토큰 ID(jti 클레임)를 Redis에 등록하고, 검증 시 확인한다. 이렇게 하면 상태를 일부 가지게 되지만, 탈취 대응이나 강제 로그아웃이 가능해진다.

리프레시 토큰 로테이션: 리프레시 토큰을 DB에 저장하고, 사용할 때마다 교체한다. 탈취된 토큰이 사용되면 이상 징후를 감지할 수 있다.

두 방법 모두 순수한 무상태 구조를 포기하는 트레이드오프가 있다. 높은 보안이 필요하다면 이 트레이드오프를 감수하는 게 맞다.


FAQ

Q. 마이크로서비스 환경인데 세션을 쓰면 안 되나요? 안 되는 건 아니다. 중앙 Redis를 공유 세션 스토어로 쓰면 된다. 다만 모든 서비스가 Redis에 의존하게 되고, 네트워크 비용도 발생한다. JWT가 더 자연스러운 선택이지만, 강제 무효화가 중요하다면 세션이 낫다.

Q. 리프레시 토큰은 어디에 저장해야 하나요? httpOnly + Secure + SameSite=Strict 쿠키에 저장하는 게 가장 안전하다. 서버에서는 DB나 Redis에 저장해서 사용 여부를 추적할 수 있어야 한다.

Q. HS256과 RS256 중 뭘 써야 하나요? 단일 서비스에서 발급과 검증을 모두 담당하면 HS256으로 충분하다. 다른 서비스나 서드파티가 토큰을 검증해야 한다면 RS256(비대칭 키)을 쓴다. 검증 측에 비밀 키를 공유할 필요가 없어서 안전하다.

Q. JWT 페이로드에 비밀번호나 민감 정보를 넣어도 되나요? 안 된다. JWT는 기본적으로 Base64로 인코딩된 것이지 암호화된 게 아니다. 누구나 디코딩해서 내용을 볼 수 있다. 민감 정보는 절대 페이로드에 넣지 않는다.

Q. 세션 쿠키에 Secure 플래그를 안 붙이면 어떻게 되나요? HTTP 환경에서도 쿠키가 전송되어 중간자 공격(MITM)으로 탈취될 수 있다. 프로덕션에서는 반드시 Secure 플래그를 달고, HTTPS만 사용해야 한다.

Q. 소셜 로그인(OAuth)을 구현할 때는 어떤 방식이 맞나요? OAuth 자체가 액세스 토큰 기반이라 JWT와 잘 맞는다. 다만 내부 사용자 세션 관리는 별도로 처리해야 한다. 소셜 로그인으로 받은 토큰과 내 서비스의 인증 세션은 분리해서 관리하는 게 깔끔하다.

Q. 토큰 탈취가 의심될 때 즉각 대응할 방법이 있나요? 세션이라면 서버에서 해당 세션을 삭제하면 끝이다. JWT라면 앞서 설명한 jti 블랙리스트 방식을 사용하거나, 리프레시 토큰을 무효화해서 새 액세스 토큰 발급을 막는 게 현실적인 방법이다.


마무리 체크리스트

새 프로젝트에서 인증 방식을 결정하기 전에 아래를 점검해보자.

  •  강제 로그아웃(탈취 대응)이 즉각 필요한가? → 세션 우선 검토
  •  서비스가 여러 도메인 또는 마이크로서비스로 분산되어 있는가? → JWT 고려
  •  JWT를 쓴다면 액세스 토큰 만료를 1시간 이하로 설정했는가?
  •  리프레시 토큰을 DB/Redis에 저장해서 무효화 가능하게 했는가?
  •  세션을 쓴다면 Redis 등 외부 스토어를 사용하고 있는가?
  •  쿠키에 httpOnlySecureSameSite 속성을 모두 설정했는가?
  •  로그인 성공 시 세션 ID를 재발급하고 있는가?
  •  JWT 페이로드에 민감 정보가 포함되지 않았는가?

인증 방식 선택은 시작일 뿐이다. 다음 단계로는 토큰 로테이션 전략과 OAuth 2.0 연동을 다루면 더 견고한 인증 시스템을 만들 수 있다.

댓글 남기기