오늘 해야할 일
- 로그인 이메일 인증 ✅
- 마이페이지 닉네임 변경시 중복확인 버튼 만들기 ✅
- 비밀번호 찾기 기능 ✅
- 서버 상태 모니터링 방법
로그인 이메일 인증
- 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
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'smtp.gmail.com'
EMAIL_PORT = 587
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
DEFAULT_FROM_EMAIL = '블로그 관리자 <your_email@gmail.com>'
-
구글 앱 비밀번호 발급 방법
- 구글 계정 관리 -> 보안 탭으로 이동
- '2단계 인증'이 켜져 있어야 함

- 검색창에 '앱 비밀번호' 검색 후 클릭
- 앱 이름을 '블로그 프로젝트' 등으로 입력하고 만들기 클릭
- 화면에 나오는 16자리 영문자를 복사해서 위의 EMAIL_HOST_PASSWORD 자리에 넣어주시면 됩니다.
-
캐시(Cache) 설정 (redis미사용)
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'unique-snowflake',
}
}
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1",
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
}
}
}
Serializer
- 사용자가 입력한 값이 올바른 이메일 형식인지, 이미 가입된 사람은 아닌지 검사
class EmailSendSerializer(serializers.Serializer):
email = serializers.EmailField(
required=True,
error_messages={
"invalid": "올바른 이메일 형식이 아닙니다.",
"required": "이메일을 입력해주세요."
}
)
def validate_email(self, value):
"""입력된 이메일(value)에 대해 추가적인 정밀 검사를 수행"""
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:
"""이메일 주소를 넘겨받아 인증번호를 발송하는 함수"""
code = str(random.randint(100000, 999999))
cache_key = f"email_code_{email}"
cache.set(cache_key, code, timeout=300)
subject = "[블로그] 회원가입 이메일 인증번호"
message = f"인증번호는 {code} 입니다.\n5분 안에 입력해주세요."
send_mail(
subject,
message,
settings.DEFAULT_FROM_EMAIL,
[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)
if saved_code and saved_code == code:
cache.delete(cache_key)
verified_key = f"email_verified_{email}"
cache.set(verified_key, True, timeout=1800)
return True
return False
view
class EmailSendView(APIView):
"""이메일 발송 요청을 처리하는 API 뷰"""
def post(self, request):
serializer = EmailSendSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
email = serializer.validated_data.get("email")
EmailVerificationService.send_verification_code(email)
return Response(
{"message": "인증번호가 이메일로 발송되었습니다."},
status=status.HTTP_200_OK
)
class EmailVerifyView(APIView):
"""# 사용자가 입력한 인증번호를 검증하는 API 뷰"""
def post(self, request):
serializer = EmailVerifySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
email = serializer.validated_data.get("email")
code = serializer.validated_data.get("code")
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}"
is_verified = cache.get(verified_key)
if not is_verified:
raise ValidationError("이메일 인증이 완료되지 않았습니다.")
user = User.objects.create_user(
email=email,
nickname=nickname,
password=password,
is_email_verified=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("소셜 로그인으로 가입된 계정입니다. 해당 소셜 플랫폼으로 로그인해주세요.")
code = str(random.randint(100000, 999999))
cache_key = f"pwd_reset_{email}"
cache.set(cache_key, code, timeout=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)
if not saved_code or saved_code != code:
raise ValidationError("인증번호가 일치하지 않거나 만료되었습니다.")
user = User.objects.filter(email=email).first()
if user:
user.set_password(new_password)
user.save()
cache.delete(cache_key)
view
class PasswordResetRequestView(APIView):
"""비밀번호 재설정 '인증번호 발송'을 처리하는 뷰"""
def post(self, request):
serializer = PasswordResetRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
email = serializer.validated_data.get("email")
PasswordResetService.send_reset_code(email)
return Response(
{"message": "비밀번호 재설정 인증번호가 발송되었습니다."},
status=status.HTTP_200_OK
)
class PasswordResetConfirmView(APIView):
"""비밀번호 '변경 (인증번호 확인)'을 처리하는 뷰"""
def post(self, request):
serializer = PasswordResetConfirmSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
email = serializer.validated_data.get("email")
code = serializer.validated_data.get("code")
new_password = serializer.validated_data.get("new_password")
PasswordResetService.reset_password(email, code, new_password)
return Response(
{"message": "비밀번호가 성공적으로 변경되었습니다."},
status=status.HTTP_200_OK
)
배포
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) 형태의 튜플로 반환합니다.
"""
if current_user.nickname == nickname:
return True, "현재 사용 중인 닉네임입니다."
is_exist = User.objects.filter(nickname=nickname).exists()
if is_exist:
return False, "이미 사용 중인 닉네임입니다."
return True, "사용 가능한 닉네임입니다."
view
class CheckNicknameAPIView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
nickname = request.query_params.get("nickname", "").strip()
if not nickname:
return Response({"detail": "닉네임을 입력해주세요."}, status=400)
is_available, detail_message = check_nickname_available(request.user, nickname)
return Response({
"is_available": is_available,
"detail": detail_message
})
결과
이전

이후

문제

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]처리함