ERD데이터 모델링테크 딥다이브

쇼핑몰 팀 프로젝트 ②-2 ERD 설계와 검증 — 데이터 모델링과 ERD 읽는 법

· 2026-10-21 발행· 26분 읽기

목차

팀 쇼핑몰 프로젝트 첫날, 저는 ERD(Entity-Relationship Diagram, 데이터베이스의 표와 표 사이 관계를 그린 그림) 에 표를 열세 개 그려 놓고 꽤 뿌듯했어요. 그런데 튜터 피드백을 받고 보니 그 열세 개 중 제대로 서 있는 표가 거의 없었습니다. 주문 표의 키는 UUID(긴 난수 식별자) 인데 주문상품 표가 그걸 정수로 받고 있었고, 장바구니 두 표에 같은 외래키가 네 개씩 반복돼 있었죠.

이 글은 그 실패에서 시작해요. 시리즈로는 2편의 후반부입니다. 1편 에서 화면과 “보이는 데이터” 목록을 적었고, 2-1편 에서 고객 여정을 따라 도메인과 명사·동사, 트랜잭션 경계, 접근 경로를 뽑아 엔티티 목록을 만들었어요. 이 글(2-2) 은 그 목록을 받아 ERD 로 세우고, 그 ERD 를 읽는 법과 DB 에서 검증하는 법을 다룹니다. 여정과 도메인 이야기는 여기서 되풀이하지 않고 2-1 로 넘겨요. 개념은 수업에서 배운 것으로 설명하고, 증거는 전부 제 프로젝트에서 가져옵니다. 다 읽고 나면 남의 ERD 를 보고 “이 선은 무슨 뜻이고, 이 표는 왜 여기 있나” 를 문장으로 말할 수 있고, 자기 ERD 를 체크리스트 하나로 점검할 수 있어요. 이 글의 데이터베이스(DB) 는 PostgreSQL(오픈소스 관계형 DB) 입니다.

이 글의 지도. 위에는 데이터 모델링 4단계, 가운데에는 ERD 를 읽는 다섯 가지, 아래에는 내 팀 프로젝트의 1차·튜터 피드백·2차·3차 ERD 타임라인이 놓여 있다 이 글의 지도. 개념은 위 두 줄에서, 증거는 아래 줄에서 가져온다

ERD 는 왜 코드보다 먼저 그려야 할까?

데이터 모델링은 화면과 업무에서 저장할 것을 읽어 내어 그림으로, 다시 표로, 마지막에 DDL(표를 만드는 SQL 명령문. SQL 은 DB 에 묻고 명령하는 언어) 로 굳히는 네 단계 작업이다.

화면부터 만들면 저장할 데이터가 무엇인지를 코드를 짜다가 뒤늦게 알게 돼요. 저희 팀이 정확히 그랬습니다. 상세 페이지를 만들다가 사이즈마다 재고가 다르다는 걸 깨달았고, 그 순간 상품 표 하나로는 안 된다는 게 드러났죠. 모델링은 이 발견을 앞으로 당기는 절차예요.

네 단계는 이렇게 이어집니다. 첫째, 업무 파악은 화면과 여정에서 저장할 명사를 적는 일인데, 이건 2-1 에서 끝냈어요. 둘째, 개념 모델링은 그 명사를 엔티티로 세우고 관계선을 긋는 단계예요. 셋째, 논리 모델링에서 키와 타입을 정하고 중복을 정리합니다. ERD 라고 부르는 그림은 보통 이 단계의 산출물이에요. 넷째, 물리 모델링은 DB 를 고르고 DDL 을 쓰는 일입니다.

이 순서가 중요한 이유는 뒤로 갈수록 바꾸는 값이 커지기 때문이에요. 개념 단계에서 엔티티 하나를 지우는 건 상자 하나를 지우는 거지만, DDL 까지 만든 뒤 표를 쪼개면 그 표를 읽는 코드까지 전부 따라 바뀝니다.

데이터 모델링 4단계를 왼쪽에서 오른쪽으로 배치하고 각 단계가 묻는 것, 입력, 산출, 쇼핑몰 예를 적은 그림. 아래에 추상에서 구체로 가는 화살표 각 단계는 앞 단계의 산출물을 입력으로 받는다. 이 글은 ②·③ 을 주로 다룬다

미리 말해 두면, 이 4단계 구분은 수업과 블로그 글 하나(인파, 2021)에서 가져온 정리라 교과서마다 세는 법이 조금 달라요. 다만 “그림을 먼저, DDL 을 나중에” 는 어디서나 같습니다.

