Dev Lab5 min read

서버·DB 없이 만든 블로그 구독 시스템: 설계·구현·교훈의 기록

Next.js 내장 서버와 Gmail API로 구독을 구현하고 PinCode 인증을 붙였습니다. 운영 중 겪은 nonce 충돌과 다중 Relayer 결정, 그리고 “결국 서버가 필요하다”는 결론까지 한 번에 공유합니다.

#블로그#구독#서버리스#Next.js#Gmail#API#핀코드#인증

이 글은 “서버/DB 없이 시작한 구독 시스템”을 제품(UX) + 운영(신뢰성/비용/정책) 관점에서 다시 정리한 기록입니다.
결론부터 말하면, MVP는 가능했지만 안정적인 운영을 위해서는 결국 “상태(state)와 작업(job)을 분리”해야 했습니다.

구독 시스템 전체 아키텍처


TL;DR

  • 구독 시스템의 본질은 “이메일 수집”이 아니라 신뢰할 수 있는 상태 전이(state machine) 입니다.
  • 서버리스(Vercel)에서는 동일 요청이 중복 실행될 수 있어, “중복 요청”을 기본값으로 보고 설계해야 합니다.
  • 이메일 인증에서 가장 먼저 무너지는 지점은 보통
    1. 발송 제한/쿼터/정책
    2. 재전송 남용
    3. 스팸 판정/전달 실패
    4. 상태 불일치(코드는 보냈는데 서버는 기억 못함)
  • 운영 가능한 최소 구조는 (A) 상태 저장소(KV/DB) + (B) 비동기 작업(큐/워커) + (C) 멱등성입니다.
  • “DB 없이도 된다”는 말은 “영속 상태가 필요 없는 제품”에서만 성립합니다. 인증/구독은 영속 상태가 필요합니다.

왜 구독 시스템이 제품적으로 중요한가

구독은 “기능”이 아니라 채널입니다.

  • 새로운 글이 올라왔을 때 독자가 다시 돌아오게 만드는 장치
  • 콘텐츠 발행 주기를 “유입”과 연결하는 장치
  • 실험(AB 테스트, 주제별 반응)을 가능하게 하는 장치

문제는, 구독이 한 번 깨지면(메일이 안 오거나 인증이 실패하면)
사용자는 다시 시도하지 않는 경우가 많다는 점입니다.
그래서 초기부터 “기능 구현”보다 “실패해도 다시 돌아오는 흐름”이 중요합니다.


운영 관점에서 구독 시스템을 다시 정의하기

구독 시스템을 “제품/운영 관점”으로 보면 아래 3가지만 남습니다.

  1. 사용자는 “이메일을 맡겨도 괜찮다”는 신뢰를 얻어야 한다
  2. 운영자는 “중복/악용/봇”을 막으면서 전달률을 유지해야 한다
  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)

저장해야 하는 최소 상태는 이것뿐입니다.

  • email
  • 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 없이 시작했다가 무엇이 먼저 무너졌는지”를 제품/운영 관점으로 정리한 기록입니다.

참고 링크

다음으로 읽어볼 글