Supabase Auth 이메일·비밀번호 회원가입·로그인·세션 기초

··설명 약 11분·작성: 해비·블로그 소개

Supabase 이메일 회원가입 성공과 로그인 세션 생성은 어떻게 구분하나요?

회원가입 요청 성공과 로그인 세션 생성은 같은 상태가 아닙니다. 이메일 확인이 필요한 설정에서는 확인 절차 후 로그인까지 따로 관찰해야 합니다.

적용 조건·범위: 서버에서는 getClaims 또는 상황에 따라 getUser를 사용해 검증된 신원을 확인합니다.

본문 근거: supabase.com/docs/guides/auth/passwords · supabase.com/docs/reference/javascript/auth-signup · supabase.com/docs/guides/auth

Supabase Auth와 auth.users

Supabase Auth는 사용자 계정을 Auth 전용 schema에서 관리합니다.

앱의 public schema에 users 테이블을 직접 복제해 인증을 다시 구현할 필요는 없습니다.

나중에 닉네임 같은 앱 전용 프로필이 필요하면 public.profiles를 만들고 auth.users의 사용자 ID를 외래키로 연결합니다.

이 구조는 5회차에서 만듭니다.

이번 회차에서는 Auth 자체에 집중합니다.

브라우저용 Auth client 만들기

아직 @supabase/ssr을 사용하지 않습니다.

3회차는 브라우저에서 Supabase Auth의 기본 동작을 이해하는 단계이므로 @supabase/supabase-js의 createClient를 사용합니다.

lib/supabase/client.ts:

'use client';

import { createClient as createSupabaseClient, type SupabaseClient } from '@supabase/supabase-js';

let client: SupabaseClient | undefined;

export function createClient() {
  if (client) {
    return client;
  }

  const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
  const key = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;

  if (!url || !key) {
    throw new Error('Supabase 공개 설정이 필요합니다.');
  }

  client = createSupabaseClient(url, key);

  return client;
}

브라우저에서 하나의 클라이언트를 재사용합니다.

8회차에서는 이 파일을 @supabase/ssr의 createBrowserClient 기반으로 바꿉니다.

이메일·비밀번호 회원가입

회원가입 API는 signUp입니다.

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

중요한 점은 error가 없다고 항상 로그인 상태가 된 것은 아니라는 것입니다.

Confirm email이 활성화되어 있다면 가입 요청 후 이메일 확인을 먼저 요구할 수 있습니다.

이 경우 사용자 객체가 만들어져도 session이 null일 수 있습니다.

따라서 다음 두 상태를 분리합니다.

가입 요청 성공
≠
현재 로그인 세션 생성

이메일·비밀번호 로그인

로그인은 signInWithPassword를 사용합니다.

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

성공하면 현재 브라우저 세션에 access token과 refresh token이 저장됩니다.

이후 Supabase SDK로 데이터 요청을 보내면 사용자 access token이 함께 전달되고 데이터베이스는 authenticated 역할과 사용자 JWT를 기준으로 RLS를 평가할 수 있습니다.

아직 사용자별 RLS 정책은 만들지 않습니다.

그 작업은 6회차에서 진행합니다.

로그아웃

const { error } = await supabase.auth.signOut();

브라우저 세션이 제거되고 다음 데이터 요청은 다시 비로그인 상태가 됩니다.

getSession은 언제 사용할까

브라우저에서는 현재 저장된 세션을 UI 상태에 반영할 때 getSession을 사용할 수 있습니다.

const {
  data: { session },
} = await supabase.auth.getSession();

이 값은 현재 브라우저가 가지고 있는 세션을 읽습니다.

하지만 8회차의 Next.js 서버 권한 검증에서는 getSession에 들어 있는 user 객체만 신뢰하지 않습니다.

쿠키 같은 클라이언트 영향이 가능한 저장소의 값을 그대로 권한 판단에 사용하면 안 되기 때문입니다.

서버에서는 getClaims 또는 상황에 따라 getUser를 사용해 검증된 신원을 확인합니다.

onAuthStateChange로 UI 상태 맞추기

로그인이나 로그아웃이 발생하면 화면도 바뀌어야 합니다.

const {
  data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
  setSession(session);
});

return () => {
  subscription.unsubscribe();
};

이 구독으로 로그인 후 사용자 정보 영역을 표시하고 로그아웃 후 폼을 다시 보여줄 수 있습니다.

AuthForm 전체 구현

components/auth/auth-form.tsx:

