← 블로그 홈

Django Ninja로 구현하는 카카오 소셜 로그인 완벽 가이드

개요

개요

모던 웹 애플리케이션에서 소셜 로그인은 필수 기능이 되었습니다. 특히 한국에서 카카오 로그인은 사용자 편의성과 신뢰성 면에서 가장 인기 있는 인증 방식 중 하나입니다. 이 포스트에서는 Django Ninja를 사용하여 카카오 소셜 로그인을 구현하는 전체 과정을 설명하겠습니다.

1. 카카오 소셜 로그인이 필요한 이유

1.1 사용자 편의성

  • 간편한 가입/로그인: 복잡한 회원가입 과정 생략
  • 신뢰할 수 있는 인증: 카카오의 검증된 인증 시스템 활용
  • 개인정보 최소화: 필요한 정보만 선택적으로 수집

1.2 개발자 관점

  • 개발 시간 단축: 인증 로직 구현 시간 절약
  • 보안성 향상: OAuth 2.0 표준 프로토콜 사용
  • 유지보수 용이: 카카오에서 보안 업데이트 관리

2. 카카오 개발자 센터 설정

2.1 애플리케이션 등록

# 1. 카카오 개발자 센터 접속
https://developers.kakao.com/

# 2. 내 애플리케이션 > 애플리케이션 추가하기
# 3. 앱 이름, 사업자명 입력

2.2 플랫폼 설정

// Web 플랫폼 등록
Site Domain: http://localhost:8000  // 개발환경
Site Domain: https://yourdomain.com  // 운영환경

2.3 카카오 로그인 활성화

# 제품 설정 > 카카오 로그인
카카오 로그인 활성화: ON
Redirect URI: http://localhost:8000/api/auth/kakao/callback

2.4 동의항목 설정

# 개인정보보호 > 개인정보
닉네임: 필수 동의
프로필 사진: 선택 동의
카카오계정(이메일): 선택 동의

3. Django 프로젝트 설정

3.1 필요한 패키지 설치

pip install django-ninja
pip install requests
pip install PyJWT
pip install python-decouple

3.2 환경변수 설정

# .env 파일
KAKAO_CLIENT_ID=your_kakao_rest_api_key
KAKAO_CLIENT_SECRET=your_kakao_client_secret
KAKAO_REDIRECT_URI=http://localhost:8000/api/auth/kakao/callback
JWT_SECRET_KEY=your_jwt_secret_key
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7

3.3 Django 설정

# settings.py
from decouple import config

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'ninja',
    'accounts',  # 인증 앱
]

# 카카오 로그인 설정
KAKAO_CLIENT_ID = config('KAKAO_CLIENT_ID')
KAKAO_CLIENT_SECRET = config('KAKAO_CLIENT_SECRET')
KAKAO_REDIRECT_URI = config('KAKAO_REDIRECT_URI')

# JWT 설정
JWT_SECRET_KEY = config('JWT_SECRET_KEY')
JWT_ALGORITHM = config('JWT_ALGORITHM', default='HS256')
JWT_ACCESS_TOKEN_EXPIRE_MINUTES = config('JWT_ACCESS_TOKEN_EXPIRE_MINUTES', default=30, cast=int)
JWT_REFRESH_TOKEN_EXPIRE_DAYS = config('JWT_REFRESH_TOKEN_EXPIRE_DAYS', default=7, cast=int)

# CORS 설정 (프론트엔드 연동 시)
CORS_ALLOWED_ORIGINS = [
    "http://localhost:3000",  # React 개발서버
    "http://127.0.0.1:3000",
]

CORS_ALLOW_CREDENTIALS = True

4. 사용자 모델 설정

4.1 확장된 User 모델

# accounts/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models


class User(AbstractUser):
    """확장된 사용자 모델"""
    
    # 카카오 연동 정보
    kakao_id = models.CharField(max_length=100, unique=True, null=True, blank=True)
    profile_image = models.URLField(blank=True, null=True)
    
    # 추가 사용자 정보
    nickname = models.CharField(max_length=50, blank=True)
    phone_number = models.CharField(max_length=20, blank=True)
    birth_date = models.DateField(null=True, blank=True)
    gender = models.CharField(
        max_length=10,
        choices=[('male', '남성'), ('female', '여성')],
        blank=True
    )
    
    # 메타데이터
    is_social_user = models.BooleanField(default=False)
    social_provider = models.CharField(max_length=20, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    
    def __str__(self):
        return self.username


class RefreshToken(models.Model):
    """JWT 리프레시 토큰 관리"""
    
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='refresh_tokens')
    token = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    expires_at = models.DateTimeField()
    is_active = models.BooleanField(default=True)
    
    class Meta:
        db_table = 'refresh_tokens'
        ordering = ['-created_at']
    
    def __str__(self):
        return f"{self.user.username}'s refresh token"

