엔터티와 속성 - 엔터티 분류 기본 설계 파생 속성과 도메인 (SQLD 기본 2장)
이 장에서 배우는 것
1장에서 모델링을 개념·논리·물리 세 단계로 나눴다. 이 장은 논리 모델의 가장 작은 재료인 엔터티와 속성을 다룬다. 시험은 분류 이름을 많이 묻지만, 실무에서 중요한 것은 분류가 설계 판단으로 이어지는 지점이다. 특히 파생 속성을 저장하면 언젠가 원본과 어긋난다는 사실은 직접 어긋나게 만들어 보면 오래 기억한다.
- 엔터티가 되기 위한 조건과, 유무형·발생 시점에 따른 분류를 정리한다.
- 속성을 기본·설계·파생, 단일값·다중값으로 나누고 각각이 테이블 설계에 주는 신호를 읽는다.
- 도메인을 타입과 CHECK 제약으로 지키는 법, 그리고 SQLite 의 느슨한 타입이 왜 함정인지 확인한다.
핵심 개념
회원 가입 화면을 만들던 개발자가 "관심 분야"를 쉼표로 이어 한 컬럼에 넣었다. 처음엔 편했다. 석 달 뒤 마케팅팀이 "관심 분야에 여행이 들어 있는 회원 수"를 달라고 했을 때 문제가 드러났다. 이 사고는 SQL 을 몰라서가 아니라 속성을 잘못 나눠서 생긴다.
엔터티와 인스턴스
엔터티는 업무에서 관리해야 하는 대상의 집합이다. 표로 옮기면 테이블 하나가 되는 경우가 많다. 엔터티에 속한 개별 대상 하나하나가 인스턴스이고, 표에서는 행 하나다. 어떤 대상이 엔터티가 되려면 다음을 갖춰야 한다.
- 업무에서 필요하고 관리하려는 정보여야 한다.
- 인스턴스를 서로 구별할 식별자가 있어야 한다.
- 인스턴스가 두 개 이상 모여야 한다. 회사 정보처럼 영원히 한 건뿐인 대상은 보통 엔터티로 두지 않는다.
- 속성을 가져야 하고, 업무 프로세스에서 쓰여야 한다.
- 다른 엔터티와 관계가 적어도 하나 있어야 한다. 코드성·통계성 엔터티는 관계를 생략하기도 한다.
엔터티 분류
| 기준 | 분류 | 예 |
|---|---|---|
| 유무형 | 유형 엔터티: 물리적 형태가 있다 | 상품, 강의실, 차량 |
| 유무형 | 개념 엔터티: 형태는 없지만 관리할 개념 | 과목, 보험 상품 종류, 조직 |
| 유무형 | 사건 엔터티: 업무 중에 발생한다 | 주문, 수강 신청, 입금 |
| 발생 시점 | 기본 엔터티: 다른 엔터티에 의존하지 않고 생긴다. 자신의 식별자를 가진다 | 고객, 상품, 사원 |
| 발생 시점 | 중심 엔터티: 기본 엔터티에서 생기고 업무의 중심이 된다 | 주문, 계약 |
| 발생 시점 | 행위 엔터티: 두 개 이상의 부모에서 생기고 자주 바뀐다 | 주문 상세, 상태 변경 이력 |
속성
속성은 업무에 필요한, 더 이상 나누지 않는 정보의 최소 단위다. 인스턴스 하나에서 속성 하나는 값 하나만 가진다. 값이 여러 개라면(관심 분야 여러 개) 그 속성은 다중값 속성이고, 논리 모델에서는 별도 엔터티로 뺀다.
그림 · 엔터티·인스턴스·속성·값이 표에서 차지하는 자리 — 엔터티 order_item 은 표 하나, 인스턴스는 행 하나, 속성은 열 하나, 속성값은 칸 하나다. 한 칸에는 값이 하나만 들어간다.
- 특성에 따라: 기본 속성(업무에서 원래 있는 값: 수량, 단가), 설계 속성(업무에는 없지만 설계에서 만든 값: 주문번호 일련번호, 상태 코드), 파생 속성(다른 속성에서 계산한 값: 주문 합계).
- 구성 방식에 따라: 기본 키 속성, 외래 키 속성, 일반 속성.
- 분해 여부에 따라: 단일 속성(이름), 복합 속성(주소 = 시·구·상세).
- 값의 개수에 따라: 단일값 속성, 다중값 속성.
도메인은 속성이 가질 수 있는 값의 범위다. 논리 모델에서 "점수는 0~100 정수"라고 정하면, 물리 모델에서는 데이터 타입·길이·NOT NULL·CHECK 제약으로 구현한다.
예제 스키마
작은 문구점의 주문이다. 주문(orders)과 주문 상세(order_item)로 나눴고, 주문에는 일부러 파생 속성 order_total 을 저장해 두었다.
-- 2장: 문구점 주문 (가상 데이터). order_total 은 일부러 저장해 둔 파생 속성이다.
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer TEXT NOT NULL,
order_date TEXT NOT NULL,
order_total INTEGER
);
CREATE TABLE order_item (
order_id INTEGER NOT NULL REFERENCES orders(order_id),
line_no INTEGER NOT NULL,
product TEXT NOT NULL,
qty INTEGER NOT NULL,
unit_price INTEGER NOT NULL,
PRIMARY KEY (order_id, line_no)
);
INSERT INTO orders VALUES (1, '정민아', '2026-09-01', 23000), (2, '한결', '2026-09-02', 9000);
INSERT INTO order_item VALUES
(1, 1, '공책', 2, 4000), (1, 2, '만년필', 1, 15000), (2, 1, '연필', 3, 3000);
| order_id | line_no | product | qty | unit_price |
|---|---|---|---|---|
| 1 | 1 | 공책 | 2 | 4000 |
| 1 | 2 | 만년필 | 1 | 15000 |
| 2 | 1 | 연필 | 3 | 3000 |
주문 1의 합계는 2×4,000 + 1×15,000 = 23,000 이고, 주문 2는 3×3,000 = 9,000 이다. 저장된 order_total 과 일치한다. 적어도 지금은.
SQL과 실행 결과
파생 속성과 원본 비교
SELECT o.order_id, o.order_total,
SUM(i.qty * i.unit_price) AS computed
FROM orders o
JOIN order_item i ON i.order_id = o.order_id
GROUP BY o.order_id, o.order_total
ORDER BY o.order_id;
실행 결과:
order_id order_total computed
-------- ----------- --------
1 23000 23000
2 9000 9000
원본만 고치면 파생 값이 어긋난다
고객이 공책을 두 권 더 원했다. 담당자는 주문 상세의 수량만 고쳤다. HAVING 으로 "저장 값과 계산 값이 다른 주문"만 골라 본다.
-- 주문 1의 공책 수량을 2 → 4 로 고쳤지만 order_total 은 손대지 않았다
UPDATE order_item SET qty = 4 WHERE order_id = 1 AND line_no = 1;
SELECT o.order_id, o.order_total,
SUM(i.qty * i.unit_price) AS computed
FROM orders o
JOIN order_item i ON i.order_id = o.order_id
GROUP BY o.order_id, o.order_total
HAVING o.order_total <> SUM(i.qty * i.unit_price);
실행 결과:
order_id order_total computed
-------- ----------- --------
1 23000 31000
저장된 23,000 과 실제 31,000 이 다르다. 오류 메시지는 어디에도 없었다. 파생 속성을 저장하는 순간 원본이 바뀔 때마다 파생 값도 함께 고쳐야 하는 의무가 생긴다. 이 의무를 어떻게 지키는지(트리거, 애플리케이션, 배치)는 5장 반정규화에서 다룬다.
표 · 예제 스키마 컬럼을 속성 분류로 나누면
| 컬럼 | 특성에 따른 분류 | 이유 |
|---|---|---|
orders.order_id, order_item.line_no | 설계 속성 | 업무에는 없고 식별을 위해 설계에서 만든 일련번호 |
customer, order_date, product, qty, unit_price | 기본 속성 | 업무에서 원래 생기는 값 |
orders.order_total | 파생 속성 | 주문 상세에서 계산한 값. 원본만 고치면 저장값 23000 과 계산값 31000 이 어긋났다 |
도메인을 CHECK 로 지키기
CREATE TABLE exam (
student TEXT NOT NULL,
score INTEGER NOT NULL CHECK (score BETWEEN 0 AND 100)
);
INSERT INTO exam VALUES ('가', 100);
INSERT INTO exam VALUES ('나', 101);
실행 결과:
Runtime error near line 6: CHECK constraint failed: score BETWEEN 0 AND 100 (19)
첫 행(100)은 들어가고 두 번째 행(101)에서 멈췄다. -bail 옵션 때문에 오류가 난 곳에서 실행을 끝낸다. 오류 메시지의 줄 번호는 위 SQL 의 줄 번호다.
SQLite 의 타입 친화성
SQLite 는 컬럼 타입을 "선호"로만 다룬다. INTEGER 컬럼에 글자를 넣어도 받아 준다.
CREATE TABLE loose (n INTEGER);
INSERT INTO loose VALUES (7), ('7'), ('일곱');
SELECT n, typeof(n) AS stored_as FROM loose;
실행 결과:
n stored_as
---- ---------
7 integer
7 integer
일곱 text
'7' 은 정수로 바꿀 수 있어 integer 로 저장됐지만, '일곱' 은 text 로 그대로 들어갔다. Oracle 이나 SQL Server 라면 두 번째 값에서 오류가 난다. SQLite 에서도 엄격한 검사를 원하면 STRICT 테이블을 쓴다.
CREATE TABLE tight (n INTEGER) STRICT;
INSERT INTO tight VALUES (7);
INSERT INTO tight VALUES ('일곱');
실행 결과:
Runtime error near line 3: cannot store TEXT value in INTEGER column tight.n (19)
다중값 속성을 엔터티로 빼기
CREATE TABLE customer_flat (name TEXT, phones TEXT);
INSERT INTO customer_flat VALUES
('정민아', '010-1234-5678,02-555-0101'),
('한결', '010-9876-5432');
-- "02 로 시작하는 번호가 있는 고객"을 찾으려면 문자열을 뒤져야 한다
SELECT name FROM customer_flat
WHERE phones LIKE '02-%' OR phones LIKE '%,02-%';
-- 다중값 속성을 별도 엔터티로 뺀 모습
CREATE TABLE customer_phone (name TEXT, seq INTEGER, phone TEXT, PRIMARY KEY (name, seq));
INSERT INTO customer_phone VALUES
('정민아', 1, '010-1234-5678'), ('정민아', 2, '02-555-0101'), ('한결', 1, '010-9876-5432');
SELECT name, COUNT(*) AS phone_count FROM customer_phone GROUP BY name ORDER BY name;
실행 결과:
name
------
정민아
name phone_count
------ -----------
정민아 2
한결 1
한 칸에 번호를 쉼표로 이어 두면 "02 로 시작하는 번호"를 찾는 조건이 LIKE 두 개로 늘어나고, 번호 개수를 세려면 문자열을 잘라야 한다. 별도 엔터티로 빼면 COUNT(*) 한 번이면 된다.
그림 · 다중값 속성을 별도 엔터티로 뺀 전후 — 전화번호 여러 개를 쉼표로 이어 한 칸에 넣으면 '02 로 시작하는 번호'를 LIKE 로 뒤져야 한다. 고객 전화 엔터티로 빼면 값 하나가 행 하나가 되어 개수를 GROUP BY 로 셀 수 있다.
표준과 구현의 차이
아래 Oracle·SQL Server 동작은 비교 설명이며 이 책에서 실행하지 않았다.
| 항목 | SQLite(실행) | Oracle | SQL Server |
|---|---|---|---|
| 숫자 컬럼에 글자 저장 | 일반 테이블은 저장됨, STRICT 테이블은 오류 | 변환 불가 시 오류 | 변환 불가 시 오류 |
| CHECK 제약 | 지원 | 지원 | 지원 |
| 계산 컬럼(파생 값을 DB 가 계산) | 생성 컬럼 GENERATED ALWAYS AS | 가상 컬럼 | 계산 열 |
| 문자열 타입 이름 | TEXT | VARCHAR2 | VARCHAR, NVARCHAR |
[구현 차이] 파생 값을 저장하지 않고 DB 가 계산하게 하는 생성 컬럼은 같은 행의 값만 참조할 수 있다. 다른 테이블(주문 상세)을 더해야 하는 주문 합계는 생성 컬럼으로 만들 수 없다.
시험에서 헷갈리는 지점
판단 1. "주문 합계처럼 계산할 수 있는 값은 파생 속성이며, 성능을 위해 가능한 한 많이 저장하는 것이 좋다"
앞 문장은 맞고 뒤 문장은 틀렸다. 파생 속성은 정합성 비용이 크므로 꼭 필요한 곳에만 둔다. 위 예제처럼 원본 수정 한 번에 조용히 어긋난다.
판단 2. "상태 코드 '01', '02' 처럼 업무 규칙에 따라 설계자가 만든 값은 설계 속성이다"
맞다. 업무 현장에는 "배송 중"이라는 말만 있고 '02' 라는 값은 없다. 설계 과정에서 만든 값이므로 설계 속성이다. 채번한 일련번호도 설계 속성이다.
판단 3. "행위 엔터티는 부모 엔터티 없이 독립적으로 생성된다"
틀렸다. 독립적으로 생성되는 것은 기본 엔터티다. 행위 엔터티는 두 개 이상의 부모 엔터티로부터 생기며(주문 상세 = 주문 + 상품) 데이터가 자주 바뀌고 양이 많다.
판단 4. "한 인스턴스의 한 속성은 여러 값을 가질 수 있다"
틀렸다. 한 인스턴스에서 한 속성은 하나의 값만 가진다. 여러 값이 필요하면 다중값 속성이며, 별도 엔터티로 분리한다. 이 규칙이 4장의 제1정규형과 이어진다.
연습 문제
- 온라인 서점에서 회원, 주문, 주문 상세, 배송 상태 변경 이력을 발생 시점 기준(기본·중심·행위)으로 분류하라.
- 강의실, 과목, 수강 신청을 유무형 기준(유형·개념·사건)으로 분류하라.
- 다음 속성을 기본·설계·파생 속성으로 나누라. (가) 고객 이름 (나) 주문 합계 금액 (다) 주문 상태 코드 (라) 상품 단가 (마) 주문 번호 채번용 일련번호
- 예제 스키마에서 주문 2의 연필 단가를 3,500 으로 고친 뒤, 저장 합계와 계산 합계를 주문별로 조회하라. 결과를 먼저 예측하라.
- 회원의 "관심 분야"가 여러 개일 수 있다. 논리 모델에서 이 속성을 어떻게 처리해야 하는지, 그리고 그렇게 하지 않았을 때 생기는 조회상의 불편 두 가지를 쓰라.
정답과 해설
1. 회원: 기본, 주문: 중심, 주문 상세: 행위, 배송 상태 변경 이력: 행위. 회원은 스스로 생기고, 주문은 회원에서 생겨 업무의 중심이 되며, 주문 상세와 이력은 주문(과 상품, 상태)에서 생기고 자주 쌓인다.
2. 강의실: 유형, 과목: 개념, 수강 신청: 사건.
3. 기본: (가), (라). 설계: (다), (마). 파생: (나).
4. 주문 1은 23,000 으로 그대로 일치하고, 주문 2는 저장 9,000 과 계산 3×3,500 = 10,500 이 어긋난다.
UPDATE order_item SET unit_price = 3500 WHERE order_id = 2 AND line_no = 1;
SELECT o.order_id, o.order_total, SUM(i.qty * i.unit_price) AS computed
FROM orders o JOIN order_item i ON i.order_id = o.order_id
GROUP BY o.order_id, o.order_total
ORDER BY o.order_id;
실행 결과:
order_id order_total computed
-------- ----------- --------
1 23000 23000
2 9000 10500
5. 다중값 속성이므로 회원 관심 분야 엔터티(회원번호, 관심 분야)로 분리한다. 분리하지 않으면 특정 분야를 가진 회원을 찾을 때 문자열 부분 검색이 필요해 인덱스를 쓰기 어렵고, 분야별 회원 수를 세려면 문자열을 잘라야 한다. 분야 이름을 바꿀 때도 모든 문자열을 고쳐야 한다.
다음 장에서는 엔터티 사이를 잇는 관계와, 인스턴스를 구별하는 식별자를 다룬다.