'use client';

import { FormEvent, useEffect, useState } from 'react';
import type { Session } from '@supabase/supabase-js';

import { createClient } from '@/lib/supabase/client';

export function AuthForm() {
  const supabase = createClient();

  const [session, setSession] = useState<Session | null>(null);
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [message, setMessage] = useState('');
  const [pending, setPending] = useState(false);

  useEffect(() => {
    void supabase.auth.getSession().then(({ data }) => {
      setSession(data.session);
    });

    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((_event, nextSession) => {
      setSession(nextSession);
    });

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

  async function signUp(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    setMessage('');

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

    setPending(false);

    if (error) {
      setMessage(error.message);
      return;
    }

    if (!data.session) {
      setMessage('가입 요청이 완료되었습니다. 이메일 확인 후 로그인해 주세요.');
      return;
    }

    setMessage('가입과 로그인이 완료되었습니다.');
  }

  async function signIn() {
    setPending(true);
    setMessage('');

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

    setPending(false);

    if (error) {
      setMessage(error.message);
      return;
    }

    setMessage('로그인했습니다.');
  }

  async function signOut() {
    setPending(true);
    setMessage('');

    const { error } = await supabase.auth.signOut();

    setPending(false);

    if (error) {
      setMessage(error.message);
      return;
    }

    setMessage('로그아웃했습니다.');
  }

  if (session) {
    return (
      <section>
        <p>로그인 상태입니다.</p>
        <p>{session.user.email}</p>

        <button
          type="button"
          onClick={signOut}
          disabled={pending}
        >
          로그아웃
        </button>

        {message && <p>{message}</p>}
      </section>
    );
  }

  return (
    <form onSubmit={signUp}>
      <label>
        이메일
        <input
          type="email"
          value={email}
          onChange={(event) => setEmail(event.target.value)}
          required
        />
      </label>

      <label>
        비밀번호
        <input
          type="password"
          value={password}
          onChange={(event) => setPassword(event.target.value)}
          minLength={8}
          required
        />
      </label>

      <button type="submit" disabled={pending}>
        회원가입
      </button>

      <button
        type="button"
        onClick={signIn}
        disabled={pending}
      >
        로그인
      </button>

      {message && <p>{message}</p>}
    </form>
  );
}

예제에서는 학습을 위해 오류 message를 화면에 출력합니다.

실제 운영 서비스에서는 사용자에게 그대로 노출할 메시지와 개발 로그를 분리할 수 있습니다.

로그인 페이지

app/login/page.tsx:

import { AuthForm } from '@/components/auth/auth-form';

export default function LoginPage() {
  return (
    <main>
      <h1>TaskFlow 로그인</h1>
      <AuthForm />
    </main>
  );
}

사용자 ID를 폼에서 받지 않기

나중에 tasks를 저장할 때 다음과 같은 구조를 만들면 안 됩니다.

<input name="ownerId" />

사용자가 자신의 ownerId를 직접 입력하게 만들고 서버가 그 값을 신뢰하면 다른 사용자 ID를 넣을 수 있습니다.

소유자는 로그인된 사용자 신원에서 결정해야 합니다.

6회차에서는 데이터베이스에서도 auth.uid()를 기준으로 이 값을 검증합니다.

자주 발생하는 오류

가입했는데 바로 로그인되지 않는다

Confirm email 설정을 확인합니다.

가입 요청 성공과 session 생성은 별개의 상태일 수 있습니다.

새로고침은 되지만 Server Component에서는 사용자를 모른다

이번 회차는 브라우저 세션 단계입니다.

Next.js 서버가 같은 인증 상태를 쿠키로 읽도록 만드는 작업은 8회차에서 @supabase/ssr로 진행합니다.

getSession의 user를 서버 권한 판단에 그대로 사용한다

브라우저 UI에서는 현재 세션 표시에 사용할 수 있지만 서버 권한 검증의 최종 근거로 단독 사용하지 않습니다.

이 기술만 따로 연습하기

이 글의 완성 실습 ZIP을 별도 폴더에 풀어 단독 샘플로 사용합니다. 이전 회차의 작업 폴더는 필요하지 않습니다. Node.js 24 계열과 ZIP의 package-lock.json을 기준으로 README의 설치·환경 변수 절차를 한 번 진행합니다. 필요한 개발용 Supabase 프로젝트와 공개 가능한 URL·publishable key는 독자가 준비합니다. 실습은 운영 데이터와 분리합니다. 전체 파일은 아래 코드 보기에서도 확인할 수 있습니다.

  1. 별도 개발 프로젝트의 이메일 가입 정책과 허용 Redirect URL을 확인한 후 /login을 엽니다.
  2. 본인이 소유한 테스트 이메일로 가입합니다. 이메일 확인을 켠 경우 가입 응답에 user가 있어도 session은 없을 수 있으므로 즉시 로그인 완료로 기록하지 않습니다.
  3. 확인 절차를 마친 테스트 계정으로 로그인해 화면의 현재 세션 표시를 기록합니다. 새로고침 후 같은 계정인지 확인합니다.
  4. 로그아웃한 뒤 새로고침하고 비로그인 표시로 돌아오는지 확인합니다.

기대 결과: 가입 접수·이메일 확인 대기·로그인 세션·로그아웃을 각각 구별할 수 있습니다. publishable key를 바꾸어 사용자 전환을 흉내 내지 않습니다.

자가 확인·변형: 틀린 비밀번호로 로그인하면 기존 성공 메시지가 남아 성공처럼 보이지 않는지 확인하세요. 이 글의 브라우저 세션 확인만으로 서버 SSR 인증 검증까지 완료된 것은 아닙니다.

검증 범위: 실제 Supabase 요청, 계정 생성, 메일 발송, 원격 DB 정책·실행 계획 검증은 이 편집에서 실행하지 않았습니다. 위 결과는 독자가 확인할 기대값입니다.

학습 완료 체크리스트

  • signUp과 signInWithPassword의 역할을 설명할 수 있습니다.
  • 가입 성공과 session 생성이 다를 수 있다는 것을 알고 있습니다.
  • signOut으로 브라우저 인증 상태를 제거할 수 있습니다.
  • onAuthStateChange로 UI를 로그인 상태와 동기화할 수 있습니다.
  • 로그인 전 anon과 로그인 후 authenticated 역할이 달라짐을 이해했습니다.
  • SSR 쿠키 인증은 아직 구현하지 않았다는 것을 알고 있습니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 데이터 저장·인증·접근 권한을 연결합니다.

이번 주제: Supabase Auth 이메일·비밀번호 회원가입·로그인·세션 기초.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

이전 단계 시작 ZIP을 먼저 별도 작업 폴더에 풉니다. 현재 회차 ZIP은 적용 후 비교할 완성본입니다.

2회차 시작 ZIP에서 lib/supabase/client.ts, components/auth/auth-form.tsx, app/login/page.tsx를 추가합니다. 공개 조회와 인증 화면을 분리하기 위한 변경입니다.

이전 체크포인트

supabase-step-02-first-query

현재 상태

Supabase 연결 완료
공개 데이터 조회 완료
사용자 인증 없음
개인 데이터 모델 없음

이번 변경 파일

lib/
└─ supabase/
   └─ client.ts

components/
└─ auth/
   └─ auth-form.tsx

app/
└─ login/
   └─ page.tsx
전체 파일·코드 보기

실습 프로젝트 전체 코드

아래는 이 회차까지의 코드를 누적한 독립 실행용 최소 Next.js App Router 학습 앱 전체입니다. 기존 Next.js 38편 앱 전체를 복제한 것이 아닙니다. ZIP에는 의존성 잠금 파일 package-lock.json도 포함되며 본문에서는 이 자동 생성 잠금 파일만 생략합니다. 기존 TaskFlow 앱을 이어서 사용한다면 기존 설정을 보존하고 해당 회차 변경 파일을 적용하세요.

.env.example

NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=

.gitignore

node_modules/
.next/
.env.local
*.tsbuildinfo

app/layout.tsx

import type { ReactNode } from "react";
export default function RootLayout({ children }: { children: ReactNode }) { return <html lang="ko"><body>{children}</body></html>; }

app/login/page.tsx

import { AuthForm } from '@/components/auth/auth-form';


export default function LoginPage() {
  return (
    <main>
      <h1>TaskFlow 로그인</h1>
      <AuthForm />
    </main>
  );
}

app/page.tsx

import Link from "next/link";
export default function Home() { return <main><h1>TaskFlow Supabase</h1><Link href="/tasks">작업 목록</Link> <Link href="/login">로그인</Link></main>; }

app/tasks/page.tsx

import { createPublicSupabaseClient } from '@/lib/supabase/public-server';


type PublicTask = {
  id: number;
  title: string;
  created_at: string;
};


export const dynamic = 'force-dynamic';


export default async function TasksPage() {
  const supabase = createPublicSupabaseClient();


  const { data, error } = await supabase
    .from('public_tasks_lab')
    .select('id, title, created_at')
    .order('id');


  if (error) {
    return (
      <main>
        <h1>TaskFlow</h1>
        <p>Supabase 데이터를 불러오지 못했습니다.</p>
        <pre>{error.message}</pre>
      </main>
    );
  }


  const tasks = (data ?? []) as PublicTask[];


  return (
    <main>
      <h1>TaskFlow</h1>


      {tasks.length === 0 ? (
        <p>공개 작업이 없습니다.</p>
      ) : (
        <ul>
          {tasks.map((task) => (
            <li key={task.id}>{task.title}</li>
          ))}
        </ul>
      )}
    </main>
  );
}

components/auth/auth-form.tsx

'use client';


import { FormEvent, useEffect, useState } from 'react';
import type { Session } from '@supabase/supabase-js';


import { createClient } from '@/lib/supabase/client';


export function AuthForm() {
  const supabase = createClient();


  const [session, setSession] = useState<Session | null>(null);
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [message, setMessage] = useState('');
  const [pending, setPending] = useState(false);


  useEffect(() => {
    void supabase.auth.getSession().then(({ data }) => {
      setSession(data.session);
    });


    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((_event, nextSession) => {
      setSession(nextSession);
    });


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


  async function signUp(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    setMessage('');


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


    setPending(false);


    if (error) {
      setMessage(error.message);
      return;
    }


    setMessage(
      data.session
        ? '가입과 로그인이 완료되었습니다.'
        : '가입 요청이 완료되었습니다. 이메일을 확인해 주세요.',
    );
  }


  async function signIn() {
    setPending(true);
    setMessage('');


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


    setPending(false);
    setMessage(error ? error.message : '로그인했습니다.');
  }


  async function signOut() {
    setPending(true);
    setMessage('');


    const { error } = await supabase.auth.signOut();


    setPending(false);
    setMessage(error ? error.message : '로그아웃했습니다.');
  }


  if (session) {
    return (
      <section>
        <p>로그인 상태입니다.</p>
        <p>{session.user.email}</p>
        <button type="button" onClick={signOut} disabled={pending}>
          로그아웃
        </button>
        {message && <p>{message}</p>}
      </section>
    );
  }


  return (
    <form onSubmit={signUp}>
      <label>
        이메일
        <input
          type="email"
          value={email}
          onChange={(event) => setEmail(event.target.value)}
          required
        />
      </label>


      <label>
        비밀번호
        <input
          type="password"
          value={password}
          onChange={(event) => setPassword(event.target.value)}
          minLength={8}
          required
        />
      </label>


      <button type="submit" disabled={pending}>
        회원가입
      </button>


      <button type="button" onClick={signIn} disabled={pending}>
        로그인
      </button>


      {message && <p>{message}</p>}
    </form>
  );
}

docs/backend-choice.md

# TaskFlow Backend Choice


## 시작점


next-step-38-cache-update


## 선택


Supabase


## 이유


- PostgreSQL 관계형 데이터 모델을 직접 학습한다.
- Supabase Auth와 Postgres 사용자를 연결한다.
- GRANT와 RLS를 이용해 사용자별 데이터 격리를 학습한다.
- Next.js App Router에서 쿠키 기반 SSR 인증을 연결한다.
- 관계 조회와 인덱스까지 하나의 데이터 계층에서 학습한다.


## 이번 과정에서 사용하는 기능


- PostgreSQL
- Auth
- Data API
- Row Level Security
- @supabase/supabase-js
- @supabase/ssr


## 이번 과정에서 필수로 다루지 않는 기능


- Realtime
- Storage
- Edge Functions


필요 기능이 아니므로 과정 범위를 무리하게 늘리지 않는다.


## 보안 원칙


- 브라우저에는 publishable key만 사용한다.
- secret key는 클라이언트에 포함하지 않는다.
- 사용자 데이터는 RLS를 활성화한다.
- 화면에서 숨기는 것으로 권한 검증을 대신하지 않는다.
- 실제 인증 토큰과 개인정보를 학습 자료에 저장하지 않는다.


## 백엔드 트랙


Firebase 완료본을 이어서 수정하지 않는다.


Next.js TaskFlow 최종 체크포인트에서 별도 Supabase 브랜치를 시작한다.


## 다음 단계


Supabase 프로젝트를 만들고 공개 학습 데이터를 한 번 조회한다.

lib/supabase/client.ts

'use client';


import { createClient as createSupabaseClient, type SupabaseClient } from '@supabase/supabase-js';


let client: SupabaseClient | undefined;


export function createClient() {
  if (client) {
    return client;
  }


  const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
  const key = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;


  if (!url || !key) {
    throw new Error('Supabase 공개 설정이 필요합니다.');
  }


  client = createSupabaseClient(url, key);


  return client;
}

lib/supabase/public-server.ts

import 'server-only';


import { createClient } from '@supabase/supabase-js';


export function createPublicSupabaseClient() {
  const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
  const key = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;


  if (!url || !key) {
    throw new Error('Supabase 공개 설정이 필요합니다.');
  }


  return createClient(url, key, {
    auth: {
      persistSession: false,
      autoRefreshToken: false,
      detectSessionInUrl: false,
    },
  });
}

next-env.d.ts

/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

package.json

{
  "name": "taskflow-supabase-checkpoint",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "next": "16.3.5",
    "react": "19.3.0",
    "react-dom": "19.3.0",
    "@supabase/supabase-js": "2.116.0",
    "server-only": "0.0.1"
  },
  "devDependencies": {
    "typescript": "5.9.3",
    "@types/node": "24.10.1",
    "@types/react": "19.2.7",
    "@types/react-dom": "19.2.3"
  }
}

README.md

# Supabase 03 완료 체크포인트 — auth-basics

원문 회차 코드를 누적한 독립 실행용 최소 Next.js App Router 학습 앱입니다. 이전 Next.js 38편 앱 전체나 추가 CRUD 화면을 복제한 프로젝트가 아닙니다.

Node.js 24.18.0, npm 11.16.0 기준입니다.

1. `npm ci`
2. `.env.example`을 `.env.local`로 복사하고 개발 전용 Supabase URL/publishable key를 입력합니다.
3. 아래 순서대로 개발 DB에 해당 SQL을 한 번씩 적용합니다. 같은 객체가 있으면 덮어쓰지 않습니다.
4. `npm run dev` 후 `/tasks`를 확인합니다.
5. `npm run typecheck` 및 `npm run build`를 실행합니다.

SQL: `supabase/learning/02_public_tasks_lab.sql`.

`/login`은 이메일/비밀번호 인증 화면입니다. 실제 가입/확인 메일은 본인 개발 프로젝트에서 직접 검증하세요.

실제 secret/service_role 키, 비밀번호, 원격 DB 계정은 포함하지 않습니다. node_modules/.next/.env.local은 제외했습니다. ZIP의 package-lock.json으로 정확한 의존성을 재현하세요.

supabase/learning/02_public_tasks_lab.sql

begin;


create table public.public_tasks_lab (
  id bigint generated always as identity primary key,
  title text not null,
  created_at timestamptz not null default now()
);


insert into public.public_tasks_lab (title)
values
  ('Supabase 연결 확인'),
  ('RLS 공개 읽기 확인');


alter table public.public_tasks_lab
enable row level security;


revoke all
on table public.public_tasks_lab
from public, anon, authenticated;


grant select
on table public.public_tasks_lab
to anon, authenticated;


create policy "public_tasks_lab_read"
on public.public_tasks_lab
for select
to anon, authenticated
using (true);


commit;

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2017",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": [
        "./*"
      ]
    },
    "allowJs": true
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".next/dev/types/**/*.ts"
  ],
  "exclude": [
    "node_modules"
  ]
}

