
Flutter + Spring Boot 기반의 풋살 모임 관리 앱을 개발하면서 JWT 인증을 구현했습니다. 처음에는 Access Token만으로 충분할 것이라 생각했지만, 실제 릴리즈 테스트를 진행하면서 1시간마다 사용자가 로그아웃되는 문제가 발생했습니다. 이번 글에서는 이 문제를 Refresh Token으로 어떻게 해결했는지, 그리고 왜 Refresh Token Rotation이 필수인지 공유하려합니다.
개발 초기에는 보안을 우선시하여 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은 보안과 사용자 경험 모두를 만족시킬 수 있었습니다.
핵심 설계는 아래와 같습니다.
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초) → 원래 요청 재시도 → 정상 응답
사용자는 약간의 지연만 느낄 뿐, 로그인이 풀렸다는 사실을 알지 못합니다.
인덱스 덕분에 10만 건 기준으로 약 50배 빠른 조회가 가능했고, 16ms는 사용자가 체감하기 어려운 시간입니다. 무엇보다 토큰 갱신은 1시간에 한 번만 발생하므로 성능 문제는 없었습니다.
처음에는 Refresh Token을 재사용하려 했으나, 여러 글을 읽으며 Rotation의 중요성을 깨달았습니다.
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 인증을 구현하시는 분들에게 이 글이 도움이 되었으면 합니다.