2026/04/14

김기훈·2026년 4월 14일

TIL

목록 보기
190/194

오늘의 문제

black

  • 해결

    • 가상환경 지우고 다시 만들기
poetry env info --path
# 현재 연결된 가상환경의 경로를 확인합니다.
rm -rf $(poetry env info --path)
# 확인된 가상환경 폴더를 통째로 삭제하여 깨끗한 상태로 만듭니다.
poetry install --no-root
# 가상환경을 새로 생성하고 pyproject.toml에 적힌 모든 라이브러리를 다시 설치합니다.

mypy

apps/User/serializers/signup_serializer.py:1: error: Skipping analyzing "rest_framework": module is installed, but missing library stubs or py.typed marker  [import-untyped]
apps/User/serializers/signup_serializer.py:1: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
apps/User/views/signup_view.py:2: error: Skipping analyzing "rest_framework.views": module is installed, but missing library stubs or py.typed marker  [import-untyped]
apps/User/views/signup_view.py:3: error: Skipping analyzing "rest_framework.response": module is installed, but missing library stubs or py.typed marker  [import-untyped]
apps/User/views/signup_view.py:4: error: Skipping analyzing "rest_framework": module is installed, but missing library stubs or py.typed marker  [import-untyped]
Found 4 errors in 2 files (checked 55 source files)
  • 해결

    • django-stubs는 설치되어 있지만
    • DRF를 위한 djangorestframework-stubs가 빠져 있어서 발생한 문제 해결
      • poetry add --group dev djangorestframework-stubs
    • pyproject.toml 설정 업데이트
[tool.mypy]
# mypy가 사용할 외부 플러그인 목록에 장고 플러그인을 지정하여 활성화함
plugins = ["mypy_django_plugin.main"]

——————————————————————————————————————[비교]—————————————————————————————————————————
[tool.mypy]
# mypy가 사용할 외부 플러그인 목록에 장고 플러그인을 지정하여 활성화함
plugins = [
    "mypy_django_plugin.main", # Django 전용 분석 플러그인
    "mypy_drf_plugin.main"     # Django REST Framework 전용 분석 플러그인
]

login

JSON Web Token(JWT)

  • 웹과 달리 모바일 앱 환경에서는 세션 대신 주로 토큰 기반 인증 방식을 사용
    • 사용자가 아이디와 비밀번호를 보내면
    • 서버는 입장권 역할을 하는 JSON Web Token(JWT)을 발급함
    • 장고 환경에서 이를 구현하기 위해 관련 패키지를 설치 필요
      • djangorestframework-simplejwt
      • django-cors-headers
        • 외부 앱과의 원활한 통신을 위해 설치를 권장

serializer

class UserLoginSerializer(serializers.Serializer):
    # 아이디 입력을 검증하기 위한 문자열 필드
    username = serializers.CharField()
    # 비밀번호를 검증하되 응답 데이터에는 노출되지 않도록 쓰기 전용으로 선언
    password = serializers.CharField(write_only=True)

service

class LoginService:
    """사용자의 아이디와 비밀번호를 받아 검증하는 메서드"""
    
    def authenticate_user(validated_data):
        # 장고 내부의 인증 시스템을 호출하여 사용자 존재 여부와 비밀번호 일치를 확인
        user = authenticate(username=validated_data['username'], password=validated_data['password'])
        # 인증된 사용자 객체가 정상적으로 반환되었는지 검사하는 조건문
        if user:
            # JSON Web Token(리프레시 토큰 및 액세스 토큰)을 새로 생성
            refresh = RefreshToken.for_user(user)
            # 생성된 토큰 정보와 사용자 객체를 딕셔너리로 묶어 반환 준비
            return {
                # 액세스 토큰을 추출하고 문자열로 형변환하여 딕셔너리에 담기
                'access': str(refresh.access_token),
                # 리프레시 토큰 자체도 문자열로 형변환하여 딕셔너리에 함께 담기
                'refresh': str(refresh),
                # 후속 처리에서 활용할 수 있도록 인증된 사용자 객체도 담음
                'user': user
            }
        return None

view