실행·결과 확인

npm run dev

확인 순서:

1. 새 이메일로 회원가입
2. Confirm email 설정에 따른 session 유무 확인
3. 확인 완료 계정으로 로그인
4. 새로고침 후 브라우저 UI 확인
5. 로그아웃
6. 잘못된 비밀번호로 로그인 시도
7. 다른 테스트 계정으로 로그인

실제 이메일 주소, 비밀번호, JWT, 사용자 UUID는 스크린샷이나 로그에 남기지 않습니다.

완료 체크포인트

supabase-step-03-auth-basics

프로젝트 적용 후 확인 기록

독자 질문: 회원가입 응답과 실제 로그인 세션은 언제 다르게 나타날까?

따라 할 단계

  1. 서로 다른 테스트 이메일로 A·B를 준비하고 Confirm email 설정을 기록한다.
  2. A로 가입한 직후 session 유무를 확인하고, 필요한 경우 메일 확인을 마친 뒤 로그인한다.
  3. A에서 로그아웃하고 B로 로그인해 화면의 사용자 표시가 바뀌는지 확인하며, 잘못된 비밀번호도 시도한다.

예상 결과와 A/B 계정 범위

  • 확인 메일이 필요한 설정에서는 가입 직후 session이 null일 수 있다. A 로그아웃 뒤에는 A 세션이 사라지고 B 로그인 뒤 B의 세션이 보여야 한다.
  • 아직 tasks의 소유권 policy를 만들지 않았으므로 B가 A의 DB 행을 읽지 못한다는 결론은 낼 수 없다.

