서버·DB 없이 만든 블로그 구독 시스템: 설계·구현·교훈의 기록
Next.js 내장 서버와 Gmail API로 구독을 구현하고 PinCode 인증을 붙였습니다. 운영 중 겪은 nonce 충돌과 다중 Relayer 결정, 그리고 “결국 서버가 필요하다”는 결론까지 한 번에 공유합니다.
이 글은 “서버/DB 없이 시작한 구독 시스템”을 제품(UX) + 운영(신뢰성/비용/정책) 관점에서 다시 정리한 기록입니다.
결론부터 말하면, MVP는 가능했지만 안정적인 운영을 위해서는 결국 “상태(state)와 작업(job)을 분리”해야 했습니다.

TL;DR
- 구독 시스템의 본질은 “이메일 수집”이 아니라 신뢰할 수 있는 상태 전이(state machine) 입니다.
- 서버리스(Vercel)에서는 동일 요청이 중복 실행될 수 있어, “중복 요청”을 기본값으로 보고 설계해야 합니다.
- 이메일 인증에서 가장 먼저 무너지는 지점은 보통
- 발송 제한/쿼터/정책
- 재전송 남용
- 스팸 판정/전달 실패
- 상태 불일치(코드는 보냈는데 서버는 기억 못함)
- 운영 가능한 최소 구조는 (A) 상태 저장소(KV/DB) + (B) 비동기 작업(큐/워커) + (C) 멱등성입니다.
- “DB 없이도 된다”는 말은 “영속 상태가 필요 없는 제품”에서만 성립합니다. 인증/구독은 영속 상태가 필요합니다.
왜 구독 시스템이 제품적으로 중요한가
구독은 “기능”이 아니라 채널입니다.
- 새로운 글이 올라왔을 때 독자가 다시 돌아오게 만드는 장치
- 콘텐츠 발행 주기를 “유입”과 연결하는 장치
- 실험(AB 테스트, 주제별 반응)을 가능하게 하는 장치
문제는, 구독이 한 번 깨지면(메일이 안 오거나 인증이 실패하면)
사용자는 다시 시도하지 않는 경우가 많다는 점입니다.
그래서 초기부터 “기능 구현”보다 “실패해도 다시 돌아오는 흐름”이 중요합니다.
운영 관점에서 구독 시스템을 다시 정의하기
구독 시스템을 “제품/운영 관점”으로 보면 아래 3가지만 남습니다.
- 사용자는 “이메일을 맡겨도 괜찮다”는 신뢰를 얻어야 한다
- 운영자는 “중복/악용/봇”을 막으면서 전달률을 유지해야 한다
- 시스템은 “실패해도 복구 가능한 상태 전이”를 가져야 한다
이 3가지를 만족시키는 최소 단위가 바로 상태(state) 입니다.
상태(State) 설계가 전부다: 인증/구독의 상태 머신
구독 시스템은 결국 아래 상태로 정리됩니다.
REQUESTED: 이메일 입력(요청 생성)CODE_SENT: 인증 코드 발송 완료VERIFIED: 코드 검증 완료SUBSCRIBED: 구독 확정(저장 완료)BLOCKED: 남용/차단(레이트리밋/블랙리스트)
핵심은 “API가 성공했다/실패했다”가 아니라
상태가 어느 단계인지가 제품 경험을 결정한다는 점입니다.
예:
- “메일이 늦게 오면?” →
CODE_SENT상태 유지 + 재전송 UI 제공 - “코드를 여러 번 틀리면?” →
BLOCKED또는 쿨다운 - “사용자가 새로고침하면?” → 상태를 다시 읽어서 동일 화면 제공
DB가 없으면 이 상태를 시스템이 기억하지 못합니다.
그 순간부터 UX는 운에 맡겨집니다.
왜 “이메일 인증”이 가장 먼저 무너지는가
운영을 해보면 이메일 인증은 생각보다 빨리 깨집니다. 이유는 크게 4가지입니다.
1) 발송 제한/쿼터/정책
Gmail API든 어떤 수단이든 “보낼 수 있는 양”과 “보내도 되는 조건”이 있습니다.
초기 트래픽이 작아도, 특정 시간대/특정 패턴에서 제한에 걸릴 수 있습니다.
제품적 영향:
- 사용자는 “서비스가 고장”이라고 느낍니다.
- 인증이 안 되면 구독 기능 전체가 죽습니다.
대응:
- “발송 실패”를 사용자에게 숨기지 말고, 재시도/대안 루트를 제공합니다.
- 발송은 가능하면 동기 처리에서 분리합니다(뒤에서 다시 보냄).
2) 재전송(Resend) 남용
사용자는 메일이 늦으면 버튼을 여러 번 누릅니다.
봇은 더 적극적으로 남용합니다.
대응:
- 이메일 단위: 예) 60초 쿨다운, 1시간 최대 3회
- IP 단위: 예) 분당 N회 제한
- 실패가 누적되면 일시 차단(
BLOCKED)
3) 스팸 판정/전달 실패
“발송 성공”과 “사용자 수신”은 다릅니다.
대응:
- 제목/본문을 간결하게(과한 마케팅 문구 회피)
- 도메인/발송자 명확화
- 사용자에게 “스팸함 확인”을 안내하되, 그것만으로 끝내지 않기(대안 제공)
4) 상태 불일치(서버리스에서 자주 발생)
서버리스에서는 인스턴스가 여러 개로 실행되고, 재배포/스케일아웃/콜드스타트가 잦습니다.
“메모리/전역 변수”에 상태를 넣으면 곧 깨집니다.
대응:
- 상태는 반드시 외부 저장소(KV/DB)에 둔다
- 요청은 반드시 멱등하게 만든다
서버리스(Vercel)에서 더 위험해지는 지점
1) 중복 실행은 “이상”이 아니라 “기본값”
- 사용자 새로고침
- 네트워크 재시도
- 프록시 재전송
- 동일 버튼 다중 클릭
이 모든 것이 “동일 API가 여러 번 호출”되는 형태로 나타납니다.
그래서 서버리스에서는 반드시 멱등성(Idempotency)이 필요합니다.
2) 동기 처리(메일 발송)로 UX를 잡으면, 전체가 느려진다
메일 발송을 요청-응답 흐름에 묶으면:
- 발송이 느리면 사용자는 계속 버튼을 누르고
- 중복 발송/중복 상태가 폭발합니다.
제품 관점에서는 발송을 “백그라운드 작업”으로 보고,
- 즉시 응답: “요청을 접수했다”
- 이후 발송: 성공/실패를 상태로 반영 하는 편이 낫습니다.
3) 장애 시 복구가 어렵다
DB가 없으면 “어디까지 진행되었는지”를 알 수 없고, 운영자는 장애를 복구할 수 없습니다. 그 순간부터 시스템은 “재시도만 권하는 서비스”가 됩니다.
운영 가능한 최소 설계(제품 관점의 권장안)
1) 상태 저장소는 꼭 필요하다 (KV/DB)
저장해야 하는 최소 상태는 이것뿐입니다.
- requestId
- pinCodeHash(원문 저장 금지)
- status(REQUESTED/CODE_SENT/VERIFIED/SUBSCRIBED)
- expiresAt(만료)
- resendCount / failCount
- createdAt / updatedAt
여기서 중요한 건 “크게 저장”이 아니라 “반드시 저장”입니다.
2) 멱등성 키(requestId)가 있어야 한다
requestId를 만들고, 같은 requestId로 온 요청은 같은 결과를 반환합니다.
- 같은 requestId로 “발송” 요청이 오면
이미CODE_SENT면 “이미 보냈음”으로 응답 - 같은 requestId로 “검증” 요청이 오면
이미VERIFIED면 “이미 검증됨”으로 응답
이렇게 하면 “중복 실행”이 시스템을 망가뜨리지 않습니다.
3) 발송은 비동기로 분리한다(작업 queue)
제품 관점에서는 “즉시 발송”보다 “확실한 발송”이 더 중요합니다.
- API는 빠르게 “요청 접수”만 처리
- 워커가 발송을 시도하고,
- 실패하면 백오프(지수 재시도)
- 성공하면 상태를
CODE_SENT로
이 구조는 사용자 경험과 운영 안정성을 동시에 잡습니다.
4) UI는 상태 기반으로 구성한다
화면은 상태에 따라 고정됩니다.
- REQUESTED: “코드 전송 중”
- CODE_SENT: “코드 입력 화면 + 재전송(쿨다운 표기)”
- VERIFIED: “구독 완료 처리 중”
- SUBSCRIBED: “완료”
- BLOCKED: “잠시 후 다시 시도”
이렇게 하면 서버 문제가 있어도 UX가 흔들리지 않습니다.
“DB 없이 시작”이 가능했던 이유와, 끝내 한계를 만난 이유
가능했던 이유(MVP 관점)
- 트래픽이 작을 때는 운 좋게도
- 중복 요청이 적고
- 발송 제한을 덜 맞고
- 장애 복구 요구가 거의 없기 때문입니다.
한계를 만난 이유(운영 관점)
- 사용자는 “늦으면 여러 번 누른다”
- 서버리스는 “중복 실행이 잦다”
- 이메일은 “정책/쿼터/전달률”이라는 외부 요인이 강하다
결국, “상태” 없이 인증을 운영하는 건 제품적으로 무리였습니다.
언제 ‘운영형 구조’로 전환해야 하나 (전환 기준)
아래 중 하나라도 해당되면, KV/DB + 비동기 발송을 붙이는 편이 좋습니다.
- 하루 구독 요청이 20건 이상 꾸준히 발생한다
- 재전송 비율이 10%를 넘는다
- “메일이 안 와요” 문의가 발생한다
- 특정 시간대에 실패가 몰린다(쿼터/정책 가능성)
- 배포가 잦고(콘텐츠/기능 개선), 상태 유실 가능성이 커졌다
이 기준은 “트래픽 규모”라기보다 운영 리스크가 사용자 경험에 영향을 주기 시작하는 지점입니다.
운영 체크리스트(제품 관점)
- PinCode는 TTL이 있는가? (예: 10분)
- 재전송 쿨다운/최대 횟수가 있는가?
- 이메일/아이피 단위 레이트리밋이 있는가?
- requestId 기반 멱등성이 구현되어 있는가?
- 발송 성공/실패가 상태에 반영되는가?
- 실패 시 사용자에게 “다음 행동”을 제공하는가? (재시도/대기/문의)
- 내부 트래픽/봇을 분리하는가?
결론
서버리스로도 구독 시스템을 만들 수는 있습니다.
하지만 “구독”은 기능이 아니라 제품의 신뢰를 다루는 영역이라,
운영이 시작되는 순간부터 상태 저장소 + 멱등성 + 비동기 발송이 사실상 필수에 가깝습니다.
이 글은 “서버/DB 없이 시작했다가 무엇이 먼저 무너졌는지”를 제품/운영 관점으로 정리한 기록입니다.