
로그아웃 좀 그만 시켜라 (JWT 토큰 자동 갱신의 정석)
앱 켤 때마다 로그인하라고요? 사용자는 떠납니다. Access Token 만료 시 Refresh Token으로 몰래 갱신하고, 실패했던 요청을 재시도하는 완벽한 인터셉터(Interceptor) 패턴을 구현해 봅니다.

앱 켤 때마다 로그인하라고요? 사용자는 떠납니다. Access Token 만료 시 Refresh Token으로 몰래 갱신하고, 실패했던 요청을 재시도하는 완벽한 인터셉터(Interceptor) 패턴을 구현해 봅니다.
프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

로그인 화면을 만들었는데 키보드가 올라오니 노란 줄무늬 에러가 뜹니다. resizeToAvoidBottomInset부터 스크롤 뷰, 그리고 채팅 앱을 위한 reverse 팁까지, 키보드 대응의 모든 것을 정리해봤습니다.

HTTP는 무전기(오버) 방식이지만, 웹소켓은 전화기(여보세요)입니다. 채팅과 주식 차트가 실시간으로 움직이는 기술적 비밀.

사용자가 가장 싫어하는 경험 1위는 "잘 쓰고 있는데 갑자기 로그아웃되는 것"입니다. 보안 때문에 Access Token 수명을 30분으로 짧게 잡는 건 이해하지만, 그렇다고 30분마다 로그인 화면을 띄우면 사용자는 앱을 지워버립니다.
우리의 목표는 "사용자가 모르게(Silent) 토큰을 갱신하고, 끊김 없이 앱을 쓰게 하는 것"입니다.
Authorization: Bearer ...로 실려 갑니다.시나리오:
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)
}
}
만약 화면 진입 시 API 요청이 5개가 동시에 나갔는데, 토큰이 만료된 상태라면?
5개 요청 모두 401 에러를 받고, 갱신 요청도 5번 보내게 됩니다.
서버는 "방금 갱신해줬는데 또 달라고?" 하며 Invalid Token 에러를 뱉고 꼬이기 시작합니다(Race Condition).
Dio의 Lock 기능을 써야 합니다. 하지만 최신 버전에서는 QueuedInterceptorsWrapper가 이를 자동으로 처리해줍니다.
즉, 첫 번째 401이 발생하면 인터셉터가 잠기고(Lock), 갱신이 끝날 때까지 나머지 요청들은 대기열(Queue)에 쌓입니다.
만약 커스텀 로직을 짠다면 Mutex나 Completer 패턴을 써서, 갱신 요청은 한 번만 실행되도록 보장해야 합니다.
보안을 더 강화하려면 Refresh Token Rotation을 적용해야 합니다. 토큰을 갱신할 때, Refresh Token도 같이 바꿔버리는 것입니다. 탈취된 Refresh Token을 일회용으로 만들어버리는 전략입니다.
onError에서 401 상태 코드를 잡는다./refresh API를 호출한다.Lock 사용).flutter_secure_storage에 암호화해서 저장한다.이 패턴만 잘 구현해도 앱의 사용자 경험(UX) 점수가 50점은 올라갑니다.