ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [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의 동작 방식을 이해하는 데 시간이 좀 걸렸는데, 핵심은 이런 흐름입니다:

    1. 사용자가 "구글로 로그인" 버튼 클릭
    2. 구글 인증 페이지로 리다이렉트
    3. 사용자가 구글에서 로그인
    4. 구글이 우리 앱에 인증 코드 전달
    5. 우리 앱이 그 코드로 구글에서 Access Token 요청
    6. 구글이 Access Token 발급
    7. 우리 앱이 그 토큰으로 사용자 정보 가져오기
    # 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, 그리고 토큰 관리 시스템을 구현하면서 정말 많은 것을 배웠습니다.

    처음에는 복잡해 보였지만, 하나씩 이해하고 구현해보니 왜 이런 방식들이 널리 사용되는지 알 수 있었습니다.

    특히 보안과 사용자 경험 사이의 균형을 맞추는 것이 가장 중요하다는 것을 깨달았습니다.

    너무 복잡하면 사용자가 불편하고, 너무 간단하면 보안에 문제가 생기거든요.

    다음에는 더 고도화된 인증 방식들에 대해서도 공부해보려고 합니다.

Designed by Tistory.