벡엔드 세팅 들어가기 1

이언덕·2025년 10월 11일
post-thumbnail

🧱 프로젝트 만들기

Supabase로 백엔드를 처음 세팅하면서 진행 과정을 기록하려고 한다.
이 세팅들은 커스텀 데일리 플래너 프로젝트에 적용할 것이다.

각 단계별로 실제 화면 캡처와 함께 내가 한 설정을 정리해두었다.


1. Supabase 계정 만들기 (Sign up / Organization)

먼저 Supabase 공식 사이트에 접속했다.
👉 https://supabase.com

오른쪽 상단 Sign up 버튼을 눌러 가입을 진행했다.
이메일, GitHub, Google 중 아무 방법으로 로그인해도 된다.
나는 GitHub 계정으로 바로 연결했다.


처음 로그인하면 Organization(조직 이름) 을 만들라고 나온다.
이건 “내 프로젝트들을 모아둘 팀 이름” 같은 개념이다.
나는 프로젝트 이름에 맞춰 CustomDailyPlanner 라고 지정했다.


가입이 끝나면 대시보드 상단에
Your organizations 목록이 보이게 된다면 정상 ✅


💬 정리 메모

  • Organization은 팀 폴더 개념이라 프로젝트 여러 개를 모아둘 수 있다.
  • 개인 프로젝트라도 일관성 있게 이름을 지어두면 관리가 훨씬 편하다.

2. Supabase에서 새 프로젝트 생성 (New Project)

이제 첫 프로젝트를 만든다.
대시보드 우측 상단 「New Project」 버튼을 클릭한다.
방금 만든 CustomDailyPlanner Organization을 선택한다.


입력해야 할 항목은 다음과 같다 👇

항목예시 값설명
Project namecustom-daily-planner-dev환경 구분(-dev, -prod)을 이름에 포함
Database password안알랴줌나중에 DB 연결 시 사용하므로 반드시 기록
Regionap-northeast-2 (Seoul)가장 가까운 리전 선택
PricingFree무료 플랜으로 시작해도 충분

모두 입력한 뒤 「Create new project」 버튼 클릭.

몇 초 후 Supabase가 자동으로 데이터베이스를 초기화하고
대시보드로 이동한다.


💬 참고 팁

  • 프로젝트 이름은 나중에 바꿀 수 있지만,
    처음부터 앱이름-환경명 형태로 정하면 관리가 편하다.
    (예: planner-dev, planner-prod)
  • Database password는 잃어버리면 복구가 불가능하므로
    꼭 비밀번호 관리앱(1Password, Bitwarden 등)에 저장한다.

3. 프로젝트 주소(URL)와 공개 키(anon key) 복사

(Project Settings → API)
이제 프론트엔드와 연결할 때 필요한 프로젝트 주소와 공개 키를 복사한다.

  1. 왼쪽 메뉴에서 ⚙️ Project Settings → API 클릭 (Data API = Project URL / API Keys = anon public key & service_role key)
  2. 아래 항목들을 찾는다 👇
항목설명
Project URLSupabase API 요청의 기본 주소
anon public key클라이언트에서 접근 가능한 공개 키

.


💬 주의

  • service_role key도 함께 보이지만,
    이건 서버 전용이라 절대 프론트엔드에서 사용하면 안 된다.
  • anon key는 프런트용이지만,
    .env.local에 넣고 git에 커밋하지 말 것.


    이 두 값은 이후
    3️⃣ 프론트 연결(환경변수 설정) 단계에서 사용할 예정이다.

이 단계 완료 기준

  • Supabase 계정 및 Organization 생성 완료
  • 새 프로젝트 생성 완료 (대시보드 확인)
  • Project URL, anon key 복사 완료



🔐 로그인 기능 켜기 (Auth 설정)

Supabase의 인증(Auth) 기능을 활성화해
이메일/비밀번호로 회원가입하고 로그인할 수 있도록 설정한다.

로그인 성공 후 돌아올 Redirect URL 설정까지 함께 진행했다.


1. 이메일/비밀번호 로그인 ON (Enable Email Auth)

Supabase 대시보드 왼쪽 메뉴에서
Authentication → Sign In / Providers 탭을 클릭 후.

맨 위의 User Signups 섹션에서
Allow new users to sign up 옵션을 ON 으로 켰다.
이 스위치를 켜야 새 사용자가 회원가입을 할 수 있다.

그 아래의 Confirm email 옵션은
회원가입 시 이메일 인증 링크를 보내는 설정이다.
나는 초반 테스트를 위해 ON 상태 그대로 유지했다.

마지막으로 Save changes 버튼을 눌러 저장했다.
알림이 뜨면 정상 적용 완료 ✅


💬 용어 정리

  • Provider
    로그인 수단을 뜻한다.
    이메일 외에도 Google, GitHub 같은 OAuth 로그인 방식이 포함된다.
  • Confirm Email
    이 옵션을 켜면 회원가입 후 이메일로 발송된 링크를 눌러야
    최종 로그인할 수 있다.
    테스트 단계에서는 OFF로 두고 빠르게 개발용 로그인 테스트만 진행한다.

2. 이메일 Provider 세부 설정 (Email Provider Options)

Email Provider를 클릭하면 세부 옵션을 설정할 수 있다.

대시보드에서 Auth Providers → Email → > 클릭 후 확인.

