기능 구현을 서두르다 테이블을 먼저 만들면 나중에 관계가 꼬이고 중복 데이터가 쌓입니다. ERD는 그림을 예쁘게 그리는 도구가 아니라, 요구사항을 데이터 구조로 검증하는 안전장치입니다. 초기에 관계를 명확히 잡아야 API와 쿼리도 단순해집니다.
엔티티-관계 모델의 기본 개념을 익히고, 실제 쇼핑몰 도메인을 ERD로 설계하는 과정을 통해 데이터 모델링 역량을 키웁니다.
- 1엔티티, 속성, 관계의 개념과 정의
- 2까마귀발 표기법(Crow's Foot Notation)
- 31:1, 1:N, N:M 관계 유형
- 4쇼핑몰 ERD 실전 설계
- 5N:M 중간 테이블 구현
데이터 모델링과 ERD — 테이블 설계의 시작점
데이터베이스 설계의 첫 번째 단계는 코드나 SQL이 아닙니다. 비즈니스 도메인을 이해하고, 어떤 데이터가 존재하며, 그 데이터들이 서로 어떤 관계를 맺는지를 시각화하는 것입니다. **ERD(Entity-Relationship Diagram)**는 바로 그 작업을 위한 표준 도구입니다.
ERD 핵심 3요소 — 엔티티, 속성, 관계
새 기능 개발 전에 "ERD 먼저 그려봐"라는 말을 듣습니다. 네모와 선으로 이루어진 다이어그램인데, 어디서부터 시작해야 할지 모릅니다. 엔티티, 속성, 관계 — 이 세 가지를 정확히 이해해야 ERD가 코드보다 먼저 데이터 구조를 논의하는 팀 공용 언어가 됩니다.

엔티티 (Entity)
엔티티는 비즈니스 도메인에서 독립적으로 식별 가능하고 데이터를 저장할 가치가 있는 객체나 개념입니다. ERD에서는 직사각형으로 표현되며, 데이터베이스에서 테이블이 됩니다.
엔티티를 식별할 때는 세 가지 질문을 던져봅니다. 이 개체에 대해 여러 데이터를 저장해야 하는가? 다른 개체와 구별되는 고유한 식별자가 있는가? 이 개체에 대한 쿼리가 자주 발생하는가? 쇼핑몰 도메인이라면 User, Product, Category, Order, OrderItem, Cart, Review가 엔티티 후보입니다.
강한 엔티티 vs 약한 엔티티:
모든 엔티티가 독립적으로 존재하는 것은 아닙니다. 부모 엔티티 없이는 의미가 없는 종속 엔티티를 약한 엔티티라고 부릅니다.
| 구분 | 정의 | 예시 |
|---|---|---|
| 강한 엔티티 | 자체 PK로 독립 존재 가능 | User, Product, Category |
| 약한 엔티티 | 부모 엔티티에 종속, 부모 없이 의미 없음 | OrderItem (Order 없이는 의미 없음) |
속성 (Attribute)
속성은 엔티티가 가지는 개별 데이터 항목입니다. ERD에서 타원으로 표현되며, DB에서 컬럼이 됩니다.
| 속성 종류 | 설명 | 예시 |
|---|---|---|
| 단순 속성 | 더 이상 분해 불가 | age, price |
| 복합 속성 | 더 작은 단위로 분해 가능 | address → city + street + zipcode |
| 다값 속성 | 여러 값을 가질 수 있음 | phone_numbers → 별도 테이블로 분리 |
| 유도 속성 | 다른 속성으로부터 계산 | age (birthdate로부터 계산) |
| 기본키 속성 | 엔티티를 고유하게 식별 | user_id, product_id |
관계 (Relationship)와 카디널리티
관계는 두 엔티티 사이의 연관성을 나타냅니다. **카디널리티(Cardinality)**는 관계에 참여하는 인스턴스의 수입니다.
까마귀발 표기법 (Crow's Foot Notation)
까마귀발 표기법은 선 끝 기호로 최솟값(왼쪽 기호)과 최댓값(오른쪽 기호)을 동시에 표현합니다. 원(○)은 "0개 가능"을, 수직선(|)은 "정확히 1개"를, 까마귀발(<)은 "여러 개"를 나타냅니다.
기호 의미:
─────| 정확히 1 (Exactly One) — 반드시 1개 존재
────○| 0 또는 1 (Zero or One) — 있거나 없거나
─────< 1 이상 (One or Many) — 최소 1개, 여러 개 가능
────○< 0 이상 (Zero or Many) — 없거나 여러 개 가능
1:N 관계 — User와 Order:
User ─────|────○< Order
(한 사용자) (0개 이상의 주문)
→ 사용자는 반드시 1명, 주문은 0개 이상
N:M 관계 — Order와 Product:
Order ○<────|────○< Product
(여러 주문) (여러 상품)
→ 한 주문에 여러 상품, 한 상품이 여러 주문에 포함
세 가지 관계 유형
1:1 관계 — 각 인스턴스가 정확히 하나의 상대 인스턴스와 연결됩니다. 구현 시 자식 테이블의 FK 컬럼에 UNIQUE 제약을 추가합니다.
User ─────|─────| UserProfile
한 사용자는 프로필을 정확히 하나만 가짐
구현: UserProfile.user_id UNIQUE FK
1:N 관계 — 하나의 인스턴스가 여러 상대 인스턴스와 연결됩니다. 가장 흔한 관계 유형입니다. 구현 시 N쪽 테이블에 FK 컬럼을 추가합니다.
Category ─────|────○< Product
한 카테고리에 여러 상품이 속함
구현: Product.category_id FK
N:M 관계 — 양쪽 모두 여러 인스턴스와 연결됩니다. RDBMS에서는 직접 표현할 수 없으므로 중간 테이블로 분해합니다.
Order ○<────────────○< Product
한 주문에 여러 상품, 한 상품이 여러 주문에 포함
구현: OrderItem 중간 테이블 필요
실전 ERD 설계 — 쇼핑몰 데이터 모델링
ERD 표기법은 이해했는데 실제로 어떻게 그리는지 막막합니다. 상품, 주문, 사용자가 있는데 이들이 어떤 관계인지, N:M 관계를 테이블로 어떻게 표현하는지 모릅니다. 쇼핑몰 도메인은 JOIN, N:M, 중간 테이블을 모두 포함하는 실무 대표 케이스로, 이 설계를 따라가면 실제 프로젝트 ERD 작성이 가능해집니다.

쇼핑몰 도메인 분석
SQL을 작성하기 전에 비즈니스 요구사항을 먼저 문장으로 정리합니다. 요구사항에서 명사는 엔티티 후보, 동사는 관계 후보가 됩니다.
- 사용자는 회원가입 후 로그인할 수 있다
- 상품은 카테고리에 속한다 (카테고리는 계층 구조)
- 사용자는 여러 상품을 장바구니에 담을 수 있다
- 사용자는 주문을 생성하고, 주문에는 여러 상품이 포함된다
- 사용자는 구매한 상품에 리뷰를 작성할 수 있다
- 상품은 여러 이미지를 가질 수 있다
쇼핑몰 ERD (까마귀발 ASCII 다이어그램)
아래 다이어그램에서 화살표 방향은 FK가 위치하는 쪽(N쪽)을 가리킵니다. categories의 parent_id는 같은 테이블을 참조하는 자기참조 관계로 계층 구조를 표현합니다.
┌─────────────────┐ ┌─────────────────────┐
│ categories │ │ products │
├─────────────────┤ ├─────────────────────┤
│ PK id │──┐ │ PK id │
│ name │ └──> │ FK category_id │
│ FK parent_id ───┘ │ name │
│ slug │ │ description │
│ created_at │ │ price │
└─────────────────┘ │ stock │
│ created_at │
└──────────┬──────────┘
│
┌────────────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ product_imgs │ │ order_items │ │ cart_items │
├──────────────┤ ├─────────────────┤ ├──────────────────┤
│ PK id │ │ PK id │ │ PK id │
│ FK product_id│ │ FK order_id │ │ FK cart_id │
│ url │ │ FK product_id │ │ FK product_id │
│ sort_order│ │ quantity │ │ quantity │
└──────────────┘ │ unit_price │ │ added_at │
└────────┬────────┘ └────────┬─────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ users │ │ orders │ │ carts │
├─────────────────┤ ├─────────────────┤ ├─────────────────┤
│ PK id │───>│ PK id │ │ PK id │
│ email (UQ) │ │ FK user_id │ │ FK user_id (UQ) │
│ password │ │ status │ │ created_at │
│ name │ │ total │ └─────────────────┘
│ created_at │ │ created_at │
└────────┬────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ reviews │
├─────────────────┤
│ PK id │
│ FK user_id │
│ FK product_id │
│ rating (1-5) │
│ content │
│ created_at │
└─────────────────┘
SQL 구현 — 핵심 테이블 생성
ERD를 확정한 뒤 SQL로 변환합니다. 테이블 생성 순서는 참조 방향에 따라 결정됩니다. 참조 대상(부모 테이블)을 먼저 생성해야 FK 선언이 가능합니다. 여기서는 categories → products → users → orders → order_items 순서입니다.
order_items의 unit_price는 products.price를 그대로 참조하는 것이 아니라 주문 시점의 가격을 별도로 저장합니다. 이후 상품 가격이 변경되어도 주문 당시 금액이 보존되어야 하기 때문입니다.
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
parent_id INT REFERENCES categories(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE products (
id SERIAL PRIMARY KEY,
category_id INT NOT NULL REFERENCES categories(id),
name VARCHAR(255) NOT NULL,
description TEXT,
price DECIMAL(12, 2) NOT NULL CHECK (price >= 0),
stock INT NOT NULL DEFAULT 0 CHECK (stock >= 0),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password CHAR(60) NOT NULL,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id),
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','paid','shipped','delivered','cancelled')),
total DECIMAL(12, 2) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE order_items (
id SERIAL PRIMARY KEY,
order_id INT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id INT NOT NULL REFERENCES products(id),
quantity INT NOT NULL CHECK (quantity > 0),
unit_price DECIMAL(12, 2) NOT NULL,
UNIQUE(order_id, product_id)
);
실행 완료 또는 조회 결과가 표시됩니다.
- 엔티티 경계—각 테이블이 하나의 책임만 갖는지 확인합니다.
- 관계 방향—1:N, N:M 관계가 FK나 조인 테이블로 표현되는지 봅니다.
- 중복 후보—같은 속성이 여러 테이블에 반복되지 않는지 점검합니다.
N:M 중간 테이블의 핵심 원칙
N:M 관계를 중간 테이블로 분해할 때 지켜야 할 설계 원칙입니다. 두 FK의 조합에 UNIQUE 제약을 걸어야 같은 조합이 중복 삽입되는 것을 방지할 수 있습니다. 관계 자체에 속하는 데이터(수량, 가격 스냅샷 등)는 중간 테이블의 컬럼으로 추가합니다.
N:M 중간 테이블 설계 체크리스트:
✓ 양쪽 테이블의 PK를 FK로 포함
✓ 두 FK의 조합에 UNIQUE 제약 (중복 방지)
✓ 관계 자체에 속하는 속성은 중간 테이블 컬럼으로 추가
예: order_items의 quantity, unit_price는 "주문-상품" 관계의 속성
✓ unit_price는 반드시 주문 시점 가격 스냅샷으로 저장
(현재 products.price 참조 금지 — 가격 변경 시 과거 주문 금액 오염)
orders 테이블에 product_ids 컬럼을 만들고 상품 ID를 콤마로 구분해 저장했습니다. 처음에는 단순해 보였지만, 각 상품의 수량과 주문 당시 가격을 저장할 방법이 없었습니다. 결국 quantities라는 컬럼도 추가했고, 두 컬럼의 배열 인덱스를 맞춰 파싱하는 애플리케이션 코드가 생겼습니다. 특정 상품이 포함된 주문 목록을 조회하는 SQL도 LIKE '%42%' 방식이 되어 인덱스를 타지 못했고, 상품 ID 42와 142가 혼용되는 버그도 발생했습니다.
원인: 1NF를 위반하는 구조입니다. N:M 관계는 반드시 중간 테이블로 분해해야 합니다.
해결 방법: order_items 중간 테이블을 생성하고 데이터를 마이그레이션합니다. order_id + product_id 복합 UNIQUE 제약으로 중복을 방지하고, quantity와 unit_price를 별도 컬럼으로 저장합니다.
신규 기능을 개발할 때 코드보다 ERD를 먼저 작성하는 것이 효과적입니다. dbdiagram.io나 draw.io 같은 도구로 ERD를 그린 뒤 팀 리뷰를 진행하면, 구현 이전에 잘못된 관계 설계를 발견할 수 있습니다. 예를 들어 "쿠폰은 특정 사용자에게만 발급될 수도 있고, 전체 공개될 수도 있다"는 요구사항은 ERD 리뷰에서 user_id가 NULL 허용인지 결정해야 한다는 논의로 이어집니다. 이 결정을 코드 작성 후에 하면 스키마 마이그레이션 비용이 발생합니다.
실제 팀에서 사용하는 ERD 리뷰 체크리스트는 다음과 같습니다. N:M 관계에 중간 테이블이 있는가? 모든 엔티티에 PK가 있는가? ON DELETE 정책이 비즈니스 규칙과 일치하는가? 자주 조인되는 컬럼에 인덱스가 있는가? 이 체크리스트를 통과한 ERD가 SQL DDL 작성의 출발점이 됩니다.
다음 모듈에서는 테이블, 스키마, 컬럼 등 데이터베이스 핵심 용어와 계층 구조를 다룹니다.