엔티티는 무엇을 담는 상자인가?

엔티티는 저장할 대상 하나를 가리키는 상자이고, 그 상자가 실물·개념·문서·이력·코드 중 무엇을 기록하는지에 따라 필요한 열과 수명이 달라진다.

쇼핑몰로 치면, 엔티티는 2-1 에서 뽑은 “회원”, “상품”, “주문” 같은 명사 하나하나예요. 그런데 명사를 나열하는 것만으로는 표가 안 나옵니다. 회원과 주문은 겉으로는 둘 다 상자지만 성격이 달라요. 회원은 가끔 수정되고 오래 남는 기준 데이터고, 주문은 한 번 확정되면 바뀌면 안 되는 전표입니다. 종류를 먼저 붙여야 “이 표에는 수정 시각이 필요한가”, “지워도 되나” 가 따라 나와요.

엔티티 다섯 종류를 다섯 칸으로 나누고 각 칸에 쇼핑몰 예시와 설계 힌트를 적은 그림 다섯 종류와 우리 쇼핑몰의 대응. 오른쪽 두 칸(이력·코드)은 3차 ERD 에서 아직 표가 아니다

표기법은 단순해요. 상자 위에 이름, 안에 열 이름과 타입, 왼쪽에 키 표시(PK·FK) 가 들어갑니다. 아래는 저희 팀 3차 ERD 에서 상품 쪽 세 상자를 잘라 온 것이에요.

3차 ERD 에서 PRODUCTS 상자와 그 아래 PRODUCT_IMAGES, PRODUCT_OPTIONS 상자를 잘라 낸 그림. 각 상자에 uuid id PK, 열 이름, 한글 설명이 보인다 실제 데이터. PRODUCTS 는 유형(실물) 상자, 그 아래 이미지와 옵션 상자는 상품에 딸린 유형 상자다. 가운데 열이 이름, 왼쪽이 타입, PK·FK 가 키 표시

3차 ERD 의 여덟 상자를 종류로 나누면 이렇게 돼요.

종류 3차 ERD 의 상자 그래서 붙인 열
유형 brands · products · product_options · product_images · users deleted_at (지우지 않고 내린다)
무형 cart_items 없음 (주문되면 실제로 지운다)
문서 orders · order_items 주문상품에 이름·단가 복사
이력 없음 (2차의 order_logs) —
코드 없음 (status 열의 허용값 목록으로 대신) —

관계선 끝의 기호는 어떻게 읽을까?

관계선의 양 끝은 “이쪽 하나가 저쪽을 최소 몇 개, 최대 몇 개 가지는가” 를 적은 기호이며, 그 답이 곧 FK 의 NULL(빈 값) 허용 여부와 UNIQUE(중복 금지) 로 구현된다.

쇼핑몰로 치면, 회원과 주문 사이의 선은 “회원 한 명이 주문을 몇 건 할 수 있나” 와 “주문 한 건은 회원이 몇 명인가” 라는 질문 두 개예요. 기호를 외우기보다 이 두 질문을 매번 문장으로 바꾸는 게 핵심입니다.

까마귀발 기호 네 가지(0 또는 1, 정확히 1, 0 이상, 1 이상)와 users-orders 예시 선을 양방향 문장으로 읽은 그림 동그라미는 선택, 막대는 필수, 세 갈래는 여러 개. 한 선을 양쪽에서 두 번 읽는다

많이 헷갈리는 게 실선과 점선이에요. 이건 필수·선택이 아닙니다. 실선(식별 관계) 은 부모의 키가 자식의 기본키 일부가 된다는 뜻, 점선(비식별 관계) 은 부모 키가 자식의 일반 열로만 들어간다는 뜻이에요. 저희 3차 ERD 는 여덟 표 전부 자기 UUID 를 키로 두어 모든 선이 비식별입니다.

식별 관계(실선, 자식 PK 에 부모 키 포함)와 비식별 관계(점선, 자식은 자기 키)를 나란히 비교한 그림 선 모양은 자식 키의 구성을 말한다. 필수·선택은 선 끝 기호가 말한다

실제 데이터로 3차 ERD 의 관계선 여덟 개를 전부 읽어 볼게요. 저희 팀이 그린 선을 제가 두 방향 문장으로 옮긴 것입니다. 처음 이 표를 만들 때는 일곱 줄만 적었다가, 그림을 다시 열어 세어 보니 옵션에서 주문상품으로 가는 “팔린다” 선을 빠뜨렸더라고요. 그림에서 읽어 낸 수는 반드시 그림을 다시 열어 세어야 해요.