4.2 모델 등록

# settings.py에 추가
AUTH_USER_MODEL = 'accounts.User'
# 마이그레이션 실행
python manage.py makemigrations
python manage.py migrate

5. JWT 유틸리티 구현

5.1 JWT 헬퍼 함수

# accounts/jwt_utils.py
import jwt
from datetime import datetime, timedelta
from django.conf import settings
from django.contrib.auth import get_user_model
from .models import RefreshToken

User = get_user_model()


class JWTManager:
    """JWT 토큰 관리 클래스"""
    
    @staticmethod
    def generate_tokens(user):
        """액세스 토큰과 리프레시 토큰 생성"""
        
        # 액세스 토큰 생성
        access_payload = {
            'user_id': user.id,
            'username': user.username,
            'email': user.email,
            'exp': datetime.utcnow() + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES),
            'iat': datetime.utcnow(),
            'type': 'access'
        }
        
        access_token = jwt.encode(
            access_payload,
            settings.JWT_SECRET_KEY,
            algorithm=settings.JWT_ALGORITHM
        )
        
        # 리프레시 토큰 생성
        refresh_payload = {
            'user_id': user.id,
            'exp': datetime.utcnow() + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS),
            'iat': datetime.utcnow(),
            'type': 'refresh'
        }
        
        refresh_token = jwt.encode(
            refresh_payload,
            settings.JWT_SECRET_KEY,
            algorithm=settings.JWT_ALGORITHM
        )
        
        # 리프레시 토큰 DB 저장
        RefreshToken.objects.create(
            user=user,
            token=refresh_token,
            expires_at=datetime.utcnow() + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
        )
        
        return access_token, refresh_token
    
    @staticmethod
    def verify_token(token, token_type='access'):
        """토큰 검증"""
        try:
            payload = jwt.decode(
                token,
                settings.JWT_SECRET_KEY,
                algorithms=[settings.JWT_ALGORITHM]
            )
            
            if payload.get('type') != token_type:
                return None
            
            return payload
        
        except jwt.ExpiredSignatureError:
            return None
        except jwt.InvalidTokenError:
            return None
    
    @staticmethod
    def refresh_access_token(refresh_token):
        """리프레시 토큰으로 액세스 토큰 갱신"""
        payload = JWTManager.verify_token(refresh_token, 'refresh')
        
        if not payload:
            return None
        
        # DB에서 리프레시 토큰 확인
        try:
            token_obj = RefreshToken.objects.get(
                token=refresh_token,
                is_active=True,
                expires_at__gt=datetime.utcnow()
            )
            user = token_obj.user
        except RefreshToken.DoesNotExist:
            return None
        
        # 새 액세스 토큰 생성
        access_payload = {
            'user_id': user.id,
            'username': user.username,
            'email': user.email,
            'exp': datetime.utcnow() + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES),
            'iat': datetime.utcnow(),
            'type': 'access'
        }
        
        access_token = jwt.encode(
            access_payload,
            settings.JWT_SECRET_KEY,
            algorithm=settings.JWT_ALGORITHM
        )
        
        return access_token

6. 카카오 API 서비스 구현

6.1 카카오 인증 서비스

# accounts/kakao_service.py
import requests
from django.conf import settings
from django.contrib.auth import get_user_model
from typing import Optional, Dict, Any

User = get_user_model()


