-
[Django] JWT로 로그인 구현하면서 배운 OAuth와 토큰 관리 이야기IT 2025. 5. 29. 13:59
안녕하세요! 오늘은 제가 Django 프로젝트에서 JWT 기반 인증 시스템을 구현하면서 겪었던 경험과 그 과정에서 알게 된 OAuth, Access Token, Refresh Token에 대해 이야기해보려고 합니다.
🚀 시작은 단순한 로그인이었는데...
처음에는 단순히 "로그인 기능만 만들면 되겠지"라고 생각했습니다. 그런데 막상 구현하려고 보니 생각보다 고려해야 할 것들이 많더라고요. 특히 보안 측면에서 어떤 방식이 가장 안전하고 효율적인지 고민이 많았습니다.
그러던 중 JWT(JSON Web Token)에 대해 알게 되었고, 이것이 제가 원하는 stateless한 인증 방식이라는 것을 깨달았습니다.
🔑 JWT가 뭐길래?
JWT는 간단히 말해서 사용자 정보를 JSON 형태로 안전하게 전송할 수 있는 토큰입니다. 가장 큰 장점은 서버에서 세션을 관리할 필요가 없다는 점이었습니다.
# Django에서 JWT 토큰 생성 예시 import jwt from datetime import datetime, timedelta from django.conf import settings def create_jwt_token(user): payload = { 'user_id': user.id, 'username': user.username, 'exp': datetime.utcnow() + timedelta(hours=1) # 1시간 후 만료 } token = jwt.encode(payload, settings.SECRET_KEY, algorithm='HS256') return token
처음 구현했을 때는 정말 신기했습니다. 토큰 하나로 사용자를 인증할 수 있다니!
🤔 그런데 OAuth는 또 뭐야?
JWT를 구현하면서 자연스럽게 OAuth에 대해서도 알게 되었습니다. OAuth는 쉽게 말해서 "구글 로그인", "카카오 로그인" 같은 외부 서비스를 통한 인증 방식입니다.
OAuth의 동작 방식을 이해하는 데 시간이 좀 걸렸는데, 핵심은 이런 흐름입니다:
- 사용자가 "구글로 로그인" 버튼 클릭
- 구글 인증 페이지로 리다이렉트
- 사용자가 구글에서 로그인
- 구글이 우리 앱에 인증 코드 전달
- 우리 앱이 그 코드로 구글에서 Access Token 요청
- 구글이 Access Token 발급
- 우리 앱이 그 토큰으로 사용자 정보 가져오기
# Django에서 구글 OAuth 구현 예시 import requests from django.shortcuts import redirect from django.conf import settings def google_login_callback(request): code = request.GET.get('code') # Access Token 요청 token_response = requests.post('https://oauth2.googleapis.com/token', { 'client_id': settings.GOOGLE_CLIENT_ID, 'client_secret': settings.GOOGLE_CLIENT_SECRET, 'code': code, 'grant_type': 'authorization_code', 'redirect_uri': settings.GOOGLE_REDIRECT_URI, }) token_data = token_response.json() access_token = token_data.get('access_token') # 사용자 정보 가져오기 user_response = requests.get( 'https://www.googleapis.com/oauth2/v2/userinfo', headers={'Authorization': f'Bearer {access_token}'} ) user_data = user_response.json() # 여기서 사용자 정보로 우리 앱의 JWT 토큰 생성
💡 Access Token과 Refresh Token의 차이점
이 부분이 정말 헷갈렸던 부분입니다. 왜 토큰이 두 개나 필요한지 처음에는 이해가 안 됐거든요.
Access Token
- 실제 API 요청에 사용하는 토큰입니다
- 보통 수명이 짧습니다 (15분~1시간)
- 만료되면 더 이상 사용할 수 없습니다
Refresh Token
- Access Token을 새로 발급받기 위한 토큰입니다
- 수명이 깁니다 (며칠~몇 주)
- 보안상 더 안전한 곳에 저장됩니다
# Django에서 토큰 갱신 구현 def refresh_access_token(request): refresh_token = request.data.get('refresh_token') try: # Refresh Token 검증 payload = jwt.decode(refresh_token, settings.REFRESH_SECRET_KEY, algorithms=['HS256']) user_id = payload.get('user_id') # 새로운 Access Token 생성 user = User.objects.get(id=user_id) new_access_token = create_jwt_token(user) return JsonResponse({ 'access_token': new_access_token, 'message': '토큰이 성공적으로 갱신되었습니다.' }) except jwt.ExpiredSignatureError: return JsonResponse({'error': '리프레시 토큰이 만료되었습니다.'}, status=401)
🛡️ 보안을 위한 고민들
구현하면서 가장 많이 고민했던 부분이 보안이었습니다.
토큰을 어디에 저장할까?
- LocalStorage: 편하지만 XSS 공격에 취약
- Cookie: CSRF 공격 고려 필요하지만 httpOnly 설정으로 XSS 방어 가능
- SessionStorage: 탭 닫으면 사라지는 단점
결국 저는 Access Token은 메모리에, Refresh Token은 httpOnly 쿠키에 저장하는 방식을 선택했습니다.
# Django에서 쿠키에 Refresh Token 저장 def login_view(request): # ... 인증 로직 ... response = JsonResponse({ 'access_token': access_token, 'message': '로그인 성공' }) # Refresh Token을 httpOnly 쿠키에 저장 response.set_cookie( 'refresh_token', refresh_token, max_age=7*24*60*60, # 7일 httponly=True, secure=True, # HTTPS에서만 samesite='Strict' ) return response
🔄 실제 프론트엔드에서의 활용
백엔드 구현도 중요하지만, 프론트엔드(flutter)에서 어떻게 사용하는지도 중요했습니다.
// token_service.dart - 토큰 관리 서비스 import 'package:shared_preferences/shared_preferences.dart'; import 'package:dio/dio.dart'; class TokenService { static const String _accessTokenKey = 'access_token'; static const String _refreshTokenKey = 'refresh_token'; // Access Token 저장/조회 static Future<void> saveAccessToken(String token) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_accessTokenKey, token); } static Future<String?> getAccessToken() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString(_accessTokenKey); } // Refresh Token 저장/조회 static Future<void> saveRefreshToken(String token) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_refreshTokenKey, token); } static Future<String?> getRefreshToken() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString(_refreshTokenKey); } // 토큰 삭제 (로그아웃) static Future<void> clearTokens() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_accessTokenKey); await prefs.remove(_refreshTokenKey); } } dart// api_client.dart - Dio 클라이언트 설정 import 'package:dio/dio.dart'; import 'token_service.dart'; class ApiClient { late Dio _dio; ApiClient() { _dio = Dio(BaseOptions( baseUrl: 'https://your-api-server.com/api', connectTimeout: const Duration(seconds: 5), receiveTimeout: const Duration(seconds: 3), )); _setupInterceptors(); } void _setupInterceptors() { // 요청 인터셉터 - Access Token 자동 추가 _dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) async { final accessToken = await TokenService.getAccessToken(); if (accessToken != null) { options.headers['Authorization'] = 'Bearer $accessToken'; } handler.next(options); }, // 응답 인터셉터 - 토큰 만료 시 자동 갱신 onError: (error, handler) async { if (error.response?.statusCode == 401) { // 토큰 갱신 시도 final refreshed = await _refreshToken(); if (refreshed) { // 원래 요청 재시도 final accessToken = await TokenService.getAccessToken(); error.requestOptions.headers['Authorization'] = 'Bearer $accessToken'; final response = await _dio.fetch(error.requestOptions); handler.resolve(response); return; } else { // 리프레시 실패 시 로그인 페이지로 await TokenService.clearTokens(); // Navigator로 로그인 페이지로 이동하는 로직 추가 } } handler.next(error); }, )); } Future<bool> _refreshToken() async { try { final refreshToken = await TokenService.getRefreshToken(); if (refreshToken == null) return false; final response = await _dio.post('/token/refresh/', data: {'refresh_token': refreshToken}, options: Options(headers: {'Authorization': null}) // 기존 토큰 제거 ); final newAccessToken = response.data['access_token']; await TokenService.saveAccessToken(newAccessToken); return true; } catch (e) { print('토큰 갱신 실패: $e'); return false; } } Dio get dio => _dio; }
// auth_service.dart - 인증 관련 서비스 import 'package:dio/dio.dart'; import 'api_client.dart'; import 'token_service.dart'; class AuthService { final ApiClient _apiClient = ApiClient(); // 일반 로그인 Future<Map<String, dynamic>> login(String username, String password) async { try { final response = await _apiClient.dio.post('/auth/login/', data: { 'username': username, 'password': password, }); final accessToken = response.data['access_token']; final refreshToken = response.data['refresh_token']; // 토큰 저장 await TokenService.saveAccessToken(accessToken); await TokenService.saveRefreshToken(refreshToken); return {'success': true, 'message': '로그인 성공'}; } on DioException catch (e) { return { 'success': false, 'message': e.response?.data['message'] ?? '로그인 실패' }; } } // 구글 OAuth 로그인 Future<Map<String, dynamic>> googleLogin() async { try { // google_sign_in 패키지 사용 final GoogleSignInAccount? googleUser = await GoogleSignIn().signIn(); if (googleUser == null) { return {'success': false, 'message': '구글 로그인 취소'}; } final GoogleSignInAuthentication googleAuth = await googleUser.authentication; // 백엔드에 구글 토큰 전송 final response = await _apiClient.dio.post('/auth/google/', data: { 'access_token': googleAuth.accessToken, 'id_token': googleAuth.idToken, }); final accessToken = response.data['access_token']; final refreshToken = response.data['refresh_token']; await TokenService.saveAccessToken(accessToken); await TokenService.saveRefreshToken(refreshToken); return {'success': true, 'message': '구글 로그인 성공'}; } catch (e) { return {'success': false, 'message': '구글 로그인 실패: $e'}; } } // 로그아웃 Future<void> logout() async { await TokenService.clearTokens(); await GoogleSignIn().signOut(); // 구글 로그아웃도 함께 } // 로그인 상태 확인 Future<bool> isLoggedIn() async { final accessToken = await TokenService.getAccessToken(); return accessToken != null; } }
// login_page.dart - 로그인 페이지에서 사용 class LoginPage extends StatefulWidget { @override _LoginPageState createState() => _LoginPageState(); } class _LoginPageState extends State<LoginPage> { final AuthService _authService = AuthService(); final TextEditingController _usernameController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); Future<void> _handleLogin() async { final result = await _authService.login( _usernameController.text, _passwordController.text, ); if (result['success']) { Navigator.pushReplacementNamed(context, '/home'); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(result['message'])), ); } } Future<void> _handleGoogleLogin() async { final result = await _authService.googleLogin(); if (result['success']) { Navigator.pushReplacementNamed(context, '/home'); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(result['message'])), ); } } @override Widget build(BuildContext context) { return Scaffold( body: Column( children: [ TextField( controller: _usernameController, decoration: InputDecoration(labelText: '사용자명'), ), TextField( controller: _passwordController, decoration: InputDecoration(labelText: '비밀번호'), obscureText: true, ), ElevatedButton( onPressed: _handleLogin, child: Text('로그인'), ), ElevatedButton( onPressed: _handleGoogleLogin, child: Text('구글로 로그인'), ), ], ), ); } }
🎯 마무리하며
JWT와 OAuth, 그리고 토큰 관리 시스템을 구현하면서 정말 많은 것을 배웠습니다.
처음에는 복잡해 보였지만, 하나씩 이해하고 구현해보니 왜 이런 방식들이 널리 사용되는지 알 수 있었습니다.
특히 보안과 사용자 경험 사이의 균형을 맞추는 것이 가장 중요하다는 것을 깨달았습니다.
너무 복잡하면 사용자가 불편하고, 너무 간단하면 보안에 문제가 생기거든요.
다음에는 더 고도화된 인증 방식들에 대해서도 공부해보려고 합니다.
'IT' 카테고리의 다른 글
FCM과 Local Notification은 완전히 다르다. (0) 2025.06.07 AI는 보조 도구일 뿐, 신이 아닙니다: 코드 작성 보조 AI툴 활용 가이드 (1) 2025.05.30 검색에 잘 걸리게 하고 싶어서 찾아본 SEO와 AEO 정리 (0) 2025.05.29 [Flutter] 안드로이드 앱 심사 올리는 절차 소개 (0) 2025.05.29 [Flutter] ScrollController를 통한 자동 스크롤 구현 (0) 2025.05.27