[Django] Redis 활용하여 이메일 인증하기

김동욱·2024년 3월 18일

Django

목록 보기
4/6
post-thumbnail

프로젝트를 진행하면서 두 가지 이유로 Redis를 도입하기로 결정했다.

  • 이메일에 전송된 인증번호 및 인증 상태 관리를 위해
  • 랭킹 데이터를 캐시하기 위해

인증번호와 인증 상태 정보는 임시 데이터로, 데이터베이스에 저장할 필요가 없다. 현재 사용 중인 Postgresql 데이터베이스는 데이터에 대한 TTL을 지원하지 않는다. 생성 시간을 기준으로 데이터를 삭제하는 cron 작업을 통해 불필요한 레코드를 삭제할 수 있지만, 추가적인 운영 오버헤드가 발생하기에 고려하지 않았다.

Redis를 도입하기로 결정한 가장 큰 이유는 랭킹 데이터 캐시를 위함이다. 현재 개발 중인 프로젝트에는 주차별 랭킹과 전체 랭킹에 대한 조회 기능이 존재한다. 랭킹과 같이 변동성이 적으면서 비교적 큰 연산의 경우에 인-메모리 데이터베이스를 활용하는 것이 다양한 측면에서 이점이 있다.

host 환경에 Redis 설치하기

Django에서 Redis를 사용하려면 호스트 환경에 레디스가 설치되어 있어야 한다. 현재 macOS 환경 위에서 개발을 하기 때문에 homebrew를 활용하여 redis를 설치했다. redis 관련하여 사용한 명령어는 다음과 같다.

참고로 redis에 패스워드를 설정하여 보안을 강화할 수 있다. 필자 같은 경우는 불필요했지만, 만약 필요한 상황이라면 아래의 설정과 동일하게 진행하면 된다.

# Homebrew가 설치되어 있다고 가정한다.

brew install redis # redis 설치
redis-server # forground에서 redis 실행
brew services start redis # background에서 redis 실행
brew services restart redis # redis 재실행
brew services info redis # redis 실행 상태 확인
brew services stop redis # redis 실행 중지
redis-cli # redis cli 접속

# redis-cli를 통한 비밀번호 설정(선택)
config get requirepass # 설정한 비밀번호 조회
config set requirepass <password> # 비밀번호 설정(프로세스 종료 후 재시작)
auth <password> # 비밀번호 입력

운영 환경에서는 도커 컴포즈를 사용하여 배포를 진행 중이다. 따라서 도커 컴포즈 스크립트에 redis 컨테이너 설정을 추가했다. backend 컨테이너가 cache 컨테이너를 의존하기 때문에 depends_on 옵션을 추가했다.

[docker-compose.yml]
    ...

    backend:
        container_name: backend
        ...
        depends_on:
            - cache
            
    cache:
        container_name: cache
        image: redis:7
        ports:
            - "6379:6379"

패키지 설치 및 설정 추가

Django에서 Redis를 사용하기 위해서 추가적인 패키지를 설치해야 한다. 다음의 명령어를 가상환경에 입력하자.

pip install django-redis

settings.py에 따로 CACHES 관련하여 설정을 하지 않으면 dummy cache를 사용한다. dummy cache는 캐시를 시뮬레이션하지만 데이터 저장이나 검색을 수행하지 않기 때문에, 실제 캐시 처리를 하지 않는 것이다. Django에서는 다양한 캐시를 지원한다. 해당 링크를 통해 확인할 수 있다. 해당 프로젝트에서는 Redis를 사용할 것이기 때문에 아래와 같이 설정했다.

CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://localhost:6379/0",
        "OPTION": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
            "PASSWORD": "<password>",    # 비밀번호 설정 시에만
        },
    }
}

참고로 LOCATION 옵션의 0 은 Redis의 0번 데이터를 사용한다는 것이다.(참고로 0 ~ 15번 사이의 데이터베이스가 존재한다.) 추가적으로 비밀번호 설정 등의 기능도 제공한다. 추가 설정 관련한 확인은 해당 링크에서 하면 된다.

데이터 캐싱하기

앞서 Redis 사용 목적 중 하나가 '이메일에 전송된 인증번호 및 인증 상태 관리를 위함'이라고 했다. 기존에 구현한 회원가입용 이메일 인증번호 전송 로직은 다음과 같다.