class KakaoAuthService:
    """카카오 인증 서비스"""
    
    KAKAO_TOKEN_URL = "https://kauth.kakao.com/oauth/token"
    KAKAO_USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"
    
    @classmethod
    def get_access_token(cls, authorization_code: str) -> Optional[str]:
        """인가 코드로 액세스 토큰 획득"""
        
        data = {
            'grant_type': 'authorization_code',
            'client_id': settings.KAKAO_CLIENT_ID,
            'client_secret': settings.KAKAO_CLIENT_SECRET,
            'redirect_uri': settings.KAKAO_REDIRECT_URI,
            'code': authorization_code,
        }
        
        try:
            response = requests.post(cls.KAKAO_TOKEN_URL, data=data, timeout=10)
            response.raise_for_status()
            
            token_data = response.json()
            return token_data.get('access_token')
        
        except requests.exceptions.RequestException as e:
            print(f"카카오 토큰 요청 실패: {e}")
            return None
    
    @classmethod
    def get_user_info(cls, access_token: str) -> Optional[Dict[str, Any]]:
        """액세스 토큰으로 사용자 정보 조회"""
        
        headers = {
            'Authorization': f'Bearer {access_token}',
            'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8'
        }
        
        try:
            response = requests.get(cls.KAKAO_USER_INFO_URL, headers=headers, timeout=10)
            response.raise_for_status()
            
            return response.json()
        
        except requests.exceptions.RequestException as e:
            print(f"카카오 사용자 정보 요청 실패: {e}")
            return None
    
    @classmethod
    def create_or_update_user(cls, kakao_user_info: Dict[str, Any]) -> User:
        """카카오 사용자 정보로 User 생성 또는 업데이트"""
        
        kakao_id = str(kakao_user_info['id'])
        kakao_account = kakao_user_info.get('kakao_account', {})
        profile = kakao_account.get('profile', {})
        
        # 기본 정보 추출
        email = kakao_account.get('email', '')
        nickname = profile.get('nickname', f'user_{kakao_id}')
        profile_image_url = profile.get('profile_image_url', '')
        
        # 사용자 생성 또는 조회
        user, created = User.objects.get_or_create(
            kakao_id=kakao_id,
            defaults={
                'username': f'kakao_{kakao_id}',
                'email': email,
                'nickname': nickname,
                'profile_image': profile_image_url,
                'is_social_user': True,
                'social_provider': 'kakao',
                'is_active': True,
            }
        )
        
        # 기존 사용자 정보 업데이트
        if not created:
            user.email = email or user.email
            user.nickname = nickname or user.nickname
            user.profile_image = profile_image_url or user.profile_image
            user.save()
        
        return user
    
    @classmethod
    def process_kakao_login(cls, authorization_code: str) -> Optional[User]:
        """카카오 로그인 전체 프로세스 처리"""
        
        # 1. 액세스 토큰 획득
        access_token = cls.get_access_token(authorization_code)
        if not access_token:
            return None
        
        # 2. 사용자 정보 조회
        user_info = cls.get_user_info(access_token)
        if not user_info:
            return None
        
        # 3. 사용자 생성 또는 업데이트
        user = cls.create_or_update_user(user_info)
        
        return user

7. Django Ninja API 구현

7.1 인증 스키마 정의

# accounts/schemas.py
from ninja import Schema
from typing import Optional
from datetime import datetime


class KakaoLoginUrlResponse(Schema):
    """카카오 로그인 URL 응답"""
    login_url: str


class TokenResponse(Schema):
    """토큰 응답"""
    access_token: str
    refresh_token: str
    token_type: str = "Bearer"
    expires_in: int


class UserProfileResponse(Schema):
    """사용자 프로필 응답"""
    id: int
    username: str
    email: str
    nickname: str
    profile_image: Optional[str] = None
    is_social_user: bool
    social_provider: str
    created_at: datetime


class RefreshTokenRequest(Schema):
    """리프레시 토큰 요청"""
    refresh_token: str


class ErrorResponse(Schema):
    """에러 응답"""
    error: str
    message: str

7.2 인증 클래스 구현

# accounts/auth.py
from ninja.security import HttpBearer
from django.contrib.auth import get_user_model
from django.http import HttpRequest
from .jwt_utils import JWTManager

User = get_user_model()


class JWTAuth(HttpBearer):
    """JWT 인증 클래스"""
    
    def authenticate(self, request: HttpRequest, token: str):
        """JWT 토큰 인증"""
        payload = JWTManager.verify_token(token, 'access')
        
        if not payload:
            return None
        
        try:
            user = User.objects.get(id=payload['user_id'])
            return user
        except User.DoesNotExist:
            return None


# 인증 인스턴스 생성
jwt_auth = JWTAuth()

7.3 카카오 인증 API 구현

# accounts/api.py
from ninja import Router
from ninja.responses import Response
from django.conf import settings
from django.http import HttpRequest
from urllib.parse import urlencode
from .schemas import (
    KakaoLoginUrlResponse, 
    TokenResponse, 
    UserProfileResponse, 
    RefreshTokenRequest,
    ErrorResponse
)
from .kakao_service import KakaoAuthService
from .jwt_utils import JWTManager
from .auth import jwt_auth

