작업일2026. 07

SQL 문법은 비교적 금방 익힐 수 있습니다. 하지만 테이블을 어디까지 나눌지, 부모를 삭제했을 때 자식 데이터를 어떻게 처리할지, 컬럼 타입을 무엇으로 선택할지 같은 설계는 훨씬 오래 고민하게 됩니다. 데이터가 쌓인 뒤에는 바꾸기 어렵기 때문이죠.
같이 지울지, 삭제를 막을지, 주문은 남기고 고객만 비울지를 설계할 때 정해 둡니다.
CREATE TABLE 문법과 SELECT 기본 형태는 여기서 다루지 않습니다. 대신 설계 단계에서 실제로 결정해야 하는 것들을 순서대로 정리해보겠습니다.
테이블을 나누는 이유
고객 이름과 주문 내역을 한 테이블에 같이 담으면 세 가지 문제가 생깁니다. 이상 현상(Anomaly)이라고 부르고, 정규화는 이 셋을 막기 위한 규칙입니다.
세 가지 이상 현상의 원인은 같습니다. 성격이 다른 정보가 한 테이블에 섞여 있기 때문입니다. 고객은 customers에, 주문은 orders에 나눠 담고 주문 쪽에서 고객을 가리키게 하면 셋 다 사라집니다.
외래키를 걸 때 정하는 것
외래키를 선언하면 데이터베이스가 참조 무결성을 지켜 줍니다. 없는 고객 번호를 주문에 넣으려 하면 거부되고, 주문이 남아 있는 고객을 지우려 할 때도 막힙니다.
여기서 결정해야 하는 것이 부모를 지울 때 자식을 어떻게 할지입니다. 기본값에 맡기지 않고 업무 규칙에 맞게 골라야 합니다.
| 옵션 | 동작 | 쓰는 상황 |
|---|---|---|
ON DELETE CASCADE |
부모를 지우면 자식도 함께 삭제 | 주문을 지우면 주문 항목도 같이 사라져야 할 때. 주문 없이 존재할 이유가 없는 데이터 |
ON DELETE RESTRICT |
자식이 있으면 부모 삭제를 차단 | 카테고리를 지우기 전에 그 안의 상품을 먼저 정리하게 만들 때 |
ON DELETE SET NULL |
부모를 지우면 자식의 외래키를 NULL로 |
담당 직원이 퇴사해도 고객 정보는 남기고 담당자만 비울 때 |
ON DELETE NO ACTION |
RESTRICT와 거의 같고 검사 시점이 다름 | Oracle과 SQL Server의 기본값 |
ON UPDATE CASCADE |
부모 키가 바뀌면 자식도 따라 변경 | 학번 같은 자연키가 바뀔 때. 애초에 대체키를 쓰는 편이 낫습니다 |
CREATE TABLE order_items (
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INT NOT NULL,
unit_price NUMERIC(10,2) NOT NULL,
PRIMARY KEY (order_id, product_id),
FOREIGN KEY (order_id) REFERENCES orders(order_id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE RESTRICT
);
같은 테이블 안에서도 옵션이 갈립니다. 주문이 사라지면 그 주문 항목은 남을 이유가 없으니 CASCADE이고, 상품은 주문 기록이 남아 있는 한 함부로 지우면 안 되니 RESTRICT입니다.
CASCADE는 연쇄됩니다. 부모의 부모까지 이어져 있으면 한 번의 DELETE로 예상보다 많은 행이 사라집니다. 삭제 대신 deleted_at 컬럼을 두고 지운 것으로 표시만 하는 방식(소프트 삭제)을 쓰는 경우가 많은 이유가 여기 있습니다.관계와 교차 테이블
관계는 한쪽 행이 반대쪽 행 몇 개와 연결되는지로 세 가지로 나뉩니다. 이 중 실제로 설계 판단이 필요한 것은 N:M입니다.
N:M 관계는 외래키만으로 표현할 수 없어서 가운데 테이블이 반드시 들어갑니다. 주문 하나에 상품이 여러 개 담기고 같은 상품이 여러 주문에 들어가는 구조가 그렇습니다. order_items 같은 교차 테이블을 두면 다대다가 1:N 두 개로 풀립니다.
quantity · unit_price
교차 테이블의 기본키는 두 외래키를 묶은 복합키로 두거나, 별도의 일련번호를 두는 방식 중에 고릅니다. 복합키로 두면 같은 조합이 두 번 들어가는 것을 데이터베이스가 막아 줍니다.
조회 방향이 양쪽 다 있다면 인덱스도 양방향으로 만들어 둡니다. 수강 테이블이라면 INDEX(student_id, course_id)와 INDEX(course_id, student_id)를 함께 두는 식입니다. 학생으로도 찾고 과목으로도 찾기 때문입니다.
약한 엔티티
약한 엔티티는 자기만의 기본키가 없어서 부모 없이는 구분되지 않는 테이블입니다. 주문 항목은 몇 번째 항목인지만으로는 식별되지 않고, 어느 주문의 몇 번째인지가 있어야 구분됩니다.
- 부모의 기본키와 자신의 부분키를 묶어 복합 기본키를 만듭니다.
- 부모가 사라지면 존재할 이유가 없으므로
ON DELETE CASCADE를 겁니다.
정규화를 어디까지
정규화 단계는 여러 단계로 나뉘지만, 내용은 전부 한 테이블에 성격이 다른 정보를 섞지 말라는 말입니다.
| 정규형 | 조건 | 없애는 것 | 예 |
|---|---|---|---|
| 1NF | 한 칸에 값 하나만 | 반복 그룹, 다중값 속성 | 과목 = 'DB, 네트워크' 를 두 행으로 분리 |
| 2NF | 1NF + 부분 함수 종속 제거 | 복합키의 일부에만 딸린 속성 | (학번, 과목코드)가 키인데 학생 이름은 학번에만 딸림 |
| 3NF | 2NF + 이행적 종속 제거 | 키가 아닌 속성끼리의 종속 | 학번 → 학과코드 → 학과명 사슬을 끊음 |
| BCNF | 3NF + 모든 결정자가 후보키 | 후보키가 아닌 결정자 | 3NF를 만족해도 남는 예외를 정리 |
실무에서는 보통 3NF 또는 BCNF까지 적용합니다. 더 나눌수록 중복은 줄지만 조회할 때 이어 붙일 테이블이 늘어나 JOIN 비용이 커지기 때문입니다.
그래서 조회가 아주 잦은 곳에서는 일부러 중복을 남기는 반정규화를 검토합니다. 다만 순서가 있습니다. 정규화가 기본이고, 반정규화는 측정한 근거를 갖고 되돌리는 선택입니다. 처음부터 반정규화된 구조로 시작하면 갱신 이상을 애플리케이션이 전부 떠안게 됩니다.
개념 · 논리 · 물리 세 단계
모델링은 한 번에 끝나지 않고 추상적인 것에서 구체적인 것으로 세 단계를 거칩니다. 각 단계에서 정하는 것이 다르고, 앞 단계를 건너뛰면 뒤에서 되돌아오게 됩니다.
타입을 정할 때 걸리는 것
물리 설계에서 자주 실수하는 세 가지가 있습니다. 전부 나중에 바꾸기 어려운 항목입니다.
- 금액은
NUMERIC(10,2)로 둡니다.FLOAT이나DOUBLE은 이진 부동소수점이라0.1 + 0.2가 정확히0.3이 되지 않습니다. 합계가 1원씩 어긋나는 정산 문제가 여기서 나옵니다. - 시각은
TIMESTAMPTZ로 둡니다. 타임존 없이 저장하면 서버 지역이 바뀌거나 사용자가 해외에 있을 때 시각이 어긋납니다. - 문자열은 최대 길이를 정해 둡니다. PostgreSQL에서
TEXT와VARCHAR(n)은 저장 방식이 같지만, 인덱스를 걸 컬럼이라면 길이를 막아 두는 편이 낫습니다. 긴 값이 그대로 인덱스 키가 되면 트리가 깊어집니다.
정리하면
설계할 때 명시적으로 정하고 넘어가야 하는 것들입니다. 기본값에 맡기면 나중에 문제가 됩니다.
- 외래키마다 삭제 옵션을 정합니다. 함께 지울지(
CASCADE), 막을지(RESTRICT), 비울지(SET NULL). - N:M은 교차 테이블로 풀고, 그 연결에만 붙는 값을 함께 담습니다. 구매 시점 단가가 대표적입니다.
- 정규화는 3NF까지 가고, 반정규화는 측정한 뒤에 합니다. 순서를 바꾸면 갱신 이상을 코드가 떠안습니다.
- 금액은
NUMERIC, 시각은TIMESTAMPTZ. 데이터가 쌓인 뒤에는 바꾸기 어렵습니다. - 카디널리티는 업무 규칙으로 정합니다. 지금 하나인 것과 앞으로도 하나여야 하는 것은 다릅니다.
참고
- PostgreSQL: Constraints (외래키 옵션)
- PostgreSQL: Numeric Types
- PostgreSQL: Date/Time Types
- ERD 작성 도구: dbdiagram.io (DBML 문법으로 텍스트를 쓰면 ERD가 그려집니다)