선 부모 → 자식 읽기 자식 → 부모 읽기 구현
brands — products 브랜드 하나에 상품 0개 이상 상품은 브랜드 정확히 하나 brand_id NOT NULL
products — product_images 상품 하나에 이미지 0장 이상 이미지는 상품 정확히 하나 product_id NOT NULL
products — product_options 상품 하나에 사이즈 0개 이상 사이즈는 상품 정확히 하나 product_id NOT NULL
users — cart_items 회원 하나에 장바구니 줄 0개 이상 줄은 회원 정확히 하나 user_id NOT NULL
product_options — cart_items 사이즈 하나가 장바구니 줄 0개 이상에 줄은 사이즈 정확히 하나 option_id NOT NULL
users — orders 회원 하나에 주문 0건 이상 주문은 회원 정확히 하나 user_id NOT NULL
orders — order_items 주문 하나에 주문상품 1줄 이상 줄은 주문 정확히 하나 order_id NOT NULL. “1줄 이상” 은 제약으로 못 적는다
product_options — order_items 사이즈 하나가 주문상품 줄 0개 이상에 (팔린다) 줄은 사이즈 정확히 하나 option_id NOT NULL

일곱 번째 줄이 중요해요. 그림은 “주문에는 상품이 최소 하나” 라고 말하지만, DB 는 그 최소값을 강제할 방법이 없습니다. 그래서 그림과 DB 가 늘 일치하지는 않아요. ERD 는 설계의 약속이고, 그 약속이 실제로 지켜지는지는 제약과 코드에서 따로 확인해야 합니다.

기본키와 외래키는 무엇을 보장할까?

기본키는 한 표 안에서 행 하나를 유일하게 가리키는 열이고, 외래키는 다른 표의 기본키 값을 적어 둔 열이며, 참조 무결성은 외래키 값이 반드시 부모에 존재하도록 DB 가 지키는 규칙이다.

쇼핑몰로 치면, 주문서에 적힌 회원 번호가 외래키이고, 회원 명부의 번호가 기본키예요. 참조 무결성은 “명부에 없는 번호로는 주문서를 못 쓴다” 는 규칙입니다. 이름이나 이메일로 연결하면 안 되는 이유도 여기 있어요. 이름은 바뀌고 겹치니까요.

product_options 를 부모, cart_items 를 자식으로 놓고 존재하는 옵션 참조는 성립, 없는 옵션 참조는 거부되는 모습을 그린 그림. 아래에 PK, FK, 참조 무결성 정의 FK 제약이 없어도 값은 적을 수 있다. 다만 그때 “…ff” 는 조용히 들어온다

DDL 로 적으면 이렇게 돼요. 아래는 제가 3차 ERD 의 장바구니 표를 줄여 다시 쓴 것입니다.

CREATE TABLE cart_items (
  id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id   UUID NOT NULL REFERENCES users(id),           -- 외래키 ①
  option_id UUID NOT NULL REFERENCES product_options(id), -- 외래키 ②
  quantity  INTEGER NOT NULL CHECK (quantity > 0),
  UNIQUE (user_id, option_id)                             -- 같은 옵션은 한 줄만
);

REFERENCES 가 외래키 선언, CHECK 는 값의 범위 검사, NOT NULL 은 비워 둘 수 없다는 뜻이에요.

실제로 돌려 봤어요. 이 글을 쓰면서 PGlite(브라우저와 Node 에서 도는 PostgreSQL, 설치 없이 실행 가능) 로 3차 ERD 여덟 표를 만들고 없는 옵션 번호로 장바구니 줄을 넣어 봤더니 이렇게 거부됐습니다.

[ERR] 없는 옵션을 장바구니에 담기
  insert or update on table "cart_items" violates foreign key constraint "cart_items_option_id_fkey"
  Key (option_id)=(00000000-0000-0000-0000-0000000000ff) is not present in table "product_options".

위 그림의 “…ff” 가 바로 이 줄이에요. 반대로 주문된 옵션을 지우려고 하면 자식이 남아 있다며 역시 거부됩니다. 이 두 방향이 참조 무결성의 전부예요.

다대다는 왜 표 두 개로 안 될까?