항목설명내 설정
Enable Email Provider이메일 로그인 기능 전체 ON/OFF✅ ON
Secure email change이메일 변경 시 새 주소에서도 확인 필요✅ ON
Secure password change최근 로그인한 유저만 비밀번호 변경 가능❌ OFF (테스트 단계)
Prevent leaked passwords약하거나 유출된 비밀번호 차단❌ OFF (Pro 전용 기능)
Minimum password length최소 비밀번호 길이6자 (기본값)
Password Requirements특수문자/대문자 요구 여부기본(Default)
Email OTP Expiration인증 링크 만료 시간(초 단위)3600초 (1시간)
Email OTP LengthOTP 자리 수6자리

.


💬 정리 메모

  • Secure 옵션들은 보안 단계를 강화하는 기능이다.
    초반에는 꺼두고, 실제 서비스 전환 시 다시 켜면 된다.
  • OTP는 비밀번호 초기화나 인증 링크의 유효 시간에 영향을 준다.

3. 리다이렉트 주소 등록 (Redirect URL)

이제 로그인 성공 후 돌아올 페이지를 지정한다.

Authentication → URL Configuration 메뉴로 이동.
아래 두 주소를 Redirect URLs에 추가했다 👇

환경주소
개발용(Local)http://localhost:3000/auth/callback
배포용(Prod)https://내-배포-도메인/auth/callback

설정 후 Save 버튼 클릭 → “Settings saved” 메시지 확인.




⚠️ 주의

  • Redirect URL이 등록되지 않으면 로그인 직후
    “Not allowed origin” 에러가 발생한다.
  • 개발/배포 환경 각각 반드시 등록해야 한다.

4. 테스트 회원가입/로그인 (Test Auth Flow)

모든 설정이 완료됐으니 실제로 작동을 확인했다.

Authentication → Users 탭으로 이동해
테스트 방법은 2가지로 진행할 수 있다.
수동으로 유저를 추가하거나, 프론트엔드 로그인 폼에서 회원가입을 진행할 수 있다.
현재 프론트 UI가 안 만들어져 있다보니 수동으로 유저를 추가해야 했다.

🧩 수동으로 유저 추가하기 (Create new user)

화면 오른쪽 상단의 「Add user」 버튼을 클릭하고,
드롭다운에서 「Create new user」를 선택한다.

팝업 창이 뜨면 다음 항목을 입력한다 👇

항목설명
Email address테스트용 이메일 주소 입력 (예: test@example.com)
User Password간단한 테스트용 비밀번호 입력 (예: 12345678)
Auto Confirm User체크 ✅ (이메일 인증 없이 즉시 활성화)

💬 정리 메모

  • Auto Confirm User 옵션을 켜면 인증 메일을 생략하고 즉시 로그인 가능하다.
  • 이 방식은 테스트용으로만 사용하고, 실제 운영 시엔 Confirm Email ON으로 전환해야 한다.

🧩 생성된 유저 정보 확인하기 (User Overview)

유저가 추가되면 목록에서 해당 이메일을 클릭해
상세 정보 패널(Overview) 을 열 수 있다.

여기서 확인할 수 있는 주요 정보 👇

항목설명
UID유저 고유 ID. 다른 테이블(profiles.user_id) FK로 연결할 때 사용
Created at / Updated at생성 및 수정 시각
Confirmed at이메일 인증 완료 시각 (Auto Confirm이면 바로 채워짐)
Provider Information어떤 로그인 방식(Email/OAuth)으로 가입했는지
Reset password수동으로 비밀번호 재설정 메일 전송 가능
Send magic link비밀번호 없이 이메일 링크로 로그인하도록 전송
Danger zone유저 삭제, 차단(Ban), MFA 해제 같은 관리 기능 제공

💬 정리 메모

  • UID는 Supabase에서 각 유저를 구분하는 식별자이자,
    profiles 테이블과 연결되는 핵심 키다.
  • Confirmed at이 비어 있으면 이메일 인증이 안 된 상태.
    Auto Confirm으로 생성했다면 바로 채워진다.
  • “Send password recovery” / “Send magic link” 버튼은
    실제 서비스 운영 시 비밀번호 분실 대응용으로 활용할 수 있다.



🧩 프론트엔드 로그인 폼으로 테스트하기 (추후)

UI가 완성되면 supabase.auth.signUp() 또는 signInWithPassword() 메서드를 사용해
직접 로그인/회원가입 동작을 테스트할 수 있다.
그때는 Supabase Auth 클라이언트(createBrowserClient)의 상태 변화(onAuthStateChange)로
세션이 정상적으로 발급되는지 확인한다.

🧪 점검 체크리스트

테스트 항목정상 조건
회원가입 후 Users 탭새 유저가 목록에 표시됨
새로고침 후 상태 유지auth.onAuthStateChange 이벤트 정상 작동
상세 정보 패널UID, Provider, Confirmed at 정상 표시
로그인 후 세션 발급 (프론트에서 테스트 할 때)access_token, refresh_token 생성 확인

💬 테스트 팁

  • Confirm Email이 ON이라면 메일함에서 인증 링크를 눌러야 최종 로그인 가능.
  • OFF 상태에서는 바로 로그인돼서 테스트하기 편하다.
  • 테스트 계정은 나중에 Delete user 버튼으로 간단히 제거할 수 있다.

이 단계 완료 기준

  • 이메일 로그인 Provider 활성화(Enabled)
  • Allow new users to sign up ON
  • Redirect URLs(로컬/배포) 등록 완료
  • 테스트 회원가입 및 로그인 성공



