JWT 인증 구현기(AccessToken, RefreshToken, Redis)

dongwoo you·2025년 5월 20일
0

myboard-dev-log

목록 보기
3/8
post-thumbnail

이번 글에서는 MyBoard 프로젝트에 JWT 기반 인증을 도입한 과정을 단계별로 정리하려고 합니다.

  1. AccessToken(AT)을 구현한 방법을 소개
  2. 토큰 인증 과정에서의 의문점
  3. AT만 사용할 때 발생하는 한계를 보완하기 위해 RefreshToken(RT)을 설계한 과정 4. 클라이언트에서 처리하는 과정
  4. RT를 데이터베이스 대신 Redis 1차 캐시로 관리했을 때의 장점

순서대로 내용을 정리하려고 합니다.


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.

다양한 인증 방법 중 JWT를 채택한 이유

웹 애플리케이션에서 가장 많이 사용하는 인증 방식은 서버에 세션을 저장하는 세션 기반 방법입니다.

세션 기반은 보안성이 높고 강제 로그아웃이나 세션 만료를 서버에서 제어할 수 있다는 장점이 있지만, 서버 메모리나 DB에 세션 정보를 유지해야 하므로 부하가 커지고 여러 서버로 확장할 때 세션 동기화가 필요합니다.

반면 JWT(Json Web Token) 는 토큰 자체에 사용자 ID나 권한(Role) 같은 클레임 정보를 담아 전송하고, 서버는 별도의 세션 저장 없이 토큰만 검증하면 되기 때문에 완전한 무상태(Stateless) API 구현이 가능합니다.

또한 필터 단계에서 토큰 서명을 검증한 뒤 사용자의 권한 정보를 바로 꺼내 쓸 수 있어 성능과 확장성 측면에서도 적합하다고 판단하여 JWT를 선택했습니다.


JWT 구조와 인증 흐름

JWT는 크게 세 부분으로 이루어집니다.

  • Header: 사용할 서명 알고리즘(alg)과 토큰 타입(typ)이 JSON 형태로 저장
  • Payload: sub(주로 사용자 식별자), exp(만료시간), roles 같은 클레임이 포함
  • Signature: Base64UrlEncode(header) + "." + Base64UrlEncode(payload)에 비밀키를 적용한 HMACSHA256 서명값

인증 절차는 다음과 같습니다.

  1. 클라이언트가 로그인 요청을 보내면 서버에서 사용자 자격 증명을 확인하고 AT를 발급합니다.
  2. 클라이언트는 이후 모든 API 요청에 HTTP 헤더 Authorization: Bearer <AT>를 포함합니다.
  3. 서버는 매 요청마다 JWT 필터를 통해 서명을 검증하고 클레임을 파싱해 사용자 정보를 SecurityContext에 저장합니다.
  4. SecurityContext에 저장된 사용자 ID나 권한을 바탕으로 비즈니스 로직을 처리한 뒤 응답을 반환합니다.

AccessToken(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);
	}
}
  1. 요청 도착 -> 헤더에서 토큰 추출
  2. validate(token) 을 통해 토큰 생성시 secretKey 값과 같은지 확인
  3. 2번의 과정이 true 이면, 현재 스레드의 SecurityContext에 인증 정보 저장

Jwts.parser()
			.setSigningKey(jwtProperties.getSecretKey())
			.parseClaimsJws(token);
		return true;

setSigningKey

지금까지 jwt를 구현할때, 토큰을 검증하는 과정에서 setSigningKey 를 통해 서명을 검증하는 줄 알고 있었습니다.

아래 Jwts.parseCliamsJws 인터페이스를 뜯어보겠습니다.

Jwts.parseClaimsJws(token)

Jws<Claims> parseClaimsJws(String var1) throws ExpiredJwtException, 
											UnsupportedJwtException, 
                                            MalformedJwtException, 
                                            SignatureException, 
                                            IllegalArgumentException;
  • ExpiredJwtException: 토큰의 exp(만료 시간)를 지났을 때
  • UnsupportedJwtException: 지원하지 않는 형식의 JWT일 때
  • MalformedJwtException: 토큰 포맷이 잘못됐을 때
  • SignatureException: 서명 검증에 실패했을 때
  • IllegalArgumentException: 입력값이 null이거나 빈 문자열일 때

해당 인터페이스를 구현하는 메서드는 두 가지입니다.

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의 한계를 보완하기 위한 RefreshToken(RT) 설계

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;
    ...
}

로그인 시 ATRT를 동시에 생성해 저장하고, 클라이언트는 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에 담게됩니다.


RT 엔티티 관리에서 Redis 1차 캐시로 전환했을 때의 장점

DB에 RT를 저장하는 방식은 일관성 유지를 위해 편리하지만, 대규모 동시 요청이 몰릴 때 DB 부하가 커질 수 있습니다.

또한 만료된 토큰을 데이터베이스에서 수동으로 제거해야 된다는 번거로움이 발생할 수 있습니다.
따라서 Redis를 1차 캐시로 활용해 RT를 관리하는 구조로 개선했습니다.

Redis에 RT를 저장할 때는 RedisTemplateopsForValue() 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);

로그인시 ATRT를 만료시간을 각각 설정하여 생성하고, 생성한 RTrefreshTokenStore에 저장하였습니다.

//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을 사용한 이유

  1. RT(email:key, token:value, TTL) 모두 문자열이므로 별도 Serializer 설정 없이 그대로 사용이 가능합니다.
  2. 성능 최적화: String 전용 Serializer는 다른 직렬화 방식보다 가볍고 빠릅니다.
  • 복잡한 객체를 다루지 않고, 단순 키-값만 캐시하거나 토큰을 저장할 때는 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

profile
꾸준함 빼면 시체

0개의 댓글