에러 0%, JWT 인증에서 Refresh Token까지

J_Eddy·2025년 12월 4일
post-thumbnail

Flutter + Spring Boot 기반의 풋살 모임 관리 앱을 개발하면서 JWT 인증을 구현했습니다. 처음에는 Access Token만으로 충분할 것이라 생각했지만, 실제 릴리즈 테스트를 진행하면서 1시간마다 사용자가 로그아웃되는 문제가 발생했습니다. 이번 글에서는 이 문제를 Refresh Token으로 어떻게 해결했는지, 그리고 왜 Refresh Token Rotation이 필수인지 공유하려합니다.

문제의 시작: Access Token만으로는 부족했다

초기 구현의 판단

개발 초기에는 보안을 우선시하여 Access Token의 만료 시간을 1시간으로 설정했습니다. JWT의 장점인 Stateless를 유지하면서도 토큰 탈취 시 피해를 최소화하려는 의도였습니다.

app:
  jwt:
    secret: ${JWT_SECRET}
    access-exp-seconds: 3600  # 1시간

백엔드 핵심 구현

JWT 생성과 검증은 JwtProvider에서 담당합니다.

@Component
public class JwtProvider {
    private final Key key;
    private final long accessExpSeconds;

    public String createAccessToken(Long userId, String identifier, String displayName) {
        Instant now = Instant.now();
        return Jwts.builder()
                .setSubject(String.valueOf(userId))
                .claim("identifier", identifier)
                .claim("name", displayName)
                .setIssuedAt(Date.from(now))
                .setExpiration(Date.from(now.plusSeconds(accessExpSeconds)))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }
}

HS256 대칭키 방식을 선택한 이유는 서버가 하나뿐이라, 굳이 구조가 더 복잡한 RS256까지 쓸 필요는 없었습니다.

그래서 설정과 운영이 더 단순한 HS256 대칭키 방식을 선택했습니다. Subject에는 userId를, 커스텀 클레임에는 자주 사용되는 사용자 정보를 포함했습니다.

모든 API 요청은 JwtAuthInterceptor를 거칩니다.

@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
    String auth = req.getHeader("Authorization");
    if (auth == null || !auth.startsWith("Bearer ")) {
        throw new BizException(BizExceptionCode.INVALID_TOKEN);
    }

    try {
        String token = auth.substring(7);
        Jws<Claims> jws = jwtProvider.parse(token);
        Long userId = Long.valueOf(jws.getBody().getSubject());
        req.setAttribute(ATTR_USER_ID, userId);
        return true;
    } catch (ExpiredJwtException ex) {
        throw new BizException(BizExceptionCode.TOKEN_EXPIRED);
    }
}

프론트엔드 구현

Flutter에서는 Dio의 Interceptor를 활용해 모든 요청에 자동으로 토큰을 추가했습니다.

_dio.interceptors.add(
  InterceptorsWrapper(
    onRequest: (options, handler) async {
      final token = _cachedToken ?? await _storage.getToken();
      if (token != null) {
        options.headers['Authorization'] = 'Bearer $token';
      }
      return handler.next(options);
    },
    onError: (error, handler) async {
      if (error.response?.statusCode == 401) {
        await _storage.clear();
        clearAuthHeader();
      }
      return handler.next(error);
    },
  ),
);

문제 발견

앱 릴리즈 테스트 후 문제가 명확해졌습니다.

"앱으로 경기 일정 보다가 갑자기 로그인 화면으로 튕김"
"아침에 로그인했는데 점심 때 다시 로그인하라고 함"

시뮬레이터로 테스트시에는 빌드 후 바로 앱을 사용하기 때문에 문제가 없었는데, 릴리즈 테스트 중에는 1시간마다 강제 로그아웃되는 현상이 쉽게 재현되었습니다.

해결 방안: Refresh Token 도입

설계 원칙

여러 해결 방안을 검토한 결과, Refresh Token이 최선이었습니다.

검토한 다른 방안들:

  • Access Token 만료 시간 연장(30일): 토큰 탈취 시 장기간 사용 가능, 보안상 위험
  • Session 방식 전환: Stateless의 장점 포기, 서버 메모리 부담, 수평 확장 어려움

Refresh Token은 보안과 사용자 경험 모두를 만족시킬 수 있었습니다.

핵심 설계는 아래와 같습니다.

  1. DB 저장: Refresh Token은 DB에 저장
  2. Rotation: 갱신 시 새 Refresh Token 발급
  3. 이중 검증: JWT 만료와 DB 만료 모두 확인
  4. 사용자당 단일 토큰: 새 로그인 시 이전 토큰 삭제

데이터베이스 설계

