JWT(Json Web Token)는 사용자의 인증 정보를 안전하게 클라이언트에 저장하고 전달하기 위해 만들어진 토큰 기반 인증 방식.
💡 기존의 세션 기반 인증은 서버에 상태(Session)을 저장해야 하지만, JWT는 서버에 별도 저장 없이 인증 정보를 클라이언트가 직접 보관합니다.
이 때문에 Stateless(무상태) 인증 방식이라고도 불립니다.
JWT는 총 3개의 부분으로 구성되며, 각 부분은 Base64Url 인코딩되어 .으로 구분된 문자열입니다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJ1c2VySWQiOiIxMjMiLCJyb2xlIjoiUk9MRV9VU0VSIn0
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
| 구분 | 설명 |
|---|---|
| 🔖 Header | 토큰의 타입과 해싱 알고리즘 정보 (예: HS256) |
| 📄 Payload | 토큰에 담을 클레임(Claim) - 사용자 정보, 만료시간 등 |
| 🔏 Signature | 위의 내용을 비밀 키로 서명한 값 - 위조 방지용 |
{
"alg": "HS256",
"typ": "JWT"
}
{
"sub": "access-token",
"userId": "123",
"role": "USER",
"exp": 1691335599
}
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
[로그인 요청]
→ [서버: 유효한 사용자 확인]
→ [AccessToken + RefreshToken 발급]
→ [클라이언트 저장 및 이후 요청 시 AccessToken 전송]
→ [서버에서 토큰 검증 후 자원 제공]
Authorization: Bearer <AccessToken>
| 구분 | Access Token | Refresh Token |
|---|---|---|
| ⏳ 만료 시간 | 짧음 (예: 15분) | 김 (예: 2주) |
| 🔐 저장 위치 | 클라이언트 (localStorage 등) | 클라이언트 or 서버 (Redis 등) |
| 🌀 사용 목적 | 매 요청마다 인증용 | 만료된 AccessToken 재발급 |
| ❌ 유출 시 위험 | 낮음 (짧은 유효기간) | 높음 (재발급 가능, 관리 중요!) |
📌 Refresh Token은 보통 Redis에 저장하고 유효성 검사에 사용합니다.
Spring Boot 환경에서 JWT 기반 인증 로직을 적용하여,
// build.gradle
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
@Component
public class JwtTokenProvider {
private final String secretKey = "your-secret-key";
private final long accessTokenValidity = 1000L * 60 * 15; // 15분
private final long refreshTokenValidity = 1000L * 60 * 60 * 24 * 7; // 7일
public String createAccessToken(String userId, String role) {
return createToken(userId, role, accessTokenValidity, "access");
}
public String createRefreshToken(String userId) {
return createToken(userId, null, refreshTokenValidity, "refresh");
}
private String createToken(String userId, String role, long validity, String type) {
Claims claims = Jwts.claims().setSubject(userId);
if (role != null) claims.put("role", role);
claims.put("type", type);
Date now = new Date();
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + validity))
.signWith(SignatureAlgorithm.HS256, secretKey.getBytes())
.compact();
}
public String getUserId(String token) {
return Jwts.parser()
.setSigningKey(secretKey.getBytes())
.parseClaimsJws(token)
.getBody()
.getSubject();
}
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(secretKey.getBytes()).parseClaimsJws(token);
return true;
} catch (Exception e) {
return false;
}
}
}
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
if (tokenProvider.validateToken(token)) {
String userId = tokenProvider.getUserId(token);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userId, null, List.of());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response);
}
}
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
POST /auth/refresh
Authorization: Bearer {RefreshToken}
{
"accessToken": "new-access-token",
"refreshToken": "new-refresh-token"
}
| 기능 | 설명 |
|---|---|
| AccessToken 발급 | 로그인 시 createAccessToken() 사용 |
| RefreshToken 발급 | createRefreshToken() |
| AccessToken 인증 필터 | JwtAuthenticationFilter |
| Spring Security 연동 | addFilterBefore()로 필터 등록 |
| 재발급 로직 설계 | /auth/refresh 엔드포인트로 구현 |
| 문제 상황 | Redis 저장으로 해결할 수 있는 것 |
|---|---|
| 🔓 Refresh Token이 유출되었을 때 | 서버에서 중앙 관리 → 강제 무효화 가능 |
| 🚫 로그아웃 시 토큰 무효화 | Redis에서 해당 토큰 삭제 |
| 🔁 다중 로그인 제어 | 사용자별로 하나의 Refresh Token만 유지 가능 |
| 🧠 서버 재시작에도 유지 | Redis는 별도 인메모리 저장소이므로 세션 유지 가능 |
📌 Refresh Token은 Access Token을 재발급할 수 있는 열쇠이기 때문에 반드시 서버에서 관리되어야 해!
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
spring:
data:
redis:
host: localhost
port: 6379
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private final RedisTemplate<String, String> redisTemplate;
private final long refreshTokenValidity = 1000L * 60 * 60 * 24 * 7; // 7일
public void saveRefreshToken(String userId, String refreshToken) {
redisTemplate.opsForValue().set(
"RT:" + userId,
refreshToken,
refreshTokenValidity,
TimeUnit.MILLISECONDS
);
}
public String getRefreshToken(String userId) {
return redisTemplate.opsForValue().get("RT:" + userId);
}
public void deleteRefreshToken(String userId) {
redisTemplate.delete("RT:" + userId);
}
}
🔑 키 형식은 "RT:사용자ID"로 구분해주면 여러 토큰과 혼동 방지 가능.
2) 로그인 시 저장
String refreshToken = jwtTokenProvider.createRefreshToken(userId); refreshTokenService.saveRefreshToken(userId, refreshToken);
public TokenResponse reissue(String userId, String refreshToken) {
String storedToken = refreshTokenService.getRefreshToken(userId);
if (!refreshToken.equals(storedToken)) {
throw new InvalidTokenException("Refresh Token 불일치");
}
// 재발급
String newAccessToken = jwtTokenProvider.createAccessToken(userId, "ROLE_USER");
String newRefreshToken = jwtTokenProvider.createRefreshToken(userId);
// Redis 업데이트
refreshTokenService.saveRefreshToken(userId, newRefreshToken);
return new TokenResponse(newAccessToken, newRefreshToken);
}
public void logout(String userId) {
refreshTokenService.deleteRefreshToken(userId);
}
→ 사용자가 로그아웃하면 재발급이 불가능해지므로, 이후 Access Token이 만료되면 인증 실패가 일어남.
| 항목 | 설명 |
|---|---|
| 🔒 Refresh Token은 반드시 서버에서 관리 (Redis 등) | 민감한 토큰이므로 DB나 Redis 같은 중앙 저장소에 저장하고, 탈취 가능성을 줄이기 위해 클라이언트 저장은 지양함 |
| 🕐 Access Token은 클라이언트 저장, 짧은 만료 | 요청마다 사용되므로 클라이언트에 저장되며, 유효 기간은 짧게 설정해 보안 리스크를 줄임 |
| 🚫 탈취 시 즉시 Redis에서 삭제 가능 | 토큰이 유출된 경우 서버에서 해당 Refresh Token을 Redis에서 제거하면 즉시 무효화됨 |
| 🔁 재발급 시 Refresh Token도 교체 & Redis 갱신 | Refresh Token 자체도 유출될 수 있기 때문에 재발급 시 항상 새로 생성하여 Redis에 갱신함 |
| 🧪 매 요청마다 Access Token 검사, 만료 시 재발급만 허용 | Access Token은 요청마다 검사하고, 만료되었을 때만 Refresh Token을 통해 새 토큰을 발급함 |
| 구성 요소 | 구현 위치 |
|---|---|
| Redis 연결 설정 | application.yml |
| 저장 로직 | RefreshTokenService |
| 로그인 시 저장 | 로그인 로직에서 saveRefreshToken() |
| 재발급 검증 및 갱신 | /auth/refresh API |
| 로그아웃 처리 | deleteRefreshToken() 호출 |