2026/03/17 Blog - 27

김기훈·2026년 3월 17일

TIL

목록 보기
167/194
post-thumbnail

코딩테스트(11047)


오늘 해야할 일

  • 로그인 이메일 인증 ✅
  • 마이페이지 닉네임 변경시 중복확인 버튼 만들기 ✅
  • 비밀번호 찾기 기능 ✅
  • 서버 상태 모니터링 방법

로그인 이메일 인증

  • User 모델 수정
    • is_email_verified 같은 Boolean 필드를 추가하여 인증 여부를 추적
  • 이메일 발송 로직
    • Django의 send_mail 기능을 활용하거나
    • AWS SES, SendGrid 등의 외부 이메일 API 연동이 필요
  • 인증 토큰 관리
    • Redis 등을 이용해 인증 번호(또는 링크 토큰)의 유효 시간(예: 3분)을 관리하는 로직 추가
  • 인증 흐름 선택
    • Hard Verification
      • 이메일 인증을 완료해야만 회원가입이 최종 완료됨.
    • Soft Verification
      • 회원가입과 로그인은 즉시 가능하지만, 글 작성 등 특정 행동을 하려면 이메일 인증을 요구함.

User 모델 수정

  • 모델에 해당 유저가 이메일 인증을 마친 유저인지 판별하는 필드를 추가
is_email_verified = models.BooleanField(          
        default=False,                                
    )
  • 소셜로그인에서는 깃허브나 디스코드가 이미 인증한 이메일이므로
    • is_email_verified=True를 함께 넘겨주도록 수정
                    user = User.objects.create_user(
                        email=email,
                        nickname=unique_nickname,
                        password=None,
                        is_email_verified=True,
                    )


메일 발송 및 캐시 설정(settings.py)

  • 이제 Django가 메일을 보낼 수 있도록 허락
    • 6자리 인증번호를 5분 동안만 기억할 '캐시(Cache)' 메모리를 설정
  • 이메일 발송 설정 (SMTP) - Gmail

# Django에서 기본 제공하는 SMTP 이메일 발송 엔진 사용
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
# 구글의 메일 서버 주소를 지정
EMAIL_HOST = 'smtp.gmail.com'
# 구글 SMTP 서버와 통신하기 위한 권장 포트 번호(587)
EMAIL_PORT = 587
# 메일 전송 과정의 보안을 위해 TLS 암호화를 사용
EMAIL_USE_TLS = True

#이메일을 발송할 구글 계정 주소
EMAIL_HOST_USER = env("EMAIL_HOST_USER", default="") 
# 구글 계정의 '앱 비밀번호'(일반 비밀번호 아님)
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", default="") 
# 수신자에게 보여질 '보내는 사람'의 기본 주소
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER 

# <>는 내 실제 email, 이렇게 하면 이메일 발송자가 '블로그 관리자' 로 보내짐
DEFAULT_FROM_EMAIL = '블로그 관리자 <your_email@gmail.com>' 
  • 구글 앱 비밀번호 발급 방법

    • 구글 계정 관리 -> 보안 탭으로 이동
    • '2단계 인증'이 켜져 있어야 함
    • 검색창에 '앱 비밀번호' 검색 후 클릭
    • 앱 이름을 '블로그 프로젝트' 등으로 입력하고 만들기 클릭
    • 화면에 나오는 16자리 영문자를 복사해서 위의 EMAIL_HOST_PASSWORD 자리에 넣어주시면 됩니다.
  • 캐시(Cache) 설정 (redis미사용)

# 2. 캐시(Cache) 설정
# 지금은 로컬 개발 단계이므로 메모리 캐시를 사용(서버를 껐다 켜면 사라지지만, 가볍고 설정이 필요 없어 아주 좋음)
CACHES = {
    'default': { # Django의 기본 캐시 저장소 이름
        'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', # 서버의 RAM(메모리)을 캐시 저장소로 활용하는 엔진
        'LOCATION': 'unique-snowflake', # 여러 캐시가 충돌하지 않도록 메모리 영역의 고유 이름을 지정
    }
}
  • Redis 캐시 설정

    • pip install django-redis