🔗 프론트와 연결 (환경변수)

Supabase 대시보드에서 복사한 Project URL / anon key
프론트엔드(.env)로 연결해 createClient()가 정상 동작하는지 확인했다.


1. .env 파일 만들기 (Environment File)

프로젝트 루트에 .env 파일을 만들었다.
이 파일은 내 로컬에서만 쓰는 비밀값을 넣는 곳이며 git에 올리지 않는다.

.gitignore 확인

  • .gitignore에 아래 패턴이 포함되어 있는지 확인:
    .env
    .env.*



    💬 메모
  • 이미 .env.local을 쓰고 있다면 그대로 사용해도 됨(Next.js 관례).
  • 윈도우/맥 동일하게 동작.

2. 값 채우기 (Project URL / anon key 넣기)

Supabase 대시보드 Project Settings → API에서 복사한 값을 입력했다.

# .env  (또는 .env.local)
NEXT_PUBLIC_SUPABASE_URL=https://<your-project-ref>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<your-anon-public-key>

💡 규칙

  • NEXT_PUBLIC_ 접두사는 브라우저에서 읽을 수 있는 공개 키 전용.
  • service_role key는 절대 넣지 말 것(서버용, 지금 단계에선 불필요).
  • 값은 한 줄로 입력(개행/공백 섞이면 파싱 에러 발생).


3. 클라이언트 생성 테스트 (Supabase Client)

.env에 넣은 값이 앱에서 정상 로딩되는지,
createClient()통신이 되는지,
auth.getSession()정상 응답을 주는지 확인했다.

3-0 패키지 설치 (필수)

프로젝트 루트에서 @supabase/supabase-js를 설치한다.

# 패키지 매니저에 맞게 하나만 실행
pnpm add @supabase/supabase-js
# npm i @supabase/supabase-js
# yarn add @supabase/supabase-js

3-1) 클라이언트 파일 만들기

// lab/supabaseClient.ts
import { createClient } from '@supabase/supabase-js';

export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

3-2) 디버그 페이지에서 연결 체크

  • App Router 예시 (app/debug/page.tsx):
// app/debug/page.tsx
'use client';

import { useEffect, useState } from 'react';
import { supabase } from '@/lab/supabaseClient';

export default function DebugPage() {
  const [state, setState] = useState<{ hasSession: boolean; error: any }>({
    hasSession: false,
    error: null,
  });

  useEffect(() => {
    (async () => {
      const { data, error } = await supabase.auth.getSession();
      setState({ hasSession: !!data.session, error });
    })();

    // 세션 변화도 같이 로그
    const { data: sub } = supabase.auth.onAuthStateChange((_evt, session) => {
      setState({ hasSession: !!session, error: null });
    });
    return () => sub.subscription.unsubscribe();
  }, []);

  return (
    <pre style={{ whiteSpace: 'pre-wrap' }}>
      {JSON.stringify(state, null, 2)}
    </pre>
  );
}
  • Pages Router 예시 (pages/debug.tsx):
// pages/debug.tsx
import { supabase } from '@/lab/supabaseClient';

export default function DebugPage() {
  const test = async () => {
    const { data, error } = await supabase.auth.getSession();
    console.log({ hasSession: !!data.session, error });
  };

  // 간단 실행
  test();
  return <div>Check console & network</div>;
}

이제 서버를 실행해서 로컬 /debug페이지를 확인해보자!


4. 앱 실행 & 에러 확인 (Run / Verify)

이제 실제로 Supabase와 연결이 잘 되는지 확인했다.
로컬에서 Next.js 앱을 실행하고 /debug 페이지를 열어본다.

🧩 실행 명령어

pnpm dev
# 또는 npm run dev / yarn dev

서버가 뜨면 브라우저에서 http://localhost:3000/debug 로 이동한다.



🧠 결과 확인

현재는 로그인 기능을 아직 연결하지 않았기 때문에,
auth.getSession()의 결과는 아래처럼 나온다 👇

{
  "hasSession": false,
  "error": null
}

이건 정상 상태다 ✅
즉,

  • .env에 입력한 값이 잘 로딩되었고
  • Supabase 클라이언트가 서버와 정상 통신 중이며
  • 아직 세션(로그인 상태)은 없다는 뜻이다.

🔐 나중에 로그인 기능을 연결하면?

다음 단계에서 로그인 기능(supabase.auth.signInWithPassword)을 붙이고 나면,
로그인 후 /debug 페이지에서 아래처럼 바뀌게 된다 👇

{
  "hasSession": true,
  "error": null
}
  • hasSession: true → 로그인 성공 및 세션 유지 중
  • 새로고침을 해도 값이 그대로면 세션 유지 정상 동작

⚠️ 흔한 오류와 해결

현상원인해결
error: {... fetch failed ...}Project URL 오타Supabase 대시보드에서 다시 복사
error: {... invalid key ...}anon key 줄바꿈/공백한 줄로 입력
TypeError: Cannot read properties of undefined환경변수 미로딩서버 재시작(Ctrl + Cpnpm dev)
페이지 접속 시 404디버그 파일 경로 오타/app/debug/page.tsx 위치 확인

정상 완료 기준

  • /debug 페이지 접속 시 hasSession: false, error: null 출력
  • 콘솔/네트워크 에러 없음
  • dev 서버 정상 기동 (ready - started server on 0.0.0.0:3000)

