본문 바로가기

바이브코딩

11. 회원가입과 비밀번호 찾기 - Supabase 이메일 인증 구현

Supabase 이메일 인증 구현 - 11편. 회원가입과 비밀번호 찾기

Supabase 이메일 인증으로 안전한 회원가입 시스템을 구축합니다.
이 글에서는 이메일 템플릿, 인증 콜백, 재발송 기능, 비밀번호 찾기를 다룹니다.


📧 가짜 이메일 문제

회원가입을 만들고 테스트하다가 문제를 발견했습니다.

아무 이메일이나 입력해도 가입이 됩니다. asdf@asdf.com 같은 존재하지 않는 이메일도요.

이러면 문제가 생깁니다.

  • 견적서를 보내도 안 도착함
  • 비밀번호 찾기가 안 됨
  • 스팸 계정이 늘어남

Claude에게 물었습니다.

이메일 인증을 추가해야 할 것 같아.

Supabase에 이메일 인증이 내장되어 있다고 했습니다.


🔧 이메일 인증 활성화

Supabase 대시보드에서 설정합니다.

Authentication → Providers → Email → "Confirm email" → ON

이제 회원가입하면 인증 메일이 발송됩니다.

이메일 템플릿 수정

기본 템플릿이 영어입니다. 한국어로 바꿨습니다.

Authentication → Email Templates → Confirm signup

<h2>회원가입 인증</h2>
<p>안녕하세요! 아래 버튼을 클릭하여 이메일을 인증해주세요.</p>
<p>
  <a href="{{ .ConfirmationURL }}"
     style="background: #2563eb; color: white; padding: 12px 24px;
            border-radius: 8px; text-decoration: none;">
    이메일 인증하기
  </a>
</p>
<p>이 링크는 24시간 동안 유효합니다.</p>

예쁜 버튼도 만들었습니다.


🔄 인증 콜백 처리

사용자가 이메일 링크를 클릭하면 어디로 가야 할까요?

Supabase가 특정 URL로 리다이렉트합니다. 그 URL을 처리하는 코드가 필요합니다.

Redirect URL 설정

Supabase 대시보드 → Authentication → URL Configuration

Site URL: https://your-domain.com
Redirect URLs:
  - https://your-domain.com/auth/callback
  - http://localhost:3000/auth/callback  (개발용)

콜백 라우트 생성

// app/auth/callback/route.ts
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'

export async function GET(request: Request) {
  const requestUrl = new URL(request.url)
  const code = requestUrl.searchParams.get('code')
  const next = requestUrl.searchParams.get('next') ?? '/mypage'

  if (code) {
    const supabase = await createClient()

    const { error } = await supabase.auth.exchangeCodeForSession(code)

    if (!error) {
      return NextResponse.redirect(new URL(next, requestUrl.origin))
    }
  }

  return NextResponse.redirect(
    new URL('/auth/error?message=인증에 실패했습니다', requestUrl.origin)
  )
}

이메일 링크의 code를 세션으로 교환합니다. 성공하면 마이페이지로, 실패하면 에러 페이지로.


📝 가입 완료 페이지

회원가입 후 바로 마이페이지로 가면 안 됩니다. 이메일 인증을 안 했으니까요.

안내 페이지를 만들었습니다.

// app/[locale]/(auth)/signup/complete/page.tsx
import { Mail } from 'lucide-react'

export default function SignupCompletePage() {
  return (
    <div className="text-center py-12">
      <div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
        <Mail className="w-8 h-8 text-blue-600" />
      </div>

      <h1 className="text-2xl font-bold mb-2">
        이메일을 확인해주세요
      </h1>

      <p className="text-gray-600 mb-6">
        입력하신 이메일 주소로 인증 메일을 보냈습니다.<br />
        메일함을 확인하고 인증을 완료해주세요.
      </p>

      <div className="bg-gray-50 p-4 rounded-lg text-sm text-gray-500">
        <p>이메일이 오지 않았나요?</p>
        <ul className="mt-2 space-y-1">
          <li>• 스팸 메일함을 확인해주세요</li>
          <li>• 1-2분 정도 기다려주세요</li>
          <li>• 이메일 주소가 정확한지 확인해주세요</li>
        </ul>
      </div>
    </div>
  )
}

🔄 인증 메일 재발송

이메일이 안 올 수도 있습니다. 스팸함에 갔거나, 기다리다 만료됐거나.

재발송 기능을 추가했습니다.