다대다 관계는 양쪽이 서로를 여러 개 가질 수 있는 관계이며, 두 외래키를 함께 담는 세 번째 표로 꺼내 일대다 두 개로 바꾼다.

쇼핑몰로 치면, 상품 하나가 여러 분류에 속하고 분류 하나에 여러 상품이 있는 경우예요. 상품 표에 분류를 적자니 한 칸에 여러 개가 들어가야 하고, 분류 표에 상품을 적자니 같은 문제가 반대로 생깁니다. 한 칸에 하나만 적는 게 표의 규칙이라서요.

products 와 categories 를 N:M 으로 잇고 싶지만 불가능하다는 ①과, product_categories 중간 표로 1:N 두 개로 푼 ②를 위아래로 그린 그림 중간 표의 기본키는 두 외래키의 조합. 같은 조합이 두 번 들어오지 못한다

실제 데이터로는 저희 3차 ERD 가 이 표를 아직 안 갖고 있어요. products.category 에 Style #트렌치 처럼 문자열 하나만 넣습니다. 제가 PGlite 에 넣은 행도 그 모양이에요.

[OK] 할인가격 자동 계산
[{"name":"트렌치 코트","base_price":159000,"sale_price":129000,"discount_price":30000}]

상품 하나가 분류 하나만 갖는다면 이걸로 충분하고, 여러 분류에 속해야 하는 순간 위 그림의 중간 표가 필요해요. 수업의 프로젝트 안내는 상품-카테고리를 다대다로 두라고 했고, 저희는 범위를 줄이느라 문자열로 접어 뒀습니다. 열린 질문으로 남아 있어요.

정규화와 시점 복사는 왜 충돌하지 않을까?

정규화는 같은 사실을 여러 곳에 적지 않도록 표를 나누는 일이고, 시점 복사는 사건 당시의 값을 일부러 베껴 두는 일이며, 둘은 “이 값이 누구에게 딸린 사실인가” 로 갈린다.

쇼핑몰로 치면, 상품 가격은 상품에 딸린 사실이라 상품 표 한 곳에만 둬요. 그런데 주문 당시 단가는 상품이 아니라 그 주문에 딸린 사실입니다. 가격이 다음 주에 바뀌어도 지난주 영수증은 그대로여야 하니까요.

정규화 세 단계를 한 줄씩만 둘게요. 1NF 는 한 칸에 값 하나, 2NF 는 복합키의 일부에만 딸린 열을 떼어 내기, 3NF 는 키가 아닌 열이 다른 키 아닌 열을 결정하면 떼어 내기예요. 저희 1차 ERD 에서 장바구니 줄마다 브랜드 번호와 판매자 번호가 반복된 게 3NF 위반의 전형이었어요. 상품 번호만 알면 따라 나오는 값이니까요.

products 마스터의 이름과 가격이 바뀔 때, 참조만 하면 주문 내역 화면이 바뀌고, 주문 순간에 order_items 로 복사하면 그때 값이 남는 것을 비교한 그림 같은 “중복” 처럼 보여도 단가는 (그 주문, 그 시각) 에 딸린 다른 사실이다

실제로 해 봤어요. PGlite 에서 M 사이즈 두 개를 주문한 뒤 상품명을 트렌치 코트 (리뉴얼), 판매가를 99,000 으로 바꾸고 주문 내역을 다시 읽었습니다.

열 주문상품 표 (복사본) 상품 표 (현재)
이름 트렌치 코트 트렌치 코트 (리뉴얼)
단가 129,000 99,000
수량 2 —

주문상품 쪽이 그대로예요. 이 표에는 updated_at 도 deleted_at 도 없습니다. 만들어진 뒤 바뀌면 안 되는 전표라서요.

다섯 가지는 어떤 규칙으로 하나의 ERD 가 될까?

합칠 때 쓰는 규칙은 세 줄이에요. 외래키는 항상 “여러 개” 쪽에 둔다. 다대다는 중간 표로 푼다. 관계를 먼저 긋고 열은 나중에 채운다. 세 번째가 제일 자주 어겨져요. 열부터 채우면 관계를 그으면서 열을 지우게 됩니다.

관계 세 종류가 실제 열로 바뀌는 모습을 견주면 이렇습니다.

관계 뜻 어디에 무엇이 생기나 3차 ERD 의 예
1:1 하나에 최대 하나 한쪽에 FK + UNIQUE (또는 FK 를 PK 로) 없음 (2차의 users — seller_profiles)
1:N 하나에 여러 개 N 쪽에 FK 여덟 선 전부
N:M 서로 여러 개 중간 표에 FK 두 개 없음 (카테고리를 문자열로 접음)