auth_router = Router()


@auth_router.get("/kakao/login-url", response=KakaoLoginUrlResponse)
def get_kakao_login_url(request: HttpRequest):
    """카카오 로그인 URL 생성"""
    
    params = {
        'client_id': settings.KAKAO_CLIENT_ID,
        'redirect_uri': settings.KAKAO_REDIRECT_URI,
        'response_type': 'code',
        'scope': 'profile_nickname,profile_image,account_email',
    }
    
    login_url = f"https://kauth.kakao.com/oauth/authorize?{urlencode(params)}"
    
    return {
        "login_url": login_url
    }


@auth_router.post("/kakao/callback", response={200: TokenResponse, 400: ErrorResponse})
def kakao_callback(request: HttpRequest, code: str):
    """카카오 로그인 콜백 처리"""
    
    try:
        # 카카오 로그인 처리
        user = KakaoAuthService.process_kakao_login(code)
        
        if not user:
            return 400, {
                "error": "KAKAO_LOGIN_FAILED",
                "message": "카카오 로그인에 실패했습니다."
            }
        
        # JWT 토큰 생성
        access_token, refresh_token = JWTManager.generate_tokens(user)
        
        return {
            "access_token": access_token,
            "refresh_token": refresh_token,
            "token_type": "Bearer",
            "expires_in": settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60
        }
    
    except Exception as e:
        return 400, {
            "error": "INTERNAL_ERROR",
            "message": f"로그인 처리 중 오류가 발생했습니다: {str(e)}"
        }


@auth_router.post("/refresh", response={200: TokenResponse, 400: ErrorResponse})
def refresh_token(request: HttpRequest, data: RefreshTokenRequest):
    """액세스 토큰 갱신"""
    
    access_token = JWTManager.refresh_access_token(data.refresh_token)
    
    if not access_token:
        return 400, {
            "error": "INVALID_REFRESH_TOKEN",
            "message": "유효하지 않은 리프레시 토큰입니다."
        }
    
    return {
        "access_token": access_token,
        "refresh_token": data.refresh_token,  # 기존 리프레시 토큰 유지
        "token_type": "Bearer",
        "expires_in": settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60
    }


@auth_router.get("/profile", auth=jwt_auth, response=UserProfileResponse)
def get_user_profile(request: HttpRequest):
    """사용자 프로필 조회"""
    
    user = request.auth
    
    return {
        "id": user.id,
        "username": user.username,
        "email": user.email,
        "nickname": user.nickname,
        "profile_image": user.profile_image,
        "is_social_user": user.is_social_user,
        "social_provider": user.social_provider,
        "created_at": user.created_at
    }


@auth_router.post("/logout", auth=jwt_auth)
def logout(request: HttpRequest):
    """로그아웃 (리프레시 토큰 무효화)"""
    
    user = request.auth
    
    # 사용자의 모든 리프레시 토큰 비활성화
    from .models import RefreshToken
    RefreshToken.objects.filter(user=user, is_active=True).update(is_active=False)
    
    return {"message": "로그아웃되었습니다."}

8. 메인 API 연결

8.1 Django Ninja API 설정

# main/api.py
from ninja import NinjaAPI
from accounts.api import auth_router

api = NinjaAPI(
    title="Django Ninja with Kakao Login API",
    version="1.0.0",
    description="카카오 소셜 로그인이 구현된 Django Ninja API"
)

# 인증 라우터 등록
api.add_router("/auth/", auth_router)


@api.get("/health")
def health_check(request):
    """헬스 체크"""
    return {"status": "healthy"}


# 보호된 API 예시
from accounts.auth import jwt_auth

@api.get("/protected", auth=jwt_auth)
def protected_endpoint(request):
    """JWT 인증이 필요한 보호된 엔드포인트"""
    user = request.auth
    return {
        "message": f"안녕하세요, {user.nickname}님!",
        "user_id": user.id
    }

8.2 URL 설정

# urls.py
from django.contrib import admin
from django.urls import path
from main.api import api

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', api.urls),
]

9. 프론트엔드 연동 예시

9.1 JavaScript 클라이언트

// kakaoAuth.js
class KakaoAuthClient {
    constructor(baseURL = 'http://localhost:8000/api') {
        this.baseURL = baseURL;
        this.accessToken = localStorage.getItem('access_token');
        this.refreshToken = localStorage.getItem('refresh_token');
    }
    
