개발놀이터

JWT 인증에서 Redis에 장애가 발생했을 때 대비책에 대한 전략 본문

CS 지식/데이터베이스

JWT 인증에서 Redis에 장애가 발생했을 때 대비책에 대한 전략

마늘냄새폴폴 2024. 1. 28. 19:36

이번에도 Redis에 관한 내용...은 아니고 JWT에 대한 내용입니다. Redis는 조연입니다. 

 

많은 분들이 회원 인증에 JWT를 사용하곤 하십니다. 실제로 저도 프로젝트에 JWT와 Spring Security를 접목시켜 회원 인증을 진행했습니다.

 

프로젝트에 JWT로 인증을 진행하는건 좋은데 그 뒤가 궁금했습니다. 만약에 Redis에 장애가 발생하면 JWT인증은 어떻게 될까? 

 

JWT와 Redis

구글링 한번이면 나오는 내용이기 때문에 자세히 다루지는 않겠습니다. 

 

JWT는 stateless한 인증 방식이라고 알고들 계시겠지만 이는 반은 맞고 반은 틀린 이야기입니다. 보통 JWT로 인증을 진행할 때 Access Token은 쿠키에 Refresh Token은 Redis에 저장하곤 합니다. 

 

쿠키에 HTTP only 태그를 붙이면 쿠키 갈취에 대해서 어느정도 방어할 수 있지만 쿠키는 언제든지 갈취당할 수 있는 자원입니다. 때문에 Refresh Token까지 쿠키에 저장하는 것은 굉장히 위험합니다. 

 

이 때문에 Redis와 같은 Key-Value Store 인 NoSQL에 Refresh Token을 저장하곤 하죠. 

 

JWT의 인증 순서는 다음과 같습니다. 

 

  1. Access Token을 검증
  2. Access Token이 없다면 Refresh Token 검증
  3. Refresh Token을 검증하고 Access Token 재발급

 

여기서 1번 과정은 확실히 stateless합니다. 하지만 2번 과정은 stateful입니다. 

 

서버쪽에서 Redis에 네트워크 I/O를 주고 받고 Refresh Token 값을 가져와야 하기 때문입니다. 

 

그렇다는 말은 Redis에 장애가 발생하면 Refresh Token을 가져올 방법이 없다는 뜻입니다. 그럼 Redis의 장애시 회원 인증이 안되네요? 

 

하지만 이렇게 말씀하실 수도 있습니다. 

 

"저는 Access Token으로 Authentication 객체를 만들어서 Security Context Holder에 저장하고 있는데요? 그럼 적어도 사용자가 애플리케이션을 벗어나기 전까진 인증할 수 있는 것은 아닌가요?"

 

맞습니다. 하지만 보통 Authentication을 가져올 때 Access Token이 만료되는 것을 기준으로 Filter Chain에서 확인하기 때문에 Access Token이 만료되는 순간 인증되지 않습니다. 

 

즉, Access Token이 살아있는 약 30분 (보통 30분으로 TTL을 설정한다고 하더라구요) 동안만 인증되고 그 후는 인증이 되지 않습니다. 

 

그럼 어떡하라는 말인가...

 

Hybrid Authentication

Redis에 장애가 발생했을 때를 대비해 뒷배를 두는 것입니다. Redis에서 stateful하게 Refresh Token을 검증하고 애플리케이션 내부적으로 stateless하게 검증하는 방식을 따로 두는 방법입니다. 

 

우선 jjwt와 같은 JWT를 안전하게 검증할 수 있는 라이브러리가 필요합니다. 

 

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.2</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.2</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
    <version>0.11.2</version>
    <scope>runtime</scope>
</dependency>

 

그리고 아래와 같이 JWT를 검증합니다. 

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.security.Key;

public class JwtTokenValidator {

    private String secretKey = "your-very-secure-secret-key"; 

    // JWT의 Refresh Token을 검증하기 위한 메서드입니다.
    public boolean validateStatelessToken(String token) {
        try {
            Key key = Keys.hmacShaKeyFor(secretKey.getBytes());

            // 토큰을 파싱하여 만약 매치되지 않는다면 예외를 터트립니다.
            Jws<Claims> claimsJws = Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token);

            Claims claims = claimsJws.getBody();

            // 여기에 검증을 추가할 수도 있습니다. issuer나 audience, expiration 등을 검증하면 됩니다.
            // e.g., claims.getIssuer().equals("expectedIssuer")

            return true;
        } catch (JwtException | IllegalArgumentException e) {
            System.err.println("Invalid JWT token: " + e.getMessage());
            return false;
        }
    }
}

 

하지만 저는 이런 생각이 들더군요. Redis가 정상작동하면 굳이 stateless 토큰을 검증하지 않아도 되잖아? 

 