다섯 항목을 “그림에서 무엇을 보고, 무엇을 묻고, DB 에서 무엇으로 확인하나” 로 모으면 아래가 이 글의 총정리예요.

읽기 항목 그림에서 보는 것 묻는 질문 DB 에서 확인하는 것
엔티티 종류 상자 이름 실물·개념·전표·사건·코드 중 무엇? deleted_at·updated_at 유무
관계선 기호 선 양 끝 최소 몇, 최대 몇? (양방향) FK 의 NOT NULL, UNIQUE
키와 무결성 PK·FK 표시 부모 없는 자식이 들어올 수 있나? REFERENCES, 자료형 일치
다대다 세 갈래가 양쪽에 한 칸에 여러 개를 넣고 싶은가? 중간 표와 복합 UNIQUE
시점 복사 전표 상자의 중복 열 사건 뒤에도 바뀌면 안 되는 값인가? 복사 열, 수정 열 없음

읽는 순서는 이 표의 위에서 아래예요. 종류를 붙이고, 선을 양쪽에서 읽고, 제약으로 구현됐는지 보고, 전표 상자에서 복사 열을 찾습니다.

3차 ERD 를 체크리스트로 읽으면 무엇이 보일까?

이제 위 총정리를 체크리스트로 바꿔서 저희 팀 3차 ERD 여덟 표에 전부 같은 열로 적용해 볼게요. 판정 항목은 다섯 개예요.

  1. 종류: 유형·무형·문서·이력·코드 중 무엇인가
  2. 부모: 어느 표를 가리키는가 (FK)
  3. 최소·최대: 부모 하나에 이 표가 몇 개인가
  4. 키: PK 자료형이 부모와 같은가
  5. 복사 vs 참조: 다른 표의 값을 베껴 둔 열이 있는가, 있다면 정당한가

먼저 그림 전체예요. 2026년 10월 19일에 팀이 확정한 3차 최종입니다.

팀 3차 최종 ERD 전체. BRANDS, PRODUCTS, PRODUCT_IMAGES, PRODUCT_OPTIONS, USERS, CART_ITEMS, ORDERS, ORDER_ITEMS 여덟 상자가 까마귀발 선 여덟 개로 이어져 있다 3차 최종 ERD (2026-10-19). 모든 키가 uuid 다. 생성·수정·삭제 시각 열은 이 그림 뒤에 문서로만 더했고 그림에는 아직 없다

표 ① 종류 ② 부모 ③ 최소·최대 ④ 키 자료형 일치 ⑤ 복사 열
brands 유형 — — UUID 없음
products 유형 brands 브랜드 하나에 0개 이상 UUID = UUID 없음 (discount_price 는 계산 열)
product_images 유형 products 상품 하나에 0장 이상 UUID = UUID 없음
product_options 유형 products 상품 하나에 0개 이상 UUID = UUID 없음
users 유형 — — UUID 없음
cart_items 무형 users, product_options 회원 하나에 0줄 이상 UUID = UUID 없음 (상품 정보는 옵션 → 상품으로 따라감)
orders 문서 users 회원 하나에 0건 이상 UUID = UUID 있음. 받는 사람·주소를 회원 표가 아닌 주문에 둔다 — 정당 (이사해도 지난 배송지 유지)
order_items 문서 orders, product_options 주문 하나에 1줄 이상 UUID = UUID 있음. 상품명·사이즈·단가 — 정당 (시점 복사)

표를 채우니 세 가지가 보여요. 먼저 ② 열의 부모를 세면 0+1+1+1+0+2+1+2 로 여덟이고, 이건 앞의 관계선 표 여덟 행, 그림의 선 여덟 개와 같은 수예요. 관계선 표의 행 수, 그림의 선 수, 체크리스트의 부모 외래키 합은 셋 다 같아야 하고, 하나라도 다르면 어딘가 빠뜨린 거예요. 제가 일곱 줄로 잘못 적었던 걸 잡아낸 게 이 대조였어요. 다음으로 ④ 열이 전부 같다는 것 — 1차에서 가장 아팠던 자료형 불일치가 “전부 UUID” 한 결정으로 사라졌습니다. 그리고 ⑤ 의 복사가 두 표에만 있고 둘 다 문서 상자라는 것. 종류를 먼저 붙이면 복사가 정당한지 바로 판정돼요.

