이 글의 순서
- A. 다시 꺼내 보는 집합과 술어: 집합과 술어를 고등학교 정의로 짧게 복습합니다.
- B. 테이블은 집합입니다: 테이블 정의, 컬럼 순서, WHERE를 집합으로 읽습니다.
- C. 같은 술어끼리 모으기: 사건과 대상 나누기: 어떤 열을 어느 테이블에 둘지 정합니다.
- D. SQL이 이론과 어긋나는 자리: 같은 행, 기본키, NULL을 실행 결과로 확인합니다.
- 정리와 이해 확인: 질문 다섯 개로 스스로 확인합니다.
개요 · Overview
“관계형 데이터베이스는 집합론과 술어논리에 기반한다.” 교재에서 이 문장을 읽고 고개는 끄덕였는데, 막상 CREATE TABLE과 SELECT에서는 그 집합이 잘 보이지 않으셨죠?
저는 수학을 전공했지만, 이 문장이 손에 잡힌 건 실무에서였습니다. 보험 사기 탐지용 데이터 마트를 인수인계받던 중에 함께 일하던 DBA 한 분이 이렇게 말씀하셨어요. “테이블은 집합이야. 컬럼 순서는 상관없어.” 그 한마디로 테이블 설계와 쿼리가 하나로 꿰어졌습니다. 사건은 사건끼리, 그 사건을 설명하는 대상은 대상끼리 모아야 한다는 게 보였고, 쿼리도 결과를 먼저 “이 조건을 만족하는 행의 집합”으로 적어 두니 쉽게 풀렸습니다.
이 글은 그 연결을 보험 예제로 따라갑니다. 다 읽고 나면 여러분의 테이블 하나를 골라 “이 테이블은 어떤 원소들의 집합인가”를 한 문장으로 말할 수 있을 거예요. 예제 데이터는 모두 지어낸 것이고, SQL은 2026-10-03에 SQLite 3.43.2로 실행했습니다.
학습 목표 · Learning Objectives
- 집합의 두 성질인 순서 없음과 중복 없음이 테이블의 행과 열에서 어떻게 나타나는지 설명할 수 있다.
- 테이블 정의를 술어로, 행을 참인 명제로, WHERE를 부분집합 선택으로 읽을 수 있다.
- 한 테이블에 같은 술어를 만족하는 데이터만 모이도록 사건과 대상을 나눌 수 있다.
- 집합의 중복 금지와 기본키 제약의 차이를 구분할 수 있다.
- NULL이 들어간 조건이 참과 거짓 두 값의 논리에서 벗어나는 지점을 확인할 수 있다.
A. 다시 꺼내 보는 집합과 술어
테이블을 읽는 데 필요한 수학은 세 가지뿐입니다.
- 집합은 순서도 중복도 따지지 않습니다. {1, 2}와 {2, 1}은 같은 집합이고, {1, 2, 2}라고 적어도 원소는 1과 2 두 개입니다.
- 술어는 빈칸이 있는 문장입니다. “x는 짝수다”는 x에 4를 넣으면 참, 3을 넣으면 거짓이 됩니다. “계약 c는 계약자 h가 맺었다”처럼 빈칸이 여러 개일 수도 있어요.
- 조건제시법은 술어로 집합을 만듭니다. { x ∈ {1, 2, 3, 4, 5, 6} | x는 짝수 }는 {2, 4, 6}입니다.
세 번째가 가장 중요한 다리입니다. 세로선 왼쪽이 SQL의 FROM, 오른쪽이 WHERE에 해당하거든요.
B. 테이블은 집합입니다
B1. 테이블 정의는 술어이고, 행은 참인 명제입니다
보험계약 테이블을 하나 만들어 보겠습니다. 월보험료가 아직 입력되지 않은 계약도 있어서 monthly_premium에만 NULL을 허용했어요.
CREATE TABLE contract (
contract_no TEXT NOT NULL PRIMARY KEY,
holder_name TEXT NOT NULL,
monthly_premium INTEGER
);
INSERT INTO contract (contract_no, holder_name, monthly_premium)
VALUES
('PL-1001', '김하늘', 30000),
('PL-2001', '박서준', 80000),
('PL-3001', '이도윤', NULL);이 테이블 정의는 “계약 c는 계약자 h가 월보험료 m으로 맺었다”라는 술어입니다. 열 하나하나가 빈칸이에요. 행 (‘PL-1001’, ‘김하늘’, 30000)은 빈칸을 채운 명제 “계약 PL-1001은 계약자 김하늘이 월보험료 30,000원으로 맺었다”이고, 이 행이 테이블에 있다는 건 이 명제를 참으로 기록했다는 뜻입니다.
| 관계형 모델 | 집합론과 술어논리 | 위 예제 |
|---|---|---|
| 테이블 정의 | 술어 | 계약 c는 계약자 h가 월보험료 m으로 맺었다 |
| 행 | 참인 명제 | (‘PL-1001’, ‘김하늘’, 30000) |
| 테이블 | 행의 집합 | 행 세 개의 집합 |
| WHERE 조건 | 부분집합을 고르는 술어 | monthly_premium >= 50000 |
B2. “컬럼 순서는 상관없어”의 뜻
행에 순서가 없듯이, 이론상 열에도 순서가 없습니다. 열은 위치가 아니라 이름으로 구분하니까요. 술어를 “계약자 h가 월보험료 m으로 계약 c를 맺었다”로 바꿔 읽어도 같은 사실이잖아요. DBA 분의 말은 바로 이 뜻이었습니다.
행 순서도 마찬가지라서, ORDER BY 없이 조회하면 행은 정해지지 않은 순서로 나옵니다(PostgreSQL 문서). 순서가 필요하면 ORDER BY로 직접 적으세요. 순서는 테이블의 성질이 아니라 조회할 때 붙이는 요구사항입니다.
Q. 그럼 SQL에서도 컬럼 순서는 아무 의미가 없나요?
A. 예외가 하나 있습니다. 열 이름을 생략한 INSERT INTO holder VALUES (...)는 테이블을 만들 때의 열 순서대로 값을 넣어서, 이름과 주소를 바꿔 넣어도 오류 없이 들어갑니다. 그래서 실무에서는 INSERT와 SELECT에 열 이름을 꼭 적습니다.
해설 노트: 이름과 주소가 뒤바뀌는 INSERT 직접 해 보기
CREATE TABLE holder (
holder_no TEXT NOT NULL PRIMARY KEY,
holder_name TEXT NOT NULL,
address TEXT NOT NULL
);
INSERT INTO holder VALUES ('C001', '서울', '김하늘');
SELECT holder_no, holder_name, address FROM holder;| holder_no | holder_name | address |
|---|---|---|
| C001 | 서울 | 김하늘 |
SELECT *의 결과 열 순서도 테이블을 만들 때의 순서를 따릅니다. 열 이름을 적으면 쿼리가 다시 순서 없는 집합의 세계로 돌아옵니다.
B3. WHERE는 술어로 부분집합을 고릅니다
월보험료가 50,000원 이상인 계약을 골라 보겠습니다.
SELECT contract_no
FROM contract
WHERE monthly_premium >= 50000;| contract_no |
|---|
| PL-2001 |
이 결과를 조건제시법으로 쓰면 { t ∈ contract | t.monthly_premium ≥ 50000 }입니다. 무엇의 집합인지(FROM)와 어떤 조건인지(WHERE)를 먼저 정하면, 나머지는 문법을 채우는 일이 됩니다. AND는 교집합, OR는 합집합에 해당해요.
그런데 PL-3001은 결과에 없습니다. 월보험료가 NULL이라 조건을 참으로 만들지 못했기 때문인데, 왜 “거짓”이라고 쓰지 않았는지는 D4절에서 이야기하겠습니다.
C. 같은 술어끼리 모으기: 사건과 대상 나누기
DBA 분의 한마디가 제게 가장 크게 남긴 건 이 부분입니다. 테이블이 집합이라면, 한 테이블에는 같은 술어를 만족하는 원소만 모여야 합니다.
C1. 술어 두 개가 한 테이블에 섞이면
보험금 청구 테이블에 계약자 주소를 함께 넣어 두었다고 해 볼게요. 계약자 C001 김하늘이 두 번 청구했고(CL-001, CL-002), 주소가 인천으로 바뀌자 누군가 CL-002의 주소만 고쳤습니다. 그 뒤 C001의 주소를 조회하면 이렇게 나옵니다.
| holder_no | holder_address |
|---|---|
| C001 | 서울 |
| C001 | 인천 |
오류는 하나도 없었는데, 테이블이 “C001의 주소는 서울이다”와 “C001의 주소는 인천이다”를 동시에 참이라고 말하고 있습니다.
원인은 한 행이 문장 두 개를 말하고 있기 때문이에요. “청구 c는 계약자 h가 금액 m을 청구했다”와 “계약자 h의 주소는 a다”. 뒤의 문장은 청구가 아니라 계약자에 대한 사실이라서, 청구가 늘 때마다 같은 명제가 복사되고, 복사본 하나만 고치면 답이 둘이 됩니다.
그래서 술어마다 테이블을 하나씩 둡니다. 청구(claim)는 청구 문장을, 계약자(policyholder)는 주소 문장을 맡게 나누면, 주소는 계약자 테이블에서 한 번만 고치면 되고 두 청구 모두 인천을 보여 줍니다.
해설 노트: 섞인 테이블과 나눈 테이블 SQL 전체
CREATE TABLE claim_mixed (
claim_no TEXT NOT NULL PRIMARY KEY,
holder_no TEXT NOT NULL,
holder_name TEXT NOT NULL,
holder_address TEXT NOT NULL,
claim_amount INTEGER NOT NULL
);
INSERT INTO claim_mixed (
claim_no, holder_no, holder_name, holder_address, claim_amount
)
VALUES
('CL-001', 'C001', '김하늘', '서울', 500000),
('CL-002', 'C001', '김하늘', '서울', 300000),
('CL-003', 'C002', '박서준', '부산', 200000);
UPDATE claim_mixed
SET holder_address = '인천'
WHERE claim_no = 'CL-002';
SELECT DISTINCT holder_no, holder_address
FROM claim_mixed
WHERE holder_no = 'C001'
ORDER BY holder_address;나눈 뒤에는 이렇게 됩니다.
PRAGMA foreign_keys = ON;
CREATE TABLE policyholder (
holder_no TEXT NOT NULL PRIMARY KEY,
holder_name TEXT NOT NULL,
address TEXT NOT NULL
);
CREATE TABLE claim (
claim_no TEXT NOT NULL PRIMARY KEY,
holder_no TEXT NOT NULL REFERENCES policyholder (holder_no),
claim_amount INTEGER NOT NULL
);
INSERT INTO policyholder (holder_no, holder_name, address)
VALUES
('C001', '김하늘', '서울'),
('C002', '박서준', '부산');
INSERT INTO claim (claim_no, holder_no, claim_amount)
VALUES
('CL-001', 'C001', 500000),
('CL-002', 'C001', 300000),
('CL-003', 'C002', 200000);
UPDATE policyholder
SET address = '인천'
WHERE holder_no = 'C001';
SELECT c.claim_no, p.holder_name, p.address, c.claim_amount
FROM claim AS c
INNER JOIN policyholder AS p
ON c.holder_no = p.holder_no
ORDER BY c.claim_no;| claim_no | holder_name | address | claim_amount |
|---|---|---|---|
| CL-001 | 김하늘 | 인천 | 500000 |
| CL-002 | 김하늘 | 인천 | 300000 |
| CL-003 | 박서준 | 부산 | 200000 |
해설 노트: 청구할 때의 주소가 필요하다면요?
사기 조사에서는 “청구할 당시 어느 주소였나”가 단서일 수 있습니다. 그 주소는 계약자가 아니라 청구라는 사건에 대한 사실이에요. 술어가 “청구 c는 주소 a에서 접수되었다”로 바뀌니까 이번에는 청구 테이블에 두는 것이 맞습니다. 열 이름보다 그 열이 어떤 문장의 빈칸인지를 먼저 보세요.
C2. 킴벌의 그레인 선언은 술어를 정하는 일입니다
데이터 마트 설계에 많이 쓰는 랄프 킴벌의 차원 모델링도 같은 생각입니다. 킴벌은 설계의 핵심 단계로 그레인(grain) 선언, 곧 “사실 테이블 한 행이 정확히 무엇을 나타내는지” 정하는 일을 꼽고, 그레인이 다른 데이터를 한 사실 테이블에 섞지 말라고 합니다(『The Data Warehouse Toolkit』 3판, 39쪽). 한 행이 무엇을 나타내는지 정하는 일은 곧 술어를 정하는 일이죠.
| 구분 | 보험 데이터의 예 | 한 행의 술어 |
|---|---|---|
| 사실 테이블(사건) | 보험금 청구 처리 트랜잭션 | 청구 c에 대해 날짜 d에 금액 m의 처리가 일어났다 |
| 차원 테이블(대상) | 계약자, 상품, 날짜 | 계약자 h의 이름은 n이고 주소는 a다 |
제가 인수인계받던 사기 탐지 마트도 결국 이 질문의 반복이었어요. 이 테이블의 한 행은 무슨 문장인가, 그 문장에 맞지 않는 열은 어디로 보내야 하나.
D. SQL이 이론과 어긋나는 자리
SQL은 집합론을 그대로 구현하지 않았습니다. “테이블은 집합이니까”라고만 믿으면 오히려 함정에 빠지는 자리가 두 군데 있어요. 같은 행과 NULL입니다.
D1. 제약이 없으면 같은 행이 두 번 들어갑니다
기본키 없는 테이블에 (‘PL-1001’, ‘김하늘’, ‘TERM-10’)을 두 번 넣으면 두 번 다 성공합니다. 전체 행 수는 2, 중복을 뺀 행 수는 1입니다.
이렇게 같은 원소가 여러 번 들어갈 수 있는 모임을 다중집합(multiset)이라고 합니다. 장바구니를 떠올리면 쉬워요. 우유를 두 개 담으면 장바구니에는 우유가 두 개 있지만, 집합이라면 {우유} 하나만 남습니다. 그래서 영어로는 가방이라는 뜻의 bag이라고도 부릅니다. SQL 테이블은 기본적으로 다중집합이고, 집합이 필요하면 DISTINCT나 UNION(UNION ALL이 아닌)으로 직접 중복 제거를 요청해야 합니다.
같은 행이 두 번 있으면 COUNT(*)와 SUM도 그 행을 두 번 셉니다. 기본키 없는 테이블에 적재를 다시 돌렸을 때 흔히 생기는 일이니, 집계가 이상하면 전체 행 수와 DISTINCT 행 수부터 비교해 보세요.
해설 노트: 같은 행을 두 번 넣는 SQL
CREATE TABLE contract_bag (
contract_no TEXT,
holder_name TEXT,
product_code TEXT
);
INSERT INTO contract_bag (contract_no, holder_name, product_code)
VALUES ('PL-1001', '김하늘', 'TERM-10');
INSERT INTO contract_bag (contract_no, holder_name, product_code)
VALUES ('PL-1001', '김하늘', 'TERM-10');
SELECT COUNT(*) AS row_count FROM contract_bag;
SELECT COUNT(*) AS distinct_count
FROM (
SELECT DISTINCT contract_no, holder_name, product_code
FROM contract_bag
);| row_count | distinct_count |
|---|---|
| 2 | 1 |
『Database System Concepts』도 관계는 튜플의 집합이라 중복이 있을 수 없지만, 실제 데이터베이스의 테이블은 제약으로 막지 않는 한 중복을 허용한다고 설명합니다.
D2. 기본키는 테이블을 다시 집합으로 묶습니다
같은 데이터를 계약번호(contract_no)가 기본키인 테이블에 두 번 넣으면, 두 번째 INSERT가 실패합니다.
Runtime error near line 10: UNIQUE constraint failed: contract.contract_no (19)기본키 값이 다르면 행 전체도 다르니, 기본키가 있는 테이블에는 같은 행이 두 번 들어갈 수 없습니다. “기본키로 중복을 막는 것이 집합의 개념”이라는 말은 이 점에서 맞아요.
해설 노트: 기본키가 있는 테이블에 같은 행을 두 번 넣는 SQL
CREATE TABLE contract (
contract_no TEXT NOT NULL PRIMARY KEY,
holder_name TEXT NOT NULL,
product_code TEXT NOT NULL
);
INSERT INTO contract (contract_no, holder_name, product_code)
VALUES ('PL-1001', '김하늘', 'TERM-10');
INSERT INTO contract (contract_no, holder_name, product_code)
VALUES ('PL-1001', '김하늘', 'TERM-10');D3절의 SQL은 이 테이블에 이어서 실행합니다.
D3. 기본키는 집합의 중복 금지보다 강한 규칙입니다
그렇다고 둘이 같은 규칙은 아닙니다. 계약번호는 같고 이름만 다른 행을 넣어 보면 차이가 보입니다.
| 넣으려는 행 | 집합 기준(행 전체가 같은가) | 기본키 contract_no 기준 | 실행 결과 |
|---|---|---|---|
| (‘PL-1001’, ‘김하늘’, ‘TERM-10’) 두 번째 | 같은 원소, 하나로 본다 | 거절 | 오류 |
| (‘PL-1001’, ‘박서준’, ‘TERM-10’) | 다른 원소, 들어갈 수 있다 | 거절 | 오류 |
| (‘PL-2001’, ‘박서준’, ‘HEALTH-20’) | 다른 원소, 들어갈 수 있다 | 허용 | 입력됨 |
두 번째 줄에서 기준이 갈립니다. 집합으로만 보면 들어갈 수 있는 행인데, 기본키는 “계약번호가 같으면 같은 계약이다”라는 더 강한 규칙으로 막았어요.
기본키가 있으면 집합 성질은 따라오지만, 집합 성질만으로 기본키가 정해지지는 않습니다. “계약번호 하나에 계약 한 건”은 수학이 아니라 업무를 보고 설계자가 정합니다. C절에서 술어를 정한 것처럼요.
해설 노트: 두 번째·세 번째 행을 넣는 SQL
INSERT INTO contract (contract_no, holder_name, product_code)
VALUES ('PL-1001', '박서준', 'TERM-10');
INSERT INTO contract (contract_no, holder_name, product_code)
VALUES ('PL-2001', '박서준', 'HEALTH-20');
SELECT contract_no, holder_name, product_code
FROM contract
ORDER BY contract_no;Runtime error near line 13: UNIQUE constraint failed: contract.contract_no (19)| contract_no | holder_name | product_code |
|---|---|---|
| PL-1001 | 김하늘 | TERM-10 |
| PL-2001 | 박서준 | HEALTH-20 |
교과서에서는 튜플을 유일하게 식별하는 속성 집합을 슈퍼키, 그중 최소인 것을 후보 키, 설계자가 고른 하나를 기본키라고 하고, 키를 정하는 일은 실제 업무 환경의 제약을 나타낸다고 설명합니다.
D4. NULL이 들어오면 참과 거짓으로 끝나지 않습니다
B1절의 계약 테이블에서 같은 조건과 그 부정으로 각각 조회해 보겠습니다.
SELECT contract_no FROM contract WHERE monthly_premium >= 50000;
SELECT contract_no FROM contract WHERE NOT (monthly_premium >= 50000);| 조회 | 결과 |
|---|---|
| WHERE 조건 | PL-2001 |
| WHERE NOT 조건 | PL-1001 |
월보험료가 NULL인 PL-3001은 어느 쪽에도 없습니다. 고전 논리라면 P와 NOT P를 합치면 전체가 되어야 하는데 말이죠. SQL은 참, 거짓에 알 수 없음(UNKNOWN)을 더한 3치 논리를 씁니다. NULL과 비교하면 UNKNOWN이 되고, WHERE는 참인 행만 남기니 두 조회 모두에서 빠집니다.
같은 이유로 UNIQUE 제약은 NULL끼리를 서로 다른 값으로 보아서, UNIQUE 열에 NULL인 행을 여러 개 넣을 수 있습니다. 기본키는 UNIQUE에 NOT NULL을 더한 제약이라 이 문제가 없고(PostgreSQL 문서), 그래서 원소를 가르는 식별자로 알맞습니다.
해설 노트: 비교 결과와 UNIQUE NULL 직접 보기
SELECT
contract_no,
monthly_premium >= 50000 AS is_high
FROM contract
ORDER BY contract_no;| contract_no | is_high |
|---|---|
| PL-1001 | 0 |
| PL-2001 | 1 |
| PL-3001 | NULL |
SQLite는 비교 결과를 1과 0으로 보여 주는데, PL-3001에 찍힌 NULL이 UNKNOWN입니다.
CREATE TABLE holder (
holder_no TEXT NOT NULL PRIMARY KEY,
email TEXT UNIQUE
);
INSERT INTO holder (holder_no, email) VALUES ('H-01', NULL);
INSERT INTO holder (holder_no, email) VALUES ('H-02', NULL);
SELECT COUNT(*) AS null_email_rows FROM holder WHERE email IS NULL;| null_email_rows |
|---|
| 2 |
해설 노트: SQLite에서 기본키 열에 NOT NULL을 따로 적은 이유
SQL 표준에서 PRIMARY KEY는 NOT NULL을 뜻합니다. 그런데 SQLite는 호환성 때문에 초기 버전의 버그를 그대로 두어서, INTEGER PRIMARY KEY 열, WITHOUT ROWID 테이블, STRICT 테이블이 아니면 NOT NULL을 선언하지 않은 기본키 열에 NULL을 허용합니다(SQLite 문서). 이 글의 DDL에 NOT NULL을 함께 적은 이유입니다.
정리와 이해 확인
테이블 설계는 같은 술어를 만족하는 것끼리 한 집합에 모으고, 무엇이 같으면 같은 원소인지를 키로 정하는 일입니다. 쿼리는 그 집합에서 술어로 부분집합을 고르는 일이고요. 다만 SQL 테이블은 제약이 없으면 같은 행을 허용하는 다중집합이고, NULL이 들어간 조건에는 UNKNOWN이 하나 더 있다는 점은 기억해 두세요.
내일 여러분의 테이블 하나를 골라 한 행이 말하는 문장을 “OO는 OO이다” 꼴로 적어 보세요. 그 문장의 빈칸이 아닌 열이 보이면 그 열은 다른 테이블에 속할 가능성이 큽니다. 그리고 무엇이 같으면 같은 대상인지 고르면, 그게 그 테이블의 키예요.
아래 질문으로 스스로 확인해 보세요. 답은 그 아래 노트에 있습니다.
- 같은 SELECT를 두 번 실행했는데 행 순서가 달랐습니다. 데이터가 바뀐 걸까요?
WHERE monthly_premium >= 50000의 결과를 조건제시법으로 쓰면 어떻게 되나요?- 청구 테이블에 계약자 주소 열이 있습니다. 이 테이블의 술어는 몇 개의 문장으로 이루어져 있나요?
- 기본키가 contract_no일 때 (‘PL-1001’, ‘박서준’, ‘TERM-10’)은 김하늘의 행과 중복인가요?
- WHERE 조건의 결과와 WHERE NOT 조건의 결과를 합치면 항상 전체 행이 되나요?
해설 노트: 이해 확인 답과 이유
- 꼭 그렇지는 않습니다. 관계는 집합이라 순서가 없고, ORDER BY가 없으면 DBMS는 정해지지 않은 순서로 행을 돌려줍니다.
- { t ∈ contract | t.monthly_premium ≥ 50000 }입니다. 술어를 참으로 만드는 행만 모은 부분집합입니다.
- 두 개입니다. “청구 c는 계약자 h가 금액 m을 청구했다”와 “계약자 h의 주소는 a다”가 섞여 있습니다. 두 번째 문장은 계약자에 대한 사실이므로 계약자 테이블에 모으는 것이 맞습니다.
- 행 전체로 보면 다른 원소라 집합에는 함께 들어갈 수 있습니다. 하지만 기본키는 계약번호 하나에 한 행이라는 업무 규칙이므로 거절합니다.
- 아닙니다. 조건 값이 NULL(UNKNOWN)인 행은 두 결과 모두에서 빠집니다.
해설 노트: 참고 자료
- E. F. Codd, “A Relational Model of Data for Large Shared Data Banks”, Communications of the ACM 13(6), 1970, pp. 377-387, §1.3, §1.5.
- Abraham Silberschatz, Henry F. Korth, S. Sudarshan, 『Database System Concepts』 제2장 관계형 모델 소개(한국어 번역본 39, 43, 44, 49쪽).
- Ralph Kimball, Margy Ross, 『The Data Warehouse Toolkit』 3판, 38~40쪽(차원 모델링의 네 단계, 그레인, 사실과 차원), 390쪽(보험 청구 트랜잭션).
- Kimball Group: Dimensional Modeling Techniques (2026-10-03 확인)
- PostgreSQL 문서: Constraints, Logical Operators, Sorting Rows (2026-10-03 확인)
- SQLite 문서: CREATE TABLE (2026-10-03 확인)