본문 바로가기

바이브코딩

10. 회원가입부터 로그아웃까지 - Supabase Auth 로그인 구현

Supabase Auth 로그인 구현 - 10편. 회원가입부터 로그아웃까지

Supabase AuthServer Action으로 인증 시스템을 구축합니다.
이 글에서는 회원가입, 로그인, 로그아웃, 세션 관리, 보안 팁을 다룹니다.


🔐 드디어 로그인 만들기

DB 설계와 기반 시스템이 갖춰졌습니다. 이제 사용자를 받을 차례입니다.

로그인 기능이 없으면 누가 누군지 모릅니다. 문의를 누가 했는지, 견적을 누구에게 보낼지 알 수가 없습니다.

인증(Authentication)이 필요했습니다.

Claude에게 물었습니다.

Supabase로 로그인 기능을 만들 수 있어?

Supabase Auth가 내장되어 있어서 별도 서비스 없이 바로 쓸 수 있다고 했습니다.


🎯 Supabase Auth의 장점

Firebase Auth와 비슷하지만, 몇 가지 장점이 있었습니다.

  1. Supabase에 내장되어 있음 (별도 설정 불필요)
  2. 이메일/비밀번호, OAuth(Google, GitHub 등) 지원
  3. 세션 관리 자동화
  4. RLS와 연동 가능

특히 RLS와 연동된다는 게 좋았습니다. "이 사용자는 자기 데이터만 볼 수 있다" 같은 규칙을 DB 레벨에서 적용할 수 있으니까요.


📝 회원가입 구현

먼저 회원가입부터 만들었습니다.

스키마 정의

Zod로 검증 스키마를 만들었습니다.

// lib/validations/auth.ts
import { z } from 'zod'

export const signupSchema = z.object({
  email: z
    .string()
    .min(1, '이메일을 입력하세요')
    .email('올바른 이메일 형식이 아닙니다'),
  password: z
    .string()
    .min(1, '비밀번호를 입력하세요')
    .min(8, '비밀번호는 8자 이상이어야 합니다'),
  passwordConfirm: z.string(),
  name: z.string().min(1, '이름을 입력하세요'),
  phone: z.string().optional(),
  company: z.string().optional(),
}).refine((data) => data.password === data.passwordConfirm, {
  message: '비밀번호가 일치하지 않습니다',
  path: ['passwordConfirm'],
})

export type SignupFormData = z.infer<typeof signupSchema>

Server Action

Claude에게 Server Action으로 회원가입 로직을 요청했습니다.

// app/actions/auth.ts
'use server'

import { createClient } from '@/lib/supabase/server'
import { signupSchema } from '@/lib/validations/auth'

export async function signup(formData: FormData) {
  const supabase = await createClient()

  const rawData = {
    email: formData.get('email') as string,
    password: formData.get('password') as string,
    passwordConfirm: formData.get('passwordConfirm') as string,
    name: formData.get('name') as string,
    phone: formData.get('phone') as string,
    company: formData.get('company') as string,
  }

  const result = signupSchema.safeParse(rawData)
  if (!result.success) {
    return { error: result.error.errors[0].message }
  }

  const { email, password, name, phone, company } = result.data

  const { data, error } = await supabase.auth.signUp({
    email,
    password,
    options: {
      data: { name, phone, company }
    }
  })

  if (error) {
    return { error: error.message }
  }

  return { success: true }
}

Supabase Auth의 signUp 함수 하나로 끝납니다. options.data에 추가 정보도 넣을 수 있습니다.


🚪 로그인 구현

회원가입보다 더 간단했습니다.

// app/actions/auth.ts
export async function login(formData: FormData) {
  const supabase = await createClient()

  const email = formData.get('email') as string
  const password = formData.get('password') as string

  const { data, error } = await supabase.auth.signInWithPassword({
    email,
    password,
  })

  if (error) {
    return { error: '이메일 또는 비밀번호가 올바르지 않습니다' }
  }

  return { success: true }
}

signInWithPassword 함수 하나로 로그인이 됩니다.

로그인 페이지

'use client'

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { login } from '@/app/actions/auth'
import { useRouter } from 'next/navigation'

const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1),
})

type LoginFormData = z.infer<typeof loginSchema>