주문 쪽을 잘라서 ⑤ 를 눈으로 확인해 볼게요.

3차 ERD 에서 ORDERS 상자와 ORDER_ITEMS 상자, 둘을 잇는 “포함한다” 선을 잘라 낸 그림. ORDER_ITEMS 에 주문 당시 상품명·사이즈·단가 열이 보인다 ORDER_ITEMS 의 아래 세 줄이 복사 열. ORDERS 의 받는 사람·주소도 회원 표를 참조하지 않고 직접 들고 있다

위 표의 ③ 에서 order_items 의 “1줄 이상” 은 DB 가 못 지킨다고 했죠. 그래서 주문 코드는 장바구니에서 선택된 줄을 고르고, 주문상품을 넣고, 재고를 빼고, 장바구니를 비우는 네 동작을 트랜잭션(전부 성공하거나 전부 취소되는 묶음) 하나로 묶어요. 왜 이 네 동작이 한 묶음인지는 2-1 의 트랜잭션 경계에서 다뤘고, 여기서는 제가 PGlite 에서 그 트랜잭션을 돌린 결과만 둘게요.

[OK] 주문 트랜잭션
[OK] 재고
[{"size":"L","stock":0},{"size":"M","stock":1}]

M 사이즈 재고가 3 에서 1 로 줄었어요(2개 주문). 그리고 같은 옵션을 두 번 담았을 때 줄이 하나로 합쳐지는지도 확인했습니다. 두 번째 담기는 첫 번째보다 50밀리초 뒤에 실행했어요.

[OK] 장바구니 줄 수·수량
[{"rows":1,"qty":2,"trigger_touched":true}]

줄은 하나, 수량은 둘, 그리고 수정 시각을 자동으로 찍는 트리거가 돌았다는 표시까지요. 회원과 옵션의 조합에 중복 금지를 걸어 두었기에 가능한 동작이에요. 50밀리초를 왜 띄웠는지는 아래 함정에서 이야기할게요.

1차 ERD 는 무엇이 틀렸고 어떻게 고쳤나?

이제 처음으로 돌아갈게요. 10월 13일 첫날 제가 ERD Cloud(브라우저에서 ERD 를 그리는 협업 도구) 에 그린 1차는 표 열세 개였습니다. User, Seller, Brand, Product, Product_images, Cart, Cart_Item, Order, Order_product, Order_status, Delivery, Refund, Return. 많아 보이죠. 그런데 튜터 피드백과 그 뒤 정리를 거치며 드러난 문제는 표가 모자라서가 아니라 역할이 겹치고 키가 어긋나서 였어요.

1차의 문제 어느 읽기 항목을 어긴 것인가 2차(14표)에서 3차(8표)에서
주문의 키는 UUID 인데 주문상품의 참조 열은 정수 키와 무결성 (자료형) 전부 UUID 로 통일 그대로 전부 UUID
장바구니 두 표 모두에 회원·상품·브랜드·판매자 FK 반복 정규화 (3NF) cart_items 한 표, FK 두 개 그대로
상품 이미지에 브랜드·판매자 FK 까지 정규화 (3NF) 상품 FK 하나만 그대로
사이즈를 상품 표의 문자열 한 칸에 엔티티 종류 (유형 안의 실물 분리) product_variants 신설 product_options
주문 상태 표에 수령인·배송지·배송일이 섞임 엔티티 종류 (이력과 문서 혼동) 배송은 orders, 상태 변경은 order_logs orders 로 흡수, 로그는 보류
배송 표에 주소·수량을 또 저장 정규화 orders 에 통합 그대로
판매자 표가 회원 표와 이어지지 않음 관계선 (1:1 누락) seller_profiles 가 users 를 1:0..1 로 확장 판매자 자체를 범위 밖으로
상품과 회원에 쿠폰 여부를 참/거짓 한 칸으로 다대다 (정책과 발급) coupons + user_coupons 쿠폰 범위 밖
날짜 타입 세 가지 혼용 타입 계약 TIMESTAMPTZ 로 통일 같음

1차 열세 표를 왼쪽에, 3차 여덟 표를 오른쪽에 놓고 합쳐진 것, 빠진 것, 새로 생긴 것을 화살표로 이은 그림 열세 개에서 여덟 개로. 줄어든 다섯 개는 지운 게 아니라 역할을 다른 표에 넘기거나(Delivery·Order_status) 범위 밖으로 미룬 것(Seller·Refund·Return) 이다