# 2. 캐시(Cache) 설정
CACHES = {  
    "default": {  # 기본으로 사용할 캐시 설정의 이름
        "BACKEND": "django_redis.cache.RedisCache",  # django-redis 패키지의 캐시 엔진을 사용한다고 선언

        # 127.0.0.1 (로컬호스트)의 6379 포트(Redis 기본 포트)에 접속하며, 1번 데이터베이스(/1)를 사용하겠다는 주소
        "LOCATION": "redis://127.0.0.1:6379/1",

        "OPTIONS": {  # Redis 연결에 필요한 세부 옵션들을 설정
            # Django와 Redis를 이어주는 기본 클라이언트 객체를 지정
            "CLIENT_CLASS": "django_redis.client.DefaultClient",  
        }
    }
}

Serializer

  • 사용자가 입력한 값이 올바른 이메일 형식인지, 이미 가입된 사람은 아닌지 검사
class EmailSendSerializer(serializers.Serializer):
    # 입력값이 이메일 형식(text@test.com)인지 자동으로 검증해 주는 필드
    email = serializers.EmailField(                           
        required=True,                           
        # 형식이 틀렸을경우 보여줄 메세지 
        error_messages={
            "invalid": "올바른 이메일 형식이 아닙니다.",
            "required": "이메일을 입력해주세요."
        }
    )

    def validate_email(self, value):
        """입력된 이메일(value)에 대해 추가적인 정밀 검사를 수행"""
        # DB를 조회하여 해당 이메일로 이미 가입된 유저가 있는지 확인
        if User.objects.filter(email=value).exists():
            raise serializers.ValidationError("이미 가입된 이메일입니다.")
        return value


class EmailVerifySerializer(serializers.Serializer):
    email = serializers.EmailField(required=True)
    code = serializers.CharField(
        required=True,
        max_length=6,
        min_length=6,
        error_messages={
            "required": "인증번호를 입력해주세요.",
            "max_length": "인증번호는 6자리입니다.",
            "min_length": "인증번호는 6자리입니다."
        }
    )

service

  • 실제로 난수를 생성하고, 캐시(Redis)에 저장하고, 이메일을 보내는 "진짜 일"을 하는 곳
class EmailVerificationService:
    """ 이메일 인증 관련 핵심 로직들을 모아둔 서비스 클래스"""
    @staticmethod
    def send_verification_code(email: str) -> None:
        """이메일 주소를 넘겨받아 인증번호를 발송하는 함수"""
        # 100000부터 999999 사이의 무작위 숫자를 생성해 문자열로 만듬
        code = str(random.randint(100000, 999999))

        # 이 유저만의 고유한 캐시 방 이름(키)을 생성 (예: email_code_abc@abc.com)
        cache_key = f"email_code_{email}"
        # 생성한 방 이름에 인증번호를 저장하고, 300초(5분) 뒤에 자동 폭파되게 설정
        cache.set(cache_key, code, timeout=300)

        # 사용자 메일함에 표시될 이메일의 제목
        subject = "[블로그] 회원가입 이메일 인증번호"
        # 사용자에게 보여질 이메일의 본문 내용
        message = f"인증번호는 {code} 입니다.\n5분 안에 입력해주세요."

        # 실제로 이메일 발송 작업을 실행
        send_mail(
            subject,  # 메일 제목
            message,  # 메일 본문
            settings.DEFAULT_FROM_EMAIL,  # 보내는 사람 (settings.py 기준)
            [email],  # 받는 사람 (사용자가 입력한 이메일 주소를 리스트에 담음)
            # 메일 발송에 실패하면 서버가 에러를 내뱉도록 하여 문제를 빨리 파악하게 함
            fail_silently=False,
        )

    @staticmethod
    def verify_code(email: str, code: str) -> bool:
        """이메일과 사용자가 입력한 번호를 받아 맞는지 검사"""
        # 저장할 때 썼던 것과 똑같은 캐시 방 이름을 생성
        cache_key = f"email_code_{email}"
        # 해당 방 이름으로 캐시를 뒤져서 저장된 인증번호를 꺼내옴
        saved_code = cache.get(cache_key)

        # 저장된 번호가 존재하고(5분 안 지남), 사용자가 입력한 번호와 똑같다면
        if saved_code and saved_code == code:
            # 인증이 끝났으므로 보안을 위해 사용된 인증번호 캐시를 즉시 지움
            cache.delete(cache_key)

            # 인증을 통과했다는 것을 증명하기 위한 새로운 캐시 방 이름을 만듬
            verified_key = f"email_verified_{email}"
            # 회원가입을 마칠 때까지 여유를 주기 위해 1800초(30분) 동안 '인증됨(True)' 딱지를 붙여둠
            cache.set(verified_key, True, timeout=1800)
            return True  

        return False