[views.py]

class UserRegisterRequestAuthCodeAPIView(APIView):
    permission_classes = [AllowAny]

    @transaction.atomic
    def post(self, request: Request) -> Response:
        serializer = UserRegisterEmailVerificationSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        receiver = serializer.validated_data.get("email")
        code = random.randint(10000, 99999)
        send_auth_code_to_email(receiver, code)

        UserRegistrationEmailAuthStatus.objects.update_or_create(email=receiver, defaults={"code": code})

        return Response(
            data={
                "detail": "회원가입을 위한 인증번호가 전송됐습니다.",
            },
            status=status.HTTP_200_OK
        )
        

[models.py]

class UserRegistrationEmailAuthStatus(models.Model):
    email = models.EmailField(unique=True, help_text="이메일")
    code = models.IntegerField(help_text="인증 번호")
    created_at = models.DateTimeField(auto_now=True, help_text="생성 시간")
    status = models.BooleanField(default=False, help_text="인증 상태")

    class Meta:
        db_table = "user_register_auth_status"

UserRegistrationEmailAuthStatus 모델을 통해 인증 번호와 인증 상태가 저장된다. 해당 인증 데이터는 오직 '인증'을 위한 데이터이다. 또한 10분간 인증을 하지 않으면 다시 인증을 해야하는데, 10분이 지나면 생성한 데이터를 삭제하거나 유효 시간을 검증하거나 추가적인 로직이 필요하다. 하지만 캐시를 사용하여 해당 데이터에 10분간의 유효 시간을 설정하면 효율적으로 해결할 수 있다.

기존 로직을 다음과 같이 변경했다.

[views.py]

class UserRegisterRequestAuthCodeAPIView(APIView):
    permission_classes = [AllowAny]

    @transaction.atomic
    def post(self, request: Request) -> Response:
        serializer = UserRegisterEmailVerificationSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        receiver: str = serializer.validated_data.get("email")
        code: int = random.randint(10000, 99999)
        send_auth_code_to_email(receiver, code)

        cache.set(f"{receiver}:register:code", code, timeout=600)
        cache.set(f"{receiver}:register:status", "uncertified", timeout=600)

        return Response(
            data={
                "detail": "회원가입을 위한 인증번호가 전송됐습니다.",
            },
            status=status.HTTP_200_OK
        )

UserRegistrationEmailAuthStatus 모델은 불필요하여 삭제했다. 인증번호를 캐시에 저장하게 변경했다.

인증번호를 검증하는 api 또한 캐시에서 조회하게 리팩토링했다. 수정된 로직을 살펴보자.

[views.py]

class UserRegisterAuthCodeValidationAPIView(APIView):
    permission_classes = [AllowAny]

    def post(self, request: Request) -> Response:
        serializer = UserRegisterAuthCodeVerificationSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        receiver: str = serializer.validated_data.get("email")

        cache.set(f"{receiver}:register:status", "certified", timeout=3600)

        return Response(
            data={
                "detail": "회원가입을 위한 인증번호 확인에 성공했습니다. 인증은 1시간 동안 유효합니다.",
            },
            status=status.HTTP_200_OK
        )
        
[serializers.py]

class UserRegisterAuthCodeVerificationSerializer(serializers.Serializer):
    
    ...

    def validate_email(self, value: str) -> str:
        if not cache.get(f"{value}:register:code"):
            raise InvalidFieldException("해당 이메일의 인증번호 요청 내역이 존재하지 않습니다.")
        return value

    def validate(self, data: dict[str, Any]) -> dict[str, Any]:
        receiver = data["email"]
        entered_code = data["code"]

        if cache.get(f"{receiver}:register:status") == "certified":
            raise InvalidFieldStateException("이미 인증 완료된 사용자입니다.")

        if cache.get(f"{receiver}:register:code") != entered_code:
            raise InvalidFieldException("인증번호가 일치하지 않습니다.")

        return data

serializer에서 캐싱된 값을 검증하고, view에서 검증이 완료된 이메일의 인증 상태를 수정 하도록 구현했다.

profile
안녕하세요! 질문과 피드백은 언제든지 환영입니다:)

0개의 댓글