    // 카카오 로그인 URL 획득
    async getKakaoLoginUrl() {
        const response = await fetch(`${this.baseURL}/auth/kakao/login-url`);
        const data = await response.json();
        return data.login_url;
    }
    
    // 카카오 로그인 처리
    async processKakaoCallback(code) {
        const response = await fetch(`${this.baseURL}/auth/kakao/callback`, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({ code })
        });
        
        if (response.ok) {
            const tokens = await response.json();
            this.setTokens(tokens.access_token, tokens.refresh_token);
            return tokens;
        }
        
        throw new Error('카카오 로그인 실패');
    }
    
    // 토큰 저장
    setTokens(accessToken, refreshToken) {
        this.accessToken = accessToken;
        this.refreshToken = refreshToken;
        localStorage.setItem('access_token', accessToken);
        localStorage.setItem('refresh_token', refreshToken);
    }
    
    // 인증된 API 요청
    async authenticatedRequest(url, options = {}) {
        const headers = {
            'Authorization': `Bearer ${this.accessToken}`,
            'Content-Type': 'application/json',
            ...options.headers
        };
        
        let response = await fetch(`${this.baseURL}${url}`, {
            ...options,
            headers
        });
        
        // 토큰 만료 시 갱신 시도
        if (response.status === 401) {
            const refreshed = await this.refreshAccessToken();
            if (refreshed) {
                headers['Authorization'] = `Bearer ${this.accessToken}`;
                response = await fetch(`${this.baseURL}${url}`, {
                    ...options,
                    headers
                });
            }
        }
        
        return response;
    }
    
    // 액세스 토큰 갱신
    async refreshAccessToken() {
        if (!this.refreshToken) return false;
        
        try {
            const response = await fetch(`${this.baseURL}/auth/refresh`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    refresh_token: this.refreshToken
                })
            });
            
            if (response.ok) {
                const tokens = await response.json();
                this.setTokens(tokens.access_token, tokens.refresh_token);
                return true;
            }
        } catch (error) {
            console.error('토큰 갱신 실패:', error);
        }
        
        return false;
    }
    
    // 사용자 프로필 조회
    async getUserProfile() {
        const response = await this.authenticatedRequest('/auth/profile');
        return response.json();
    }
    
    // 로그아웃
    async logout() {
        await this.authenticatedRequest('/auth/logout', { method: 'POST' });
        this.clearTokens();
    }
    
    // 토큰 삭제
    clearTokens() {
        this.accessToken = null;
        this.refreshToken = null;
        localStorage.removeItem('access_token');
        localStorage.removeItem('refresh_token');
    }
}

// 사용 예시
const authClient = new KakaoAuthClient();

// 카카오 로그인 버튼 클릭 처리
document.getElementById('kakao-login-btn').addEventListener('click', async () => {
    const loginUrl = await authClient.getKakaoLoginUrl();
    window.location.href = loginUrl;
});

// 카카오 콜백 페이지에서 처리
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');

if (code) {
    authClient.processKakaoCallback(code)
        .then(tokens => {
            console.log('로그인 성공:', tokens);
            // 메인 페이지로 리다이렉트
            window.location.href = '/dashboard';
        })
        .catch(error => {
            console.error('로그인 실패:', error);
        });
}

9.2 React 컴포넌트 예시

// KakaoLogin.jsx
import React, { useState, useEffect } from 'react';
import { KakaoAuthClient } from './kakaoAuth';