view

class EmailSendView(APIView):
    """이메일 발송 요청을 처리하는 API 뷰"""
    def post(self, request):
        # 1. 입력데이터 검증
        serializer = EmailSendSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        # 2. 무사히 검사를 통과한 깨끗한 이메일 데이터를 꺼내옴
        email = serializer.validated_data.get("email")
        # 3. 서비스 레이어 호출
        EmailVerificationService.send_verification_code(email)

        return Response(
            {"message": "인증번호가 이메일로 발송되었습니다."},
            status=status.HTTP_200_OK
        )


class EmailVerifyView(APIView):
    """# 사용자가 입력한 인증번호를 검증하는 API 뷰"""
    def post(self, request):
        # 1. 입력데이터 검증
        serializer = EmailVerifySerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        # 2. 검사를 통과한 이메일을 꺼냅니다.
        email = serializer.validated_data.get("email")
        # 3. # 검사를 통과한 6자리 인증번호를 꺼냅니다.
        code = serializer.validated_data.get("code")
        
        # 4. 서비스레이어 호출 
        is_verified = EmailVerificationService.verify_code(email,code)  

        if is_verified:  
            return Response(
                {"message": "이메일 인증이 완료되었습니다."},
                status=status.HTTP_200_OK
            )

        return Response(
            {"message": "인증번호가 일치하지 않거나 만료되었습니다."},
            status=status.HTTP_400_BAD_REQUEST
        )

가입 시 인증 여부 검사 로직 추가

  • 누군가 꼼수를 써서 이메일 인증을 안 하고 바로 회원가입 API를 찌를 때를 대비하여 문지기를 세우자
class SignupService:
    @staticmethod
    def create_user(validated_data: dict):
        """
        유효성 검사가 완료된 데이터를 받아 유저를 생성합니다.
        """
        email = validated_data.get("email")
        password = validated_data.get("password")
        nickname = validated_data.get("nickname")

        user = User.objects.create_user(
            email=email, nickname=nickname, password=password
        )

        return user
        
——————————————————————————————————————[비교]—————————————————————————————————————————
class SignupService:
    """회원가입 관련 비즈니스 로직 클래스"""
    @staticmethod
    def create_user(validated_data: dict):
        """
        유효성 검사가 완료된 데이터를 받아 유저를 생성합니다.
        """
        email = validated_data.get("email")
        password = validated_data.get("password")
        nickname = validated_data.get("nickname")

        # 방어 로직 (문지기 역할)
        # 검증 서비스에서 만들어두었던 '인증 완료 딱지' 캐시 키
        verified_key = f"email_verified_{email}"
        # 레디스에 이 키가 존재하는지(True인지) 질문
        is_verified = cache.get(verified_key)

        if not is_verified:                
            raise ValidationError("이메일 인증이 완료되지 않았습니다.")    

        # 인증을 무사히 통과했다면 실제 유저를 DB에 생성
        user = User.objects.create_user(                              
            email=email,                     
            nickname=nickname,               
            password=password,               
            is_email_verified=True # 인증을 통과한 사람이므로 이메일 인증 여부를 True로 설정
        )

        # 보안 및 최적화(회원가입이 무사히 끝났으므로, 더 이상 필요 없는 인증 캐시를 레디스에서 삭제)
        cache.delete(verified_key)

        return user

비밀번호 찾기

  • 현재 소셜 로그인(GitHub, Discord) 유저들은 비밀번호 없이 가입되도록
    • user/managers.py에서 set_unusable_password() 처리 했음
  • 따라서 비밀번호 찾기 로직에서는
    • "이 계정이 일반(이메일) 가입자인지, 소셜 가입자인지 구분하는 방어 로직"이 반드시 필요

serializer

  • 사용자가 보낸 이메일과 새 비밀번호 형식이 올바른지 검사하는 곳
class PasswordResetRequestSerializer(serializers.Serializer): 
    """비밀번호 재설정 '요청(메일 발송)'을 검증하는 시리얼라이저"""
    email = serializers.EmailField(                           
        required=True,
        error_messages={"invalid": "올바른 이메일 형식이 아닙니다.", "required": "이메일을 입력해주세요."}
    )

