쇼핑몰로 배우는 데이터 모델링 — 논리모델링·정규화·물리모델링부터 Supabase·API·Next.js까지

초보자가 쇼핑몰을 만들 때 가장 먼저 부딪히는 벽은 화면도, 코드도 아닙니다. "데이터를 어떤 표(테이블)로 나눌 것인가" 입니다. 여기서 잘못 설계하면 나중에 데이터가 꼬이고 버그가 쏟아집니다.
이 글은 하나의 쇼핑몰 예제로 데이터 모델링의 전 과정을 따라갑니다.
- 논리 모델링 — 무엇을 저장할지 "개념"으로 정리
- 정규화 — 표를 올바르게 쪼개는 규칙(1NF·2NF·3NF)
- 물리 모델링 — 실제 DB 테이블(타입·키·외래키·인덱스)로 변환
- Supabase 테이블 생성 — 마이그레이션 + RLS
- API + Next.js — 만든 테이블을 실제로 사용

이 글은 시리즈의 4편입니다. 1편 소셜 로그인 · 2편 세션으로 내 API 호출 · 3편 RLS 게시판
1. 논리 모델링 — "무엇을, 어떻게 연결할까"
논리 모델링은 DB 종류와 무관하게, 현실의 개념을 엔티티(표가 될 대상) 와 관계로 정리하는 단계입니다. 코드를 짜기 전에 종이에 그리는 설계도라고 보면 됩니다.
쇼핑몰을 한 문장씩 뜯어봅시다.
- "회원이 상품을 골라 주문한다."
- "상품은 카테고리에 속한다."
- "한 번의 주문에는 여러 상품이 담길 수 있다."
여기서 명사를 뽑으면 엔티티가 보입니다 — 회원 · 카테고리 · 상품 · 주문. 그리고 "주문에 여러 상품"을 표현할 주문상품이 하나 더 필요합니다(곧 설명).
엔티티와 속성
| 엔티티 | 속성(무엇을 저장하나) |
|---|---|
| 회원(member) | 이메일, 이름 … (Supabase가 auth.users로 제공) |
| 카테고리(category) | 카테고리명 |
| 상품(product) | 상품명, 가격, 재고, 설명 |
| 주문(order) | 주문자, 주문일시, 상태, 총액 |
| 주문상품(order_item) | 어떤 주문에 / 어떤 상품을 / 몇 개 / 얼마에 |
관계 — 1:N 과 N:M
관계는 "하나가 여럿과 연결되는가"로 따집니다.
- 카테고리 1 : N 상품 — 한 카테고리에 상품이 여러 개. (전자기기 카테고리 ⊃ 이어폰, 키보드 …)
- 회원 1 : N 주문 — 한 회원이 주문을 여러 번.
- 주문 N : M 상품 — 한 주문에 상품 여러 개, 한 상품이 여러 주문에. 양쪽 다 여럿입니다.
[카테고리] 1 ──< N [상품] [회원] 1 ──< N [주문] 1 ──< N [주문상품] N >── 1 [상품] └─ 주문 N:M 상품을 "주문상품"으로 풀어냄
🔑 핵심: 관계형 DB는 N:M 관계를 직접 표현하지 못합니다. 그래서 그 사이에 교차 테이블(주문상품) 을 두어
1:N+N:1두 개로 풀어냅니다. 이게 초보자가 가장 많이 놓치는 부분입니다.
2. 정규화 — 표를 "올바르게" 쪼개는 규칙
처음엔 누구나 모든 걸 한 표에 욱여넣고 싶어집니다. 이런 식으로요.
❌ 비정규형 (모든 걸 한 표에)
| 주문번호 | 주문자 | 상품들 | 카테고리 | 총액 |
|---|---|---|---|---|
| 1 | 홍길동 | 이어폰, 키보드 | 전자기기, 전자기기 | 209000 |
문제가 한가득입니다.
- "상품들" 한 칸에 여러 값 → "이어폰이 몇 개 팔렸나?" 같은 질문에 답할 수 없음
- 같은 정보(주문자·카테고리)가 중복 → 수정 시 일부만 바뀌어 데이터가 어긋남(이상 현상)
정규화는 이걸 단계적으로 고치는 규칙입니다.
1NF (제1정규형) — "한 칸에는 한 값만"
반복되는 값을 행으로 분리합니다.
| 주문번호 | 주문자 | 상품 | 카테고리 | 단가 | 수량 |
|---|---|---|---|---|---|
| 1 | 홍길동 | 이어폰 | 전자기기 | 89000 | 1 |
| 1 | 홍길동 | 키보드 | 전자기기 | 120000 | 1 |
이제 한 칸에 한 값(원자값)입니다. 하지만 주문자·상품 정보가 행마다 반복됩니다.
2NF (제2정규형) — "키의 일부에만 매달린 정보 분리"
이 표의 식별자(키)는 (주문번호 + 상품) 입니다. 그런데
- 주문자는 주문번호에만 매달려 있고(상품과 무관),
- 카테고리·단가는 상품에만 매달려 있습니다(주문번호와 무관).
이렇게 키의 일부에만 종속된 걸 분리합니다(부분 종속 제거).
- 주문(order) : 주문번호, 주문자
- 상품(product) : 상품번호, 상품명, 카테고리, 가격
- 주문상품(order_item) : 주문번호, 상품번호, 수량, 단가 스냅샷
💡 단가를 주문상품에 스냅샷으로 또 저장하는 이유: 상품 가격은 나중에 바뀔 수 있지만, "그때 얼마에 샀는지" 는 영수증처럼 고정돼야 하기 때문입니다. 이건 중복이 아니라 의도된 기록입니다.
3NF (제3정규형) — "키가 아닌 값에 매달린 정보 분리"
상품 표에 카테고리(이름)가 그대로 들어 있습니다. 만약 카테고리에 설명·이미지 같은 속성이 늘어나면, 그건 상품이 아니라 카테고리에 종속된 정보입니다(이행 종속). 그래서 카테고리를 독립 테이블로 떼고, 상품은 카테고리 번호(외래키) 만 가집니다.
- 카테고리(category) : 카테고리번호, 카테고리명
- 상품(product) : 상품번호, 상품명, category_id(FK), 가격, 재고
이렇게 하면 "전자기기"라는 이름이 DB에 딱 한 번만 저장됩니다. 이름을 바꿔도 한 곳만 고치면 됩니다.
정규화 결과 → 우리가 만들 4개 테이블: categories, products, orders, order_items. 바로 1번에서 그린 논리 모델과 정확히 일치합니다.
3. 물리 모델링 — 실제 DB 테이블로 변환
논리 모델을 특정 DB(PostgreSQL/Supabase) 의 문법으로 옮기는 단계입니다. 여기서 정하는 것들:
- 자료형: 가격 →
integer(원 단위), 이름 →text, 시간 →timestamptz, 회원 →uuid - 기본키(PK):
bigint generated always as identity(자동 증가) - 외래키(FK): 관계를 DB가 강제 (
references) — 없는 카테고리를 가리키는 상품을 막음 - 제약(constraint):
check (price >= 0),unique (order_id, product_id) - 인덱스(index): 자주 조인/조회하는 외래키 컬럼에 (
products(category_id)등) → 빠른 조회
| 논리(개념) | 물리(Postgres) |
|---|---|
| 상품번호 | id bigint ... primary key |
| 카테고리에 속함 | category_id bigint references categories(id) |
| 가격(0 이상) | price integer check (price >= 0) |
| 주문자 | user_id uuid references auth.users(id) |
| 한 주문에 같은 상품 중복 금지 | unique (order_id, product_id) |
4. Supabase에 테이블 만들기 (마이그레이션 + RLS)
supabase migration new create_shop_schema 로 파일을 만들고, 위 물리 모델을 SQL로 작성합니다.
-- 카테고리 create table public.categories ( id bigint generated always as identity primary key, name text not null unique, created_at timestamptz not null default now() ); -- 상품 (카테고리 1:N 상품) create table public.products ( id bigint generated always as identity primary key, category_id bigint not null references public.categories (id), name text not null, price integer not null check (price >= 0), stock integer not null default 0 check (stock >= 0), description text, created_at timestamptz not null default now() ); create index on public.products (category_id); -- 주문 (회원 1:N 주문) create table public.orders ( id bigint generated always as identity primary key, user_id uuid not null default auth.uid() references auth.users (id) on delete cascade, status text not null default 'PENDING', total_amount integer not null default 0, created_at timestamptz not null default now() ); create index on public.orders (user_id); -- 주문상품 (주문 N:M 상품 교차 테이블) create table public.order_items ( id bigint generated always as identity primary key, order_id bigint not null references public.orders (id) on delete cascade, product_id bigint not null references public.products (id), quantity integer not null check (quantity > 0), unit_price integer not null, -- 주문 시점 단가 스냅샷 unique (order_id, product_id) ); create index on public.order_items (order_id);
그리고 RLS(행 단위 보안) 로 접근 규칙을 DB가 강제하게 합니다. (RLS의 자세한 설명은 3편 참고)
alter table public.categories enable row level security; alter table public.products enable row level security; alter table public.orders enable row level security; alter table public.order_items enable row level security; -- 상품/카테고리: 누구나 조회(쇼핑몰 구경) create policy "카테고리 공개 조회" on public.categories for select using (true); create policy "상품 공개 조회" on public.products for select using (true); -- 주문: 본인 것만 조회/생성 create policy "내 주문 조회" on public.orders for select to authenticated using ((select auth.uid()) = user_id); create policy "내 주문 생성" on public.orders for insert to authenticated with check ((select auth.uid()) = user_id); -- 주문상품: 본인 주문에 속한 것만 create policy "내 주문상품 조회" on public.order_items for select to authenticated using (exists (select 1 from public.orders o where o.id = order_id and o.user_id = (select auth.uid()))); create policy "내 주문상품 생성" on public.order_items for insert to authenticated with check (exists (select 1 from public.orders o where o.id = order_id and o.user_id = (select auth.uid()))); grant select on public.categories, public.products to anon, authenticated; grant select, insert on public.orders, public.order_items to authenticated;
마지막으로 구경할 거리가 있도록 시드 데이터를 넣고,
insert into public.categories (name) values ('전자기기'), ('의류'), ('도서'); insert into public.products (category_id, name, price, stock, description) values ((select id from public.categories where name = '전자기기'), '무선 이어폰', 89000, 50, '노이즈 캔슬링 지원'), ((select id from public.categories where name = '전자기기'), '기계식 키보드', 120000, 30, '청축 스위치'), ((select id from public.categories where name = '의류'), '코튼 티셔츠', 19000, 100, '100% 면'), ((select id from public.categories where name = '도서'), 'Next.js 입문', 28000, 20, '초보자를 위한 안내서');
클라우드 Supabase면 supabase db push, 로컬이면 supabase migration up 으로 적용합니다.
5. API 만들기 — 조인으로 정규화의 열매를 거두기
상품 목록 — src/app/api/products/route.ts
정규화로 카테고리를 분리했으니, 화면에 보여줄 때는 조인으로 카테고리명을 가져옵니다. Supabase에선 categories(name) 한 줄이면 됩니다.
import { NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; // GET /api/products — 상품 목록 (누구나) export async function GET() { const supabase = await createClient(); const { data, error } = await supabase .from("products") .select("id, name, price, stock, description, categories(name)") // ← 조인! .order("id"); if (error) return NextResponse.json({ error: error.message }, { status: 500 }); // 중첩된 categories.name 을 평평하게 펴서 응답 const products = (data ?? []).map((p) => { const cats = p.categories as { name: string } | { name: string }[] | null; return { id: p.id, name: p.name, price: p.price, stock: p.stock, description: p.description, category: (Array.isArray(cats) ? cats[0]?.name : cats?.name) ?? null, }; }); return NextResponse.json(products); }
주문 생성 — src/app/api/orders/route.ts
주문은 로그인 필요(인증 헬퍼는 3편의 getAuthContext). 핵심은 가격을 클라이언트에서 받지 않고 DB에서 다시 조회하는 것 — 가격 조작을 막습니다.
export async function POST(request: Request) { const { supabase, user } = await getAuthContext(request); if (!user) return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); const { items } = await request.json(); // [{ product_id, quantity }] // 1) 현재 가격을 DB에서 조회(클라이언트가 보낸 가격을 믿지 않음) const ids = items.map((i) => i.product_id); const { data: products } = await supabase.from("products").select("id, price").in("id", ids); const priceMap = new Map(products.map((p) => [p.id, p.price])); // 2) 총액 계산 + 주문상품 행 구성(단가 스냅샷) let total = 0; const rows = items.map((i) => { const price = priceMap.get(i.product_id); total += price * i.quantity; return { product_id: i.product_id, quantity: i.quantity, unit_price: price }; }); // 3) 주문 생성 → 주문상품 생성 (RLS 가 "본인 주문"만 허용) const { data: order } = await supabase .from("orders").insert({ user_id: user.id, total_amount: total, status: "PENDING" }) .select().single(); await supabase.from("order_items").insert(rows.map((r) => ({ ...r, order_id: order.id }))); return NextResponse.json({ ...order, items: rows }, { status: 201 }); }
내 주문 목록(
GET /api/orders)은 한 번의 조인으로 주문 + 항목 + 상품명을 가져옵니다:
select("id, total_amount, order_items(quantity, unit_price, products(name))"). 정규화로 잘 쪼개 두면, 필요할 때 조인으로 합치기는 쉽습니다.
6. Next.js에서 사용하기 — src/app/shop/page.tsx
만든 API를 화면에서 호출합니다. 카테고리 배지는 조인으로 가져온 값입니다.
"use client"; import { useEffect, useState } from "react"; export default function ShopPage() { const [products, setProducts] = useState([]); useEffect(() => { fetch("/api/products", { cache: "no-store" }).then((r) => r.json()).then(setProducts); }, []); return ( <div className="grid grid-cols-2 gap-3"> {products.map((p) => ( <div key={p.id} className="rounded-xl border p-4"> <span className="rounded-full bg-gray-100 px-2 text-xs">{p.category}</span> <h2 className="font-medium">{p.name}</h2> <strong>{p.price.toLocaleString("ko-KR")}원</strong> </div> ))} </div> ); }
결과가 맨 위 스크린샷입니다 — 카테고리·가격·재고가 깔끔하게 표시됩니다.
7. 정말 동작할까? — E2E로 검증
Playwright로 실제 클라우드 Supabase에 붙여 검증했습니다.
// 상품 목록/상세는 비로그인도 OK + 카테고리 조인 확인 const products = await (await request.get("/api/products")).json(); expect(products[0].category).toBeTruthy(); // 조인된 카테고리명 // 주문은 로그인 필요(401) → 로그인 시 생성, 총액 일치 expect((await request.post("/api/orders", { data: { items } })).status()).toBe(401); const order = await (await buyerApi.post("/api/orders", { data: { items } })).json(); expect(order.total_amount).toBe(a.price * 2 + b.price * 1); // 타인은 내 주문을 못 본다 (RLS) const others = await (await otherApi.get("/api/orders")).json(); expect(others.find((o) => o.id === order.id)).toBeFalsy();
전부 통과 ✅
✓ 상품 목록/상세는 비로그인도 보이고, 카테고리가 조인된다 ✓ UI: /shop 에 상품과 카테고리가 표시된다 ✓ 주문은 로그인해야 하고(401), 생성·조회되며 타인은 못 본다
마치며 — 모델링이 8할이다
- 논리 모델링으로 "무엇을, 어떻게 연결할지" 먼저 그린다(엔티티·1:N·N:M).
- N:M은 교차 테이블(주문상품)로 푼다.
- 정규화(1·2·3NF) 로 중복을 없애고 표를 올바르게 쪼갠다 — 단, 단가 스냅샷처럼 "의도된 기록"은 예외.
- 물리 모델링에서 타입·PK·FK·인덱스·제약을 정한다.
- 잘 쪼개 두면 조인으로 합치기는 쉽고, 데이터는 꼬이지 않는다.
설계가 탄탄하면 그 위의 API와 화면은 술술 풀립니다. 즐거운 모델링 되세요! 🚀





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