- Published on
UUID 토큰 기반 뉴스레터 구독/해지 시스템
- Authors

- Name
- Hyo814
UUID 토큰 기반 뉴스레터 구독/해지 시스템
뉴스레터 서비스의 UX 기본값은 "가입 없이 이메일만으로" 입니다. 로그인 시스템을 만들 필요도, 비밀번호를 관리할 필요도 없지만, 대신 해지는 안전해야 합니다. 남의 이메일을 마음대로 해지하면 곤란하고, 본인이 해지하려는데 비밀번호를 요구하는 것도 이상합니다.
PyNews에서는 UUID 토큰을 구독자마다 발급하고, 그 토큰을 해지의 유일한 키로 사용하는 방식을 택했습니다.
1. 모델 — 4개 필드로 충분
class Subscriber(models.Model):
email = models.EmailField(unique=True)
token = models.UUIDField(default=uuid.uuid4, unique=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.email
각 필드의 역할:
email(unique=True): 같은 이메일로 중복 구독 방지.token(UUIDField,default=uuid.uuid4,unique=True): 해지 시 본인 확인용. 비밀번호 대체물.is_active: 해지 시 레코드를 삭제하지 않고 플래그만 내림. 재구독, 통계, 법적 기록 보존에 유리.created_at(auto_now_add=True): 최초 구독 시각.
비밀번호, 이름, 프로필 같은 건 없습니다. 뉴스레터에 꼭 필요한 정보만 남겨두니, 나중에 삭제 요청이 들어와도 지울 것이 명확합니다(GDPR/개인정보 관점).
2. 왜 UUID인가 — 자동 증가 ID가 안 되는 이유
해지 링크를 만들려면 URL 파라미터로 식별자를 넘겨야 합니다. 가장 쉬운 방법은 pk를 쓰는 것:
❌ https://pynews.example/unsubscribe?id=42
그런데 이렇게 두면 "?id=43, ?id=44..." 를 순차로 돌리는 것만으로 전체 구독자를 해지시킬 수 있습니다. 그냥 재앙입니다.
UUID는 128비트 난수라 추측 불가능합니다.
✅ https://pynews.example/unsubscribe?token=3f7a2c4e-9b1d-4e2a-8c6f-1e3b5d7f9a2c
같은 장점을 얻는 다른 선택지도 있습니다 — secrets.token_urlsafe(32)로 만든 문자열 토큰, 서명된 URL(django.core.signing) 등. UUID를 고른 이유는:
- Django에
UUIDField가 기본 내장. default=uuid.uuid4로 자동 생성되니 모델이 추가 훅 없이 자기완결.- PostgreSQL에서는 네이티브 UUID 타입을 써서 인덱스 효율도 좋음.
3. 구독 API — SubscribeView
class SubscribeView(APIView):
def post(self, request):
serializer = SubscribeSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(
{"message": "구독이 완료되었습니다!"},
status=status.HTTP_201_CREATED,
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# serializers.py
class SubscribeSerializer(serializers.ModelSerializer):
class Meta:
model = Subscriber
fields = ["email"]
- 클라이언트는 이메일만 보냅니다.
token은 모델의default=uuid.uuid4로 서버에서 자동 생성. 프론트에 노출되지 않습니다.- 이메일은
unique=True라 중복 시 serializer에서 자동400반환.
직렬화 스펙을 일부러 좁게 잡았기 때문에, 악의적인 클라이언트가 is_active: false나 token: ...을 같이 보내도 무시됩니다.
4. 해지 API — UnsubscribeView
class UnsubscribeView(APIView):
def post(self, request):
token = request.data.get("token")
try:
subscriber = Subscriber.objects.get(token=token)
subscriber.is_active = False
subscriber.save()
return Response({"message": "구독이 해지되었습니다."})
except Subscriber.DoesNotExist:
return Response(
{"error": "유효하지 않은 토큰입니다."},
status=status.HTTP_404_NOT_FOUND,
)
두 가지가 포인트입니다.
(1) 레코드를 지우지 않고 is_active=False
subscriber.is_active = False
subscriber.save()
delete()를 쓰지 않는 이유:
- 재구독 시 이력 복원 가능 — 같은 사람이 다시 구독하면 기존 레코드를
True로 되돌리면 됩니다. - 통계 유지 — 이탈률, 기간별 순구독자 수 같은 지표를 뽑을 때 soft-delete가 편합니다.
- 법적 요구사항 — 수신동의/거부 이력은 일정 기간 보관해야 하는 경우가 많습니다.
단, 완전 삭제 요청(GDPR "잊혀질 권리" 등) 이 들어오면 별도 경로로 하드 삭제해야 합니다. 이건 is_active=False와는 다른 레이어의 기능입니다.
(2) 발송 목록 필터
send_newsletter 명령에서는 이 플래그로 필터링합니다.
subscribers = Subscriber.objects.filter(is_active=True)
해지된 구독자는 자동으로 발송 대상에서 빠집니다.
5. 메일 본문에 개인화된 해지 링크 붙이기
send_newsletter에서 각 메시지마다 해지 토큰을 꼬리에 붙입니다.
for sub in subscribers:
unsub_note = f"\n\n---\n구독 해지: 토큰 {sub.token}"
messages.append((subject, body + unsub_note, None, [sub.email]))
send_mass_mail(messages, fail_silently=True)
실제 프로덕션이라면 토큰을 클릭 가능한 링크로 감싸는 편이 자연스럽습니다.
unsub_url = f"https://python-news.vercel.app/unsubscribe?token={sub.token}"
unsub_note = f"\n\n---\n구독 해지: {unsub_url}"
프론트 쪽 /unsubscribe 페이지에서 token 쿼리스트링을 읽어 UnsubscribeView로 POST를 보내면, 사용자는 한 번의 클릭으로 해지됩니다. 로그인 절차가 전혀 필요 없습니다.
6. 보안 고려사항
UUID 기반 토큰은 충분히 강력하지만, 몇 가지 디테일을 챙겨야 합니다.
(1) 메일 본문 노출 최소화
이메일은 평문이 많고, 발신 로그 등에 본문이 남기도 합니다. 토큰 자체를 본문에 노출하기보다 URL에 담아두고, 사용자가 클릭할 때만 드러나게 하는 편이 안전합니다.
(2) 유출 시 대응
토큰이 유출돼도 해지 외의 권한은 없습니다. 최악의 경우 누군가 대신 해지하는 수준이고, 재구독은 원 소유자가 다시 하면 됩니다. 그래도 토큰을 회전(rotate)하고 싶다면:
# 재발급 로직 예시
subscriber.token = uuid.uuid4()
subscriber.save(update_fields=["token"])
보내는 메일마다 새 토큰을 담는 식으로 운영할 수도 있지만, PyNews는 그 정도 민감도가 아니라 고정 토큰을 유지합니다.
(3) CSRF / Rate limit
UnsubscribeView는 DRF APIView라 기본적으로 CSRF 보호를 받지 않지만(stateless), 브루트 포스 방어용 rate limit은 필요합니다. UUID 충돌 가능성은 무시할 수 있는 수준이지만, 애초에 시도 자체를 제한하는 편이 낫습니다.
# settings.py — DRF throttle 예시
REST_FRAMEWORK = {
"DEFAULT_THROTTLE_CLASSES": ["rest_framework.throttling.AnonRateThrottle"],
"DEFAULT_THROTTLE_RATES": {"anon": "30/minute"},
}
7. URL 패턴
# news/urls.py
urlpatterns = [
path("subscribe/", views.SubscribeView.as_view(), name="subscribe"),
path("unsubscribe/", views.UnsubscribeView.as_view(), name="unsubscribe"),
# ...
]
두 엔드포인트 모두 POST 단일 메서드입니다. GET /unsubscribe/?token=...로도 받고 싶다면 뷰를 분기하거나, 프론트에서 POST로 변환해 보내면 됩니다. 후자가 멱등성 관점에서 더 깔끔합니다.
8. 전체 플로우 요약
[구독]
사용자 → POST /subscribe {email}
→ Subscriber(email, token=uuid4(), is_active=True) 생성
→ 201 응답
[주간 발송]
크론 → send_newsletter
→ Subscriber.filter(is_active=True) 순회
→ 각자 메일에 고유 token 삽입
→ send_mass_mail() 일괄 발송
[해지]
사용자 → 메일 속 해지 링크 클릭
→ POST /unsubscribe {token}
→ is_active = False (레코드는 유지)
→ 200 응답
정리
뉴스레터의 구독/해지는 결국 "로그인 없는 인증" 문제입니다. UUID 토큰을 비밀번호 대체물로 쓰고 is_active 플래그로 soft-delete 하면 이 패턴을 단순하게 풀 수 있습니다. Django 기본 기능(UUIDField, APIView, send_mass_mail)만으로 구현되니, 작은 프로젝트부터 프로덕션 뉴스레터까지 스케일하기 좋은 구조입니다.
다음 글에서는 이 시스템이 올려두는 Post 테이블의 editorial/crawled 구분 모델을 어떻게 설계했는지 다루겠습니다.