export default function LoginPage() {
  const router = useRouter()
  const form = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
  })

  const onSubmit = async (data: LoginFormData) => {
    const formData = new FormData()
    formData.append('email', data.email)
    formData.append('password', data.password)

    const result = await login(formData)

    if (result.error) {
      form.setError('root', { message: result.error })
      return
    }

    router.push('/mypage')
  }

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      {/* 폼 필드들... */}
    </form>
  )
}

🚶 로그아웃

로그아웃은 더 간단했습니다.

// app/actions/auth.ts
export async function logout() {
  const supabase = await createClient()
  await supabase.auth.signOut()
  redirect('/login')
}
// 버튼에서 사용
<form action={logout}>
  <button type="submit">로그아웃</button>
</form>

Server Action을 form action으로 바로 쓸 수 있어서 편했습니다.


👤 현재 사용자 가져오기

로그인한 사용자 정보가 필요할 때가 많습니다.

서버 컴포넌트에서

import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'

export default async function MyPage() {
  const supabase = await createClient()

  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    redirect('/login')
  }

  return (
    <div>
      <h1>{user.email}님, 안녕하세요!</h1>
      <p>이름: {user.user_metadata.name}</p>
    </div>
  )
}

클라이언트 컴포넌트에서

'use client'

import { createClient } from '@/lib/supabase/client'
import { useEffect, useState } from 'react'
import { User } from '@supabase/supabase-js'

export function UserInfo() {
  const [user, setUser] = useState<User | null>(null)
  const supabase = createClient()

  useEffect(() => {
    supabase.auth.getUser().then(({ data }) => {
      setUser(data.user)
    })
  }, [])

  if (!user) return null

  return <span>{user.email}</span>
}

🔄 세션 동기화

사용자가 다른 탭에서 로그아웃하면 어떻게 될까요?

현재 탭은 로그인 상태라고 생각합니다. 세션 변화를 감지해야 합니다.

// components/AuthProvider.tsx
'use client'

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

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const router = useRouter()
  const supabase = createClient()

  useEffect(() => {
    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      (event, session) => {
        if (event === 'SIGNED_OUT') {
          router.push('/login')
        }
        if (event === 'SIGNED_IN') {
          router.refresh()
        }
      }
    )

    return () => {
      subscription.unsubscribe()
    }
  }, [])

  return <>{children}</>
}

onAuthStateChange로 세션 변화를 구독합니다. 로그아웃되면 로그인 페이지로, 로그인되면 화면을 새로고침합니다.


⚠️ 삽질했던 것들

로그인 후 세션이 없음

로그인은 성공하는데, 마이페이지에서 사용자 정보가 안 나왔습니다.

쿠키가 제대로 설정 안 된 거였습니다. 미들웨어에서 세션을 갱신해야 합니다.

// middleware.ts
export async function middleware(request: NextRequest) {
  const response = NextResponse.next()

  const supabase = createServerClient(...)

  await supabase.auth.getSession() // 이게 중요!

  return response
}

회원가입 후 바로 로그인 안 됨

Supabase 기본 설정이 이메일 인증을 요구합니다.

개발 중에는 Supabase 대시보드에서 "Confirm email"을 꺼두면 됩니다. 배포할 때는 다시 켜야 합니다.


💡 보안 팁

Claude가 알려준 것들입니다.

비밀번호 규칙 강화

password: z
  .string()
  .min(8, '8자 이상')
  .regex(/[A-Z]/, '대문자 포함')
  .regex(/[a-z]/, '소문자 포함')
  .regex(/[0-9]/, '숫자 포함')

HTTPS 필수

배포 시 반드시 HTTPS를 사용해야 합니다. Vercel은 자동으로 적용됩니다.

CAPTCHA 추가

스팸 가입을 막으려면 CAPTCHA를 넣는 게 좋습니다. 14편에서 다룹니다.


📋 이번 편 요약

항목 내용
다룬 기능 회원가입, 로그인, 로그아웃, 세션 관리
사용 기술 Supabase Auth, Server Action, Zod
핵심 파일 app/actions/auth.ts, lib/supabase/server.ts
보안 팁 비밀번호 규칙 강화, CAPTCHA, HTTPS

다음 편에서는 이메일 인증과 비밀번호 찾기를 구현합니다.

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