5. 보안 메모 (Do / Don’t)

  • .env은 절대 커밋 금지(리포지토리에 올리면 키 유출 위험).
  • 협업자는 각자 로컬에 .env를 만든다(값은 노션 등으로 공유).
  • 배포 환경(Vercel 등) 에서는 프로젝트 설정의 Environment Variables
    동일한 키를 등록한다
    • NEXT_PUBLIC_SUPABASE_URL
    • NEXT_PUBLIC_SUPABASE_ANON_KEY


      💡 추가 팁
  • Prod와 Dev를 분리했다면, 배포 프로젝트에는 Prod용 값을 넣는다.
  • Vercel에선 저장 후 Redeploy 해야 반영된다.

이 단계 완료 기준

  • .env(또는 .env.local) 생성 및 .gitignore 확인
  • NEXT_PUBLIC_SUPABASE_URL/ANON_KEY 입력
  • /debug 페이지에서 getSession() 호출 성공
  • 흔한 오류 없이 dev 서버 정상 기동



⚙️ 공통 준비 (자동 시간 / 아이디)

Supabase 데이터베이스에서 기본적으로 필요한 공통 자동 기능을 설정했다.
앞으로 만들 모든 테이블에 공통으로 적용될 기능으로,
새 레코드의 id를 자동으로 랜덤 UUID로 생성하고
수정 시 updated_at 컬럼이 자동으로 갱신되도록 세팅했다.


1. 랜덤 아이디 기능 켜기 (pgcrypto / UUID)

Supabase 대시보드의 SQL Editor를 열고, 새 쿼리 탭(+ 버튼)에서 아래 코드를 실행했다.
이 기능을 켜면 새 데이터가 추가될 때마다 id 값이 자동으로 랜덤 UUID로 채워진다.
(1, 2, 3... 같은 숫자 자동 증가 대신, 고유한 문자열 ID가 생성됨)

create extension if not exists pgcrypto;

SQL 실행 후 하단에 “Success. No rows returned” 메시지가 뜨면 정상 완료.
한 프로젝트(DB)당 한 번만 실행하면 된다.


💬 정리 메모

  • 이제 테이블 생성 시 아래 구문을 사용할 수 있다.
    id uuid default gen_random_uuid() primary key
  • pgcrypto 확장은 UUID 생성 기능을 제공하며, Supabase에 기본 내장돼 있다.
  • 한 번 실행해두면 이후 모든 테이블에서 사용할 수 있다.

2. updated_at 자동 갱신 함수 만들기 (Trigger Function)

이제 수정 시각(updated_at)을 자동으로 갱신해주는 함수(Function) 를 만든다.
SQL Editor에서 새 탭(+)을 눌러 아래 코드를 실행한다.

create or replace function public.set_updated_at()
returns trigger
language plpgsql
as $$
begin
  new.updated_at := now(); -- UTC 권장 (timestamptz)
  return new;
end;
💲💲; -- 지금 여기 코드에서 이상해서 이모지로 대체했음

실행 후 “Success. No rows returned” 메시지가 뜨면 정상적으로 생성된 것이다.




💬 정리 메모

  • now()는 UTC 기준으로 저장된다.
  • 한국 시간으로 표시할 때는 프론트엔드에서 변환하면 된다.
  • 이 함수는 이후 각 테이블에서 트리거(trigger) 로 연결해 자동 실행된다.

3. 신규 유저 가입 시 profiles 자동 생성 함수 (이 부분 벡엔드 세팅 들어가기 2에 적어놓음)

⚠️ 내가 모르고 글을 하나 더 써버림,,,, 아래 글보다는 자세한건 벡엔드 세팅 들어가기 2 첫부분을 보면 된다

profiles는 “로그인(회원가입) 시 자동 생성”을 전제로 설계했다.
그래서 auth.users에 새로운 유저가 생성되는 순간, public.profiles1:1 프로필 row를 자동으로 만들어준다.
이 자동 생성이 보장돼야 이후 로직(예: profiles 기반 이메일 중복 확인, 초기 설정 저장)이 안정적으로 굴러간다.


SQL Editor에서 새 쿼리 탭(+)을 열고 아래 코드를 실행한다.

create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
  insert into public.profiles (id, email)
  values (
    new.id,
    lower(new.email)
  )
  on conflict (id) do nothing;

  return new;
end;
💲💲;  -- 이상해서 이모지로 대체

3-1. 신규 유저 생성 트리거 연결 (auth.usersprofiles) (이 부분 벡엔드 세팅 들어가기 2에 적어놓음)

⚠️ 내가 모르고 글을 하나 더 써버림,,,, 아래 글보다는 자세한건 벡엔드 세팅 들어가기 2 첫부분을 보면 된다

함수만 만들어서는 자동 생성이 실행되지 않는다.
auth.users에 유저가 추가되는 순간 위 함수를 실행하도록, after insert 트리거를 연결해야 한다.

drop trigger if exists on_auth_user_created on auth.users;

create trigger on_auth_user_created
after insert on auth.users
for each row
execute function public.handle_new_user();

📌 확인 포인트

  • 이 트리거는 auth.users에 새 row가 생길 때마다 실행된다.
  • 결과적으로 public.profiles동일한 id(= auth.users.id) 로 1행이 자동 생성된다.
  • 이미 프로필이 존재하면 on conflict (id) do nothing 때문에 안전하게 스킵된다.

4. 트리거 연결 (Before Update Trigger)

