로그아웃 좀 그만 시켜라 (JWT 토큰 자동 갱신의 정석)
1. "앱 켤 때마다 로그인해야 하나요?"
사용자가 가장 싫어하는 경험 1위는 "잘 쓰고 있는데 갑자기 로그아웃되는 것"입니다. 보안 때문에 Access Token 수명을 30분으로 짧게 잡는 건 이해하지만, 그렇다고 30분마다 로그인 화면을 띄우면 사용자는 앱을 지워버립니다.
우리의 목표는 "사용자가 모르게(Silent) 토큰을 갱신하고, 끊김 없이 앱을 쓰게 하는 것"입니다.
2. 원리 이해: Access Token vs Refresh Token
- Access Token (입장권): 수명이 짧음 (30분~1시간). API 요청 시 헤더에
Authorization: Bearer ...로 실려 갑니다. - Refresh Token (재발급권): 수명이 김 (2주~1달). Access Token이 만료되었을 때, 새 표를 달라고 서버에 보낼 때 씁니다. 안전한 저장소(Flutter Secure Storage)에 보관해야 합니다.
시나리오:
- 사용자가 프로필 수정을 요청함.
- 서버: "401 Unauthorized (토큰 만료됨)"
- 앱(인터셉터): "아, 만료됐구나. 잠깐만 기다려."
- 앱: Refresh Token으로 새 Access Token 발급 요청.
- 서버: "자, 여기 새 토큰."
- 앱: 새 토큰으로 아까 실패했던 프로필 수정 요청을 재시도.
- 사용자: (아무 일도 없었던 것처럼) "수정 완료!"
3. Dio Interceptor 구현 (핵심 코드)
Flutter의 국민 HTTP 클라이언트 Dio는 Interceptor라는 강력한 기능을 제공합니다.
QueuedInterceptorsWrapper를 사용하면 토큰 갱신 중에 발생하는 다른 요청들을 줄 세울(Queue) 수 있습니다.
class AuthInterceptor extends Interceptor {
final Dio dio;
final SecureStorage storage;
AuthInterceptor(this.dio, this.storage);
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
// 1. 401 에러가 아니면 그냥 통과
if (err.response?.statusCode != 401) {
return handler.next(err);
}
// 2. 이미 갱신 시도 중인지 확인 (무한 루프 방지)
// Access Token이 없어서 401이 떴는데, Refresh 요청도 401이면 로그아웃 처리
if (err.requestOptions.path == '/auth/refresh') {
await _logout();
return handler.next(err);
}
try {
// 3. 토큰 갱신 시도
final refreshToken = await storage.read(key: 'refreshToken');
if (refreshToken == null) {
// 리프레시 토큰도 없으면 로그아웃
return handler.next(err);
}
// 4. 새 토큰 발급 요청 (이때는 인터셉터가 없는 Dio 객체 써야 함)
final response = await Dio().post(
'https://api.myapp.com/auth/refresh',
data: {'refreshToken': refreshToken},
);
final newAccessToken = response.data['accessToken'];
final newRefreshToken = response.data['refreshToken']; // Rotation 시
// 5. 로컬 저장소 업데이트
await storage.write(key: 'accessToken', value: newAccessToken);
if (newRefreshToken != null) {
await storage.write(key: 'refreshToken', value: newRefreshToken);
}
// 6. 실패했던 요청 재시도 (헤더 교체)
final options = err.requestOptions;
options.headers['Authorization'] = 'Bearer $newAccessToken';
final retryResponse = await dio.fetch(options);
// 7. 성공 결과 반환
return handler.resolve(retryResponse);
} catch (e) {
// 갱신 실패 시 로그아웃
await _logout();
return handler.next(err);
}
}
Future<void> _logout() async {
await storage.deleteAll();
// 네비게이션 이동 로직 (GetX/GoRouter)
}
}
4. 동시성 문제 (Concurrency Issue)
만약 화면 진입 시 API 요청이 5개가 동시에 나갔는데, 토큰이 만료된 상태라면?
5개 요청 모두 401 에러를 받고, 갱신 요청도 5번 보내게 됩니다.
서버는 "방금 갱신해줬는데 또 달라고?" 하며 Invalid Token 에러를 뱉고 꼬이기 시작합니다(Race Condition).
해결책: Lock & Queue
Dio의 Lock 기능을 써야 합니다. 하지만 최신 버전에서는 QueuedInterceptorsWrapper가 이를 자동으로 처리해줍니다.
즉, 첫 번째 401이 발생하면 인터셉터가 잠기고(Lock), 갱신이 끝날 때까지 나머지 요청들은 대기열(Queue)에 쌓입니다.
만약 커스텀 로직을 짠다면 Mutex나 Completer 패턴을 써서, 갱신 요청은 한 번만 실행되도록 보장해야 합니다.
5. 보안 강화: Refresh Token Rotation
보안을 더 강화하려면 Refresh Token Rotation을 적용해야 합니다. 토큰을 갱신할 때, Refresh Token도 같이 바꿔버리는 것입니다. 탈취된 Refresh Token을 일회용으로 만들어버리는 전략입니다.
- 해커가 Refresh Token 탈취.
- 사용자가 정상적으로 토큰 갱신 (서버: "너 Refresh Token 썼으니까 이거 폐기함. 새 거 줄게.")
- 해커가 탈취한 토큰으로 접근 시도.
- 서버: "이거 이미 폐기된 토큰인데? 누가 훔쳐갔구나. 연관된 모든 토큰 강제 만료!"
- 사용자는 강제 로그아웃되지만, 해커도 차단됨.
6. 요약
- 401 감지: 인터셉터
onError에서 401 상태 코드를 잡는다. - Silent Refresh: 사용자 모르게 백그라운드에서
/refreshAPI를 호출한다. - Retry: 실패했던 요청 헤더에 새 토큰을 끼워서 재전송한다.
- Concurrency: 여러 요청이 동시에 401을 뱉어도 갱신은 한 번만 수행해야 한다 (
Lock사용). - Secure Storage: 토큰은 반드시
flutter_secure_storage에 암호화해서 저장한다.
이 패턴만 잘 구현해도 앱의 사용자 경험(UX) 점수가 50점은 올라갑니다.