class UserLoginView(APIView):
    """클라이언트가 POST 방식으로 데이터를 전송할 때 실행되는 메서드"""

    def post(self, request):
        # 1. 입력 데이터 검증
        serializer = UserLoginSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        # 2. 서비스 레이어 호출
        result = LoginService.authenticate_user(serializer.validated_data)
        # 3. 서비스 레이어에서 성공적인 결과(토큰 데이터)가 돌아왔는지 확인
        if result:
            # 4. 성공했을 경우 클라이언트에게 보낼 응답 데이터를 구성하여 반환
            return Response(
                {
                    # 로그인이 성공했다는 안내 문구
                    "message": "로그인에 성공했습니다.",
                    # 발급받은 액세스 토큰을 응답 본문에 포함
                    "access": result['access'],
                    # 발급받은 리프레시 토큰을 응답 본문에 포함
                    "refresh": result['refresh']
                },
                status=status.HTTP_200_OK,
            )
        # 5. 인증 결과가 실패인 경우 실행되는 부분
        return Response(
            {"message": "아이디 또는 비밀번호가 잘못되었습니다."},
            status=status.HTTP_401_UNAUTHORIZED,
        )

설정 추가

THIRD_PARTY_APPS: list[str] = [
    "rest_framework",
    "drf_spectacular",
]

