메인 콘텐츠로 바로가기
노트 목록

API 스펙 정의부터 구현까지 — 그리고 SQL 없이 쓰는 Supabase 쿼리 완벽 가이드

김성박660
목차19

4편에서 쇼핑몰 테이블(카테고리·상품·주문·주문상품)을 만들었습니다. 테이블이 준비됐다면 다음 순서는 "이 데이터로 어떤 API를 제공할지" 스펙을 정하는 것입니다.

이 글은 두 부분입니다.

  1. API 스펙 정의 → Next.js 구현 : 기획자·비전공자도 회의에서 같이 정할 수 있는 스펙을 만들고, 그대로 코드로 옮깁니다.
  2. SQL 없이 쓰는 Supabase 쿼리 가이드 : 조회(필터·정렬·조인·집계)·입력·수정·삭제를 이 문서만 보고도 개발할 수 있게 정리했습니다. (모든 예제는 실제 클라우드에서 동작 검증)

쇼핑몰


1부. API 스펙 정의 — 기획자도 함께

왜 스펙을 먼저 정하나?

코드를 짜기 전에 "어떤 요청에 어떤 응답을 줄지" 를 표로 합의해두면, 프론트·백엔드·기획자가 같은 그림을 봅니다. API 스펙은 어려운 문서가 아닙니다. 딱 6가지만 정하면 됩니다.

항목무슨 뜻인가예시
기능무엇을 하는가상품 목록 보기
메서드동작의 종류GET(조회) POST(생성) PUT(수정) DELETE(삭제)
경로(URL)어디로 요청하나/api/products
권한누가 쓸 수 있나누구나 / 로그인 필요 / 본인만
요청무엇을 보내나{ "items": [{ "product_id": 1, "quantity": 2 }] }
응답무엇을 돌려받나200 [{...}], 401 { "error": "..." }

💡 기획자 팁: 스펙 회의에서 가장 중요한 질문은 "이 기능은 누가 쓸 수 있나(권한)"와 "실패하면 어떤 응답을 주나(상태 코드)"입니다. 이 둘만 명확히 합의해도 개발이 훨씬 수월해집니다.

우리 쇼핑몰 API 스펙

기능메서드경로권한요청(body)성공 응답실패
상품 목록GET/api/products누구나—200 상품 배열—
상품 상세GET/api/products/{id}누구나—200 상품 1건404 없음
주문 생성POST/api/orders로그인{ items: [{ product_id, quantity }] }201 주문401 비로그인 / 400 잘못된 입력
내 주문 목록GET/api/orders로그인—200 내 주문 배열401 비로그인
카테고리 통계GET/api/stats/categories누구나—200 카테고리별 합계—

상태 코드 약속(스펙의 공용어):

코드의미
200성공(조회/수정/삭제)
201생성 성공
400잘못된 요청(입력 오류)
401인증 필요(로그인 안 함)
403권한 없음(로그인했지만 자격 없음)
404대상 없음

이 표 하나면 회의가 끝납니다. 이제 그대로 코드로 옮깁니다.


2부. 스펙대로 Next.js에서 구현

스펙의 한 줄이 Next.js Route Handler 파일 하나에 대응합니다. 경로 /api/products → 파일 src/app/api/products/route.ts.

// 스펙: [GET /api/products | 누구나 | 200 상품 배열] import { NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; export async function GET() { const supabase = await createClient(); const { data, error } = await supabase .from("products") .select("id, name, price, stock, categories(name)") // 카테고리는 조인 .order("id"); if (error) return NextResponse.json({ error: error.message }, { status: 500 }); return NextResponse.json(data); // 200 }

POST /api/orders 스펙(로그인 필요, 401/400)도 그대로 옮깁니다.

// 스펙: [POST /api/orders | 로그인 | 201 / 401 / 400] export async function POST(request: Request) { const { supabase, user } = await getAuthContext(request); // 쿠키/Bearer 인증 if (!user) return NextResponse.json({ error: "로그인 필요" }, { status: 401 }); const { items } = await request.json(); if (!Array.isArray(items) || items.length === 0) return NextResponse.json({ error: "상품이 없습니다" }, { status: 400 }); // ... 주문 생성 (4편 참고) ... return NextResponse.json(order, { status: 201 }); }

