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

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

김성박660
목차17

초보자가 쇼핑몰을 만들 때 가장 먼저 부딪히는 벽은 화면도, 코드도 아닙니다. "데이터를 어떤 표(테이블)로 나눌 것인가" 입니다. 여기서 잘못 설계하면 나중에 데이터가 꼬이고 버그가 쏟아집니다.

이 글은 하나의 쇼핑몰 예제로 데이터 모델링의 전 과정을 따라갑니다.

  1. 논리 모델링 — 무엇을 저장할지 "개념"으로 정리
  2. 정규화 — 표를 올바르게 쪼개는 규칙(1NF·2NF·3NF)
  3. 물리 모델링 — 실제 DB 테이블(타입·키·외래키·인덱스)로 변환
  4. Supabase 테이블 생성 — 마이그레이션 + RLS
  5. 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홍길동이어폰전자기기890001
1홍길동키보드전자기기1200001

이제 한 칸에 한 값(원자값)입니다. 하지만 주문자·상품 정보가 행마다 반복됩니다.

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와 화면은 술술 풀립니다. 즐거운 모델링 되세요! 🚀

댓글

불러오는 중...

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