이제 위에서 만든 set_updated_at() 함수를 테이블에 연결했다.
레코드가 수정될 때마다 updated_at 컬럼이 자동으로 갱신되도록 설정한다.
아래는 todosprofiles 테이블에 적용한 예시다.

4-1) 테스트용 테이블 생성

트리거를 연결하려면 테이블이 먼저 존재해야 한다.
아직 todosprofiles가 없다면, 새 탭(+)을 열고 아래 예시를 SQL Editor에 붙여넣어 실행한다.

⚠️ 주의

테이블은 한번 만들어지면 나중에 SQL에서 수정 작업을 해야한다.
수정을 하려면 SQL Editor에서 수정 코드를 통해 테이블을 수정하거나 Table Editor에서 테이블을 수정해야만 한다 컬럼 하나만 바꾼다고 테이블 컬럼이 바뀌지 않는다!

-- profiles 테이블
create table if not exists public.profiles (
  id          uuid primary key default gen_random_uuid(),
  email       text unique,
  nickname    text,
  avatar_url  text,
  created_at  timestamptz not null default now(),
  updated_at  timestamptz not null default now()
);

-- todos 테이블
create table if not exists public.todos (
  id          uuid primary key default gen_random_uuid(),
  user_id     uuid not null,
  title       text not null,
  is_done     boolean not null default false,
  due_date    date,
  created_at  timestamptz not null default now(),
  updated_at  timestamptz not null default now()
);



4-2) 트리거 연결

이제 위에서 만든 함수 set_updated_at()을 테이블에 연결한다.
새 탭(+)을 하나 더 열고 아래 코드를 실행한다.

-- todos 테이블 트리거 연결
drop trigger if exists trg_set_timestamp_todos on public.todos;
create trigger trg_set_timestamp_todos
before update on public.todos
for each row
execute function public.set_updated_at();

-- profiles 테이블 트리거 연결
drop trigger if exists trg_set_timestamp_profiles on public.profiles;
create trigger trg_set_timestamp_profiles
before update on public.profiles
for each row
<execute function public.set_updated_at();

실행 후 “Success. No rows returned” 메시지가 뜨면 완료.
각 테이블마다 한 번씩만 연결해두면 된다.




💬 정리 메모

  • 테이블이 없으면 relation "public.todos" does not exist 에러가 발생한다.
    → 트리거는 항상 테이블 생성 후 연결해야 한다.
  • 앞으로 추가될 테이블(events_daily, events_weekly, notes, habits, dashboard_layouts)에도 동일한 방식으로 연결 예정이다.
  • 트리거 이름은 각 테이블별로 다르게 지정한다.
    (예: trg_set_timestamp_<테이블명>)

5. 테스트 (작동 확인)

이제 자동 갱신이 잘 되는지 테스트를 진행했다.
SQL Editor에서는 로그인 컨텍스트가 없어서 auth.uid()null이 된다.
따라서 실제 유저 UID를 가져와서 user_id에 넣는 방식으로 테스트했다.

  • 0️⃣ 테스트용 UID 하나 가져오기 (Users에 계정이 있어야 함)

    ↑ 결과로 나온 id 값을 아래 1️⃣에 붙여 넣는다.
-- 1️⃣ 새 데이터 추가 (실제 UID 사용)
insert into public.todos (id, user_id, title)
values (gen_random_uuid(), '여기에_위_쿼리에서_가져온_UID', 'trigger-test');

-- 2️⃣ 제목 수정
update public.todos
set title = 'trigger-test-updated'
where title = 'trigger-test';

-- 3️⃣ 결과 확인
select title, created_at, updated_at
from public.todos
order by updated_at desc
limit 1;

✅ 기대 결과(정상 동작)

3️⃣ 결과 확인 쿼리에서 맨 위 1행이 다음처럼 보여야 정상

title                  created_at                  updated_at
--------------------------------------------------------------
trigger-test-updated   2025-10-10 02:40:12+00      2025-10-10 02:41:05+00

판단 포인트

  • titletrigger-test-updated 로 바뀌어 있다.
  • updated_atcreated_at보다 늦은 시각으로 찍힌다(보통 몇 초~몇 십 초 차이).
  • 시간대는 UTC(+00) 로 보이는 게 정상(Supabase 기본).

이 단계 완료 기준

  • pgcrypto 확장 활성화 완료
  • set_updated_at() 함수 생성 완료
  • 트리거 연결 완료 (todos, profiles)
  • updated_at 자동 갱신 확인 (실제 UID로 테스트 통과)



🗃️ 데이터 표(테이블) 만들기

이번 단계에서 실제 서비스용 최종 스키마로 테이블들을 정리한다.
이전 단계에서 만들었던 테스트용 profiles, todos 정의는 이 스키마로 교체한다.
기존에 연결해 둔 트리거와 트리거테스트도 먼저 삭제(drop)한 뒤 재생성해 중복을 방지한다.


1. SQL Editor 열기 (기존 스크립트 열어서 “수정 → 실행”)

  • Supabase 대시보드 → Database → SQL Editor 이동
  • 좌측 PRIVATE 목록에서 전에 저장해 둔 테이블 스크립트(예: Profiles and Todos Tables)을 클릭해 열기
  • 같은 파일 안에서 테이블 정의를 수정하고, 우측 상단 Run으로 실행
  • 하단에 Success ✅ 메시지 확인
  • 변경 이력을 남기고 싶다면 상단 Save같은 파일 저장 또는 새 이름(예: v02_create_tables.sql)으로 저장
    • 팀 작업이라면 버전 넘버를 올려 저장하는 걸 추천
      📘 참고
      새로 + New query로 탭을 늘리는 대신, 기존에 쓰던 테이블 스크립트를 수정/재사용하면 이력 관리가 더 깔끔하고, 실수(중복 생성/충돌)도 줄일 수 있다.