CREATE TABLE refresh_token (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,
    token_value VARCHAR(500) NOT NULL,
    expires_at TIMESTAMP NOT NULL,
    created_at TIMESTAMP NOT NULL,
    CONSTRAINT fk_refresh_token_user FOREIGN KEY (user_id)
        REFERENCES app_user(id) ON DELETE CASCADE
);

CREATE UNIQUE INDEX idx_token_value ON refresh_token(token_value);
CREATE INDEX idx_user_id ON refresh_token(user_id);

token_value는 갱신 시마다 조회되므로 UNIQUE 인덱스를, user_id는 로그인 시 기존 토큰 삭제를 위해 일반 인덱스를 생성했습니다.

백엔드 개선

JwtProvider에 Refresh Token 생성 메서드를 추가했습니다.

public String createRefreshToken(Long userId) {
    Instant now = Instant.now();
    return Jwts.builder()
            .setSubject(String.valueOf(userId))
            .setIssuedAt(Date.from(now))
            .setExpiration(Date.from(now.plusSeconds(refreshExpSeconds)))
            .signWith(key, SignatureAlgorithm.HS256)
            .compact();
}

Access Token과 달리 Refresh Token에는 userId만 포함합니다. 토큰의 용도가 "Access Token 재발급"으로 명확하므로 추가 정보가 불필요하며, 토큰 크기를 최소화할 수 있습니다.

가장 중요한 부분은 토큰 갱신 로직입니다.

@Transactional
public AuthRes refreshAccessToken(String refreshTokenValue) {
    try {
        // 1. JWT 검증
        Jws<Claims> claims = jwtProvider.parse(refreshTokenValue);
        Long userId = Long.parseLong(claims.getBody().getSubject());

        // 2. DB 조회 및 검증
        RefreshTokenEntity entity = refreshTokenRepository
                .findByTokenValue(refreshTokenValue)
                .orElseThrow(() -> new BizException(BizExceptionCode.REFRESH_TOKEN_NOT_FOUND));

        if (entity.isExpired()) {
            refreshTokenRepository.delete(entity);
            throw new BizException(BizExceptionCode.REFRESH_TOKEN_EXPIRED);
        }

        // 3. 사용자 정보 조회
        AppUserEntity user = appUserRepository.findById(userId)
                .orElseThrow(() -> new BizException(BizExceptionCode.USER_NOT_FOUND));

        // 4. 새 토큰 발급 (Rotation)
        String newAccessToken = jwtProvider.createAccessToken(
                user.getId(), user.getProviderUserId(), user.getDisplayName());
        String newRefreshToken = createAndSaveRefreshToken(user.getId());

        return new AuthRes(newAccessToken, newRefreshToken, UserDto.fromEntity(user));
    } catch (ExpiredJwtException e) {
        throw new BizException(BizExceptionCode.REFRESH_TOKEN_EXPIRED);
    }
}

Refresh Token Rotation의 필요성

Rotation 없이 같은 Refresh Token을 재사용하면, 토큰이 탈취될 경우 30일간 계속 사용 가능합니다. Rotation을 적용하면 갱신할 때마다 새 토큰을 발급하고 이전 토큰을 삭제하므로, 탈취된 토큰은 한 번만 사용 가능합니다.

private String createAndSaveRefreshToken(Long userId) {
    // 기존 토큰 삭제
    refreshTokenRepository.findByUserId(userId)
            .ifPresent(refreshTokenRepository::delete);

    // 새 토큰 생성 및 저장
    String tokenValue = jwtProvider.createRefreshToken(userId);
    RefreshTokenEntity entity = RefreshTokenEntity.builder()
            .userId(userId)
            .tokenValue(tokenValue)
            .expiresAt(LocalDateTime.now().plusSeconds(jwtProvider.getRefreshExpSeconds()))
            .build();

    refreshTokenRepository.save(entity);
    return tokenValue;
}

이중 검증의 의미

JWT 검증과 DB 검증 모두를 수행하였는데, 그 이유는 JWT 검증은 토큰의 무결성을, DB 검증은 서버 측 무효화 여부를 확인합니다. 로그아웃한 사용자의 JWT는 만료 전까지 유효하지만, DB에서 삭제되므로 사용할 수 없습니다.

프론트엔드 자동 갱신

만약 실제 Access Token이 만료되었을 때 사용자 입장에서 토큰 갱신을 인지하지 못하게 만드는 것이 핵심입니다. Dio Interceptor의 onError에서 401 에러를 감지하고 자동으로 갱신하도록 하였습니다.