2차와 3차는 구분해 둘게요. 2차 14표는 1차와 튜터 피드백을 입력으로 AI 와 함께 쓴 설계 제안서이고, 10월 14일에 제가 팀 기준으로 채택했습니다. 하지만 채택은 검증이 아니에요. 튜터가 2차안을 승인한 기록은 없고 출처도 그 문서 하나뿐입니다. 그래서 2차는 “기능이 늘 때 따라갈 확장 목표” 로 두고, 지금 구현할 범위만 남긴 게 3차 8표예요. 3차는 팀이 직접 그린 그림이고, 생성·수정·삭제 시각 열은 제 요청으로 문서에만 더했습니다.

고치면서 배운 건 표의 개수가 설계의 품질이 아니라는 거예요. 열세 개짜리 1차보다 여덟 개짜리 3차가 더 많은 걸 보장합니다.

강의 자료끼리 어긋났던 결정도 하나 있어요. 주문번호를 기본키로 쓸 것인가예요. 한 자료는 주문번호를 BIGINT(8바이트 정수) 기본키로 두었고, 8일 뒤 자료는 내부 키는 UUID, 사람이 읽는 주문번호는 별도 UNIQUE 열로 가르쳤습니다. 저는 후자를 택했어요. 외부에 보이는 번호가 모든 자식 표의 외래키로 퍼지는 걸 피하고 싶었거든요. 두 자료가 왜 달랐는지는 아직 확인하지 못했습니다.

DB 를 돌려 보니 그림과 어디서 어긋났을까?

여기부터는 제가 실제로 부딪힌 것만 적어요. 재현 근거는 이 글을 쓰면서 PGlite 로 다시 돌린 출력입니다.

PGlite 에서 3차 ERD 를 만들고 시나리오 스물한 개를 돌린 터미널 출력 캡처. OK 와 ERR 가 번갈아 나오고 각 ERR 아래에 PostgreSQL 오류 메시지가 있다 전체 실행 출력 (2026-10-20). ERR 로 표시된 줄은 전부 “DB 가 막아야 하는 입력” 이고, 막혔다

1. 자료형이 다르면 외래키 자체가 안 만들어진다. 1차 ERD 의 “UUID 를 정수로 참조” 를 그대로 DDL 로 옮겨 봤어요.

[ERR] 1차 ERD 재현: INT 열이 UUID PK 를 참조
  foreign key constraint "bad_order_product_order_id_fkey" cannot be implemented
  Key columns "order_id" of the referencing table and "id" of the referenced table are of incompatible types: integer and uuid.

그림에서는 선이 멀쩡히 그어져 있었는데, DB 는 표를 만드는 단계에서 거부합니다. 그림 도구는 자료형을 검사하지 않아요.

2. 논리 삭제와 UNIQUE 가 부딪힌다. 사이즈를 지우지 않고 deleted_at 만 찍기로 하면, 상품과 사이즈 조합에 평범한 중복 금지를 걸었을 때 내린 M 때문에 새 M 을 못 만들어요. 그래서 “삭제 안 된 것끼리만” 비교하는 부분 유니크 인덱스를 썼고, 돌려 보니 판매 중인 M 중복은 막히고 내린 뒤 다시 만드는 건 됐습니다.

[ERR] 판매 중인 M 사이즈 중복 등록
  duplicate key value violates unique constraint "uq_options_size_live"
[OK] 내린 M 과 같은 사이즈 다시 등록 (부분 유니크)
[{"size":"M","stock":5}]

3. 1:N 을 그냥 JOIN 하면 행이 늘어난다. 상품 하나에 이미지 세 장을 넣고 상품과 이미지를 그대로 조인(JOIN, 두 표를 키로 이어 붙이는 조회) 했더니 상품이 세 줄로 나왔어요. 목록 화면에 대표 이미지 한 장만 붙이려면 상품마다 한 장만 고르는 LATERAL 서브쿼리가 필요합니다.

[OK] 상품 1 × 이미지 3 을 그냥 JOIN
[{"name":"트렌치 코트","image_url":"/img/1.jpg"},{"name":"트렌치 코트","image_url":"/img/2.jpg"},{"name":"트렌치 코트","image_url":"/img/3.jpg"}]
[OK] 대표 이미지 1장만 붙이기 (LATERAL)
[{"name":"트렌치 코트","image_url":"/img/1.jpg"}]

