ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • JWT 인증에서 Access Token, Refresh Token, Redis가 필요한 이유
    프로젝트, 트러블슈팅 2026. 3. 1. 23:07

    시작하면서

    OAuth2 로그인을 구현하면서 단순히 JWT를 발급하는 것만으로는 부족하다는 걸 느꼈다. 로그아웃이 사실상 가짜였고, 토큰이 탈취됐을 때 막을 방법이 없었다. Access Token과 Refresh Token을 분리하고 Redis를 도입하면서 이 문제들을 어떻게 해결했는지 정리해봤다.


    JWT란?

    JWT(JSON Web Token)는 서버가 발급하는 암호화된 문자열이다. 안에 사용자 정보가 담겨 있어서 서버가 DB를 조회하지 않고 토큰만 보고 누구인지 파악할 수 있다. 이게 Stateless 인증의 핵심이다.

    eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc123
            ↑                    ↑              ↑
          Header               Payload       Signature
       (알고리즘 정보)      (userId, email)  (위변조 방지)
    

    HMAC-SHA256으로 서명하기 때문에 누군가 Payload를 변조하면 서명이 달라져서 검증에서 바로 걸린다. 서버는 시크릿 키로 서명을 검증하기만 하면 된다.


    Access Token과 Refresh Token이란? 

    Access Token은 실제 API를 호출할 때 쓰는 신분증이다. 매 요청마다 Authorization: Bearer {token} 헤더에 담아서 보낸다. 서버는 이 토큰을 보고 누가 요청했는지 파악하고 권한을 확인한다. 수명이 짧아서 탈취당해도 금방 만료된다.

    Refresh Token은 Access Token이 만료됐을 때 새로 발급받기 위한 토큰이다. API 호출에는 쓰이지 않고 오직 재발급 요청에만 사용된다. 수명이 길어서 사용자가 자주 로그인하지 않아도 되게 해준다.


    Access Token과 Refresh Token을 왜 나누냐

    토큰 하나만 쓴다면 딜레마가 생긴다.

    수명을 7일로 길게 → 탈취당하면 7일 동안 해커가 마음대로 사용
    수명을 1시간으로 짧게 → 1시간마다 사용자가 다시 로그인해야 함
    

    이 문제를 해결하는 게 두 토큰을 분리하는 방식이다. Access Token은 1시간짜리로 실제 API 호출에 쓰고, Refresh Token은 7일짜리로 Access Token 재발급에만 쓴다. Access Token이 만료되면 Refresh Token으로 조용히 재발급받으면 되고, 탈취당해도 1시간 후엔 자동으로 만료된다.

    // Access Token - userId, email, 타입 포함 / 수명 1시간
    public String createAccessToken(Long userId, String email) {
        Date now = new Date();
        return Jwts.builder()
                .subject(String.valueOf(userId))
                .claim("email", email)
                .claim("type", "access")
                .issuedAt(now)
                .expiration(new Date(now.getTime() + accessTokenValidity))
                .signWith(secretKey)
                .compact();
    }
    
    // Refresh Token - userId, 타입만 포함 / 수명 7일
    public String createRefreshToken(Long userId) {
        Date now = new Date();
        return Jwts.builder()
                .subject(String.valueOf(userId))
                .claim("type", "refresh")
                .issuedAt(now)
                .expiration(new Date(now.getTime() + refreshTokenValidity))
                .signWith(secretKey)
                .compact();
    }
    

    type 클레임을 넣은 이유가 있다. Refresh Token으로 일반 API를 호출하려는 시도를 서버에서 차단하기 위해서다. 모든 요청에서 타입을 확인해서 access가 아니면 인증을 안 해준다.

    Access Token의 Payload는 이렇게 생겼다.

    {
      "sub": "1",
      "email": "test@test.com",
      "type": "access",
      "iat": 1700000000,
      "exp": 1700003600
    }
    

    JWT의 치명적인 약점

    여기까지만 하면 한 가지 문제가 있다. JWT는 Stateless라서 서버가 발급한 토큰을 어디에도 저장하지 않는다.

    ① 사용자 로그아웃
    ② 해커가 탈취한 토큰으로 API 요청
    ③ 서버: "서명 유효하네, 통과!"
    

    로그아웃을 해도 토큰은 만료 전까지 여전히 유효하다. 프론트에서 토큰을 지우는 척만 하는 거지, 서버 입장에선 아무 변화가 없다.


    Redis로 해결하기

    Redis는 메모리 기반 Key-Value 저장소다.

    MySQL → 디스크 저장 → 조회 수십ms
    Redis → 메모리 저장 → 조회 1ms 이하
    

    JWT 검증은 모든 API 요청마다 실행된다. 여기서 DB를 쓰면 매 요청마다 조회가 발생해서 성능이 떨어진다. Redis를 쓰는 이유가 여기 있다. 그리고 TTL 기능이 있어서 만료 시간을 설정하면 그 이후엔 데이터가 자동으로 삭제된다.

    Access Token - 블랙리스트

    로그아웃할 때 Access Token을 블랙리스트에 등록하고, 이후 요청에서 블랙리스트를 먼저 확인한다. TTL을 토큰의 남은 만료시간으로 설정하면 토큰이 만료된 뒤엔 Redis에서도 자동으로 사라진다.

    @Service
    @RequiredArgsConstructor
    public class TokenBlacklistService {
    
        private final RedisTemplate<String, String> redisTemplate;
    
        public void addToBlacklist(String token, long expirationTimeInMs) {
            // Key:   "blacklist:eyJhbGci..." (토큰 전체가 키)
            // Value: "logout"
            // TTL:   남은 만료시간 → 이후 자동 삭제
            redisTemplate.opsForValue().set(
                "blacklist:" + token, "logout", expirationTimeInMs, TimeUnit.MILLISECONDS
            );
        }
    
        public boolean isBlacklisted(String token) {
            return Boolean.TRUE.equals(redisTemplate.hasKey("blacklist:" + token));
        }
    }
    

    Refresh Token - 화이트리스트

    Refresh Token은 반대로 화이트리스트로 관리한다. 로그인할 때 Redis에 저장하고, 갱신 요청이 오면 저장된 토큰과 일치하는지 검증한다. refresh:{userId}를 키로 쓰기 때문에 유저당 하나만 유지되고, 새로 로그인하면 이전 토큰이 자동으로 덮어씌워진다.

    @Service
    @RequiredArgsConstructor
    public class RefreshTokenService {
    
        private final RedisTemplate<String, String> redisTemplate;
    
        public void save(Long userId, String refreshToken) {
            // Key:   "refresh:1" (userId가 키 → 유저당 하나만 존재)
            // Value: 실제 Refresh Token
            // TTL:   7일
            redisTemplate.opsForValue().set(
                "refresh:" + userId, refreshToken, refreshTokenValidity, TimeUnit.MILLISECONDS
            );
        }
    
        public boolean validate(Long userId, String refreshToken) {
            String storedToken = redisTemplate.opsForValue().get("refresh:" + userId);
            return storedToken != null && storedToken.equals(refreshToken);
        }
    
        public void delete(Long userId) {
            redisTemplate.delete("refresh:" + userId);
        }
    }
    

    왜 두 가지 방식을 나눠 쓰냐

    Access Token은 수명이 1시간으로 짧고 모든 API 요청에 쓰인다. 전부 Redis에 저장하면 메모리 낭비가 크다. 그래서 정상이 기본값이고 로그아웃된 것만 따로 기록하는 블랙리스트가 맞다.

    Refresh Token은 수명이 7일이라 탈취됐을 때 위험도가 높다. 갱신 요청은 드물게 발생하니 저장 부담도 작다. 그래서 등록된 것만 허용하는 화이트리스트가 더 안전하다.


    Token Rotation으로 탈취된 토큰 막기

    Refresh Token을 갱신할 때 Token Rotation을 적용했다. 새 토큰을 발급하면서 기존 Refresh Token을 Redis에서 즉시 덮어씌운다.

    @PostMapping("/refresh")
    public ResponseEntity<DataResponse<TokenResponse>> refresh(
            @RequestHeader("Authorization") String refreshToken) {
    
        String token = refreshToken.replace("Bearer ", "");
    
        if (!jwtTokenProvider.validateToken(token)) return ResponseEntity.status(401).build();
        if (!jwtTokenProvider.isRefreshToken(token)) return ResponseEntity.status(401).build();
    
        Long userId = jwtTokenProvider.getUserIdFromToken(token);
    
        // 화이트리스트 검증 - 저장된 토큰과 일치해야만 통과
        if (!refreshTokenService.validate(userId, token)) return ResponseEntity.status(401).build();
    
        String newAccessToken = jwtTokenProvider.createAccessToken(user.getId(), user.getEmail());
        String newRefreshToken = jwtTokenProvider.createRefreshToken(user.getId());
    
        // 새 Refresh Token으로 덮어씌우기 → 기존 토큰 즉시 무효화
        refreshTokenService.save(user.getId(), newRefreshToken);
    
        return ResponseEntity.ok(DataResponse.from(TokenResponse.of(newAccessToken, newRefreshToken)));
    }
    

    보안 효과는 이렇다.

    해커가 Refresh Token 탈취
    → 정상 사용자가 먼저 갱신 요청
    → Redis에 새 토큰으로 교체됨
    → 해커가 탈취한 토큰으로 갱신 시도
    → validate() 실패 → 차단
    

    모든 요청에서 JWT를 검증하는 필터

    JwtAuthenticationFilter가 모든 요청마다 실행된다. 순서가 중요하다. 블랙리스트 확인을 가장 먼저 하고, 그다음 서명/만료 검증, 마지막으로 타입을 확인한다.

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String jwt = getJwtFromRequest(request);
    
        if (StringUtils.hasText(jwt)) {
            if (tokenBlacklistService.isBlacklisted(jwt)) {
                // 블랙리스트 → 인증 안 해줌
    
            } else if (jwtTokenProvider.validateToken(jwt) && jwtTokenProvider.isAccessToken(jwt)) {
                Long userId = jwtTokenProvider.getUserIdFromToken(jwt);
                User user = userRepository.findById(userId).orElse(null);
    
                if (user != null) {
                    UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(
                            user, null,
                            Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))
                        );
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        }
    
        filterChain.doFilter(request, response);
    }
    

    SecurityContext에 인증 정보가 저장되면 컨트롤러에서 @AuthenticationPrincipal로 유저를 꺼낼 수 있다.


    마무리

    Access Token과 Refresh Token을 분리하고, Redis로 블랙리스트/화이트리스트를 관리하는 구조가 처음엔 복잡해 보였다. 하지만 하나씩 이유를 따져보면 각각이 명확한 문제를 해결하고 있다. Redis 없이 JWT만 쓰는 건 로그아웃이 의미없는 반쪽짜리 인증이고, 토큰을 하나만 쓰면 보안과 UX 사이에서 타협이 불가능하다. 이 구조가 현재 가장 널리 쓰이는 이유가 있다.

Designed by Tistory.