2. 기본 규칙 (공통 컬럼 + 트리거)

모든 테이블은 아래 공통 컬럼을 포함한다

id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
  • id: 고유 식별자 (UUID 자동 생성)
  • user_id: 로그인 사용자와 연결(FK)
  • created_at: 생성 시각
  • updated_at: 수정 시각(트리거 함수 set_updated_at()로 자동 갱신)

예외: profilesuser_id 대신 id가 곧 auth.users(id)를 참조한다(1:1 매칭).

규칙을 통일하면 생기는 장점

  • 모든 테이블에서 공통 속성(created_at, updated_at) 처리 방식이 동일.
  • 프론트엔드에서 타입 추론, 캐싱 로직 통합 가능.
  • RLS(권한 정책) 작성이 쉬워진다 (user_id = auth.uid() 통일).

3. profiles 테이블 만들기 (내 정보)

사용자 정보개인 설정을 저장하는 테이블이다.
로그인한 유저가 자동 생성되며, 닉네임·아바타·환경설정 값 등을 저장한다.

create table if not exists public.profiles (
  id uuid primary key references auth.users(id) on delete cascade,

  -- ✅ 중복 확인/표준 프로필 정보
  email text not null,
  name text,
  profile_image_url text,

  -- ✅ 개인 설정
  settings jsonb not null default '{}'::jsonb,

  -- ✅ 시간
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

-- ✅ 이메일 대소문자 무시 유니크 (중복 가입 방지의 핵심)
create unique index if not exists profiles_email_unique_lower
on public.profiles (lower(email));

-- ✅ updated_at 자동 갱신 트리거(너가 쓰던 방식 유지)
drop trigger if exists trg_set_timestamp_profiles on public.profiles;
create trigger trg_set_timestamp_profiles
before update on public.profiles
for each row execute function public.set_updated_at();

📌 확인 포인트

  • profiles공통 규칙의 예외 테이블이다. 일반 테이블처럼 user_id 컬럼을 두지 않고, id 자체가 auth.users.id를 참조하는 PK이자 FK로 동작한다.
    → 한 사용자당 프로필은 항상 1행(1:1 매칭)만 존재한다.

  • email중복 확인과 조회의 기준 컬럼이다.
    대소문자 차이로 인한 중복 가입을 방지하기 위해 lower(email) 기준의 유니크 인덱스(profiles_email_unique_lower)를 사용한다.

  • profiles로그인(회원가입) 시 자동 생성되는 테이블을 전제로 설계된다.
    이 동작은 auth.users에 대한 트리거로 보장되며, 프로필 생성 누락이 발생하지 않아야 profiles 기반 중복 확인이 정확해진다.

  • settings는 사용자별 환경설정 값을 저장하는 jsonb 컬럼이다.
    테마, 언어, 모듈 순서 등 구조가 자주 바뀌는 개인 설정을 유연하게 관리하기 위해 사용한다.

  • created_at, updated_at전 테이블 공통 규칙을 그대로 따른다.
    updated_atset_updated_at() 트리거에 의해 수정 시 자동 갱신되어야 한다.

4. todos 테이블 만들기 (할 일)

일간 할 일을 저장하는 메인 데이터 테이블이다.

create table if not exists public.todos (
  -- ✅ 공통 규칙: 테이블 고유 PK (자동 UUID)
  id uuid primary key default gen_random_uuid(),

  -- ✅ 공통 규칙: 소유자 FK (RLS에서 user_id = auth.uid()로 통일)
  user_id uuid not null references auth.users(id) on delete cascade,

  -- ✅ 할 일 기본 정보
  title text not null,
  description text,

  -- ✅ 상태: open/done만 허용 (체크 제약으로 값 고정)
  status text not null default 'open' check (status in ('open','done')),

  -- ✅ 우선순위: 1(높음) ~ 5(낮음) 같은 규칙으로 쓰기 좋음 (기본 3)
  priority smallint default 3,

  -- ✅ 마감일 / 완료 시각
  due_date date,
  completed_at timestamptz,

  -- ✅ 공통 규칙: 생성/수정 시각
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

-- ✅ 공통 규칙: updated_at 자동 갱신 트리거
drop trigger if exists trg_set_timestamp_todos on public.todos;
create trigger trg_set_timestamp_todos
before update on public.todos
for each row execute function public.set_updated_at();

📌 확인 포인트

  • title은 필수, description은 선택.
  • statusopen/done만 허용.
  • 완료 시 completed_at 기록.
  • 수정 시 updated_at 자동 갱신 확인.

5. events_daily 테이블 (일간 일정)

하루 단위 일정을 관리한다.

create table if not exists public.events_daily (
  -- ✅ 공통 규칙: 테이블 고유 PK (자동 UUID)
  id uuid primary key default gen_random_uuid(),

  -- ✅ 공통 규칙: 소유자 FK (RLS에서 user_id = auth.uid()로 통일)
  user_id uuid not null references auth.users(id) on delete cascade,

  -- ✅ 일정 기본 정보
  title text not null,
  notes text,

  -- ✅ 종일 일정 여부
  all_day boolean not null default false,

  -- ✅ 일정 시간 범위 (시작은 필수, 종료는 선택)
  -- start_ts: 일정 시작 시각
  -- end_ts: 일정 종료 시각(없으면 start_ts 기준 단일 시점 일정으로 취급 가능)
  start_ts timestamptz not null,
  end_ts timestamptz,

  -- ✅ 공통 규칙: 생성/수정 시각
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

-- ✅ 공통 규칙: updated_at 자동 갱신 트리거
drop trigger if exists trg_set_timestamp_events_daily on public.events_daily;
create trigger trg_set_timestamp_events_daily
before update on public.events_daily
for each row execute function public.set_updated_at();

📌 확인 포인트

  • all_day = true이면 시작·종료 시간을 무시하고 하루 전체 일정으로 처리.
  • start_ts / end_ts는 UTC 기준 저장.

6. events_weekly 테이블 (주간 일정)

요일 기반의 반복 일정을 관리한다.

create table if not exists public.events_weekly (
  -- ✅ 공통 규칙: 테이블 고유 PK (자동 UUID)
  id uuid primary key default gen_random_uuid(),

  -- ✅ 공통 규칙: 소유자 FK (RLS에서 user_id = auth.uid()로 통일)
  user_id uuid not null references auth.users(id) on delete cascade,

  -- ✅ 주간 일정 기본 정보
  title text not null,
  notes text,

  -- ✅ 기준 주(week) 정보
  -- week_start: 해당 주의 시작일(예: 월요일). 이 값을 기준으로 같은 주를 그룹핑한다.
  week_start date not null,

  -- ✅ 요일 + 시간 정보
  -- day_of_week: 요일(정수). 프로젝트 규칙을 반드시 고정해두는 게 중요하다.
  -- 예: 0=일 ~ 6=토 (또는 1=월 ~ 7=일) 중 하나로 통일 필요
  day_of_week int not null,

  -- start_time / end_time: 그 날의 시간 범위 (주간 템플릿/반복 일정에 적합)
  start_time time not null,
  end_time time not null,

  -- ✅ 공통 규칙: 생성/수정 시각
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

-- ✅ 공통 규칙: updated_at 자동 갱신 트리거
drop trigger if exists trg_set_timestamp_events_weekly on public.events_weekly;
create trigger trg_set_timestamp_events_weekly
before update on public.events_weekly
for each row execute function public.set_updated_at();

📌 확인 포인트

  • week_start: 해당 주 시작일(보통 월요일).
  • day_of_week: 1~7 (월~일).
  • 반복 일정 관리에 활용 가능.

7. events_monthly 테이블 (월간 일정)

create table if not exists public.events_monthly (
  -- ✅ 공통 규칙: 테이블 고유 PK (자동 UUID)
  id uuid primary key default gen_random_uuid(),

  -- ✅ 공통 규칙: 소유자 FK (RLS에서 user_id = auth.uid()로 통일)
  user_id uuid not null references auth.users(id) on delete cascade,

  -- ✅ 월간 일정 기본 정보
  title text not null,
  notes text,

  -- ✅ 날짜(월간 캘린더용 분해 컬럼)
  -- year/month/day를 분리해두면 "YYYY-MM" 단위 조회/그룹핑이 쉬워진다.
  year int not null,
  month int not null check (month between 1 and 12),
  day int not null check (day between 1 and 31),

  -- ✅ 시간(선택)
  -- start_time/end_time이 null이면 '하루 종일/시간 미정' 일정으로 취급 가능
  start_time time,
  end_time time,

  -- ✅ 공통 규칙: 생성/수정 시각
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

-- ✅ 공통 규칙: updated_at 자동 갱신 트리거
drop trigger if exists trg_set_timestamp_events_monthly on public.events_monthly;
create trigger trg_set_timestamp_events_monthly
before update on public.events_monthly
for each row execute function public.set_updated_at();

📌 확인 포인트

  • year + month + day로 날짜 관리.
  • 월별 달력에서 표시되는 일정 데이터를 저장.

8. habits 테이블 (습관)

사용자의 루틴 및 습관 데이터를 저장한다.

create table if not exists public.habits (
  -- ✅ 공통 규칙: 테이블 고유 PK (자동 UUID)
  id uuid primary key default gen_random_uuid(),

  -- ✅ 공통 규칙: 소유자 FK (RLS에서 user_id = auth.uid()로 통일)
  user_id uuid not null references auth.users(id) on delete cascade,

  -- ✅ 습관 기본 정보
  name text not null,

  -- ✅ 반복 주기
  -- daily: 매일
  -- weekly: 매주
  -- custom: 커스텀 규칙(예: 주 3회, 격일 등) → 추후 별도 필드/테이블로 확장 가능
  frequency text not null check (frequency in ('daily','weekly','custom')),

  -- ✅ 기간당 목표 횟수(예: daily=1회, weekly=3회 등)
  target_per_period int default 1,

  -- ✅ UI 표시용 컬러(토큰/HEX 등 자유롭게 사용)
  color text,

  -- ✅ 공통 규칙: 생성/수정 시각
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

-- ✅ 공통 규칙: updated_at 자동 갱신 트리거
drop trigger if exists trg_set_timestamp_habits on public.habits;
create trigger trg_set_timestamp_habits
before update on public.habits
for each row execute function public.set_updated_at();

📌 확인 포인트

  • frequency: daily/weekly/custom 중 선택.
  • color: 습관별 구분 색상 코드 저장.
  • 추후 달성률 계산 및 리마인더 기능과 연결될 예정.

9. notes 테이블 (메모)

간단한 텍스트 메모를 저장한다.

create table if not exists public.notes (
  -- ✅ 공통 규칙: 테이블 고유 PK (자동 UUID)
  id uuid primary key default gen_random_uuid(),

  -- ✅ 공통 규칙: 소유자 FK (RLS에서 user_id = auth.uid()로 통일)
  user_id uuid not null references auth.users(id) on delete cascade,

  -- ✅ 노트 기본 정보
  -- title: 선택(없으면 내용 기반으로 리스트에서 요약 표시 가능)
  title text,
  content text not null,

  -- ✅ 태그(다중)
  -- text[]로 간단히 시작하고, 추후 검색/분석이 필요하면 별도 tags 테이블로 확장 가능
  tags text[] not null default '{}'::text[],

  -- ✅ 공통 규칙: 생성/수정 시각
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

-- ✅ 공통 규칙: updated_at 자동 갱신 트리거
drop trigger if exists trg_set_timestamp_notes on public.notes;
create trigger trg_set_timestamp_notes
before update on public.notes
for each row execute function public.set_updated_at();

📌 확인 포인트

  • content는 필수.
  • tags는 배열 형태(예: ['idea','work']).
  • 추후 검색 기능에서 활용 예정.

10. dashboard_layouts 테이블 (대시보드 배치)

사용자가 설정한 모듈 위치 및 상태를 저장한다.

create table if not exists public.dashboard_layouts (
  -- ✅ 공통 규칙: 테이블 고유 PK (자동 UUID)
  id uuid primary key default gen_random_uuid(),

  -- ✅ 공통 규칙: 소유자 FK (RLS에서 user_id = auth.uid()로 통일)
  user_id uuid not null references auth.users(id) on delete cascade,

  -- ✅ 대시보드 레이아웃 데이터(모듈 배치/사이즈/순서 등)
  -- jsonb로 저장해 유연하게 구조 변경 가능
  layout jsonb not null,

  -- ✅ 활성 레이아웃 여부
  -- 유저가 여러 레이아웃(프리셋/히스토리)을 가질 수 있어 true 하나만 활성화되도록 설계 가능
  is_active boolean not null default true,

  -- ✅ 레이아웃 스키마/구조 버전
  -- 레이아웃 구조가 변경될 때 마이그레이션/호환 처리에 사용
  version int not null default 1,

  -- ✅ 공통 규칙: 생성/수정 시각
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

-- ✅ 공통 규칙: updated_at 자동 갱신 트리거
drop trigger if exists trg_set_timestamp_dashboard_layouts on public.dashboard_layouts;
create trigger trg_set_timestamp_dashboard_layouts
before update on public.dashboard_layouts
for each row execute function public.set_updated_at();

📌 확인 포인트

  • layout: 모듈 위치 및 구성(JSON).
  • is_active = true는 유저당 하나만 유지 (추후 인덱스로 제약 예정).

🔍 점검하기 (Smoke Test)

테이블 생성이 모두 완료됐는지, 그리고 각 테이블이 정상 작동하는지 두 가지 방법으로 점검했다.
1️⃣ 테이블 존재 여부 확인
2️⃣ 각 테이블의 행 수 확인

⚠️ 실행 방법 주의
SQL Editor는 마지막 SELECT만 결과창에 표시된다.
① 존재 확인과 ② 행 수 확인은 각각 따로 실행해야한다!.
방법:
1. 쿼리를 별도 탭/파일로 나눠 실행, 또는
2. 실행할 구문만 드래그 선택 → Run.

1️⃣ 테이블 존재 여부 확인

select 
  to_regclass('public.profiles')          as profiles,
  to_regclass('public.todos')             as todos,
  to_regclass('public.events_daily')      as events_daily,
  to_regclass('public.events_weekly')     as events_weekly,
  to_regclass('public.events_monthly')    as events_monthly,
  to_regclass('public.habits')            as habits,
  to_regclass('public.notes')             as notes,
  to_regclass('public.dashboard_layouts') as dashboard_layouts;

📌 확인 포인트

  • 결과값이 public.테이블명이면 정상 생성됨.
  • NULL이 나온다면 아직 생성되지 않은 테이블이 있음.



2️⃣ 각 테이블의 행 수 확인

select 'profiles' as table, count(*) as rows from public.profiles
union all
select 'todos', count(*) from public.todos
union all
select 'events_daily', count(*) from public.events_daily
union all
select 'events_weekly', count(*) from public.events_weekly
union all
select 'events_monthly', count(*) from public.events_monthly
union all
select 'habits', count(*) from public.habits
union all
select 'notes', count(*) from public.notes
union all
select 'dashboard_layouts', count(*) from public.dashboard_layouts;

📌 확인 포인트

  • 각 테이블의 rows 값이 0 이상이면 정상 (데이터는 없어도 괜찮음).
  • 이후 테스트 데이터를 넣으면 updated_at 자동 갱신이 정상 작동하는지도 함께 확인.


이 단계 완료 기준

  • 기존 스크립트 파일에서 테이블 정의를 수정 → 실행 완료
  • 테스트 스키마를 최종 스키마로 교체
  • 기존 트리거 삭제 후 재생성 완료
  • Smoke Test 통과 (updated_at 자동 변경 확인)

0개의 댓글