2026/02/15 Blog - 1

김기훈·2026년 2월 15일

TIL

목록 보기
141/194
post-thumbnail

오늘 한 일


로그인

settings.py

INSTALLED_APPS = [
    # ...
    'rest_framework',
    'rest_framework_simplejwt',  # JWT 라이브러리 추가
]

REST_FRAMEWORK = {
    # 기본 인증 방식을 JWT로 설정
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ),
}


# JWT 관련 설정 
SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),  # 액세스 토큰 유효 기간
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),     # 리프레시 토큰 유효 기간
    'ROTATE_REFRESH_TOKENS': False,
    'BLACKLIST_AFTER_ROTATION': False,
    'ALGORITHM': 'HS256',
    'SIGNING_KEY': SECRET_KEY,
}

serializer

  • 필수 입력(required=True) / 입력 전용(write_only=True)
  • serializers.EmailField: 이메일 형식이 맞는지 자동으로 검사
  • style={'input_type': 'password'}
    • Browsable API에서 비밀번호 입력창을 마스킹 처리함
from rest_framework import serializers

# 로그인 요청 데이터의 유효성을 검증하는 클래스
class LoginSerializer(serializers.Serializer):
    email = serializers.EmailField(write_only=True, required=True)
    
    # 비밀번호 필드: 필수 입력, 입력 전용
    password = serializers.CharField(
        write_only=True, 
        required=True, 
        style={"input_type": "password"}
    )

service

class UserService:
    @staticmethod
    def authenticate_user(email, password):
        """
        사용자 인증 및 JWT 토큰 생성을 담당하는 메서드입니다.
        """
        
        # 1. Django 기본 인증 함수(authenticate)를 사용
        """
		- 이 함수는 이메일과 비밀번호가 DB와 일치하는지 확인하고, 일치하면 User 객체를 반환함
        - 일치하지 않으면 None을 반환함
        """
        user = authenticate(email=email, password=password)

        # 2. 인증 실패 시(user가 None일 경우) 예외를 발생시킵니다.
        """
        - 이렇게 명시적으로 예외를 던지면 View에서 400/401 에러를 적절히 반환 가능
        """
        if not user:
            raise exceptions.AuthenticationFailed(
                "이메일 또는 비밀번호가 일치하지 않습니다."
            )

        # 3. 계정 활성화 여부를 확인
        """
        - 비밀번호가 맞아도 관리자가 정지시킨 계정(is_active=False)은 로그인되면 안 됨
        """
        if not user.is_active:
            raise exceptions.PermissionDenied("해당 계정은 비활성화 상태입니다.")

        # 4. JWT 토큰 생성 (Simple JWT 라이브러리 기능)
        """
        - RefreshToken.for_user(user)를 호출하면 해당 유저를 위한 Refresh/Access 토큰 쌍이 생성됨
        """
        refresh = RefreshToken.for_user(user)

        # 5. 최종 결과 반환
        """
        - View로 User 객체와 생성된 토큰 문자열을 딕셔너리 형태로 전달
        """
        return {
            "user": user,
            "access_token": str(refresh.access_token),  # Access Token 문자열
            "refresh_token": str(refresh),              # Refresh Token 문자열
        }

view

  • 로그인 엔드포인트는 인증되지 않은 사용자도 접근 가능해야 하므로 AllowAny를 설정
class LoginAPIView(APIView):
    permission_classes = [AllowAny]

    def post(self, request):
        """
        POST 요청을 처리하여 로그인을 수행합니다.
        """
        
        # 1. 입력 데이터 검증 (Serializer 활용)
        """
        - request.data에는 사용자가 보낸 JSON body(email, password)가 들어있습니다.
        - is_valid(raise_exception=True)는 유효성 검사 실패 시 자동으로 400 Bad Request를 반환함
        """
        serializer = LoginSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        # 2. 비즈니스 로직 호출 (Service Layer)
        """
        - 검증된 데이터(validated_data)에서 email과 password를 꺼내 인증 서비스에 넘김
        - 인증 실패 시 Service 내부에서 예외가 발생하므로, 여기까지 코드가 진행되었다면 인증 성공
        """
        login_data = UserService.authenticate_user(
            email=serializer.validated_data["email"],
            password=serializer.validated_data["password"],
        )

        # 3. 최종 성공 응답 반환
        """
        - 클라이언트가 사용할 수 있도록 토큰과 필요한 유저 정보를 JSON으로 구성
        """
        return Response(
            {
                "message": "로그인에 성공하였습니다.",
                "token": {
                    "access": login_data["access_token"],
                    "refresh": login_data["refresh_token"],
                },
                "user": {
                    "email": login_data["user"].email,
                    "nickname": login_data["user"].nickname,
                    # 프로필 이미지 등 필요한 정보를 추가가능
                },
            },
            status=status.HTTP_200_OK,
        )

회원가입

serializer

User = get_user_model()

class SignupSerializer(serializers.ModelSerializer):
    password = serializers.CharField(
        write_only=True,
        required=True,
        style={'input_type': 'password'},
        min_length=8  # 최소 8자 이상 (보안 정책에 따라 조절)
    )

    class Meta:
        model = User
        # 클라이언트로부터 입력받을 필드
        fields = ['email', 'nickname', 'password']

    def validate_email(self, value):
        """
        이메일 중복 여부를 커스텀하게 검증합니다.
        Model의 unique=True가 있지만, 여기서 명시적인 에러 메시지를 주는 것이 UX에 좋습니다.
        """
        # 해당 이메일로 가입된 유저가 있는지 DB에서 확인
        if User.objects.filter(email=value).exists():
            # 이미 존재한다면 유효성 검사 에러(400)를 발생
            raise serializers.ValidationError("이미 존재하는 이메일입니다.")
        # 문제가 없다면 입력받은 이메일 값을 그대로 반환
        return value

