인증과 인가 — JWT와 Session, 그리고 하이브리드 전략
로그인 기능을 구현할 때 JWT를 쓸지 Session을 쓸지, 어떤 기준으로 결정하면 좋을까요?
공부하다 보니 "JWT vs Session" 논쟁은 양자택일이 아니라는 걸 알게 되었습니다. 2025-2026년 기준 업계 컨센서스는 둘 다 단독으로는 충분하지 않다 는 것이고, 각각의 약점을 보완하는 하이브리드 전략이 주류가 되고 있습니다.
인증 vs 인가
먼저 용어를 명확히 하겠습니다.
- 인증(Authentication) — "누구인가?" 신원 확인. 로그인 과정
- ** 인가(Authorization)** — "무엇을 할 수 있는가?" 권한 확인. 접근 제어
사용자 → 로그인(인증) → 관리자 페이지 접근 시도 → 권한 확인(인가)
이 두 개념을 혼용하면 면접에서 감점 요소가 됩니다. 영어 약어로 AuthN(인증), AuthZ(인가)라고 구분하기도 합니다.
Session 기반 인증
동작 흐름
1. 클라이언트 → 서버: 아이디/비밀번호 전송
2. 서버: 검증 후 세션 생성 (서버 메모리에 저장)
3. 서버 → 클라이언트: 세션 ID를 쿠키로 전달
4. 이후 요청마다 쿠키에 세션 ID 포함 → 서버가 세션 조회
// Spring Security에서의 세션 기반 인증 설정
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/dashboard"))
.sessionManagement(session -> session
.maximumSessions(1) // 동시 세션 1개 제한
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
.build();
}
** 장점:**
- 서버에서 세션을 즉시 무효화 가능 (강제 로그아웃)
- 세션 ID 자체에는 사용자 정보가 없어 탈취 시 피해 범위 제한
- 구현이 직관적
** 약점:**
- Stateful — 서버가 상태를 저장해야 함
- 서버 확장 시 세션 공유 문제 (Sticky Session 또는 외부 저장소 필요)
- 서버 재시작 시 세션 유실 (메모리 저장 시)
JWT 기반 인증
JWT 구조
JWT는 세 부분으로 구성됩니다: Header.Payload.Signature
eyJhbGciOiJIUzI1NiJ9. ← Header (알고리즘, 타입)
eyJzdWIiOiJ1c2VyMSIsInJvbGUi. ← Payload (클레임: 사용자 정보)
SflKxwRJSMeKKF2QT4fwpMeJf36P ← Signature (서명: 위변조 방지)
- Header — 서명 알고리즘 (HS256, RS256 등)
- Payload — 사용자 정보(클레임). Base64 인코딩 (암호화 아님!)
- Signature — Header + Payload를 비밀키로 서명
Payload는 Base64 디코딩하면 누구나 읽을 수 있습니다. 민감한 정보(비밀번호 등)를 절대 넣으면 안 됩니다.
동작 흐름
1. 클라이언트 → 서버: 아이디/비밀번호 전송
2. 서버: 검증 후 JWT 생성 (비밀키로 서명)
3. 서버 → 클라이언트: JWT 반환
4. 이후 요청마다 Authorization 헤더에 JWT 포함
5. 서버: JWT 서명 검증 + 만료 시간 확인 → 인증 완료
// JWT 생성 예시
public String createToken(String username, String role) {
return Jwts.builder()
.subject(username)
.claim("role", role)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 1000 * 60 * 15)) // 15분
.signWith(secretKey)
.compact();
}
장점:
- Stateless — 서버가 상태를 저장하지 않음
- 서버 확장이 쉬움 — 어떤 서버가 받아도 토큰 검증 가능
- 마이크로서비스 간 인증 전달이 편리
** 약점:**
- ** 발급 후 강제 무효화 불가** — 탈취 시 만료까지 악용 가능
- 토큰 크기가 큼 — 매 요청마다 전송
- Payload 노출 — Base64이므로 디코딩 가능
JWT의 치명적 약점
JWT의 가장 큰 문제는 ** 토큰 탈취 시 만료 전까지 무력화할 방법이 없다 **는 것입니다.
공격자가 JWT 탈취 → 만료 시간까지 자유롭게 사용 → 서버에서 차단 불가
이를 보완하려면 결국 서버에 상태를 두어야 합니다:
- 블랙리스트 (토큰 무효화 목록) → 그러면 Stateless 장점이 사라짐
- 매우 짧은 유효 시간 → 사용자가 자주 재로그인해야 함
여기서 하이브리드 전략이 등장합니다.
하이브리드 전략 — 짧은 JWT + Redis Session
2025-2026년 업계에서 가장 권장되는 접근입니다.
핵심 아이디어
- Access Token (JWT) — 유효 시간 5~15분. 빠른 Stateless 인증
- Refresh Token — 서버(Redis)에 저장. 유효 시간 7~30일. Access Token 재발급용
- ** 세션 정보** — Redis에 저장. 즉시 무효화 가능
동작 흐름
1. 로그인 → Access Token(JWT, 15분) + Refresh Token(Redis, 7일) 발급
2. API 요청 → Access Token으로 빠른 인증 (DB 조회 없음)
3. Access Token 만료 → Refresh Token으로 새 Access Token 발급
4. 강제 로그아웃 → Redis에서 Refresh Token 삭제 → 재발급 불가
// 하이브리드 인증 흐름 예시
@PostMapping("/auth/login")
public TokenResponse login(@RequestBody LoginRequest request) {
// 사용자 검증
User user = authService.authenticate(request);
// 짧은 수명의 Access Token (JWT)
String accessToken = jwtProvider.createAccessToken(user, Duration.ofMinutes(15));
// 긴 수명의 Refresh Token (Redis에 저장)
String refreshToken = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(
"refresh:" + refreshToken,
user.getId().toString(),
Duration.ofDays(7)
);
return new TokenResponse(accessToken, refreshToken);
}
@PostMapping("/auth/refresh")
public TokenResponse refresh(@RequestBody RefreshRequest request) {
// Redis에서 Refresh Token 검증
String userId = redisTemplate.opsForValue()
.get("refresh:" + request.getRefreshToken());
if (userId == null) {
throw new UnauthorizedException("유효하지 않은 Refresh Token");
}
// 새 Access Token 발급
User user = userRepository.findById(Long.parseLong(userId)).orElseThrow();
String newAccessToken = jwtProvider.createAccessToken(user, Duration.ofMinutes(15));
return new TokenResponse(newAccessToken, request.getRefreshToken());
}
왜 이 조합이 효과적인가
| 문제 | 해결 방식 |
|---|---|
| JWT 탈취 | 유효 시간 15분 → 피해 범위 최소화 |
| JWT 강제 무효화 | Refresh Token을 Redis에서 삭제 |
| Session 확장성 | 대부분의 요청은 JWT로 Stateless 처리 |
| 사용자 경험 | Refresh Token으로 자동 재발급 → 재로그인 불필요 |
Refresh Token 보안
Refresh Token은 Access Token보다 더 중요합니다. 탈취되면 지속적으로 새 Access Token을 발급받을 수 있기 때문입니다.
보안 권장사항:
- HttpOnly 쿠키 로 전달 — JavaScript에서 접근 불가
- Secure 플래그 — HTTPS에서만 전송
- Refresh Token Rotation — 사용할 때마다 새 토큰으로 교체
- ** 사용 탐지** — 이미 사용된 Refresh Token이 다시 사용되면 모든 토큰 무효화
// Refresh Token Rotation 예시
@PostMapping("/auth/refresh")
public TokenResponse refreshWithRotation(@RequestBody RefreshRequest request) {
String key = "refresh:" + request.getRefreshToken();
String userId = redisTemplate.opsForValue().get(key);
if (userId == null) {
throw new UnauthorizedException("유효하지 않은 Refresh Token");
}
// 기존 Refresh Token 삭제
redisTemplate.delete(key);
// 새 Refresh Token 발급
String newRefreshToken = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(
"refresh:" + newRefreshToken, userId, Duration.ofDays(7));
User user = userRepository.findById(Long.parseLong(userId)).orElseThrow();
String newAccessToken = jwtProvider.createAccessToken(user, Duration.ofMinutes(15));
return new TokenResponse(newAccessToken, newRefreshToken);
}
OAuth 2.0과의 관계
OAuth 2.0은 ** 인가(Authorization) 프레임워크 **입니다. "이 앱이 내 구글 캘린더에 접근해도 되는가?"를 처리합니다.
- OAuth 2.0 자체는 인증 프로토콜이 아님
- OpenID Connect(OIDC)가 OAuth 2.0 위에 인증 계층을 추가한 것
- "구글로 로그인"은 OAuth 2.0 + OIDC 조합
JWT는 OAuth 2.0에서 Access Token의 형식으로 자주 사용됩니다. 즉, JWT는 토큰 형식이고, OAuth 2.0은 토큰을 발급/사용하는 프로토콜입니다.
정리
- ** 인증(누구인가)과 인가(뭘 할 수 있는가)는 다른 개념** — 면접에서 혼용하면 안 됨
- Session은 즉시 무효화 가능하지만 Stateful, JWT는 Stateless지만 강제 무효화 어려움
- ** 둘 다 단독으로는 충분하지 않음** — 하이브리드 전략이 2026년 기준 권장
- 짧은 JWT(15분) + Redis Refresh Token(7일) 조합이 보안과 확장성을 모두 확보
- Refresh Token은 HttpOnly 쿠키 + Rotation으로 보호
- OAuth 2.0은 인가 프레임워크, JWT는 토큰 형식 — 레이어가 다름