——————————————————————————————————————[비교]—————————————————————————————————————————
THIRD_PARTY_APPS: list[str] = [
    "rest_framework",
    "drf_spectacular",
    "rest_framework_simplejwt",
]
REST_FRAMEWORK = {
    "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
——————————————————————————————————————[비교]—————————————————————————————————————————
REST_FRAMEWORK = {
    # 기존에 작성하신 스펙타큘러 자동화 스키마 설정입니다.
    "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
    # API 요청이 들어올 때 누구인지 확인하는 기본 인증 방식들을 지정합니다.
    "DEFAULT_AUTHENTICATION_CLASSES": (
        # JWT 토큰을 해독하여 올바른 사용자인지 검증하는 라이브러리 클래스를 등록합니다.
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    ),
}

테스트


로그아웃

구현 방안

  • JWT 기반 시스템에서는 토큰을 서버가 저장하지 않으므로, 강제로 삭제할 수는 없음
  • 대신 사용이 끝난 토큰을 블랙리스트에 등록하여 재사용하지 못하도록 막는 방식으로 로그아웃을 구현

serializer

from rest_framework import serializers

class UserLogoutSerializer(serializers.Serializer):
    refresh = serializers.CharField(
        help_text="로그인 시 발급받은 refresh 토큰을 입력하세요."
    )

view

class UserLogoutView(APIView):
    """사용자의 로그아웃 요청을 처리하는 뷰 클래스를 선언"""
    permission_classes = [IsAuthenticated]

    @extend_schema(
        tags=["회원관리"],
        summary="로그아웃",
        request=UserLogoutSerializer,
    )
    def post(self, request):
        # 1. 입력데이터 검증
        serializer = UserLogoutSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        try:
            # 2. 클라이언트가 보낸 요청 데이터 중 리프레시 토큰 문자열을 찾아냄
            refresh_token = request.data["refresh"]
            # 3. 추출한 문자열을 코드로 조작 가능한 토큰 객체로 변환
            token = RefreshToken(refresh_token)
            # 4. 해당 토큰을 서버의 블랙리스트에 등록하여 더 이상 인증 수단으로 쓰지 못하게 만듬
            token.blacklist()
            # 5. 처리가 무사히 완료되면 성공 메시지와 함께 응답을 생성하여 반환
            return Response({"message": "성공적으로 로그아웃 되었습니다."}, status=status.HTTP_205_RESET_CONTENT)

        # 6. 오류 발생 시
        except Exception as e:
            print(f"로그아웃 토큰 처리 에러: {e}")
            return Response({"message": "유효하지 않은 토큰입니다."}, status=status.HTTP_400_BAD_REQUEST)

settings

THIRD_PARTY_APPS: list[str] = [
    "rest_framework",
    "drf_spectacular",
    "rest_framework_simplejwt",
]

——————————————————————————————————————[비교]—————————————————————————————————————————
THIRD_PARTY_APPS: list[str] = [
    "rest_framework",
    "drf_spectacular",
    "rest_framework_simplejwt",
    "rest_framework_simplejwt.token_blacklist",
]

테스트


회원탈퇴

구현 방안

  • 데이터베이스에서 사용자 정보를 아예 삭제(물리적 삭제)할 수도 있음
  • 하지만 연관된 데이터의 무결성을 위해 비활성화(논리적 삭제) 처리하는 방식을 널리 사용함
    • 여기서는 논리적 삭제 방식을 구현함

view

class UserWithdrawalView(APIView):
    """회원 탈퇴 요청을 처리하기 위한 뷰 클래스"""
    permission_classes = [IsAuthenticated]

    @extend_schema(
        tags=["회원관리"],
        summary="회원탈퇴"
    )

    def delete(self, request):
        # 1. 액세스 토큰을 통해 신원이 확인된 사용자 객체를 요청 데이터에서 꺼내옴
        user = request.user
        # 2. 사용자 객체의 활성화 상태 속성을 거짓(False)으로 바꾸어 논리적 삭제 처리
        user.is_active = False
        # 3. 변경된 활성 상태 속성을 데이터베이스에 최종적으로 기록하고 저장
        user.save()
        return Response({"message": "회원 탈퇴가 완료되었습니다."}, status=status.HTTP_204_NO_CONTENT)

현재 구현 문제점

  • 닉네임 필드에 고유값 제한(unique=True)을 걸어놨기 때문에 탈퇴한 동일한 닉네임으로 가입시
    • 회원가입이 오류가 발생함

해결방법

  • 탈퇴 시 계정 정보 변형

    • 탈퇴 처리를 할 때 상태값만 바꾸는 것이 아니라
    • 기존 아이디와 닉네임 뒤에 탈퇴 날짜나 특정 키워드를 강제로 덧붙여버리는 방식
      • (예시: testid -> deleted_testid_20260414)
      • 이렇게 원본 아이디를 다른 글자로 덮어써서 자리를 비워두면
      • 나중에 사용자가 동일한 원본 아이디로 새로운 계정을 만들 수 있게 됨
  • 재가입 요청 시 계정 복구

    • 회원가입 요청이 들어왔을 때
      • 가입하려는 아이디가 비활성화 상태라면 회원가입 에러를 내는 대신
      • 해당 계정의 상태를 다시 활성화(is_active = True)로 바꾸어 주는 방식
      • 과거에 작성했던 데이터들을 그대로 유지하고 싶을 때 사용
  • 유예 기간 보관 후 물리적 삭제

    • 탈퇴를 신청하면 즉시 탈퇴되는 것이 아니라 30일 동안 계정을 정지(논리적 삭제) 상태로 둡둠
      • 이 기간에는 재가입도 로그인도 불가능
    • 그리고 30일이 지나면 서버가 기록을 아예 삭제해 버려서(물리적 삭제)
      • 그 이후부터는 맘 편히 신규 가입을 할 수 있도록 만듬

해결 코드

class UserWithdrawalView(APIView):
    """회원 탈퇴 요청을 처리하기 위한 뷰 클래스"""
    permission_classes = [IsAuthenticated]

    @extend_schema(
        tags=["회원관리"],
        summary="회원탈퇴"
    )
    def delete(self, request):
        # 1. 액세스 토큰을 통해 신원이 확인된 사용자 객체를 요청 데이터에서 꺼내옴
        user = request.user

        # 2. 사용자 객체의 활성화 상태 속성을 거짓(False)으로 바꾸어 논리적 삭제 처리
        user.is_active = False

        # 3. 탈퇴 처리가 진행되는 현재 시간을 숫자로 이루어진 초 단위 값(타임스탬프)으로 변환
        current_time = int(timezone.now().timestamp())

        # 4. 삭제되었음을 알리는 문구, 현재 시간, 유저의 고유 번호를 엮어 새로운 아이디를 만듬
        user.username = f"deleted_{current_time}_{user.id}"

        # 5. 닉네임 역시 나중에 다른 사람이 사용할 수 있도록 임의의 문자열로 덮어써서 자리를 비워줌
        user.nickname = f"deleted_{current_time}_{user.id}"

        # 6. 변경된 활성 상태 속성을 데이터베이스에 최종적으로 기록하고 저장
        user.save()

        return Response({"message": "회원 탈퇴가 완료되었습니다."}, status=status.HTTP_204_NO_CONTENT)

테스트

  • 회원탈퇴 후 동일한 이름으로 가입 가능한지 테스트

모델 수정

user

unique 제거

docker-compose exec backend python manage.py makemigrations
docker-compose exec backend python manage.py migrate

class User(AbstractUser):
    nickname = models.CharField(max_length=50, unique=True)
    push_enabled = models.BooleanField(default=True)
    
——————————————————————————————————————[비교]—————————————————————————————————————————
class User(AbstractUser):
    nickname = models.CharField(max_length=50)
    push_enabled = models.BooleanField(default=True)

테스트

  • 닉네임 중복 가능 여부 확인 완료
profile
안녕하세요.

0개의 댓글