service

User = get_user_model()

class SignupService:
    @staticmethod
    def create_user(validated_data: dict):
        """
        유효성 검사가 완료된 데이터를 받아 유저를 생성합니다.
        """
        # 이메일, 비밀번호, 닉네임을 딕셔너리에서 추출
        email = validated_data.get('email')
        password = validated_data.get('password')
        nickname = validated_data.get('nickname')
		"""
        - create_user 메서드는 UserManager(managers.py)에 정의된 로직을 따름
        - 내부적으로 set_password()를 호출하여 비밀번호를 암호화(hashing) 저장
        """
        user = User.objects.create_user(
            email=email,
            nickname=nickname,
            password=password
        )
        
        return user

view

class SignupAPIView(APIView):
    permission_classes = [AllowAny]

    def post(self, request):
        """
        회원가입 요청을 처리합니다.
        """
        # 1. Serializer를 통해 입력 데이터의 형식을 검증
        serializer = SignupSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        # 2. Service Layer를 호출하여 비즈니스 로직(유저 생성)을 수행
        """
        - serializer.validated_data에는 검증이 끝난 깨끗한 데이터가 들어있음
        """
        user = SignupService.create_user(serializer.validated_data)

        # 3. 성공적으로 생성되었다면 201 Created 응답을 반환
        return Response(
            {
                "message": "회원가입이 성공적으로 완료되었습니다.",
                "user": {
                    "id": user.id,          # 생성된 유저의 고유 ID
                    "email": user.email,    # 생성된 유저의 이메일
                    "nickname": user.nickname # 생성된 유저의 닉네임
                }
            },
            status=status.HTTP_201_CREATED
        )

문제

무한로딩

[ 🔴 문제: ]
스웨거(Swagger UI) 접속은 되지만, 기능 테스트(Try it out) 시 무한 로딩이 걸리거나 
브라우저 콘솔에서 TypeError: Cannot destructure property 'type' of 'i' as it is undefined와 같은 
자바스크립트 런타임 에러가 발생하며 작동이 중단되는 현상 발생

[ 🟡 원인: ]
1. 잘못된 설정 값 형식 (String vs Dict)
기존 config/settings.py에서 SWAGGER_UI_SETTINGS를 파이썬의 딕셔너리({})가 아닌 문자열("""...""")로 전달
drf-spectacular 라이브러리는 이 설정을 딕셔너리로 받아 내부적으로 처리하는데,
문자열로 들어오자 이를 올바르게 해석하지 못해 스웨거 프론트엔드 설정이 깨짐

2. 보안 컴포넌트 누락
스웨거 UI는 SECURITY 설정을 만날 때 해당 인증 방식(예: BearerAuth)이 어떻게 작동하는지
components/securitySchemes에서 찾음
이전 코드에는 이 정의가 누락되어 있어, 자바스크립트가 존재하지 않는 객체에서 type 속성을 읽으려다 
에러(undefined)를 냄

[ 🔵 해결: ]
1. 딕셔너리 구조 채택
SWAGGER_UI_SETTINGS를 파이썬 딕셔너리 형식으로 수정하여 라이브러리가 각 설정 항목(filter, deepLinking 등)을
정확히 인식하고 자바스크립트 객체로 변환할 수 있게 함

2. 인증 스키마 명시 (APPEND_COMPONENTS)
securitySchemes를 통해 BearerAuth가 http 타입이고 bearer 방식임을 명확히 선언
이를 통해 스웨거 UI가 인증 토큰을 어디에 어떤 방식으로 담아 보내야 하는지 알게 되어 "Try it out" 기능이 정상화됨

authorizations에 2개

[ 🔴 문제: ]
Swagger UI 상단의 'Authorize' 버튼을 눌렀을 때, 동일한 JWT 인증 기능을 수행하는 
'BearerAuth'와 'jwtAuth'가 중복해서 나타나는 현상.
이로 인해 사용자가 어떤 곳에 토큰을 넣어야 할지 혼란을 느낌

[ 🟡 원인: ]
두 설정의 '충돌'이 원인
1. 자동 감지: drf-spectacular는 REST_FRAMEWORK 설정의 DEFAULT_AUTHENTICATION_CLASSES를 
   보고 "JWT를 쓰시네요? 제가 'jwtAuth'라는 이름을 알아서 만들어 드릴게요"라며 자동 생성
2. 수동 설정: SPECTACULAR_SETTINGS의 APPEND_COMPONENTS 항목에 직접 'BearerAuth'라는 이름으로 보안 설정을 정의
결과적으로 라이브러리가 만든 것과 본인이 만든 것이 합쳐져 두 개가 출력된 것

[ 🔵 해결: ]
SPECTACULAR_SETTINGS에 "AUTHENTICATION_WHITELIST": [] 설정을 추가하여 해결
이 옵션은 라이브러리가 프로젝트의 인증 클래스를 스스로 분석해서 자동으로  보안 스키마를 생성하는 것을 '차단'함
자동 생성이 막히면서 'jwtAuth'는 사라지고, 수동으로 정의한 'BearerAuth'만 명세서에 남게 되어 깔끔하게 하나로 통합된 것
profile
안녕하세요.

0개의 댓글