const KakaoLogin = () => {
    const [user, setUser] = useState(null);
    const [loading, setLoading] = useState(false);
    const authClient = new KakaoAuthClient();

    useEffect(() => {
        // 페이지 로드 시 사용자 정보 확인
        checkUserStatus();
        
        // 카카오 콜백 처리
        const urlParams = new URLSearchParams(window.location.search);
        const code = urlParams.get('code');
        
        if (code) {
            handleKakaoCallback(code);
        }
    }, []);

    const checkUserStatus = async () => {
        try {
            const profile = await authClient.getUserProfile();
            setUser(profile);
        } catch (error) {
            console.log('사용자 정보 없음');
        }
    };

    const handleKakaoLogin = async () => {
        setLoading(true);
        try {
            const loginUrl = await authClient.getKakaoLoginUrl();
            window.location.href = loginUrl;
        } catch (error) {
            console.error('로그인 URL 생성 실패:', error);
            setLoading(false);
        }
    };

    const handleKakaoCallback = async (code) => {
        setLoading(true);
        try {
            await authClient.processKakaoCallback(code);
            const profile = await authClient.getUserProfile();
            setUser(profile);
            
            // URL 정리
            window.history.replaceState({}, document.title, window.location.pathname);
        } catch (error) {
            console.error('카카오 로그인 처리 실패:', error);
        }
        setLoading(false);
    };

    const handleLogout = async () => {
        try {
            await authClient.logout();
            setUser(null);
        } catch (error) {
            console.error('로그아웃 실패:', error);
        }
    };

    if (loading) {
        return <div className="loading">로그인 처리 중...</div>;
    }

    return (
        <div className="kakao-login">
            {user ? (
                <div className="user-info">
                    <img src={user.profile_image} alt="프로필" />
                    <h3>{user.nickname}님 환영합니다!</h3>
                    <p>이메일: {user.email}</p>
                    <button onClick={handleLogout}>로그아웃</button>
                </div>
            ) : (
                <button 
                    className="kakao-login-btn"
                    onClick={handleKakaoLogin}
                    disabled={loading}
                >
                    카카오로 로그인
                </button>
            )}
        </div>
    );
};

export default KakaoLogin;

10. 테스트 구현

10.1 유닛 테스트

# accounts/tests.py
from django.test import TestCase
from django.contrib.auth import get_user_model
from unittest.mock import patch, Mock
from .kakao_service import KakaoAuthService
from .jwt_utils import JWTManager

User = get_user_model()


class KakaoAuthServiceTest(TestCase):
    """카카오 인증 서비스 테스트"""
    
    def setUp(self):
        self.mock_kakao_user_info = {
            'id': 12345678,
            'kakao_account': {
                'email': 'test@example.com',
                'profile': {
                    'nickname': '테스트사용자',
                    'profile_image_url': 'https://example.com/profile.jpg'
                }
            }
        }
    
    @patch('accounts.kakao_service.requests.post')
    def test_get_access_token_success(self, mock_post):
        """액세스 토큰 획득 성공 테스트"""
        mock_response = Mock()
        mock_response.json.return_value = {'access_token': 'test_token'}
        mock_response.raise_for_status.return_value = None
        mock_post.return_value = mock_response
        
        token = KakaoAuthService.get_access_token('test_code')
        
        self.assertEqual(token, 'test_token')
        mock_post.assert_called_once()
    
    @patch('accounts.kakao_service.requests.get')
    def test_get_user_info_success(self, mock_get):
        """사용자 정보 조회 성공 테스트"""
        mock_response = Mock()
        mock_response.json.return_value = self.mock_kakao_user_info
        mock_response.raise_for_status.return_value = None
        mock_get.return_value = mock_response
        
        user_info = KakaoAuthService.get_user_info('test_token')
        
        self.assertEqual(user_info, self.mock_kakao_user_info)
        mock_get.assert_called_once()
    
    def test_create_or_update_user(self):
        """사용자 생성/업데이트 테스트"""
        user = KakaoAuthService.create_or_update_user(self.mock_kakao_user_info)
        
        self.assertEqual(user.kakao_id, '12345678')
        self.assertEqual(user.email, 'test@example.com')
        self.assertEqual(user.nickname, '테스트사용자')
        self.assertTrue(user.is_social_user)
        self.assertEqual(user.social_provider, 'kakao')


class JWTManagerTest(TestCase):
    """JWT 매니저 테스트"""
    
    def setUp(self):
        self.user = User.objects.create_user(
            username='testuser',
            email='test@example.com'
        )
    
    def test_generate_tokens(self):
        """토큰 생성 테스트"""
        access_token, refresh_token = JWTManager.generate_tokens(self.user)
        
        self.assertIsNotNone(access_token)
        self.assertIsNotNone(refresh_token)
        
        # 액세스 토큰 검증
        access_payload = JWTManager.verify_token(access_token, 'access')
        self.assertEqual(access_payload['user_id'], self.user.id)
        
        # 리프레시 토큰 검증
        refresh_payload = JWTManager.verify_token(refresh_token, 'refresh')
        self.assertEqual(refresh_payload['user_id'], self.user.id)
    
    def test_refresh_access_token(self):
        """액세스 토큰 갱신 테스트"""
        _, refresh_token = JWTManager.generate_tokens(self.user)
        
        new_access_token = JWTManager.refresh_access_token(refresh_token)
        
        self.assertIsNotNone(new_access_token)
        
        # 새 액세스 토큰 검증
        payload = JWTManager.verify_token(new_access_token, 'access')
        self.assertEqual(payload['user_id'], self.user.id)