물론 stateless 토큰은 HS256알고리즘을 사용했기 때문에 성능적인 부분에서 뒤지지않습니다. 하지만 뭔가... 둘 다 사용하는 것은 효율적이지 않은 것 처럼 보여서 Redis가 살아있을 땐 Redis를, 죽었을 땐 stateless 토큰을 사용하도록 구현했습니다. 

 

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

@Service
public class TokenValidationService {

    private static final Logger logger = LoggerFactory.getLogger(TokenValidationService.class);

    private final RedisService redisService;	// 이건 Redis를 관리하는 Service 객체
    private final JwtTokenService jwtTokenService;	// 이건 JWT를 관리하는 Service 객체

    @Autowired
    public TokenValidationService(RedisService redisService, JwtTokenService jwtTokenService) {
        this.redisService = redisService;
        this.jwtTokenService = jwtTokenService;
    }

    public boolean validateRefreshToken(String token) {
        try {
            // Redis에 Refresh Token을 검증
            return redisService.validateTokenWithRedis(token);
        } catch (RedisConnectionException | RedisCommandExecutionException e) {
            // Redis에 연결이 되지 않거나 값을 가져올 수 없을 때 에러 로그를 찍고 stateless한 토큰 검증
            logger.error("Redis validation failed, falling back to stateless validation: {}", e.getMessage());
            return jwtTokenService.validateStatelessToken(token);
        } catch (Exception e) {
            // 이외의 예외는 여기서 에러 메시지를 출력
            logger.error("Token validation error: {}", e.getMessage());
            return false;
        }
    }
}

 

이렇게 구현을 다 하고 보니까 뭔가 이상했습니다. 

 

    public boolean validateRefreshToken(String token) {
        ...
    }

 

이 부분 파라미터로 넘기는 token이 Refresh Token이란 말이죠? 근데 지금 전제가 Redis에 장애가 발생한 상황입니다. 

 

그럼 Refresh Token이 담겨있는 Redis에서 조회할 수 없고 그럼 검증할 수 없는데 어떻게 파라미터로 받아올 수 있다는 것이지?

 

구조를 바꿔야한다

Hybrid Authentication 방식은 어떻게든 Redis의 도움을 받지 않고 Refresh Token을 가져와야 합니다. 

 

그럼 필연적으로 제가 사용했던 Access Token은 쿠키에 Refresh Token은 Redis에 저장하는 방식은 Hybrid Authentication 방식과 어울리지 않습니다. 

 

방법은 세 가지가 떠올랐습니다. 

 

  1. EHCache (JPA 2차캐시) 에 저장해두자
  2. Authorization 헤더에 JWT를 담자
  3. SESSIONID를 이용해서 쿠키에 Refresh Token을 저장하자

결국 저는 2번을 선택했습니다. 

 

1번과 3번 모두 혹하는 생각이었지만 고민끝에 2번을 결정하게 되었습니다. 

 

1번은 결국 캐시 동기화 문제 때문에 선택할 수 없었습니다. 물론 제 프로젝트는 분산 환경이 아니라 괜찮긴 합니다만... 분산 환경을 생각했을 때 캐시 동기화 문제가 생길 수 있기 때문에 문제가 될만한 것을 선택하기 꺼려졌습니다. 

 

3번은 SESSIONID는 HTTP only 쿠키이고 HTTPS 프로토콜까지 사용되기 때문에 갈취에서 안전하다곤 하지만 그래도 결국 갈취될 수 있는 자원인 것은 분명하기 때문에 모험을 하진 않았습니다. 

 

Authorization 헤더에 JWT를 저장함으로써 Redis를 사용하지 못할 땐 Authorization 헤더에서 Refresh Token을 꺼내서 검증하면 되는 것이었습니다. 

 

1번과 3번보다 조금 더 깔끔한 방법이라 2번을 선택했습니다. 

 

 

마치며

이런 뻘짓이 조금은 무색할정도로 간단한 방법이 있긴 있었습니다... 이전에 포스팅 했던 Sentinel을 사용해서 Redis의 가용성을 높이면 되는 것이거든요...

 

사실 Sentinel을 사용하면 Redis 장애시 RDBMS도 같이 뻗어버리는 문제와 JWT인증이 안되는 문제도 해결할 수 있습니다. 

 

조금 무식한 방법이긴 하지만 이런 방법도 있다는 것을 경험하고 싶었습니다. 사실 모든 뻘짓 후에 머릿속에 '그냥 Sentinel 사용하면 되는거 아냐?' 가 떠올랐지만 말이죠...

 

뻘짓한게 아까워서 포스팅으로 작성하는 것이지만,  여러분 그냥 Sentinel 쓰세요 ㅎㅎ.. 

 

이렇게 JWT인증시 Redis에 장애가 발생하면 어떻게 해야하는지 전략에 대해서 알아봤습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~