class PasswordResetConfirmSerializer(serializers.Serializer):
    """비밀번호 '변경(인증번호 확인)'을 검증하는 시리얼라이저"""
    email = serializers.EmailField(required=True)            
    code = serializers.CharField(
        required=True,
        max_length=6,
        min_length=6,
        error_messages={"required": "인증번호를 입력해주세요.", "max_length": "인증번호는 6자리입니다.", "min_length": "인증번호는 6자리입니다."}
    )
    new_password = serializers.CharField(
        required=True,
        write_only=True, # 보안을 위해 쓰기 전용으로 설정 (응답으로 내려가지 않음)
        error_messages={"required": "새 비밀번호를 입력해주세요."}
    )

Service

  • 1) 유저가 존재하는지 2) 소셜 계정은 아닌지 확인하고 메일을 쏨
class PasswordResetService:
    """비밀번호 찾기 관련 비즈니스 로직 클래스"""

    @staticmethod
    def send_reset_code(email: str) -> None:
        """이메일을 받아 인증번호를 발송"""
        # 유저 존재 여부 및 소셜 계정 검증 
        user = User.objects.filter(email=email).first()  

        if not user:
            raise ValidationError("가입되지 않은 이메일입니다.")

        # 유저가 일반 비밀번호가 없다면 (즉, 소셜 로그인 계정이라면)
        if not user.has_usable_password():
            raise ValidationError("소셜 로그인으로 가입된 계정입니다. 해당 소셜 플랫폼으로 로그인해주세요.")

        # 6자리 인증번호 생성 및 레디스 저장
        code = str(random.randint(100000, 999999))  # 무작위 6자리 숫자 생성
        cache_key = f"pwd_reset_{email}"  # 비밀번호 찾기 전용 캐시 방 이름 생성
        cache.set(cache_key, code, timeout=300)  # 레디스에 5분(300초) 동안 저장

        # 이메일 발송
        subject = "[블로그] 비밀번호 재설정 인증번호"  # 메일 제목
        message = f"비밀번호 재설정 인증번호는 {code} 입니다.\n5분 안에 입력하여 비밀번호를 변경해주세요."  # 메일 내용

        send_mail(
            subject,
            message,
            settings.DEFAULT_FROM_EMAIL,  # 아까 설정해둔 '블로그 관리자 <이메일>' 주소
            [email],
            fail_silently=False,
        )

    @staticmethod
    def reset_password(email: str, code: str, new_password: str) -> None:
        """인증번호 확인 후 비밀번호를 실제 변경"""
        cache_key = f"pwd_reset_{email}"  # 레디스에서 꺼내올 방 이름
        saved_code = cache.get(cache_key)  # 저장된 인증번호 가져오기

        # 1. 인증번호 일치 여부 확인
        if not saved_code or saved_code != code:  
            raise ValidationError("인증번호가 일치하지 않거나 만료되었습니다.")

        # 2. 비밀번호 변경 적용
        user = User.objects.filter(email=email).first()
        if user:
            # Django의 set_password 함수가 알아서 비밀번호를 안전하게 암호화
            user.set_password(new_password)
            # 변경된 비밀번호를 DB에 최종 저장(Commit)
            user.save()  

        # 3. 보안 최적화(사용이 끝난 인증번호는 레디스에서 즉시 파기하여 재사용을 막음)
        cache.delete(cache_key)  

view

class PasswordResetRequestView(APIView):
    """비밀번호 재설정 '인증번호 발송'을 처리하는 뷰"""
    def post(self, request):
        # 1. 입력데이터 검증
        serializer = PasswordResetRequestSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        
        # 2. 검사를 무사히 통과한 깨끗한 이메일을 가져옴
        email = serializer.validated_data.get("email") 

        # 3. 서비스레이어 호출
        PasswordResetService.send_reset_code(email)

        return Response(
            {"message": "비밀번호 재설정 인증번호가 발송되었습니다."},
            status=status.HTTP_200_OK  
        )


class PasswordResetConfirmView(APIView):  
    """비밀번호 '변경 (인증번호 확인)'을 처리하는 뷰"""
    def post(self, request):  
        # 1. 입력데이터 검증
        serializer = PasswordResetConfirmSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        email = serializer.validated_data.get("email")  # 검증된 이메일을 꺼냄
        code = serializer.validated_data.get("code")  # 검증된 6자리 인증번호를 꺼냄
        new_password = serializer.validated_data.get("new_password")  # 검증된 새로운 비밀번호를 꺼냄

        # 2. 서비스레이어 호출 
        PasswordResetService.reset_password(email, code, new_password)

        return Response(  
            {"message": "비밀번호가 성공적으로 변경되었습니다."},
            status=status.HTTP_200_OK
        )