10.2 API 테스트

# accounts/test_api.py
from django.test import TestCase
from ninja.testing import TestClient
from unittest.mock import patch
from main.api import api


class KakaoAuthAPITest(TestCase):
    """카카오 인증 API 테스트"""
    
    def setUp(self):
        self.client = TestClient(api)
    
    def test_get_kakao_login_url(self):
        """카카오 로그인 URL 생성 테스트"""
        response = self.client.get("/auth/kakao/login-url")
        
        self.assertEqual(response.status_code, 200)
        data = response.json()
        self.assertIn('login_url', data)
        self.assertIn('kauth.kakao.com', data['login_url'])
    
    @patch('accounts.api.KakaoAuthService.process_kakao_login')
    @patch('accounts.api.JWTManager.generate_tokens')
    def test_kakao_callback_success(self, mock_generate_tokens, mock_process_login):
        """카카오 콜백 성공 테스트"""
        from django.contrib.auth import get_user_model
        User = get_user_model()
        
        # Mock 설정
        mock_user = User(id=1, username='testuser')
        mock_process_login.return_value = mock_user
        mock_generate_tokens.return_value = ('access_token', 'refresh_token')
        
        response = self.client.post("/auth/kakao/callback", json={"code": "test_code"})
        
        self.assertEqual(response.status_code, 200)
        data = response.json()
        self.assertEqual(data['access_token'], 'access_token')
        self.assertEqual(data['refresh_token'], 'refresh_token')
    
    def test_kakao_callback_failure(self):
        """카카오 콜백 실패 테스트"""
        with patch('accounts.api.KakaoAuthService.process_kakao_login', return_value=None):
            response = self.client.post("/auth/kakao/callback", json={"code": "invalid_code"})
            
            self.assertEqual(response.status_code, 400)
            data = response.json()
            self.assertEqual(data['error'], 'KAKAO_LOGIN_FAILED')

11. 보안 고려사항

11.1 토큰 보안

# accounts/security.py
import secrets
from django.conf import settings
from django.core.cache import cache
from datetime import datetime, timedelta


class SecurityManager:
    """보안 관리 클래스"""
    
    @staticmethod
    def generate_secure_secret():
        """안전한 시크릿 키 생성"""
        return secrets.token_urlsafe(32)
    
    @staticmethod
    def is_token_blacklisted(token):
        """토큰 블랙리스트 확인"""
        return cache.get(f"blacklisted_token:{token}", False)
    
    @staticmethod
    def blacklist_token(token, expire_time):
        """토큰 블랙리스트 추가"""
        cache.set(
            f"blacklisted_token:{token}",
            True,
            timeout=expire_time
        )
    
    @staticmethod
    def validate_redirect_uri(redirect_uri):
        """리다이렉트 URI 검증"""
        allowed_domains = [
            'localhost:3000',
            'localhost:8000',
            'yourdomain.com'
        ]
        
        from urllib.parse import urlparse
        parsed_uri = urlparse(redirect_uri)
        
        return parsed_uri.netloc in allowed_domains

11.2 Rate Limiting

# accounts/middleware.py
from django.core.cache import cache
from django.http import JsonResponse
from django.utils.deprecation import MiddlewareMixin
import time


class RateLimitMiddleware(MiddlewareMixin):
    """API 요청 제한 미들웨어"""
    
    def process_request(self, request):
        if request.path.startswith('/api/auth/'):
            client_ip = self.get_client_ip(request)
            
            # 분당 10회 제한
            key = f"rate_limit:{client_ip}"
            current_requests = cache.get(key, 0)
            
            if current_requests >= 10:
                return JsonResponse({
                    'error': 'RATE_LIMIT_EXCEEDED',
                    'message': '요청 횟수 제한을 초과했습니다.'
                }, status=429)
            
            cache.set(key, current_requests + 1, 60)  # 1분 후 초기화
        
        return None
    
    def get_client_ip(self, request):
        x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
        if x_forwarded_for:
            ip = x_forwarded_for.split(',')[0]
        else:
            ip = request.META.get('REMOTE_ADDR')
        return ip

12. 모니터링 및 로깅

12.1 로깅 설정