ERD 의 “0장 이상” 이 조회에서는 “곱하기” 로 나타나요.

4. 화면에 필요한 열이 ERD 에 없을 수 있다. 10월 15일 작업 기록에 따르면, 상세 페이지에 정가 취소선과 판매가를 넣고 나니 총액이 정가로 계산되고 있었어요. 당시 2차 ERD 의 상품 표에는 가격 열이 하나뿐이었습니다. 3차에서 base_price 와 sale_price 를 따로 두고 할인가격은 DB 가 계산하는 열로 만들었어요. 위 출력의 discount_price: 30000 이 그 결과입니다. 직접 값을 넣으려 하면 거부돼요.

5. 재고는 CHECK 가 마지막 방어선이다. 재고 1개짜리 M 에서 2개를 빼 봤더니 stock >= 0 검사가 막았어요. 두 사람이 동시에 마지막 하나를 주문하는 순간은 DB 가 막아야 하는데, 그 동시 실행 자체는 재현하지 않았습니다.

6. 시각으로 하는 검증은 시계의 눈금에 걸린다. 이 글을 고쳐 쓰며 같은 스크립트를 다시 돌렸더니 장바구니 합산 줄이 처음엔 이렇게 나왔어요.

[OK] 장바구니 줄 수·수량
[{"rows":1,"qty":2,"trigger_touched":false}]

줄도 하나, 수량도 둘인데 트리거만 안 돈 것처럼 보이죠. 원인은 트리거가 아니라 시계였어요. 두 번 담기가 1밀리초 안에 끝났고, PGlite 가 돌려주는 시각은 밀리초 단위라 수정 시각과 생성 시각이 같은 값이 됐습니다. 사람 손은 그렇게 빠르지 않지만 테스트 스크립트는 그래요. 두 번째 담기 앞에 50밀리초를 띄우고 돌리니 true 로 돌아왔습니다. “수정 시각이 생성 시각보다 뒤” 같은 검사는 시계의 해상도를 전제로 한다는 걸 이렇게 배웠어요.

요약

ERD 는 상자의 종류, 선 양 끝의 최소·최대, 키의 자료형, 다대다의 해소, 시점 복사 다섯 가지로 읽는다.

그림의 약속은 DB 제약으로 옮겨져야 비로소 지켜진다. 옮길 수 없는 약속(최소 1줄) 은 코드가 맡는다.

그림에서 센 수는 그림을 다시 열어 센다. 관계선 표의 행, 그림의 선, 외래키의 합은 같은 수여야 한다.

표의 개수는 설계의 품질이 아니다. 역할이 겹치지 않고 키가 맞는 여덟 표가 역할이 섞인 열세 표보다 낫다.

주문처럼 확정 뒤 바뀌면 안 되는 전표는 참조하지 않고 복사한다. 정규화와 충돌하는 것이 아니라 다른 사실을 적는 것이다.

저는 이제 ERD 를 볼 때 상자 수를 세지 않고 선 끝부터 읽어요. 그게 이 세 번의 설계가 남긴 습관입니다.

여기까지가 2편이에요. 2-1 에서 여정으로 엔티티를 뽑았고, 2-2 에서 그 엔티티를 ERD 로 세우고 DB 로 검증했습니다. 3편은 데이터 구조에 맞는 프론트엔드 — 이 여덟 표의 열과 관계가 화면의 타입, 컴포넌트, 주소 파라미터에 어떻게 비치는지를 다루고, 4편에서 프론트와 백엔드를 실제로 잇습니다. “전부 UUID” 결정이 화면 쪽에 주는 영향은 3편의 첫 함정이에요.

출처 — 개념은 수업의 SQL·데이터베이스 강의와 인파의 블로그 글 「데이터 모델링 개념 & ERD 다이어그램 작성 총정리」(2021) 에서 배운 것을 제 말로 다시 썼다. 2차 설계 문서는 AI 와 함께 작성했고 튜터 검토는 받지 않았다. 실행 출력은 2026-10-20 에 PGlite 0.5.10(PostgreSQL 18.6) 에서 직접 얻었다.

관련 키워드

ERD데이터 모델링쇼핑몰 데이터베이스 설계정규화외래키PostgreSQL까마귀발 표기법주문 스냅샷팀 프로젝트

앤디의 마케팅노트

마케팅을 시스템으로 만드는 과정을 매주 기록해요. 잘 된 것도, 안 된 것도 같이 봐요.

maily에서 구독하기