onError: (error, handler) async {
  if (error.response?.statusCode != 401) {
    return handler.next(error);
  }

  final errorCode = error.response?.data['code'];
  
  if (errorCode == 'TOKEN_EXPIRED') {
    try {
      final refreshToken = await _storage.getRefreshToken();
      if (refreshToken == null) {
        await _storage.clear();
        return handler.next(error);
      }

      // 토큰 갱신
      final response = await _dio.post(
        '/auth/refresh',
        data: {'refreshToken': refreshToken},
        options: Options(headers: {}),
      );

      final newAccessToken = response.data['jwt'];
      final newRefreshToken = response.data['refreshToken'];

      await _storage.saveToken(newAccessToken);
      await _storage.saveRefreshToken(newRefreshToken);
      setAuthHeader(newAccessToken);

      // 원래 요청 재시도
      final options = error.requestOptions;
      options.headers['Authorization'] = 'Bearer $newAccessToken';
      final retryResponse = await _dio.fetch(options);

      return handler.resolve(retryResponse);
    } catch (e) {
      await _storage.clear();
    }
  }
  
  return handler.next(error);
},

handler.resolve() 메서드를 사용하면 에러를 성공 응답으로 변환하여, 호출한 곳에서는 토큰 갱신이 발생했다는 것을 전혀 알 수 없습니다.

실제 동작 흐름은 다음과 같습니다.

1. Repository: _dio.get('/api/groups')
2. Interceptor: Authorization 헤더 추가
3. 서버: 401 TOKEN_EXPIRED 응답
4. Interceptor onError: 갱신 감지
5. POST /auth/refresh 호출
6. 새 토큰 저장
7. 원래 요청 재시도
8. handler.resolve()로 성공 응답 반환
9. Repository: 응답 수신 (에러 발생 사실을 모름)

구현 결과

사용자 경험 개선

변경 전:
1시간 경과 후 API 호출 → 401 에러 → 자동 로그아웃 → 사용자가 수동으로 재로그인

변경 후:
1시간 경과 후 API 호출 → 401 감지 → 자동 갱신(약 0.5초) → 원래 요청 재시도 → 정상 응답

사용자는 약간의 지연만 느낄 뿐, 로그인이 풀렸다는 사실을 알지 못합니다.

보안성 강화

  1. Access Token 만료 시간 유지: 여전히 1시간으로 유지하여 탈취 시 피해 최소화
  2. Refresh Token Rotation: 갱신마다 새 토큰 발급으로 재사용 공격 방지
  3. DB 기반 즉시 무효화: 로그아웃 시 DB에서 삭제하여 완전한 세션 종료
  4. 이중 검증: JWT와 DB 모두 검증하여 안전성 강화

성능 측정

  • Refresh Token 조회(인덱스 사용): ~5ms
  • 새 Refresh Token 저장: ~10ms
  • JWT 생성: ~1ms
  • 총 소요 시간: ~16ms

인덱스 덕분에 10만 건 기준으로 약 50배 빠른 조회가 가능했고, 16ms는 사용자가 체감하기 어려운 시간입니다. 무엇보다 토큰 갱신은 1시간에 한 번만 발생하므로 성능 문제는 없었습니다.

주요 배운 점

Refresh Token Rotation의 필수성

처음에는 Refresh Token을 재사용하려 했으나, 여러 글을 읽으며 Rotation의 중요성을 깨달았습니다.

Interceptor의 강력함

Dio의 Interceptor를 활용하면 모든 API 호출에 자동으로 토큰 갱신 로직을 적용할 수 있습니다. 각 Repository에서 재시도 로직을 반복하는 대신, 한 곳에서 처리할 수 있었습니다.

점진적 개선의 가치

처음부터 완벽한 시스템을 만들려고 했다면 출시가 늦어졌을 것입니다. MVP로 시작해 피드백을 받으며 개선하는 것이 더 효과적이었습니다.

향후 개선 방향

현재 시스템도 안정적으로 동작하지만, 몇 가지 개선 방향을 고려하고 있습니다.

만료된 토큰 자동 정리

현재는 만료된 Refresh Token이 DB에 계속 쌓입니다. Spring Scheduler로 매일 새벽 자동 정리 작업을 추가할 예정입니다.

@Scheduled(cron = "0 0 3 * * *")
public void cleanupExpiredTokens() {
    int deleted = refreshTokenRepository.deleteByExpiresAtBefore(LocalDateTime.now());
    log.info("Cleaned up {} expired tokens", deleted);
}

마치며

JWT 인증은 단순해 보이지만, 실제 운영 환경에서는 많은 고려사항이 있다는걸 느꼈습니다. 특히 사용자 입장에서의 경험을 고려하며 보안에 대해서 충분히 대응해야한다는걸 한번 더 느꼈습니다.

JWT 인증을 구현하시는 분들에게 이 글이 도움이 되었으면 합니다.

profile
논리적으로 사고하고 해결하는 것을 좋아하는 개발자입니다.

0개의 댓글