# settings.py에 추가
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'verbose': {
            'format': '[{levelname}] {asctime} {name} {process:d} {thread:d} {message}',
            'style': '{',
        },
    },
    'handlers': {
        'file': {
            'level': 'INFO',
            'class': 'logging.FileHandler',
            'filename': 'logs/kakao_auth.log',
            'formatter': 'verbose',
        },
        'console': {
            'level': 'DEBUG',
            'class': 'logging.StreamHandler',
            'formatter': 'verbose',
        },
    },
    'loggers': {
        'accounts': {
            'handlers': ['file', 'console'],
            'level': 'INFO',
            'propagate': True,
        },
    },
}

12.2 로깅 구현

# accounts/kakao_service.py에 로깅 추가
import logging

logger = logging.getLogger(__name__)


class KakaoAuthService:
    """카카오 인증 서비스 (로깅 추가)"""
    
    @classmethod
    def process_kakao_login(cls, authorization_code: str) -> Optional[User]:
        """카카오 로그인 전체 프로세스 처리"""
        
        logger.info(f"카카오 로그인 시작: code={authorization_code[:10]}...")
        
        try:
            # 1. 액세스 토큰 획득
            access_token = cls.get_access_token(authorization_code)
            if not access_token:
                logger.error("카카오 액세스 토큰 획득 실패")
                return None
            
            logger.info("카카오 액세스 토큰 획득 성공")
            
            # 2. 사용자 정보 조회
            user_info = cls.get_user_info(access_token)
            if not user_info:
                logger.error("카카오 사용자 정보 조회 실패")
                return None
            
            logger.info(f"카카오 사용자 정보 조회 성공: kakao_id={user_info['id']}")
            
            # 3. 사용자 생성 또는 업데이트
            user = cls.create_or_update_user(user_info)
            
            logger.info(f"사용자 생성/업데이트 완료: user_id={user.id}, created={'created' if user.date_joined.date() == timezone.now().date() else 'updated'}")
            
            return user
        
        except Exception as e:
            logger.error(f"카카오 로그인 처리 중 오류: {str(e)}")
            return None

13. 배포 고려사항

13.1 환경별 설정

# settings/production.py
from .base import *

# 보안 설정
DEBUG = False
ALLOWED_HOSTS = ['yourdomain.com', 'api.yourdomain.com']

# HTTPS 강제
SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')

# 카카오 설정 (운영환경)
KAKAO_REDIRECT_URI = config('KAKAO_REDIRECT_URI_PROD')

# CORS 설정 (운영환경)
CORS_ALLOWED_ORIGINS = [
    "https://yourdomain.com",
    "https://www.yourdomain.com",
]

# JWT 보안 강화
JWT_SECRET_KEY = config('JWT_SECRET_KEY_PROD')
JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 15  # 운영환경에서는 더 짧게

13.2 Docker 설정

# Dockerfile
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["gunicorn", "--bind", "0.0.0.0:8000", "main.wsgi:application"]
# docker-compose.yml
version: '3.8'

services:
  web:
    build: .
    ports:
      - "8000:8000"
    environment:
      - DJANGO_SETTINGS_MODULE=settings.production
    env_file:
      - .env.prod
    depends_on:
      - db
      - redis

  db:
    image: postgres:15
    environment:
      POSTGRES_DB: kakao_auth_db
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    volumes:
      - postgres_data:/var/lib/postgresql/data

  redis:
    image: redis:7
    ports:
      - "6379:6379"

volumes:
  postgres_data:

결론

Django Ninja를 사용한 카카오 소셜 로그인 구현은 다음과 같은 장점을 제공합니다:

주요 장점

  1. 빠른 개발: Django Ninja의 FastAPI 스타일 API로 빠른 개발
  2. 자동 문서화: OpenAPI 스펙 자동 생성
  3. 타입 안정성: Pydantic 스키마를 통한 타입 검증
  4. 확장성: JWT 기반 인증으로 마이크로서비스 아키텍처 지원

🔧 핵심 구현 사항

  • OAuth 2.0 플로우 완전 구현
  • JWT 액세스/리프레시 토큰 관리
  • 사용자 정보 자동 동기화
  • 포괄적인 에러 처리

🛡️ 보안 고려사항

  • HTTPS 강제 사용
  • 토큰 만료시간 관리
  • Rate Limiting 적용
  • 입력값 검증 및 SQL 인젝션 방지

이 가이드를 통해 안전하고 확장 가능한 카카오 소셜 로그인 시스템을 구축할 수 있습니다. 추가적인 소셜 로그인 제공자(구글, 네이버 등)도 같은 패턴으로 쉽게 확장할 수 있습니다.

이 글을 공유해보세요!