// app/actions/auth.ts
export async function resendVerificationEmail(email: string) {
  const supabase = await createClient()

  const { error } = await supabase.auth.resend({
    type: 'signup',
    email,
  })

  if (error) {
    return { error: '메일 발송에 실패했습니다' }
  }

  return { success: true }
}
'use client'

import { useState } from 'react'
import { resendVerificationEmail } from '@/app/actions/auth'

export function ResendButton({ email }: { email: string }) {
  const [isLoading, setIsLoading] = useState(false)
  const [message, setMessage] = useState('')

  const handleResend = async () => {
    setIsLoading(true)
    const result = await resendVerificationEmail(email)

    if (result.error) {
      setMessage(result.error)
    } else {
      setMessage('인증 메일을 다시 보냈습니다')
    }

    setIsLoading(false)
  }

  return (
    <div>
      <button
        onClick={handleResend}
        disabled={isLoading}
        className="text-blue-600 underline"
      >
        {isLoading ? '발송 중...' : '인증 메일 재발송'}
      </button>
      {message && <p className="text-sm mt-2">{message}</p>}
    </div>
  )
}

🔑 비밀번호 찾기

비밀번호를 잊어버린 사용자도 있습니다.

비밀번호 재설정 요청

// app/actions/auth.ts
export async function requestPasswordReset(email: string) {
  const supabase = await createClient()

  const { error } = await supabase.auth.resetPasswordForEmail(email, {
    redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/reset-password`,
  })

  if (error) {
    return { error: '메일 발송에 실패했습니다' }
  }

  return { success: true }
}

비밀번호 재설정 페이지

// app/auth/reset-password/page.tsx
'use client'

import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'

export default function ResetPasswordPage() {
  const router = useRouter()
  const supabase = createClient()
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()

    const { error } = await supabase.auth.updateUser({ password })

    if (error) {
      setError('비밀번호 변경에 실패했습니다')
      return
    }

    alert('비밀번호가 변경되었습니다')
    router.push('/login')
  }

  return (
    <form onSubmit={handleSubmit}>
      <h1>새 비밀번호 설정</h1>

      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="새 비밀번호 (8자 이상)"
        minLength={8}
      />

      {error && <p className="text-red-500">{error}</p>}

      <button type="submit">비밀번호 변경</button>
    </form>
  )
}

이메일 링크를 클릭하면 이 페이지로 옵니다. 새 비밀번호를 입력하면 끝.


⚠️ 삽질했던 것들

인증 메일이 안 옴

Supabase 무료 플랜은 시간당 4통 제한이 있습니다. 테스트하다가 다 써버렸습니다.

개발 중에는 "Confirm email"을 끄고, 배포할 때 커스텀 SMTP(Resend)를 연결하면 됩니다.

인증 링크 클릭 시 에러

Redirect URL이 등록 안 되어 있었습니다.

Supabase → Authentication → URL Configuration에서 localhost와 배포 URL 모두 등록해야 합니다.

이미 가입된 이메일

같은 이메일로 다시 가입하려 하면 에러가 납니다.

if (error?.message.includes('already registered')) {
  return { error: '이미 가입된 이메일입니다' }
}

에러 메시지를 한국어로 바꿔줬습니다.


💡 UX 개선

Claude가 제안한 것들입니다.

로딩 상태 표시

<button disabled={isSubmitting}>
  {isSubmitting ? (
    <span className="flex items-center gap-2">
      <Loader2 className="animate-spin" />
      처리 중...
    </span>
  ) : (
    '회원가입'
  )}
</button>

비밀번호 강도 표시

function PasswordStrength({ password }: { password: string }) {
  const strength = calculateStrength(password)

  return (
    <div className="h-1 bg-gray-200 rounded">
      <div
        className={cn(
          'h-full rounded transition-all',
          strength < 2 && 'bg-red-500 w-1/4',
          strength === 2 && 'bg-yellow-500 w-2/4',
          strength === 3 && 'bg-green-400 w-3/4',
          strength >= 4 && 'bg-green-600 w-full'
        )}
      />
    </div>
  )
}

비밀번호를 입력하면 강도가 시각적으로 표시됩니다.


📋 이번 편 요약

항목 내용
다룬 기능 이메일 인증, 재발송, 비밀번호 찾기
핵심 파일 app/auth/callback/route.ts, Email Templates
Supabase 설정 Confirm email ON, Redirect URLs
UX 개선 로딩 상태, 비밀번호 강도 표시

다음 편에서는 미들웨어로 페이지를 보호합니다.

💬 이메일 인증 관련 질문 있으면 댓글로 남겨주세요!