스펙 ↔ 코드 대응: 권한 → if (!user) 401, 입력 검증 → 400, 생성 성공 → 201. 스펙의 각 칸이 코드 한 줄로 1:1 매칭됩니다. 이게 "스펙 먼저"의 힘입니다.

이제 핵심 — 이 안에서 DB를 어떻게 다루는지(SQL 없이)를 전부 정리합니다.


3부. SQL 없이 쓰는 Supabase 쿼리 가이드

Supabase는 쿼리 빌더로 SQL을 대신합니다. from("테이블").select(...) 처럼 메서드를 이어 붙이면 됩니다. 아래 예제는 전부 실제로 동작 검증한 것입니다.

준비: 서버에서는 const supabase = await createClient(), 브라우저에서는 const supabase = createClient() 로 클라이언트를 얻습니다. (1편 참고)

3-1. 조회 (SELECT)

// 전체 + 컬럼 선택 + 정렬(내림차순) + 개수 제한 const { data } = await supabase .from("products") .select("id, name, price") .order("price", { ascending: false }) .limit(2); // → [{id:2,name:"기계식 키보드",price:120000}, {id:1,name:"무선 이어폰",price:89000}] // 단 한 건만 (없으면 null) — 상세 페이지에 유용 const { data: one } = await supabase .from("products").select("*").eq("id", 1).maybeSingle(); // .single() 은 정확히 1건이어야 하고, 아니면 에러

3-2. 필터 (WHERE)

await supabase.from("products").select("name,price").eq("category_id", 1); // = 같음 await supabase.from("products").select("name").gte("price", 50000); // >= 5만 이상 await supabase.from("products").select("name").lt("price", 20000); // < 2만 미만 await supabase.from("products").select("name").in("id", [1, 3]); // IN (여러 값) await supabase.from("products").select("name").ilike("name", "%키%"); // 대소문자 무시 검색(한글 OK) // → ilike("name","%키%") 는 "기계식 키보드"를 찾아냅니다 // OR 조건 await supabase.from("products").select("name").or("price.lt.20000,stock.gt.50");
메서드SQL뜻
.eq(c,v)c = v같음
.neq .gt .gte .lt .lte<> > >= < <=비교
.in(c,[...])c IN (...)목록 포함
.like / .ilikeLIKE / ILIKE패턴(%=아무 글자)
.is(c,null)c IS NULLNULL 여부
.or("a.eq.1,b.gt.2")a=1 OR b>2OR

3-3. 정렬·페이지네이션

// 0번부터 1번까지(= 1~2번째). 페이지 크기 10이면 2페이지는 range(10,19) const { data } = await supabase .from("products").select("name").order("id").range(0, 1);

3-4. 조인 (JOIN) — 정규화의 열매

외래키로 연결된 테이블을 중첩 select 로 한 번에 가져옵니다.

// 상품 + 카테고리명 (products.category_id → categories) const { data } = await supabase .from("products").select("name, price, categories(name)").eq("id", 1).single(); // → { name:"무선 이어폰", price:89000, categories:{ name:"전자기기" } } // 1:N 조인 — 주문 + 그 주문의 항목들 + 항목의 상품명 const { data: orders } = await supabase .from("orders") .select("id, total_amount, order_items(quantity, unit_price, products(name))"); // → [{ id, total_amount, order_items:[{quantity, unit_price, products:{name}}] }]

3-5. 개수와 집계 (COUNT / SUM / AVG)

(1) 개수 — count

// 전체 개수 (head:true 면 데이터 없이 개수만 → 빠름) const { count } = await supabase .from("products").select("*", { count: "exact", head: true }); // → 4 // 조건부 개수 (5만 이상 상품 수) const { count: pricey } = await supabase .from("products").select("*", { count: "exact", head: true }).gte("price", 50000); // → 2

(2) 합계·평균 — 방법 A: JavaScript에서 집계 (제일 간단)

Supabase의 PostgREST 집계 함수는 기본적으로 꺼져 있습니다. 데이터가 많지 않다면 가져와서 JS로 합치는 게 가장 쉽고 확실합니다.