흔한 오류 점검

  • 가입 성공을 곧바로 로그인 성공으로 판단했다면 Auth 설정과 session을 따로 확인한다.
  • 서버에서 getSession의 user만으로 권한을 확정하지 않는다. 서버 인증은 8회차에서 다시 다룬다.

검증 기록: 이번 편집에서 실제 Supabase 요청·A/B 계정 실험은 실행하지 않았으므로 미확인입니다.

다음 학습

메일 문구가 필요하면 4회차, 그렇지 않으면 5회차의 관계형 스키마로 이동한다.

다음 학습

다음 4회차는 가입 확인 메일을 한국어 서비스에 맞게 수정하는 선택 실습입니다.

메일 현지화가 필요 없다면 바로 5회차로 넘어가도 됩니다.

supabase-step-03-auth-basics
→ supabase-step-05-relational-schema

다음 글:

Supabase 회원가입 방법: 확인 메일 제목·본문·버튼 문구를 한국어로 바꾸기

공식 문서

확인일: 2026-09-21

체크포인트 다운로드

supabase-step-03-auth-basics 완료 ZIP 다운로드

시작 체크포인트: 2회차 완료 ZIP

글 소개·학습 준비·전체 목차
프로필과 열쇠로 표현한 Supabase 회원가입과 로그인 표지

