이번 글에서는 MyBoard 프로젝트에 JWT 기반 인증을 도입한 과정을 단계별로 정리하려고 합니다.
순서대로 내용을 정리하려고 합니다.
JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA.
Although JWTs can be encrypted to also provide secrecy between parties, we will focus on signed tokens. Signed tokens can verify the integrity of the claims contained within it, while encrypted tokens hide those claims from other parties. When tokens are signed using public/private key pairs, the signature also certifies that only the party holding the private key is the one that signed it.
웹 애플리케이션에서 가장 많이 사용하는 인증 방식은 서버에 세션을 저장하는 세션 기반 방법입니다.
세션 기반은 보안성이 높고 강제 로그아웃이나 세션 만료를 서버에서 제어할 수 있다는 장점이 있지만, 서버 메모리나 DB에 세션 정보를 유지해야 하므로 부하가 커지고 여러 서버로 확장할 때 세션 동기화가 필요합니다.
반면 JWT(Json Web Token) 는 토큰 자체에 사용자 ID나 권한(Role) 같은 클레임 정보를 담아 전송하고, 서버는 별도의 세션 저장 없이 토큰만 검증하면 되기 때문에 완전한 무상태(Stateless) API 구현이 가능합니다.
또한 필터 단계에서 토큰 서명을 검증한 뒤 사용자의 권한 정보를 바로 꺼내 쓸 수 있어 성능과 확장성 측면에서도 적합하다고 판단하여 JWT를 선택했습니다.
JWT는 크게 세 부분으로 이루어집니다.
sub
(주로 사용자 식별자), exp
(만료시간), roles
같은 클레임이 포함Base64UrlEncode(header) + "." + Base64UrlEncode(payload)
에 비밀키를 적용한 HMACSHA256
서명값인증 절차는 다음과 같습니다.
Authorization: Bearer <AT>
를 포함합니다. MyBoard에서는 JwtProvider
라는 컴포넌트에서 AT를 생성하고 파싱하도록 구현했습니다.
30분의 만료 시간을 가지는 AT를 아래 코드처럼 발급합니다
@Component
public class JwtProvider {
private final SecretKey key = Keys.hmacShaKeyFor(Base64.getDecoder().decode("your-secret-key"));
private final long accessTokenValidity = 1000 * 60 * 30; // 30분
public String generateAccessToken(Long userId, String username) {
return Jwts.builder()
.setSubject(userId.toString())
.claim("username", username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + accessTokenValidity))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(HEADER_AUTHORIZATION);
if (bearerToken != null && bearerToken.startsWith(TOKEN_PREFIX)) {
return bearerToken.substring(TOKEN_PREFIX.length());
}
return null;
}
public boolean validateToken(String token) {
if (token == null) {
return false;
}
Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey())
.parseClaimsJws(token);
return true;
}
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(getClaims(token).getSubject());
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
}
이렇게 생성된 토큰은 JwtAuthenticationFilter
에서 헤더를 확인한 뒤 파싱하여 인증 객체로 변환합니다.
필터는 OncePerRequestFilter
를 상속받아 모든 요청에 대해 한 번만 실행됩니다
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String token = jwtProvider.resolveToken(request);
if (jwtProvider.validateToken(token)) {
Authentication authentication = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
validate(token)
을 통해 토큰 생성시 secretKey
값과 같은지 확인 true
이면, 현재 스레드의 SecurityContext
에 인증 정보 저장Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey())
.parseClaimsJws(token);
return true;
setSigningKey
지금까지 jwt
를 구현할때, 토큰을 검증하는 과정에서 setSigningKey
를 통해 서명을 검증하는 줄 알고 있었습니다.
아래 Jwts.parseCliamsJws
인터페이스를 뜯어보겠습니다.
Jws<Claims> parseClaimsJws(String var1) throws ExpiredJwtException,
UnsupportedJwtException,
MalformedJwtException,
SignatureException,
IllegalArgumentException;
해당 인터페이스를 구현하는 메서드는 두 가지입니다.
1) DefaultJwtParser.parseClaimsJws
public Jws<Claims> parseClaimsJws(String claimsJws) {
return (Jws)this.parse(claimsJws, new JwtHandlerAdapter<Jws<Claims>>() {
public Jws<Claims> onClaimsJws(Jws<Claims> jws) {
return jws;
}
});
}
DefaultJwtParser
는 공통 로직 위에 간단한 핸들러 어댑터를 씌운 얇은 래퍼(wrapper) 역할을합니다.
2)ImmutableJwtParser.parseClaimsJws
public Jws<Claims> parseClaimsJws(String claimsJws) throws ExpiredJwtException,
UnsupportedJwtException,
MalformedJwtException,
SignatureException,
IllegalArgumentException {
return this.jwtParser.parseClaimsJws(claimsJws);
}
ImmutableJwtParser
가 실제 검증을 담당합니다
setSigningKey
를 통해 초기 생성시 사용한 키를 설정한 후 내부 로직에서 다양한 에러처리와 함께 토큰을 검증하는 것입니다.
AT는 만료 시간이 짧아 보안에는 유리하지만, 만료 후 다시 로그인해야 하는 불편함이 있습니다.
이를 해결하기 위해 AT와 함께 수명(expiresAt)이 긴 RT를 발급해 두고, AT가 만료되면 RT를 사용해 새로운 AT를 재발급하는 방식을 도입했습니다.
초기에는 RT를 refresh_tokens라는 엔티티로 DB에 관리했습니다:
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {
@Id @GeneratedValue
private Long id;
private Long userId;
private String token;
private Instant expiresAt;
...
}
로그인 시 AT
와 RT
를 동시에 생성해 저장하고, 클라이언트는 AT
만료 시 /api/auth/refresh
엔드포인트를 호출해 RT
를 검증받고 새 AT
를 발급받습니다.
이 방식으로 사용자는 재로그인 없이도 원활하게 인증을 유지할 수 있었습니다.
처음에는, 클라이언트가 AT
가 만료됐을 경우 어떻게 auth/refresh
를 호출하는가에 대한 의문이 있었습니다. 아래는 인증/인가 관련 클라이언트 코드의 핵심 부분 예제입니다.
// 1) 요청 헤더에 AT 자동 추가
config.headers['Authorization'] = `Bearer ${localStorage.getItem('accessToken')}`
// 2) 401 응답 감지 & 단일 재시도 플래그
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
// 3) RT로 새 AT 발급
const { data } = await axios.post('/api/auth/refresh', {}, {
headers: { Authorization: `Bearer ${localStorage.getItem('refreshToken')}` }
})
// 4) 로컬스토리지에 갱신된 AT 저장
localStorage.setItem('accessToken', data.accessToken)
// 5) 갱신된 AT로 원래 요청 재실행
originalRequest.headers['Authorization'] = `Bearer ${data.accessToken}`
return axios(originalRequest)
}
401
에러가 발생했음을 감지하고, /api/auth/refresh
uri를 통해 localStorage
에 보관하고 있던 RT
를 헤더에 넣어 전달하게 됩니다.
서버에서는 /api/auth/refresh
, /api/auth/login
시에 같은 DTO
를 반환하므로 새롭게 AT
, RT
를 발급받을 수 있고, 새롭게 발급받은 AT
를 다시 클라이언트의 localStrage
에 담게됩니다.
DB에 RT를 저장하는 방식은 일관성 유지를 위해 편리하지만, 대규모 동시 요청이 몰릴 때 DB 부하가 커질 수 있습니다.
또한 만료된 토큰을 데이터베이스에서 수동으로 제거해야 된다는 번거로움이 발생할 수 있습니다.
따라서 Redis를 1차 캐시로 활용해 RT를 관리하는 구조로 개선했습니다.
Redis에 RT를 저장할 때는 RedisTemplate
의 opsForValue()
API를 사용해 간단히 키-값 형태로 저장하고 TTL(만료 시간)을 설정할 수 있습니다:
String accessToken = jwtProvider.generateAccessToken(user.getEmail());
String refreshToken = jwtProvider.generateRefreshToken(user.getEmail());
RefreshToken rt = RefreshToken.from(request.email(), refreshToken, RT_TTL);
refreshTokenStore.save(rt);
return JwtResponse.from(accessToken, refreshToken);
로그인시 AT
와 RT
를 만료시간을 각각 설정하여 생성하고, 생성한 RT
는 refreshTokenStore
에 저장하였습니다.
//refreshTokenStore.class
private final StringRedisTemplate redisTemplate;
private static final String KEY_PREFIX = "rt:";
@Override
public void save(RefreshToken rt) {
redisTemplate.opsForValue().set(KEY_PREFIX + rt.email(), rt.value(), rt.ttl());
}
RT
저장시 StringRedisTemplate
을 사용한 이유StringRedisTemplate
을 사용하는 것이 빠르다고 합니다.StringRedisTemplate
vs RedisTemplate<String,String>
같은 역할을 하지만, 후자를 선택하게 된다면
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory cf) {
RedisTemplate<String, String> rt = new RedisTemplate<>();
rt.setConnectionFactory(cf);
rt.setKeySerializer(new StringRedisSerializer());
rt.setValueSerializer(new StringRedisSerializer());
return rt;
}
위 코드처럼, RedisConfig
설정시 직렬화를 적용하기 위해 해당 빈을 새롭게 구성해야 하는 번거로움이 있습니다.
이번 글에서는 JWT
를 사용한 사용자 인증과정 및 RT
를 도입하여 UX의 편리함을 줄 수 있는 방법을 정리하였습니다
다음 글에서는 이번에 구현한 JWT
를 통해 SecurityContext
를 사용하여 어떤 작업을 할 수 있는지 알아보겠습니다.
Reference: Introduction to JSON Web Tokens