const { data } = await supabase.from("products").select("price, categories(name)"); // 카테고리별 합계 const sumByCategory: Record<string, number> = {}; for (const p of data) { const cat = p.categories.name; sumByCategory[cat] = (sumByCategory[cat] ?? 0) + p.price; } // → { 전자기기: 209000, 의류: 19000, 도서: 28000 }

(3) 합계·평균 — 방법 B: DB 함수(RPC)로 집계 (데이터가 많을 때)

수천·수만 건이면 DB에서 집계하는 게 빠릅니다. SQL 함수를 한 번만 만들어두고, 호출은 rpc() 로 합니다.

-- 마이그레이션: 카테고리별 집계 함수 (한 번만 작성) create or replace function public.category_stats() returns table (category text, product_count bigint, total_price bigint, avg_price numeric) language sql security invoker stable as $$ select c.name, count(p.id), coalesce(sum(p.price),0), coalesce(round(avg(p.price)),0) from public.categories c left join public.products p on p.category_id = c.id group by c.name order by c.name; $$;
// 호출은 한 줄 const { data } = await supabase.rpc("category_stats"); // → [{category:"전자기기", product_count:2, total_price:209000, avg_price:104500}, ...]

정리: 개수는 count 옵션, 간단한 합계는 JS, 무거운 집계는 RPC 함수. 상황에 맞게 고르세요.

3-6. 입력 (INSERT)

// 한 건 + 저장된 결과 돌려받기 const { data } = await supabase .from("posts").insert({ title: "제목", content: "내용" }).select().single(); // → { id: 36, title:"제목", ... } // 여러 건은 배열로 await supabase.from("order_items").insert([ { order_id: 1, product_id: 1, quantity: 2, unit_price: 89000 }, { order_id: 1, product_id: 2, quantity: 1, unit_price: 120000 }, ]);

⚠️ INSERT가 되려면 그 테이블에 INSERT 권한 + RLS 정책이 있어야 합니다. (3편의 to authenticated with check (...))

3-7. 수정 (UPDATE) — eq로 대상 지정 필수

const { data } = await supabase .from("posts").update({ title: "수정됨" }).eq("id", 36).select().single(); // → { title: "수정됨" }

⚠️ eq 같은 조건을 빠뜨리면 전체 행이 수정됩니다. 항상 대상을 지정하세요. (RLS가 본인 것만 허용하면 한 겹 더 안전)

3-8. 삭제 (DELETE)

const { data } = await supabase .from("posts").delete().eq("id", 36).select(); // data.length === 1 이면 1건 삭제됨 (RLS로 권한 없으면 0건)

3-9. 한눈에 보는 SQL ↔ 쿼리빌더 대조표

하고 싶은 것SQLSupabase 쿼리빌더
조회SELECT name FROM products.from("products").select("name")
조건WHERE price >= 50000.gte("price", 50000)
검색WHERE name ILIKE '%키%'.ilike("name", "%키%")
정렬ORDER BY price DESC.order("price",{ascending:false})
페이징LIMIT 10 OFFSET 10.range(10, 19)
조인JOIN categories ....select("*, categories(name)")
개수SELECT count(*).select("*",{count:"exact",head:true})
집계SELECT sum(price) GROUP BY ...RPC 함수 + .rpc("...") (또는 JS)
입력INSERT INTO ....insert({...})
수정UPDATE ... WHERE id=1.update({...}).eq("id",1)
삭제DELETE FROM ... WHERE id=1.delete().eq("id",1)

마치며

  • API 스펙은 6칸(기능·메서드·경로·권한·요청·응답) 표면 충분합니다. 기획자도 회의에서 같이 정할 수 있습니다.
  • 스펙의 각 칸은 Next.js Route Handler 코드와 1:1로 대응됩니다(권한→401, 입력검증→400, 생성→201).
  • DB 조작은 Supabase 쿼리 빌더로 SQL 없이 가능합니다 — 조회·필터·조인·페이징·입력·수정·삭제까지.
  • 집계만 세 갈래로: 개수는 count, 간단하면 JS, 무거우면 RPC 함수.

이 문서의 표와 예제를 옆에 두면, SQL을 몰라도 쇼핑몰 데이터를 자유롭게 다룰 수 있습니다. 즐거운 개발 되세요! 🚀

댓글

불러오는 중...

댓글을 작성하려면 이 필요합니다.