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

4편에서 쇼핑몰 테이블(카테고리·상품·주문·주문상품)을 만들었습니다. 테이블이 준비됐다면 다음 순서는 "이 데이터로 어떤 API를 제공할지" 스펙을 정하는 것입니다.
이 글은 두 부분입니다.
- API 스펙 정의 → Next.js 구현 : 기획자·비전공자도 회의에서 같이 정할 수 있는 스펙을 만들고, 그대로 코드로 옮깁니다.
- 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 / .ilike | LIKE / ILIKE | 패턴(%=아무 글자) |
.is(c,null) | c IS NULL | NULL 여부 |
.or("a.eq.1,b.gt.2") | a=1 OR b>2 | OR |
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 ↔ 쿼리빌더 대조표
| 하고 싶은 것 | SQL | Supabase 쿼리빌더 |
|---|---|---|
| 조회 | 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을 몰라도 쇼핑몰 데이터를 자유롭게 다룰 수 있습니다. 즐거운 개발 되세요! 🚀





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