
Supabase로 만든 데이터베이스를 프론트엔드에서 직접 다루기 위해
API Layer를 구축했다.
이 단계에서는 Swagger 문서 확인 → axios 세팅 → 공통 클라이언트 구성 → 기능별 API 분리 → 스모크 테스트까지 진행했다.
API 레이어는 백엔드와 프론트엔드 사이의 중간 계층으로,
데이터 요청과 응답을 일관된 방식으로 관리하는 구조를 말한다.
즉, 백엔드가 제공하는 여러 API들을 프론트엔드에서 쉽게 재사용할 수 있도록
axios 설정, 에러 처리, 타입 정의, 도메인별 함수 분리를 체계화하는 작업이다.
이 레이어가 잘 구성되어 있으면
- API 호출 방식이 통일되고,
- 에러/응답 구조가 표준화되며,
- React Query 등 데이터 캐싱 도구와 연동하기도 훨씬 쉬워진다.
API에 대해서 잘 모른다면 아래 글을 읽고 확실히 한다음 넘어가자!
API란?
API 연결 방법 (자세히 나와 있어서 꼭 봐야함!!!)
Supabase는 데이터베이스 스키마를 기반으로 자동으로
RESTful API를 생성한다.
이 API를 확인하는 방법은 두 가지가 있다.
1. 스웨거(OpenAPI) 문서로API를 확인할 수 있고
2. 대시보드 API Docs(REST) 화면에서 바로API를 확인할 수도 있다.
나는 스웨거(OpenAPI)를 통해서 할 예정이다. 이 과정은 기존 벡엔드와는 조금 다르다.⚠️🚨
RLS때문인지 계속해서 조회를 못해서 2번째 방법으로 갈 것이다,,,,, 😢
스웨거(OpenAPI)는 Supabase의 REST API 스펙을 JSON/YAML 파일 형태로 추출해서
Swagger UI, Redoc, Insomnia 같은 외부 도구에서 시각적으로 확인할 수 있는 방법이다.
전체 API 구조를 한눈에 보고 싶을 때, 또는 팀 문서화/공유용으로 유용하다.
자세한 설정 절차는 아래 글을 참고하면 된다.
👉 supabase로 스웨거 문서 뽑기
1️⃣ Project URL과 anon key 복사하기
Supabase 대시보드 → Project Settings → API 에서 아래 두 값을 찾는다.
위치 항목 설명 Project Settings → API → Data API Project URL REST 요청의 기본 주소 ( https://<project-ref>.supabase.co)Project Settings → API → API Keys anon public key 브라우저에서 사용 가능한 공개 키 (⚠️ service_role은 서버 전용) .
2️⃣ 환경변수 등록 & OpenAPI 스펙 추출
아래 명령어는 터미널이나 iterm을 통해서 실행하면 된다.
다운로드를 하는 것이라서 나는desktop에 먼저 접근을 한 다음 명령어를 실행할 것이다!💻 macOS / Linux (터미널)
# 0) desktop 접근 cd desktop # 1) 환경변수 설정 export SUPABASE_URL="https://<project-ref>.supabase.co" export SUPABASE_ANON_KEY="<your-anon-key>" # 2) OpenAPI 스펙 다운로드 curl -s "${SUPABASE_URL}/rest/v1/" \ -H "apikey: ${SUPABASE_ANON_KEY}" \ -H "Authorization: Bearer ${SUPABASE_ANON_KEY}" \ -H "Accept: application/openapi+json" \ -o openapi.json # 3) (선택) jq 설치되어 있다면 간단 검증 jq '.openapi, .info.title' openapi.json
📁 실행 결과
→ 현재 폴더(desktop)에openapi.json파일이 생성된다.
⚠️ openapi.json 다운로드 위치
위에서는
desktop에 다운로드를 했지만 정작 여는 곳은 프로젝트 안에서 열기 때문에
프로젝트 안에openapi.json을 다운로드 받는게 좋다!
반드시 다운로드 받고.gitignore에openapi.json이Github에 올라가지 않도록 추가해주어야한다!!!!
3️⃣ 변경 생길 때
openapi.json자동 갱신(덮어쓰기)Supabase에서 테이블/컬럼/RLS가 바뀌면
openapi.json을 다시 추출해 덮어써야 한다.
아래 스크립트를 프로젝트에 추가해두면 한 줄로 갱신할 수 있다.⚠️ 전제: 2️⃣에서 설정한
SUPABASE_URL,SUPABASE_ANON_KEY환경변수가 현재 셸에 로드되어 있어야 한다.
(export …를 다시 실행하거나,direnv/dotenv같은 도구로 자동 로드해도 된다)📦
package.json스크립트 추가{ "scripts": { "openapi:pull": "curl -s \"$SUPABASE_URL/rest/v1/\" -H \"apikey: $SUPABASE_ANON_KEY\" -H \"Authorization: Bearer $SUPABASE_ANON_KEY\" -H \"Accept: application/openapi+json\" -o openapi.json", "openapi:watch": "npx swagger-ui-watcher ./openapi.json" } }💻 사용 예시
# DB 스키마 수정 후, 최신 스펙으로 덮어쓰기 pnpm openapi:pull # (선택) 바로 UI로 확인 pnpm openapi:watch.
4️⃣ Swagger UI — 자동 서버
🧩 실행 명령
# openapi.json 이 있는 폴더에서 실행 npx swagger-ui-watcher ./openapi.json
- Swagger UI 페이지가 자동으로 열리며,
openapi.json이 변경되면 화면이 자동 리로드된다.
🔧 옵션(필요 시)
# 포트 변경 npx swagger-ui-watcher ./openapi.json --port 5173 # 호스트 지정(다른 기기에서 접근할 때) npx swagger-ui-watcher ./openapi.json --host 0.0.0.0.
⚠️ Swagger Editor
enum오류 해결스웨거 문서를 Import할 때 아래와 같은 오류가 뜰 수 있다.
이건 Supabase가 생성한
openapi.json안에 빈 enum([]) 이 포함되어 있기 때문이다.
Swagger는 빈 배열을 허용하지 않아 “should NOT have less than 1 items” 오류를 낸다.✅ 삭제해도 Supabase API 동작에는 전혀 영향 없음.
enum은 단지 문서 검증용 제약이기 때문에 실제 서버에는 아무 변화가 없다.💻 macOS / Linux (jq 사용)
아래 명령을 openapi.json이 있는 폴더에서 실행한다. (프로젝트 폴더에 접근해서 명령어 실행하면 됨!)
# 빈 enum 전체 삭제 (openapi.json 덮어쓰기) jq ' # 명시적으로 문제가 되는 경로 먼저 제거 del( .components.parameters.preferParams.schema.enum?, .parameters.preferParams.enum? ) | # 파일 전체 순회하며 "enum: []" 패턴 삭제 def walk(f): . as $in | if type=="object" then reduce keys[] as $k ({}; . + { ($k): ($in[$k] | walk(f)) }) | f elif type=="array" then map(walk(f)) | f else f end; walk( if (type=="object" and has("enum") and (.enum|type)=="array" and (.enum|length)==0) then del(.enum) else . end ) ' openapi.json > tmp.json && mv tmp.json openapi.json
🧩 실행 후 확인
이제 Swagger Editor 또는
swagger-ui-watcher로 다시 열어본다.npx swagger-ui-watcher ./openapi.json정상적으로 열린다면 ✅ 완료!
더 이상parameters.preferParams.enum에러는 뜨지 않는다.
🧰 다음에도 쉽게 쓰려면 (선택)
package.json에 아래 스크립트를 추가해 두면
매번 명령어를 기억하지 않아도 한 줄로 바로 처리할 수 있다.{ "scripts": { "openapi:fix": "jq 'del(.components.parameters.preferParams.schema.enum?, .parameters.preferParams.enum?) | def walk(f): . as $in | if type==\"object\" then reduce keys[] as $k ({}; . + { ($k): ($in[$k] | walk(f)) }) | f elif type==\"array\" then map(walk(f)) | f else f end; walk(if (type==\"object\" and has(\"enum\") and (.enum|type)==\"array\" and (.enum|length)==0) then del(.enum) else . end)' openapi.json > openapi.json.tmp && mv openapi.json.tmp openapi.json", } }실행
pnpm openapi:fix && pnpm openapi:watch.
✔️ 정리
- 왜 에러? Swagger가 빈 enum(
[])을 허용하지 않음.- 어떻게 해결? 빈 enum만 삭제하고
openapi.json을 그대로 사용(덮어쓰기).- 영향은? 서버/API 동작엔 영향 없음(문서 검증만 통과).
- 팁:
jq가 설치되어 있지 않다면brew install jq로 간단히 설치할 수 있다.
5️⃣ 스웨거 문서 확인 및 탐색
Swagger UI가 정상적으로 실행되면 아래와 같은 화면이 나타난다 👇
좌측에는 Supabase 테이블별 엔드포인트 목록이,
우측에는 각 메서드(GET,POST,PATCH,DELETE)별
요청·응답 스키마, 쿼리 파라미터, 요청 본문 형식이 표시된다.
✅ 확인 포인트
항목 설명 Base URL https://<project-ref>.supabase.co/rest/v1공통 헤더 apikey,Authorization: Bearer <anon-key>엔드포인트 목록 각 테이블별로 자동 생성 ( /todos,/profiles,/dashboard_layouts등)요청/응답 스키마 컬럼 이름, 타입, Nullable 여부 등 DB 구조가 그대로 반영됨 💡 활용 팁
- 엔드포인트를 클릭하면 “Try it out” 버튼으로 직접 요청을 보낼 수도 있다.
GET요청 시?select=*파라미터를 붙이면 모든 컬럼을 조회할 수 있다.PATCH와POST는 요청 본문에 JSON 예시를 넣고 테스트 가능하다.
6️⃣ 테스트 — 실제 데이터 호출 및 자동 반영 확인
🧩 1️⃣ Supabase 최신 CORS 정책 확인
2025년 기준 Supabase에서는 더 이상 “Allowed Origins” 항목이 제공되지 않는다.
즉, Swagger UI가 다른 출처(예: localhost:8080) 에서 요청하면 차단된다.
이 문제는 Swagger의 “Authorize” 기능 또는 로컬 프록시(local-cors-proxy) 로 해결할 수 있다.
(나는 둘 다 할 예정!)
🧩 2️⃣ Swagger 인증 헤더 설정 (Authorize 버튼 추가)
기본적으로 Supabase에서 추출한
openapi.json(OAS 2.0)에는 보안 스키마가 빠져 있어서
상단의 🔒 Authorize 버튼이 나타나지 않는다. 아래 블록을 루트에 병합하면 버튼이 생긴다.{ "securityDefinitions": { "apikey": { "type": "apiKey", "name": "apikey", "in": "header", "description": "Supabase anon public key" }, "bearerAuth": { "type": "apiKey", "name": "Authorization", "in": "header", "description": "Format: Bearer <access_token> (Supabase Auth user session)" } }, "security": [ { "apikey": [] }, { "bearerAuth": [] } ] }수정 후 Swagger를 다시 실행한다
pnpm openapi:watch화면 오른쪽 상단에 Authorize 🔒 버튼이 보이면 정상.
클릭해서 아래 값 입력👇apikey: <YOUR_ANON_KEY> Authorization: Bearer <YOUR_ACCESS_TOKEN>
⚠️
service_role키는 서버 전용이므로 사용 금지.
⚠️ 대부분의 테이블은 RLS가 적용되므로 anon key만으로는 접근이 안 된다 → 로그인으로 받은 access_token을 넣어야 한다.
(access_token받는 방법은 아래에 👇)
💡 RLS가 없는 공개 리소스만 조회할 땐Authorization없이apikey만으로도 동작할 수 있다.
❌ 왜 Supabase 대시보드에서 이 설정을 직접 넣을 수 없을까?
Supabase는
openapi.json을 PostgREST 엔진을 통해 데이터베이스 스키마를 자동으로 분석해서 생성한다.
즉, 테이블·컬럼·RLS 정책 같은 DB 구조 정보만 포함하고,
securitySchemes,servers,info같은 문서 메타데이터는 반영되지 않는다.
🔍 Supabase가 뽑아주는 OpenAPI 문서는
“DB 구조 스냅샷”이지 “완전한 커스터마이즈 가능한 API 문서”가 아니다.
따라서 대시보드에서 어떤 설정을 추가하더라도
다시openapi.json을 추출하면 그 내용은 모두 덮어씌워진다.
즉, Supabase 내부에서 보안 스키마를 “저장”하거나 “유지”하는 기능이 없다.
🧩 3️⃣ Swagger Try it out이 CORS로 막힐 때 → 프록시 실행
Swagger를 로컬에서 실행할 때 브라우저 CORS에 막히면 로컬 CORS 프록시로 우회한다.
1) 프록시 실행# 8010 포트에서 프록시 실행 (원격은 Supabase REST) npx -y local-cors-proxy --port 8010 \ --proxyUrl https://<project-ref>.supabase.co/rest/v12)
openapi.json을 OAS2 방식으로 패치
OAS2에서는servers가 없다 →host/basePath/schemes를 써야 함.# servers 제거 + host/basePath/schemes 설정 (프록시 주소로) jq ' del(.servers) | .host = "localhost:8010" | .basePath = "/proxy/" | .schemes = ["http"] ' openapi.json > openapi.json.tmp && mv openapi.json.tmp openapi.json
- 프록시 포트를 바꿨다면
localhost:8010을 맞게 수정.- 만약 과거에
servers를 넣어뒀다면 반드시 제거해야 한다(에러 원인).
3) Swagger 재실행pnpm openapi:watch4) Authorize로 키 입력 후 → Try it out → Execute
이제 200 OK로 응답이 와야 정상 ✅
🧩 4️⃣ 기존 데이터 조회 테스트 (Try it out)
⚠️ 반드시 프록시(
localhost:8010)가 실행 중이어야 한다!
Swagger UI는8081에서, 프록시는8010에서 Supabase로 요청을 중계한다.
이 설정이 안 되어 있으면 CORS 오류 또는 빈 응답이 발생한다.🔑 액세스 토큰(access_token) 얻기
Supabase API는 RLS(행 수준 보안)가 기본 적용되어 있어서
익명 키(anon key) 만으로는 대부분의 테이블에 접근할 수 없다.
테스트하려면 사용자 로그인 후 받은 access_token이 필요하다.1️⃣ 테스트용 유저 만들기
Supabase 대시보드 → Authentication → Users → Add user → Create new user
- 이메일 / 비밀번호 입력
- ✅ Auto confirm user 체크 후 저장
(이미 유저가 있다면 이 단계는 생략)
2️⃣ 로그인하여 토큰 받기
터미널에서 아래 명령 실행
PROJECT_URL="https://<project-ref>.supabase.co" ANON_KEY="<YOUR_ANON_KEY>" EMAIL="test@example.com" PASSWORD="your-password" curl -s -X POST "${PROJECT_URL}/auth/v1/token?grant_type=password" \ -H "apikey: ${ANON_KEY}" \ -H "Content-Type: application/json" \ -d "{\"email\":\"${EMAIL}\",\"password\":\"${PASSWORD}\"}"→ 응답 JSON의
access_token값을 복사한다.
3️⃣ Swagger Authorize에 입력
apikey: <YOUR_ANON_KEY> Authorization: Bearer <YOUR_ACCESS_TOKEN>이제 Swagger에서 RLS가 걸린 테이블에도 접근 가능하다 ✅
💻 실행 순서
1️⃣ Authorize 클릭 → 키 입력
apikey: anon keyAuthorization: Bearer + access_token
2️⃣GET /todos엔드포인트 선택- 오른쪽 상단 → Try it out → Execute
3️⃣ 쿼리 파라미터에select=*추가- 요청 URL 예시:
4️⃣ Execute → 응답 확인http://localhost:8010/proxy/todos?select=*
✅ 정상 응답 예시
[ { "id": "d176b460-c52b-4611-b71c-94b3f3bed4a", "user_id": "5f84d13f-e4e9-4e57-85c9-28a17bb4d234", "title": "A의 할 일", "is_done": false, "created_at": "2025-10-11T09:14:23+00:00" } ]⚠️ 주의: RLS 정책 때문에 Swagger에서는 실제 데이터를 불러오지 못할 수 있다.
이 경우 아래 “방법 B — 대시보드 API Docs(REST)”로 바로 확인하는 편이 더 빠르다.
Supabase는 데이터베이스 스키마를 기반으로
REST API를 자동 생성하고 문서화한다.
이 문서는 대시보드의 API Docs(REST) 화면에서 바로 확인할 수 있다.
Swagger, Postman 같은 도구를 쓰지 않아도 모든 테이블과 요청 구조를 한눈에 볼 수 있다.1️⃣ 위치
Supabase 대시보드 → 좌측 메뉴 → API Docs → (GETTING STARTED / TABLES AND VIEWS)
왼쪽 사이드바에는 두 개의 주요 섹션이 있다.
GETTING STARTED: 프로젝트 설정과 인증 구조TABLES AND VIEWS: 각 테이블별 REST API 문서
2️⃣
GETTING STARTED— 기본 설정 및 인증 구조(1) Introduction
“프로젝트 연결”에 대한 개요가 나와 있다.
여기에서 Supabase REST API의 기본 엔드포인트(https://<project-ref>.supabase.co/rest/v1)와
클라이언트 초기화 코드(createClient) 예시를 확인할 수 있다.
(2) Authentication
REST 요청에서 사용하는 인증 방식이 정리되어 있다.
Authorization헤더가 없으면 익명 사용자로 요청됨Authorization: Bearer <access_token>이 포함되면 로그인 사용자로 권한이 전환됨Client API Key(
anon key)와 Service Key(service_role)의 차이도 명시되어 있다.
⚠️service_role키는 서버 전용이며 브라우저에서는 절대 사용하지 않는다.
(3) User Management
사용자 계정 관련 기본 API 흐름이 나온다.
signUp()— 회원가입signInWithPassword()— 로그인signOut()— 로그아웃- 등등... 여러가지
각 요청 예시가 JavaScript 코드로 제공되어 있어서
별도 문서 없이도 바로 테스트 가능한 구조다.
3️⃣
TABLES AND VIEWS— 테이블별 REST API 구조이 섹션에서는 프로젝트 내 모든 테이블과 뷰의 REST 엔드포인트를 확인할 수 있다.
(예:todos,profiles,habits,notes, …)(1) Introduction
- 모든 테이블은 기본적으로
public스키마에서 노출된다.- 특정 테이블을 API에 포함하고 싶지 않다면
public이 아닌 다른 스키마로 이동시키면 된다.Generate and download types버튼을 통해 TypeScript 타입 정의 파일(.d.ts)을 즉시 내려받을 수 있다.
(2) 개별 테이블 문서 (예:
todos)
- 각 컬럼의
name,type,format,description이 자동 표시된다.- 오른쪽에는 해당 컬럼을 가져오는 코드 예시가 JavaScript로 자동 생성된다.
✅ DB 스키마 변경 시, 이 문서도 자동으로 업데이트된다.
즉, 테이블 구조가 바뀔 때마다 API 문서도 실시간으로 갱신된다.
프론트엔드에서 Supabase REST 호출을 단순화하기 위해
axios를 설치한다.pnpm add axios💬 확인
package.json의 dependencies에axios,@supabase/supabase-js가 추가되어 있는지 확인한다.
(@supabase/supabase-js는 이전에 설치해 놨었음)
프로젝트 URL, anon key, timeout 등 런타임 의존 값은
.env로 분리한다.
브라우저에서 접근 가능한 값이므로NEXT_PUBLIC_접두사를 붙인다.📄
.envNEXT_PUBLIC_SUPABASE_URL="https://<project-ref>.supabase.co" NEXT_PUBLIC_SUPABASE_ANON_KEY="<your-anon-key>" NEXT_PUBLIC_API_TIMEOUT="10000"💬 주의
.env*는 커밋하지 않는다(대신.env.example은 빈 값으로 커밋).NEXT_PUBLIC_*값은 클라이언트에 노출됨 → 민감정보 금지(특히service_role절대 X).- 배포 환경(Vercel 등)에도 동일하게 등록.
axios인스턴스를 한 곳에서 관리해 baseURL(/rest/v1), 헤더(apikey/Authorization), 타임아웃, 에러 표준화를 통일한다.
Supabase SDK로부터 현재 세션의access_token을 얻어 필요할 때만 Authorization을 자동 주입한다.📂 파일 경로:
src/api/client.ts// src/api/client.ts import { createClient } from "@supabase/supabase-js"; import axios, { AxiosHeaders } from "axios"; // 1) Supabase Auth 전용 클라이언트(세션/토큰 자동 관리) export const supabaseAuth = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { auth: { persistSession: true, autoRefreshToken: true } }, ); // 2) Supabase REST 호출용 axios const baseURL = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1`; const timeout = Number(process.env.NEXT_PUBLIC_API_TIMEOUT || 10000); export const api = axios.create({ baseURL, timeout, headers: new AxiosHeaders({ "Content-Type": "application/json" }), }); // 3) 요청 인터셉터: apikey + (로그인 시) Authorization 자동 주입 api.interceptors.request.use(async (config) => { const anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { data: { session }, } = await supabaseAuth.auth.getSession(); const access = session?.access_token; const headers = AxiosHeaders.from(config.headers); headers.set("apikey", anon); if (access) headers.set("Authorization", `Bearer ${access}`); // 필요한 기본값이 있다면 여기서 추가로: // headers.set("Accept", "application/json"); config.headers = headers; return config; }); // 4) 응답/에러 표준화 api.interceptors.response.use( (res) => res, (err) => { const status = err?.response?.status; const message = err?.response?.data?.message || err?.message || "Unexpected error"; return Promise.reject({ status, message, raw: err }); }, );👇 코드 설명 (무엇을, 왜)
- Supabase Auth 전용 클라이언트
export const supabaseAuth = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { auth: { persistSession: true, autoRefreshToken: true } }, );
- 목적: 브라우저에서 로그인/세션/토큰을 자동 관리.
- 효과: 새로고침 후에도 세션 유지, 만료 직전에 access_token 자동 갱신.
- 우리가 쓰는 부분: 매 요청 직전에
auth.getSession()으로 현재 access_token만 꺼내서 axios 헤더에 붙인다.
- axios 인스턴스 (Supabase REST 전용)
const baseURL = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1`; const timeout = Number(process.env.NEXT_PUBLIC_API_TIMEOUT || 10000); export const api = axios.create({ baseURL, timeout, headers: new AxiosHeaders({ "Content-Type": "application/json" }), });
- baseURL: Supabase REST의 루트는 항상
/rest/v1. 모든 테이블 엔드포인트에 공통 적용.- timeout: 네트워크 이슈로 무한 대기 방지.
- 왜
AxiosHeaders? Axios v1에서headers는 클래스 타입이므로, 평범한 객체로 덮어쓰면 타입 에러. 초기값도AxiosHeaders로 생성해야 안전하다.
- 요청 인터셉터 (헤더 자동 주입)
api.interceptors.request.use(async (config) => { const anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { data: { session } } = await supabaseAuth.auth.getSession(); const access = session?.access_token; const headers = AxiosHeaders.from(config.headers); // 안전 병합 headers.set("apikey", anon); // ✅ 항상 주입 if (access) headers.set("Authorization", `Bearer ${access}`); // ✅ 로그인 시만 // headers.set("Accept", "application/json"); // 필요 시 추가 config.headers = headers; return config; });
- 왜 인터셉터? 각 모듈에서 헤더를 신경 쓰지 않아도 중앙에서 일관되게 붙이기 위함.
- 정책:
apikey는 항상,Authorization은 로그인된 경우에만 자동 주입.- RLS 기본: 로그인 전엔 많은 테이블에서 빈 결과/403이 나와도 정상 동작이다.
- 응답/에러 표준화
api.interceptors.response.use( (res) => res, (err) => { const status = err?.response?.status; const message = err?.response?.data?.message || err?.message || "Unexpected error"; return Promise.reject({ status, message, raw: err }); }, );
- 목적: 화면/훅에서
try/catch로 받을 때 에러 모양을 통일.- 이점:
status,message만으로 사용자 메시지/분기 처리가 쉬워진다.
Supabase 대시보드에서 프로젝트 스키마를 바탕으로 TS 타입 정의 파일을 생성한다.
이 파일은 모든 테이블의Row/Insert/Update타입과 유틸 제네릭(Tables,TablesInsert,TablesUpdate)을 포함한다.
📂 파일 경로src/api/database.d.ts // 대시보드에서 받은 자동 생성 파일📦 다운로드 경로
Supabase 대시보드 → API Docs → TABLES AND VIEWS → Introduction → Generate and download types
💬 리프레시 규칙
DB 스키마가 변경될 때마다 이 파일을 다시 다운로드해 덮어쓴다.
💡 왜 필요한가
- 컴파일 타임 검증: 컬럼 오타, 잘못된 타입을 빌드 단계에서 바로 잡는다.
- DB–코드 싱크 자동화: 스키마가 바뀌면 타입도 즉시 반영된다.
- REST/SDK 모두 호환: 백엔드 접근 방식이 바뀌어도 타입 안전성 유지.
무엇을 하나?
자동 생성 타입에서 자주 쓰는 제네릭(
Tables,TablesInsert,TablesUpdate)을 도메인 친화적 별칭으로 묶어 쓴다.
왜 필요한가?
- 가독성↑:
Tables<"todos">대신Todo로 읽힌다.- 반복 제거: API/컴포넌트 곳곳에서 동일 제네릭을 매번 적지 않아도 된다.
- 의도 드러남:
TodoInsert,TodoUpdate처럼 용도를 이름에서 바로 알 수 있다.
예시// src/api/types/domain.ts // ⚠️ 네 파일명이 database.d.ts 이므로 import 경로는 "..d/database" (확장자 X) import type { Tables, TablesInsert, TablesUpdate } from "../database"; // ── todos ───────────────────────────────────────────── export type Todo = Tables<"todos">; export type TodoInsert = TablesInsert<"todos">; export type TodoUpdate = TablesUpdate<"todos">; // ── dashboard_layouts ──────────────────────────────── export type DashboardLayout = Tables<"dashboard_layouts">; export type DashboardLayoutInsert = TablesInsert<"dashboard_layouts">; export type DashboardLayoutUpdate = TablesUpdate<"dashboard_layouts">; // ── notes ──────────────────────────────────────────── export type Note = Tables<"notes">; export type NoteInsert = TablesInsert<"notes">; export type NoteUpdate = TablesUpdate<"notes">; // ── habits ─────────────────────────────────────────── export type Habit = Tables<"habits">; export type HabitInsert = TablesInsert<"habits">; export type HabitUpdate = TablesUpdate<"habits">; // ── profiles ───────────────────────────────────────── export type Profile = Tables<"profiles">; export type ProfileInsert = TablesInsert<"profiles">; export type ProfileUpdate = TablesUpdate<"profiles">; // ── events (daily / weekly / monthly) ─────────────── export type EventDaily = Tables<"events_daily">; export type EventDailyInsert = TablesInsert<"events_daily">; export type EventDailyUpdate = TablesUpdate<"events_daily">; export type EventWeekly = Tables<"events_weekly">; export type EventWeeklyInsert = TablesInsert<"events_weekly">; export type EventWeeklyUpdate = TablesUpdate<"events_weekly">; export type EventMonthly = Tables<"events_monthly">; export type EventMonthlyInsert = TablesInsert<"events_monthly">; export type EventMonthlyUpdate = TablesUpdate<"events_monthly">;💬 효과
Tables<"todos">대신Todo로 간결하게 사용 가능- 스키마가 바뀌어도
domain.ts만 교체하면 앱 전체 타입이 갱신됨- IDE 자동완성과 타입 추론이 강화됨
*.schema.ts / *.service.ts / *.query.tsAPI 레이어는 한 파일에 다 몰아두면 금방 복잡해진다.
요청 타입, 실제 호출, 캐시 관리가 한데 섞이면 수정이 어렵고 테스트도 힘들다.
그래서 세 단계로 분리한다 👇
역할 파일 설명 요청·응답 타입(DTO) *.schema.ts데이터 형태만 정의 — 화면과 서비스 간 약속 실제 네트워크 호출 *.service.tsaxios로 Supabase REST API 호출 캐시·훅 관리 *.query.tsReact Query로 상태 동기화
todos를 먼저 만들어 놓고 테스트를 한 다음 테스트를 통과하면 나머지 테이블도API 모듈을 만들면 된다.
이 파일을 한 줄로 설명하면?
서비스들이 HTTP(axios)를 같은 방식으로 쓰게 만드는 “문지기 함수” 모음이다.
- 컴포넌트나 서비스가 직접 axios와 씨름하지 않게 해준다.
- “요청은 전부 여기로 지나가라”는 단일 통로를 만든다.
- 그래서 형식(메서드/파라미터/헤더)이 항상 같고, 나중에 정책(로깅·재시도)을 바꿔도 이 파일만 손보면 끝난다.
왜 필요한가?
앱이 서버랑 대화할 때마다 “주소·방법·짐(데이터)”을 매번 직접 적으면 실수가 쌓인다.
누군가는GET대신get을 쓰고, 헤더를 빠트리거나, 에러 처리 방식이 파일마다 달라진다.
http.ts는 “통로 하나로 줄이는 역할”을 한다.
개발자는 “어디로 무엇을 보낼지”만 적고, “어떻게 보낼지(헤더 붙이기, 재시도, 기록)”는 이 파일이 항상 똑같이 처리한다.
정책을 바꿔도 이 파일 한 곳만 고치면, 앱 전체 요청이 자동으로 바뀐다.
API 요청 예시 — “할 일 추가하기”가 실제로 어떻게 흐르는가
🧍♂️ 사용자가 '추가' 버튼 클릭 ↓ 🎨 [TodoForm.tsx] → React Query 훅 실행 (useCreateTodoMutation) ↓ ⚙️ [src/api/todos/todo.query.ts] → todoService.post(payload) 호출 → 성공 시 목록 새로고침 (invalidateQueries) ↓ 🧩 [src/api/todos/todo.service.ts] → "/todos"로 POST 요청 준비 → fetcher(...) 호출 (http.ts로 전달) ↓ 🚪 [src/api/http.ts] → 요청 형식 정리 (url, method, data) → axios 인스턴스(api)에 전달 ↓ 🚚 [src/api/client.ts] → apikey / Authorization 헤더 자동 추가 → 실제 Supabase REST API 호출 ↓ 🗄️ [Supabase 데이터베이스] → 새 할 일(Row) 저장 → 생성된 데이터 응답 반환 ↓ 🔁 (다시 위로) 서비스 → 훅 → 컴포넌트로 전달 → 화면에 새 할 일이 즉시 반영핵심은 서비스가 axios를 직접 몰라도 된다는 점이다.
요청은 반드시http.ts의 fetcher를 지나가고, 공통 규칙이 모든 요청에 자동으로 적용된다.
전체 코드 —
src/api/http.ts// src/api/http.ts import type { AxiosRequestConfig, AxiosResponse } from "axios"; import { api } from "./client"; export const HTTP_METHODS = { GET: "GET", POST: "POST", PATCH: "PATCH", DELETE: "DELETE" } as const; type Methods = keyof typeof HTTP_METHODS; // ✅ Axios가 허용하는 안전한 쿼리 파라미터 타입(원시값 + 배열) type Primitive = string | number | boolean | null | undefined; export type QueryParams = Record<string, Primitive | Primitive[]>; // ✅ 안전한 헤더 타입 export type HeaderMap = Record<string, string>; export type ApiRequestParams<TBody = unknown> = { url: string; method?: Methods; // default GET params?: QueryParams; data?: TBody; headers?: HeaderMap; config?: Omit<AxiosRequestConfig, "url" | "method" | "params" | "data" | "headers">; }; /** ✅ 기본 fetcher (전역 api 인스턴스 사용) */ export async function fetcher<TResponse, TBody = unknown>( args: ApiRequestParams<TBody>, ): Promise<AxiosResponse<TResponse>> { const { url, method = "GET", params, data, headers, config } = args; return api.request<TResponse>({ url, method, params, data, headers, ...config }); }코드 설명(블록별, “무엇/왜/어떻게”)
1) 공통 메서드/타입 —
HTTP_METHODS,type Methodsexport const HTTP_METHODS = { GET: "GET", POST: "POST", PATCH: "PATCH", DELETE: "DELETE" } as const; type Methods = keyof typeof HTTP_METHODS;무엇: 팀에서 쓸 HTTP 메서드를 상수로 고정하고, 그 키를
Methods타입으로 만든다.
왜: 누군가는"get", 다른 파일은"GET"처럼 들쭉날쭉 쓰는 일을 막는다.
어떻게:Methods는"GET" | "POST" | "PATCH" | "DELETE"유니온 타입이 되어, 오타를 컴파일 단계에서 차단한다.2) 요청 표준 형태 —
QueryParams,HeaderMap,ApiRequestParams<TBody>type Primitive = string | number | boolean | null | undefined; export type QueryParams = Record<string, Primitive | Primitive[]>; // ✅ 안전한 쿼리 타입 export type HeaderMap = Record<string, string>; // ✅ 안전한 헤더 타입 export type ApiRequestParams<TBody = unknown> = { url: string; method?: Methods; // 기본 GET params?: QueryParams; data?: TBody; headers?: HeaderMap; config?: Omit<AxiosRequestConfig, "url"|"method"|"params"|"data"|"headers">; };무엇: 서비스가 넘길 주소/방법/쿼리/바디/헤더/추가옵션의 공통 모양이다.
왜:any를 쓰지 않고도 axios가 허용하는 현실적 타입 범위만 열어 타입 안전을 보장하려고.
— 쿼리(params)는 문자열·숫자·불리언·null/undefined 및 그 배열만 허용.
— 헤더(headers)는 문자열 맵으로 고정해 혼선을 방지.
어떻게:config에서 중복 필드를 제외해 이중 지정을 막고, IDE 자동완성/타입 검증을 최대화한다.
3) 단일 진입점 —
fetcher(...)export async function fetcher<TResponse, TBody = unknown>( args: ApiRequestParams<TBody> ): Promise<AxiosResponse<TResponse>> { const { url, method = "GET", params, data, headers, config } = args; return api.request<TResponse>({ url, method, params, data, headers, ...config }); }무엇: 전역 axios 인스턴스(
api)로 항상 같은 경로로 요청을 보낸다.
왜: 인스턴스 인터셉터에서apikey/Authorization같은 공통 헤더가 자동 주입되고, 에러 표준화도 한 곳에서 해결된다.
어떻게: 서비스는fetcher({ url: "/todos", method: "POST", data })처럼 무엇을 보낼지만 적고, 전송 방식/공통 정책은 여기서 일관되게 적용된다.
무슨 파일?
이 파일은 “데이터의 모양을 약속하는 계약서”다.
화면이나 서비스에서 주고받는 데이터가 어떤 형태인지 미리 정리해 둔다.
여기에는 요청(보낼 때)과 응답(받을 때)의 타입만 들어 있다.
실제로 서버를 부르거나 데이터를 가져오진 않는다.
쉽게 말해,
“이 API는 이런 자료를 주고받을 거야”라고 정리해 둔 문서라고 생각하면 된다.나중에 Supabase 테이블 구조가 바뀌더라도,
이 파일만 수정하면 나머지 서비스나 훅은 그대로 쓸 수 있다.// src/api/todos/todo.schema.ts import type { Todo, TodoInsert, TodoUpdate } from "../types/domain"; // 응답 타입 — 서버에서 오는 데이터의 모양 export type GetTodosResultType = Todo[]; export type PostTodoResultType = Todo; export type PatchTodoResultType = Todo; export type DeleteTodoResultType = void; // 요청 DTO — 서버로 보낼 데이터의 모양 export interface PostTodoPayloadType { body: Partial<TodoInsert> & Pick<TodoInsert, "title" | "user_id">; } export interface PatchTodoPayloadType { id: string; body: TodoUpdate; } export interface ToggleTodoPayloadType { id: string; }🧩 코드 설명
export type GetTodosResultType = Todo[];서버에서 “할 일 목록 전체”를 받을 때 데이터가 Todo 객체들의 배열임을 명시한다.
→ 예를 들어[ { title: "과제", status: "todo", completed_at: null }, ... ]형태로 온다.
export interface PostTodoPayloadType { body: Partial<TodoInsert> & Pick<TodoInsert, "title" | "user_id">; }새로운 할 일을 추가할 때 서버로 보낼 데이터의 구조를 정의한다.
→title과user_id는 반드시 필요하지만, 나머지 필드는 선택 사항으로 둔다.
이처럼schema.ts는 데이터의 약속(타입)만 담당한다.
실제 호출은 다음 단계인 서비스 파일이 맡는다.
무슨 파일?
이 파일은 진짜로 서버에 요청을 보내는 곳이다.
화면(혹은 훅)이 직접 서버에 접근하지 않고,
모든 네트워크 호출은 이 파일을 거친다.
쉽게 말하면 “데이터를 가져오거나 보낼 때 통신 담당자”다.화면 쪽은
TodoService의 메서드를 통해서만
Supabase REST API에 접근한다.나중에 API 주소나 방식이 바뀌어도,
이 파일만 고치면 나머지는 그대로 쓸 수 있다.// src/api/todos/todo.service.ts import { HTTP_METHODS, fetcher, type ApiRequestParams } from "../http"; import type { Todo, TodoUpdate } from "../types/domain"; import type { DeleteTodoResultType, GetTodosResultType, PatchTodoPayloadType, PatchTodoResultType, PostTodoPayloadType, PostTodoResultType, ToggleTodoPayloadType, } from "./todo.schema"; export class TodoService { /** 목록 조회: GET /todos?select=*&order=created_at.desc */ async getList(params?: ApiRequestParams["params"]): Promise<GetTodosResultType> { const r = await fetcher<GetTodosResultType>({ url: "/todos", method: HTTP_METHODS.GET, params: { select: "*", order: "created_at.desc", ...params }, }); return r.data; } /** 추가: POST /todos (Prefer: return=representation) */ async post({ body }: PostTodoPayloadType): Promise<PostTodoResultType> { const r = await fetcher<PostTodoResultType[]>({ url: "/todos", method: HTTP_METHODS.POST, data: body, headers: { Prefer: "return=representation" }, }); return r.data[0]; } /** 수정: PATCH /todos?id=eq.<id> (Prefer) */ async patch({ id, body }: PatchTodoPayloadType): Promise<PatchTodoResultType> { const r = await fetcher<PatchTodoResultType[]>({ url: "/todos", method: HTTP_METHODS.PATCH, data: body, params: { id: `eq.${id}` }, headers: { Prefer: "return=representation" }, }); return r.data[0]; } /** 상태 토글: status done↔todo, completed_at now/null */ async toggle({ id }: ToggleTodoPayloadType): Promise<PatchTodoResultType> { // 1) 현재 행 조회 const curRes = await fetcher<Todo[]>({ url: "/todos", params: { select: "*", id: `eq.${id}`, limit: 1 }, }); const current = curRes.data[0]; // 2) 다음 상태 계산 const nextDone = current?.status !== "done"; const patch: TodoUpdate = { status: nextDone ? "done" : "todo", completed_at: nextDone ? new Date().toISOString() : null, }; // 3) 업데이트 후 대표 행 반환 const r = await fetcher<PatchTodoResultType[]>({ url: "/todos", method: HTTP_METHODS.PATCH, data: patch, params: { id: `eq.${id}` }, headers: { Prefer: "return=representation" }, }); return r.data[0]; } /** 삭제: DELETE /todos?id=eq.<id> */ async delete(id: string): Promise<void> { await fetcher<DeleteTodoResultType>({ url: "/todos", method: HTTP_METHODS.DELETE, params: { id: `eq.${id}` }, }); } }🧩 코드 설명
import { fetcher } from "../http";이 파일은
http.ts의 공통 fetcher를 불러온다.
모든 네트워크 요청은 반드시 이 함수를 통해서 나가며,
axios를 직접 호출하지 않는다.
async getList() { ... }할 일 목록을 불러오는 함수다.
Supabase REST API의GET /todos를 요청해
데이터를 생성일 최신순(내림차순)으로 가져온다.
이 함수는 응답 객체 전체가 아니라data만 반환한다.
async post({ body }) { ... }새로운 할 일을 추가하는 함수다.
Supabase REST의POST /todos로 데이터를 전송하고,
응답으로 생성된 항목을 바로 반환한다.
Prefer: return=representation헤더는
“새로 만든 데이터를 응답에 같이 포함해 달라”는 의미다.
async patch({ id, body }) { ... }기존 할 일을 수정하는 함수다.
PATCH /todos?id=eq.<id>요청으로 특정 행을 부분 업데이트한다.
예를 들어 제목이나 설명을 바꿀 때 사용한다.
수정이 끝나면 업데이트된 레코드 한 개를 돌려준다.
async toggle({ id }) { ... }할 일의 완료 상태를 토글(ON/OFF)하는 함수다.
먼저GET /todos?id=...로 현재 상태를 조회한 뒤,
status를"done"↔"todo"로 바꾼다.
완료 시엔completed_at에 현재 시간을 넣고,
취소 시엔null로 돌려준다.
결과적으로 바뀐 레코드 한 개를 반환한다.
async delete(id: string) { ... }할 일을 삭제하는 함수다.
Supabase REST의DELETE /todos?id=eq.<id>요청을 보내
해당 레코드를 삭제한다.
성공하면 별도의 응답 없이(void) 완료만 알린다.
✅ 정리
TodoService는 axios를 직접 쓰지 않고
공통 fetcher를 통해서만 요청을 보낸다.
덕분에 형식이 통일되고, 나중에 로깅·재시도 정책을
추가할 때도http.ts하나만 수정하면 된다.
무슨 파일?
이 파일은 서비스가 불러온 데이터를 캐시에 저장하고 관리한다.
컴포넌트는 axios나 fetch를 전혀 몰라도,
여기서 제공하는 훅(Hook)만 쓰면 된다.
쉽게 말해 “화면과 서버 사이의 중간 다리” 역할이다.
React Query를 이용해 데이터가 자동으로 캐싱·리패치된다.// src/api/todos/todo.query.ts import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { PatchTodoPayloadType, PostTodoPayloadType, ToggleTodoPayloadType, } from "./todo.schema"; import { TodoService } from "./todo.service"; const todoService = new TodoService(); // 캐시 키 — React Query가 요청을 구분할 때 사용 export const todosQueryKeys = { all: () => ["todos"] as const, list: () => [...todosQueryKeys.all(), "list"] as const, }; // 쿼리 옵션 — 목록 가져오기 규칙 export const todosQuery = { list: () => queryOptions({ queryKey: todosQueryKeys.list(), queryFn: () => todoService.getList(), // 서비스가 data만 반환 staleTime: 60_000, }), }; // React Query 훅들 export function useTodosQuery() { return useQuery(todosQuery.list()); } export function useCreateTodoMutation() { const qc = useQueryClient(); return useMutation({ mutationFn: (payload: PostTodoPayloadType) => todoService.post(payload), onSuccess: () => qc.invalidateQueries({ queryKey: todosQueryKeys.all() }), }); } export function usePatchTodoMutation() { const qc = useQueryClient(); return useMutation({ mutationFn: (payload: PatchTodoPayloadType) => todoService.patch(payload), onSuccess: () => qc.invalidateQueries({ queryKey: todosQueryKeys.all() }), }); } export function useToggleTodoMutation() { const qc = useQueryClient(); return useMutation({ mutationFn: (payload: ToggleTodoPayloadType) => todoService.toggle(payload), onSuccess: () => qc.invalidateQueries({ queryKey: todosQueryKeys.all() }), }); } export function useDeleteTodoMutation() { const qc = useQueryClient(); return useMutation({ mutationFn: (id: string) => todoService.delete(id), onSuccess: () => qc.invalidateQueries({ queryKey: todosQueryKeys.all() }), }); }🧩 코드 설명
const todoService = new TodoService();서비스를 실제로 사용할 인스턴스 생성 부분이다.
TodoService안에서는fetcher를 통해
Supabase REST API와 통신한다.
export const todosQueryKeys = { ... };React Query 캐시 키를 중앙에서 정의한다.
이렇게 하면 오탈자 없이 한 곳에서 관리할 수 있다.
["todos", "list"]는 목록 데이터를 구분하는 고유한 식별자다.
export const todosQuery = { list: () => queryOptions({...}) };서버에서 어떤 데이터를 가져올지 정의하는 옵션 세트다.
여기서queryFn이todoService.getList()를 호출하고,
staleTime은 데이터를 1분 동안 신선한 상태로 유지하게 만든다.
export function useTodosQuery() { ... }할 일 목록을 불러오는 훅이다.
React Query가 내부적으로 캐싱하므로
한 번 로드된 데이터는 새로고침 전까지 즉시 표시된다.
네트워크 요청도 자동으로 중복 방지된다.
export function useCreateTodoMutation() { ... }새 할 일을 추가하는 훅이다.
todoService.post()를 호출해 새로운 레코드를 만들고,
성공하면invalidateQueries로 todos 캐시를 초기화한다.
이후 목록이 자동으로 다시 불러와진다.
export function usePatchTodoMutation() { ... } export function useToggleTodoMutation() { ... } export function useDeleteTodoMutation() { ... }이 세 훅은 각각 수정 / 상태 토글 / 삭제 요청을 담당한다.
모두 공통적으로 성공 시 캐시를 새로고침해
화면이 자동으로 최신 상태로 반영되게 한다.
✅ 정리
todo.query.ts는 React Query가 데이터의 상태·캐시·자동 리패치를
대신 관리하게 해준다.
컴포넌트는 오직useTodosQuery()나useCreateTodoMutation()같은
간단한 훅만 호출하면 된다.
네트워크 통신은 모두service → http → client경로로 흐른다.
🧾 정리
구분 파일 역할 데이터 형식 정의 todo.schema.ts요청/응답 타입(데이터 모양) 정의 실제 서버 통신 todo.service.tsSupabase REST API 호출 담당 데이터 캐싱/훅 todo.query.tsReact Query 훅으로 상태 관리 이 세 파일이 하나의 세트로 작동한다.
형식(schema) → 통신(service) → 화면(query) 순으로 이어지며,
이 패턴을 익히면notes,dashboard_layouts같은 다른 모듈도
같은 구조로 쉽게 확장할 수 있다.
목적: API 레이어(클라이언트/HTTP 래퍼/서비스/훅) 연결이 제대로 됐는지 빠르게 검증한다.
포인트: 로그인 → 목록 조회 → 생성 → 토글 → 제목 수정 → 삭제 순으로 Network 탭에서 요청 흐름을 확인한다.
📂 파일 경로(예시):src/app/lab/todos/page.tsx
이 페이지는 내부에서supabaseAuth로 이메일/비밀번호 로그인을 처리하고, CRUD는 이미 구성된 훅을 호출한다:
useTodosQuery,useCreateTodoMutation,usePatchTodoMutation,useToggleTodoMutation,useDeleteTodoMutation."use client"; // 클라이언트 컴포넌트 — 브라우저에서 실행됨 import { supabaseAuth } from "@/api/client"; // Supabase 인증 클라이언트 import { useCreateTodoMutation, useDeleteTodoMutation, usePatchTodoMutation, useTodosQuery, useToggleTodoMutation, } from "@/api/todos/todo.query"; // 이미 만들어 둔 API 훅들 import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useEffect, useState } from "react"; export default function TodosSmokePage() { // ---------- ① 로그인 상태 관리 ---------- const [user, setUser] = useState<{ id: string; email?: string } | null>(null); // 로그인된 사용자 const [email, setEmail] = useState(""); // 이메일 입력값 const [password, setPassword] = useState(""); // 비밀번호 입력값 const [authLoading, setAuthLoading] = useState(false); // 로그인 중 표시 // 페이지 진입 시: 세션 복구 + 로그인 상태 구독 useEffect(() => { supabaseAuth.auth.getUser().then(({ data }) => { if (data.user) setUser({ id: data.user.id, email: data.user.email ?? "" }); }); const { data: sub } = supabaseAuth.auth.onAuthStateChange((_e, s) => { if (s?.user) setUser({ id: s.user.id, email: s.user.email ?? "" }); else setUser(null); }); return () => sub.subscription?.unsubscribe(); }, []); // 로그인 요청 const signIn = async () => { if (!email || !password) return; setAuthLoading(true); const { error, data } = await supabaseAuth.auth.signInWithPassword({ email, password }); setAuthLoading(false); if (error) return alert(`로그인 실패: ${error.message}`); setUser({ id: data.user.id, email: data.user.email ?? "" }); }; // 로그아웃 const signOut = async () => { await supabaseAuth.auth.signOut(); setUser(null); }; // ---------- ② Todo CRUD 훅 ---------- // (서버에서 내 데이터만 가져옴 — RLS 정책 덕분에 내 user_id 데이터만 보임) const { data: todos = [], isLoading, isError, error } = useTodosQuery(); const createTodo = useCreateTodoMutation(); const patchTodo = usePatchTodoMutation(); const toggleTodo = useToggleTodoMutation(); const deleteTodo = useDeleteTodoMutation(); // 새 항목 입력값 const [title, setTitle] = useState(""); // 새 할 일 추가 (user_id는 내 계정 ID 필요) const add = () => { const v = title.trim(); if (!v || !user) return; createTodo.mutate( { body: { title: v, user_id: user.id } }, { onSuccess: () => setTitle(""), onError: (e: any) => alert(e?.message ?? "추가 실패") }, ); }; // 완료 상태 토글 const toggle = (id: string) => { toggleTodo.mutate({ id }); }; // 제목 수정 (prompt 창으로) const rename = (id: string, prev: string) => { const next = prompt("제목 수정", prev)?.trim(); if (!next) return; patchTodo.mutate({ id, body: { title: next } }); }; // 항목 삭제 const remove = (id: string) => { if (!confirm("삭제할까요?")) return; deleteTodo.mutate(id); }; // ---------- ③ UI ---------- return ( <main className="mx-auto max-w-xl p-6 space-y-6"> {/* 상단 제목 */} <header> <h1 className="t-20-b">Todos 스모크 (로그인 + CRUD + RLS)</h1> <p className="t-12-m text-[#737373]"> 이메일 로그인 후 본인 데이터만 확인 </p> </header> {/* 0) 로그인 영역 */} <section className="rounded-lg border bg-white p-4 space-y-3"> <div className="flex items-center justify-between"> <h2 className="t-16-b">0) 로그인</h2> {/* 로그인 후: 이메일 + 로그아웃 버튼 */} {user && ( <div className="flex items-center gap-2"> <span className="t-12-m text-[#737373]">{user.email}</span> <Button variant="pill" onClick={signOut}> 로그아웃 </Button> </div> )} </div> {/* 로그인 전: 이메일/비밀번호 입력 */} {!user && ( <div className="flex gap-2"> <Input placeholder="이메일" value={email} onChange={(e) => setEmail(e.target.value)} /> <Input placeholder="비밀번호" type="password" value={password} onChange={(e) => setPassword(e.target.value)} /> <Button onClick={signIn} disabled={authLoading}> {authLoading ? "로그인 중…" : "로그인"} </Button> </div> )} {/* 로그인 성공 시 메시지 */} {user && ( <p className="t-12-m text-[#16a34a]"> 로그인됨 · REST 요청 시 `Authorization` 헤더 자동 포함 </p> )} </section> {/* 1) 목록 / 2) 추가 */} <section className="rounded-lg border bg-white p-4 space-y-4"> <h2 className="t-16-b">1) 목록 / 2) 추가</h2> {/* 입력창 + 추가 버튼 */} <div className="flex gap-2"> <Input placeholder="할 일 제목" value={title} onChange={(e) => setTitle(e.target.value)} disabled={!user} /> <Button onClick={add} disabled={!user || !title.trim() || createTodo.isPending} {createTodo.isPending ? "추가 중…" : "추가"} </Button> </div> {/* 상태 표시 */} {!user && ( <p className="t-12-m text-[#a3a3a3]"> 로그인 후 목록을 불러옵니다. </p> )} {isLoading && ( <p className="t-12-m text-[#737373]">불러오는 중…</p> )} {isError && ( <p className="t-12-m text-red-600"> 에러: {(error as any)?.message ?? "error"} </p> )} {/* 할 일 목록 */} <ul className="space-y-2"> {todos.map((t: any) => ( <li key={t.id} className="flex items-center justify-between rounded-md border bg-white px-3 py-2" {/* 왼쪽: 체크박스 + 제목 */} <div className="flex items-center gap-3"> <input type="checkbox" checked={!!t.is_done} onChange={() => toggle(t.id)} disabled={toggleTodo.isPending} /> <span className={ t.is_done ? "line-through text-[#737373]" : "" } {t.title} </span> </div> {/* 오른쪽: 수정 / 삭제 버튼 */} <div className="flex items-center gap-2"> <Button variant="pill" onClick={() => rename(t.id, t.title)} disabled={patchTodo.isPending} 수정 </Button> <Button variant="pill" onClick={() => remove(t.id)} disabled={deleteTodo.isPending} 삭제 </Button> </div> </li> ))} </ul> {/* 데이터 없을 때 표시 */} {user && !isLoading && todos.length === 0 && ( <p className="mt-2 t-12-m text-[#737373]">데이터가 없어요. 하나 추가해 보세요.</p> )} </section> </main> ); }🧩 0) 로그인 (Auth — Password Grant)
어디서 확인
→개발자도구 Network→ 필터auth또는token
→POST /auth/v1/token?grant_type=password클릭
확인 포인트
항목 확인할 내용 요청 URL https://<project-ref>.supabase.co/auth/v1/token?grant_type=password헤더 apikey포함본문 { email, password }응답 access_token,token_type,user상태 200 ✅ UI 반영 상단 로그인 영역에 이메일 표시, 버튼이 로그아웃으로 변경 ⚙️ 경로: DevTools →
Network→ 항목 클릭 →Headers/Preview/Response
supabase에서 미리 만들어놓은 계정을 로그인하면 위 사진 처럼 UI가 변경 되고개발자 도구에서도 성공적으로 로그인된 것을 볼 수 있다!
🧩 1) 초기 목록 불러오기 (GET)
어디서 확인
→개발자도구 Network→ 필터todos
→GET /rest/v1/todos?select=*&order=created_at.desc클릭
확인 포인트
항목 확인할 내용 요청 URL https://<project-ref>.supabase.co/rest/v1/todos?select=*&order=created_at.desc헤더 apikey/ 로그인 후Authorization: Bearer <token>쿼리 select=*,order=created_at.desc응답 [{ id, title, is_done, user_id, ... }]상태 200 ✅ UI 반영 내 계정의 투두 목록 렌더링 ⚙️ 경로: DevTools →
Network→ 항목 클릭 →Headers/Preview/Response
supabase로 로그인 된 계정에A의 할 일이라는 데이터를 미리 넣어놨었다 로그인을 하니 해당 데이터가 잘 불러와지는 모습을 볼 수 있다.
🧩 2) 항목 추가 (POST)
어디서 확인
→개발자도구 Network→ 필터todos
→ “추가” 버튼 클릭 시POST /rest/v1/todos
확인 포인트
항목 확인할 내용 요청 URL https://<project-ref>.supabase.co/rest/v1/todos헤더 apikey,Authorization: Bearer <token>,Content-Type: application/json본문 { "title": "<입력값>", "user_id": "<로그인사용자ID>" }← 이 코드에선 user_id 명시 전제응답 생성 레코드(배열/객체) [{ id, title, is_done, user_id, ... }]상태 201 또는 200 ✅ UI 반영 입력창 초기화, 새 항목이 목록 상단에 나타남 ⚙️ 경로: DevTools →
Network→POST /todos→Headers/Request Payload/Preview
B의 할 일을 추가하니 위 체크리스트처럼 잘 나오고 UI 반영도 잘 된 것을 볼 수 있다.
결과적으로supabase의 로그인된 계정todo 테이블데이터에B의 할 일이 추가 된 것을 볼 수 있다!
🧩 3) 완료 토글 (PATCH)
어디서 확인
→ 체크박스 클릭 시PATCH /rest/v1/todos?id=eq.{id}(컴포넌트:toggleTodo.mutate({ id }))
확인 포인트
항목 확인할 내용 요청 URL https://<project-ref>.supabase.co/rest/v1/todos?id=eq.{id}헤더 apikey,Authorization: Bearer <token>본문 구현에 따라 없음 또는 `{ "is_done": true false }` (서버 토글 로직일 수도 있음) 응답 [{ id, title, is_done, ... }]상태 200 ✅ UI 반영 체크/라인스루 상태가 즉시 반영 ⚙️ 경로: DevTools →
Network→PATCH /todos→Headers/Preview
🧩 4) 제목 수정 (PATCH)
어디서 확인
→ “수정” 클릭 → 프롬프트 저장 시PATCH /rest/v1/todos?id=eq.{id}(컴포넌트:patchTodo.mutate({ id, body: { title } }))
확인 포인트
항목 확인할 내용 요청 URL https://<project-ref>.supabase.co/rest/v1/todos?id=eq.{id}헤더 apikey,Authorization: Bearer <token>,Content-Type: application/json본문 { "title": "<수정된 제목>" }응답 [{ id, title, is_done, ... }]상태 200 ✅ UI 반영 해당 항목 제목이 즉시 변경됨 ⚙️ 경로: DevTools →
Network→PATCH /todos→Headers/Request Payload/Preview
기존에 있던
A의 할 일을수정 된 할 일로 바꿔보았고 UI와 요청과 응답supabase에서도 잘 반영된 모습을 볼 수 있다.
🧩 5) 항목 삭제 (DELETE)
어디서 확인
→ “삭제” 클릭 시DELETE /rest/v1/todos?id=eq.{id}(컴포넌트:deleteTodo.mutate(id))
확인 포인트
항목 확인할 내용 요청 URL https://<project-ref>.supabase.co/rest/v1/todos?id=eq.{id}헤더 apikey,Authorization: Bearer <token>응답 빈 배열 또는 영향 행 수 상태 200 또는 204 ✅ UI 반영 해당 항목이 목록에서 즉시 사라짐 ⚙️ 경로: DevTools →
Network→DELETE /todos→Headers/Response
추가한
B의 할 일을 삭제해 보았고 UI와 요청과 응답supabase에서도 잘 반영된 모습을 볼 수 있다.
❗️빠른 트러블슈팅 (이 코드 기준)
- 401 Unauthorized: 로그인 안 됨 → 먼저 0) 로그인 요청이 200인지 확인.
- 403 RLS: 정책 미설정/누락 →
user_id = auth.uid()기반 RLS 확인.- 400 Bad Request (POST): 바디에
user_id가 빠짐 → 이 스모크에선 반드시 포함.- 204인데 UI가 그대로: 쿼리 무효화/리패치가 훅 안에 없으면, 훅 구현(
invalidateQueries) 확인.