배포

  • docker 설정 추가
  redis:
    image: redis:7
    container_name: blog_redis_prod   # 배포용 이름으로 지정
    restart: always                   # 서버 재시작 시 항상 같이 켜지도록 설정
    # 배포 환경이므로 외부(ports)로 포트를 열지 않습니다.
    # 오직 내부 app_network를 통해서 backend만 접근 가능하므로 매우 안전합니다.
    networks:
      - app_network

AWS 서버(EC2) 접속 및 코드 업데이트

  • 코드 최신화는 ci/cd로 설정했음
  • .pem파일이 기본 배경화면에 있다는 기준임
# 1. EC2 서버 접속 (pem 키가 있는 폴더에서 실행)
ssh -i "blog-key.pem" ubuntu@본인의_EC2_IP
ssh -i "blog-key.pem" ubuntu@

env파일 수정

# 1. .env 파일 수정 (EMAIL_HOST_USER, EMAIL_HOST_PASSWORD 추가)
nano .env

# 2. 기존 컨테이너 안전하게 내리기 (prod 파일 지정)
docker-compose -f docker-compose.prod.yml down

# 3. 새로운 설정(Redis 포함)으로 빌드하고 백그라운드에서 실행하기
docker-compose -f docker-compose.prod.yml up -d --build

AWS 콘솔에서 바로 접속하기 (EC2 Instance Connect)

  • 키 파일이 없거나 터미널 설정이 복잡할 때 가장 빠른 방법입니다.
    • AWS 관리 콘솔에 로그인합니다.
    • EC2 서비스로 이동하여 해당 인스턴스를 선택합니다.
    • 상단의 [연결(Connect)] 버튼을 클릭합니다.
    • [EC2 인스턴스 연결] 탭에서 사용자 이름을 확인하고 다시 [연결]을 누릅니다.
    • 브라우저 내에서 새 탭으로 터미널이 열립니다.

도커 업데이트

cd ~/indi_Blog_Project

docker compose -f docker-compose.prod.yml down

docker compose -f docker-compose.prod.yml up -d --build

닉네임 중복 검사

  • 마이페이지에서 닉네임을 수정 할 때 닉네임이 중복되는 경우
    • "프로필 수정에 실패했습니다."가 뜨면서 수정이 안되는데 수정직전에 중복확인 버튼을 만들면 어떨까
  • "Debounce 적용 자동 검사" 나중에 해보기


service

def check_nickname_available(current_user, nickname: str) -> tuple[bool, str]:
    """
    닉네임 중복 여부를 검사하고 (사용가능여부_Boolean, 메시지_String) 형태의 튜플로 반환합니다.
    """

    # 1. 사용자가 입력한 닉네임이 자신의 현재 닉네임과 완전히 똑같은 경우
    if current_user.nickname == nickname:
        return True, "현재 사용 중인 닉네임입니다."

    # 2. DB의 User 테이블에서 입력받은 닉네임과 일치하는 데이터가 존재하는지(exists) 확인
    is_exist = User.objects.filter(nickname=nickname).exists()

    # 3. 만약 이미 누군가 사용 중인 닉네임이라면
    if is_exist:
        return False, "이미 사용 중인 닉네임입니다."

    # 4. 위의 두 조건에 모두 걸리지 않았다면 완전히 새로운 닉네임이므로
    return True, "사용 가능한 닉네임입니다."

view

class CheckNicknameAPIView(APIView):
    permission_classes = [IsAuthenticated]

    def get(self, request):
        # 1. URL에 포함된 '?nickname=값' 에서 값을 꺼냄
        nickname = request.query_params.get("nickname", "").strip()

        # 2. 만약 닉네임이 비어있다면, 뷰 단에서 즉시 에러를 반환합니다.
        if not nickname:
            return Response({"detail": "닉네임을 입력해주세요."}, status=400)

        # 3. 서비스레이어 호출
        is_available, detail_message = check_nickname_available(request.user, nickname)

        return Response({
            "is_available": is_available,
            "detail": detail_message
        })

결과

  • 이전

  • 이후


문제

  • DB안키면 이런 경고 나옴
Unauthorized: /api/v1/user/email/send/

[17/Mar/2026 14:17:04] "POST /api/v1/user/email/send/ HTTP/1.1" 401 102
  • 뷰를 작성할때 권한설정을 하지 않아서 발생
    • permission_classes = [AllowAny]처리함
profile
안녕하세요.

0개의 댓글