엔터티(Entity)와 관계(Relationship)를 그림으로 표현한 데이터 모델.
세 단계로 구체화한다
개념 모델 업무를 그대로 옮긴다. 주요 엔터티와 관계만
"회원이 주문을 한다"
↓
논리 모델 속성 · 키 · 정규화. DB 제품과 무관하다
회원(회원ID, 이름, 이메일) — 주문(주문ID, 회원ID, 금액)
↓
물리 모델 타입 · 인덱스 · 파티션. 제품에 맞춘다
member(id BIGINT, email VARCHAR(255) ...) + INDEX(email)
논리에서 정규화를 마치고, 물리에서 성능을 고민한다는 순서가 핵심이다. 처음부터 성능을 걱정해 넓은 테이블을 만드는 것이 흔한 실수다.
관계의 차수(카디널리티)
| 1:1 | 회원 — 회원상세 | (자주 안 쓴다. 대개 한 테이블로 합친다) |
|---|---|---|
| 1:N | 회원 — 주문 | (가장 흔하다. N 쪽이 FK 를 가진다) |
| N:M | 학생 — 과목 | (직접 표현할 수 없다 → 연결 테이블로 푼다) |
N:M은 반드시 연결 테이블로
-- 학생과 과목은 서로 여럿 → 중간 테이블을 만든다
CREATE TABLE enrollment (
student_id BIGINT REFERENCES student(id),
subject_id BIGINT REFERENCES subject(id),
enrolled_at TIMESTAMP NOT NULL DEFAULT now(), -- 관계 자체의 속성이 붙는다
PRIMARY KEY (student_id, subject_id)
);
연결 테이블은 단순한 다리가 아니다 — 관계 자체가 가지는 속성(수강 신청 시각·성적)이 여기 들어간다.
식별 관계 vs 비식별 관계
-
식별 관계 — 부모의 키가 자식의 기본키 '일부' 가 된다
- enrollment(student_id, subject_id) ← 부모 키가 PK 에 포함
- 부모 없이는 존재할 수 없다(약한 엔터티)
-
비식별 관계 — 부모의 키가 일반 속성(FK)일 뿐이다
- orders(id, member_id) ← id 가 독립적인 PK
- 부모가 없어도 논리적으로 존재 가능하다
식별 관계를 남발하면 키가 계속 길어진다
- 주문(주문ID) → 주문상품(주문ID, 상품ID) → 주문상품이력(주문ID, 상품ID, 순번)
- 손자 테이블의 PK 가 3개 칼럼. 조인과 인덱스가 무거워진다
실무 기본값은 비식별 + 대리키다
흔한 모델링 안티패턴
-
① EAV (Entity-Attribute-Value)
- 속성을 행으로 저장한다 → 조회마다 피벗. 타입 검증 불가. 대개 후회한다
- (정말 필요하면 jsonb 가 낫다)
-
② 만능 코드 테이블
- 모든 코드를 한 테이블에 몰아넣는다 → FK 를 걸 수 없다
-
③ 나중에 쓸까 봐 만든 칼럼
- col1 · col2 · reserved1 ... → 의미를 아무도 모르게 된다
면접 함정
- ❌ "1:1이면 테이블을 나눈다" → 대개 합치는 게 낫다. 접근 빈도나 보안 등급이 확연히 다를 때만 나눈다.
- ❌ "N:M은 FK 두 개면 된다" → 연결 테이블이 필요하다. 관계에도 속성이 붙는다.
물리 설계로 내려올 때 정하는 것
-- 논리: '회원은 이메일을 가진다'
-- 물리: 타입 · 길이 · NULL 여부 · 인덱스 · 기본값을 전부 정한다
CREATE TABLE member (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- TIMESTAMP 가 아니라 TZ 를 쓴다
UNIQUE (email)
);
CREATE INDEX idx_member_created ON member (created_at DESC);
TIMESTAMPTZ 는 실무에서 자주 틀리는 선택이다. 시간대 없는 TIMESTAMP로 저장하면 서버 시간대가 바뀌거나 해외 사용자가 생길 때 값의 의미가 깨진다.
타입 선택이 성능이 된다
- BIGINT vs UUID — InnoDB 에서는 PK 크기가 모든 보조 인덱스에 전파된다
- VARCHAR(255) 남발 — 길이는 저장 공간이 아니라 '검증' 이다. 의미에 맞게 정한다
- NUMERIC vs FLOAT — 금액은 반드시 NUMERIC/DECIMAL (부동소수점 오차 금지)
- TEXT vs VARCHAR — PostgreSQL 에서는 성능 차이가 없다. 제약이 필요할 때만 길이 지정