Supabase · 3 / 9 · 초급 학습 · Auth 기본

회원가입 요청이 성공해도 이메일 확인이 필요한 설정에서는 로그인 세션이 아직 없을 수 있습니다. signUp의 응답, 확인 절차 뒤 로그인, 현재 세션 표시와 로그아웃을 따로 확인합니다. 아래 독립 앱으로 이 네 상태를 구분합니다.

상단에서는 제공된 독립 샘플 앱에 Supabase Auth를 연결해 회원가입, 로그인, 로그아웃, 현재 세션 확인까지 구현합니다.

핵심은 API key와 사용자의 로그인 상태가 서로 다른 역할을 한다는 점입니다.

publishable key
→ 어떤 앱이 Supabase에 요청하는지

Auth access token
→ 어떤 사용자가 요청하는지

로그인하지 않은 요청은 anon 역할을 사용하고, 로그인한 요청은 authenticated 역할로 데이터베이스에 도달합니다.

이번 글에서 배우는 내용

  • Supabase Auth와 auth.users
  • 이메일·비밀번호 회원가입
  • signInWithPassword 로그인
  • signOut 로그아웃
  • getSession의 역할
  • onAuthStateChange로 화면 상태 동기화
  • 가입 후 session이 null일 수 있는 이유
  • anon과 authenticated 역할 차이
  • 브라우저 세션과 SSR 쿠키 세션의 차이

선수 지식

  • 2회차 Supabase 프로젝트 연결
  • React Client Component 기본
  • useState
  • form submit 처리
  • async / await

목차

  1. Supabase Auth와 auth.users
  2. 브라우저용 Auth client 만들기
  3. 이메일·비밀번호 회원가입
  4. 이메일·비밀번호 로그인
  5. 로그아웃
  6. getSession은 언제 사용할까
  7. onAuthStateChange로 UI 상태 맞추기
  8. AuthForm 전체 구현
  9. 로그인 페이지
  10. 사용자 ID를 폼에서 받지 않기
  11. 자주 발생하는 오류
  12. 학습 완료 체크리스트
  13. TaskFlow 프로젝트 적용
  14. 실습 프로젝트 전체 코드
  15. 실행·결과 확인
  16. 다음 학습
  17. 공식 문서
  18. 단독 실습과 결과 확인

이 글이 도움이 되었나요?

조회 중

분류별 과정 이동 (선택)

읽음 기록

읽음 — / 전체 9개 · 필수 8개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기