
정규화: 데이터 중복 제거
DB 설계의 기초. 데이터를 쪼개고 쪼개서 이상 현상(Anomaly)을 방지하는 과정. 제1, 2, 3 정규형을 쉽게 설명합니다.

DB 설계의 기초. 데이터를 쪼개고 쪼개서 이상 현상(Anomaly)을 방지하는 과정. 제1, 2, 3 정규형을 쉽게 설명합니다.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

데이터베이스를 처음 만들 때 나는 엑셀 스프레드시트처럼 생각했다. 필요한 정보를 전부 한 테이블에 몰아넣으면 간단하고 좋을 것 같았다. 실제로 초기에는 그렇게 했다. 주문 정보, 고객 정보, 상품 정보를 한 테이블에 다 넣었다. 쿼리도 간단했다. SELECT * 하나면 모든 정보가 다 나왔다.
그런데 문제는 데이터를 수정하거나 삭제할 때 터졌다. 고객 이메일 주소를 바꾸려는데, 그 고객의 주문이 100건이면 100개의 행을 전부 업데이트해야 했다. 실수로 하나라도 빠뜨리면 같은 고객인데 이메일이 달라지는 기괴한 상황이 벌어졌다. 주문 하나를 삭제했더니 고객 정보까지 날아가 버렸다. 나는 주문만 지우고 싶었는데.
그때 정규화라는 개념을 만났다. 처음엔 "왜 멀쩡한 테이블을 쪼개서 복잡하게 만드나"라고 생각했지만, 이상 현상(Anomaly)을 겪어본 후에는 달랐다. 결국 정규화는 "같은 정보를 여러 곳에 중복 저장하지 않기 위한 체계적인 방법"이었다. 나를 위한 정리 노트로 여기에 기록한다.
정규화를 공부하면서 제1정규형, 제2정규형, 제3정규형, BCNF, 제4정규형... 이런 용어들이 쏟아졌다. 처음엔 "이게 무슨 학문적 이론인가"라는 생각이 들었다. 각 정규형마다 수학적 정의와 함수 종속성(Functional Dependency) 같은 개념이 등장했다. 나는 CS 전공자가 아니어서 이런 표현들이 와닿지 않았다.
"부분 함수 종속성을 제거한다"는 설명을 읽었을 때, 나는 대체 뭐가 부분이고 뭐가 전체인지 감이 안 왔다. "이행적 종속성"도 마찬가지였다. A가 B를 결정하고 B가 C를 결정한다는 건 알겠는데, 그래서 뭘 어떻게 하라는 건지 막막했다.
그러다가 깨달았다. 이론을 먼저 이해하려고 하지 말고, 실제 문제 상황을 먼저 보면 된다는 것을. 이상 현상이 뭔지 직접 테이블을 만들어보고, 데이터를 넣고 빼고 수정하면서 어떤 문제가 생기는지 경험하니까 정규화가 왜 필요한지 체감됐다.
정규화를 이해하는 데 가장 와닿았던 비유는 "방 정리"였다. 어질러진 방에 옷, 책, 전자기기, 서류가 뒤섞여 있다고 상상해보자. 이 상태에서는 뭘 찾으려고 해도 시간이 오래 걸리고, 같은 물건이 여러 곳에 흩어져 있어서 일부만 정리하면 나머지는 그대로 방치된다.
정규화는 이걸 "옷은 옷장에, 책은 책장에, 서류는 서류함에" 라벨을 붙여서 분류하는 과정이다. 각 물건이 정확히 한 곳에만 존재하도록 만드는 것. 그러면 옷을 바꿀 때 옷장만 열면 되고, 책을 찾을 때 책장만 뒤지면 된다. 한 곳만 정리하면 전체가 정돈된 상태를 유지한다.
역정규화는 반대로 "자주 함께 쓰는 물건은 가까이 두기"다. 예를 들어 매일 아침 쓰는 시계와 지갑과 열쇠는 원래 각자 다른 서랍에 있어야 하지만, 편의를 위해 현관 옆 바구니에 함께 넣어둔다. 조금 중복되더라도 빠르게 꺼낼 수 있으면 그게 낫다. 나는 이 비유로 정규화와 역정규화를 받아들였다.
정규화를 배우기 전에 이상 현상(Anomaly)을 먼저 이해해야 한다. 나는 처음에 이상 현상이 뭔지 몰라서 "그냥 조심하면 되지 않나?"라고 생각했다. 하지만 실제로 겪어보니 조심한다고 해결될 문제가 아니었다. 데이터 구조 자체가 잘못 설계되어 있으면 아무리 주의해도 문제가 발생한다.
학생과 수강 과목을 하나의 테이블로 관리한다고 해보자.
CREATE TABLE Student_Course (
student_id INT,
student_name VARCHAR(100),
department VARCHAR(100),
course_id INT,
course_name VARCHAR(100),
PRIMARY KEY (student_id, course_id)
);
이 테이블에서는 학생이 최소한 하나의 과목을 수강해야만 등록할 수 있다. 새로 입학한 학생이 아직 수강 신청을 하지 않았다면? 데이터를 넣을 수가 없다. Primary Key가 student_id와 course_id 조합이기 때문에 course_id가 NULL이면 저장이 불가능하다. 이게 삽입 이상이다. 학생 정보만 추가하고 싶은데, 과목 정보 없이는 삽입이 안 된다.
나는 초기에 이걸 "그럼 과목을 임시로 '미정'이라고 넣으면 되지 않나?"라고 생각했다. 하지만 그러면 더미 데이터가 쌓이고, 나중에 이 더미를 지우는 작업이 추가로 필요하다. 데이터베이스에 거짓 데이터를 넣는 건 근본적인 해결책이 아니었다.
위 테이블에서 한 학생이 수강한 과목이 딱 하나뿐이라고 해보자. 그 학생이 수강을 취소하면? 해당 행을 삭제해야 하는데, 그러면 학생 정보(student_name, department)까지 같이 날아간다. 나는 수강 정보만 지우고 싶었는데 학생 정보까지 사라져 버린다.
실제로 이런 일이 진짜로 일어났다. 주문 테이블에 고객 정보를 함께 넣었는데, 주문을 취소하면서 고객 정보까지 같이 삭제해버린 적이 있다. 다행히 백업이 있어서 복구했지만, 그 순간 "테이블 설계가 잘못됐구나"라고 깨달았다.
학생 '홍길동'이 10개의 과목을 수강하고 있다. 홍길동의 학과가 '컴퓨터공학과'에서 '소프트웨어학과'로 변경됐다. 그러면 10개의 행 모두에서 department 컬럼을 업데이트해야 한다. 만약 실수로 9개만 업데이트하고 1개를 빠뜨리면? 같은 학생인데 학과가 두 개가 된다. 데이터 무결성이 깨진다.
-- 홍길동의 학과를 변경하려면 모든 행을 업데이트해야 함
UPDATE Student_Course
SET department = '소프트웨어학과'
WHERE student_id = 101; -- 10개 행이 모두 업데이트돼야 함
나는 이걸 "애플리케이션 로직에서 트랜잭션으로 묶으면 되지 않나?"라고 생각했다. 하지만 그건 근본 해결이 아니다. 애플리케이션이 여러 개라면? 다른 팀이 다른 언어로 같은 데이터베이스를 쓴다면? 모든 곳에서 같은 로직을 구현해야 한다. 데이터베이스 구조 자체가 중복을 강제하고 있으면 아무리 애플리케이션에서 조심해도 언젠가 문제가 생긴다.
나는 제1정규형을 "원자성(Atomicity)"이라는 단어로 처음 접했다. "원자처럼 더 이상 쪼갤 수 없는 값"이라는 설명을 들었는데, 처음엔 추상적으로 느껴졌다. 하지만 실제 예시를 보니 간단했다.
CREATE TABLE Student_Bad (
student_id INT PRIMARY KEY,
name VARCHAR(100),
courses VARCHAR(500) -- "수학, 영어, 과학" 같은 값이 들어감
);
INSERT INTO Student_Bad VALUES (1, '홍길동', '수학, 영어, 과학');
이 테이블에서 "영어를 수강하는 학생을 찾아라"는 쿼리를 어떻게 짜야 할까?
SELECT * FROM Student_Bad WHERE courses LIKE '%영어%';
이렇게 LIKE 패턴 매칭을 써야 한다. 문제는 이게 느리다는 것. 인덱스를 못 쓴다. 그리고 "영어, 영어회화, 실용영어" 같은 과목이 있으면 어떻게 구분할 건가? "과학"을 검색했는데 "과학기술론"까지 같이 나온다. 정확한 검색이 불가능하다.
나는 초기에 "그럼 구분자를 정확히 정의하면 되지 않나? 콤마와 공백을 명확히 규정하고"라고 생각했다. 하지만 그건 데이터베이스의 일을 애플리케이션으로 떠넘기는 것이다. 데이터베이스는 "이 학생이 이 과목을 수강한다"는 사실을 명확히 표현해야 하는데, 문자열 파싱으로 처리하는 건 잘못된 설계다.
CREATE TABLE Student (
student_id INT PRIMARY KEY,
name VARCHAR(100)
);
CREATE TABLE Enrollment (
student_id INT,
course_id INT,
PRIMARY KEY (student_id, course_id),
FOREIGN KEY (student_id) REFERENCES Student(student_id)
);
CREATE TABLE Course (
course_id INT PRIMARY KEY,
course_name VARCHAR(100)
);
INSERT INTO Student VALUES (1, '홍길동');
INSERT INTO Course VALUES (101, '수학'), (102, '영어'), (103, '과학');
INSERT INTO Enrollment VALUES (1, 101), (1, 102), (1, 103);
이제 "영어를 수강하는 학생"을 찾는 쿼리가 명확해진다.
SELECT s.name
FROM Student s
JOIN Enrollment e ON s.student_id = e.student_id
JOIN Course c ON e.course_id = c.course_id
WHERE c.course_name = '영어';
인덱스를 탈 수 있고, 정확한 매칭이 가능하다. 나는 이걸 통해 "1NF는 관계형 데이터베이스의 기본"이라고 받아들였다. 쉼표로 구분된 문자열을 넣는 순간, 이미 관계형 데이터베이스의 이점을 포기하는 것이다.
2NF를 이해하려면 "복합 키(Composite Key)"를 먼저 알아야 한다. Primary Key가 여러 컬럼의 조합인 경우다. 나는 처음에 "복합 키 전체가 아니라 일부에만 종속적이다"는 말이 무슨 뜻인지 몰랐다. 예시를 보고 나서야 "아, 이거구나" 싶었다.
CREATE TABLE Enrollment_Bad (
student_id INT,
course_id INT,
student_name VARCHAR(100), -- student_id에만 종속
department VARCHAR(100), -- student_id에만 종속
course_name VARCHAR(100), -- course_id에만 종속
grade CHAR(1), -- student_id와 course_id 둘 다 필요
PRIMARY KEY (student_id, course_id)
);
이 테이블의 Primary Key는 (student_id, course_id) 조합이다. 그런데 student_name과 department는 student_id만 있으면 결정된다. course_id는 필요 없다. 반대로 course_name은 course_id만 있으면 결정된다. 오직 grade(성적)만 두 값 모두 필요하다.
이게 바로 부분 함수 종속성이다. Primary Key의 일부만으로 결정되는 컬럼이 있다는 뜻. 나는 이걸 "필요 이상의 정보를 끌고 다닌다"고 이해했다. student_id가 101인 학생이 5개 과목을 수강하면, 학생 이름과 학과가 5번 중복 저장된다. 학생 이름을 바꾸려면 5개 행을 모두 업데이트해야 한다. 이게 수정 이상이다.
CREATE TABLE Student (
student_id INT PRIMARY KEY,
student_name VARCHAR(100),
department VARCHAR(100)
);
CREATE TABLE Course (
course_id INT PRIMARY KEY,
course_name VARCHAR(100)
);
CREATE TABLE Enrollment (
student_id INT,
course_id INT,
grade CHAR(1),
PRIMARY KEY (student_id, course_id),
FOREIGN KEY (student_id) REFERENCES Student(student_id),
FOREIGN KEY (course_id) REFERENCES Course(course_id)
);
이제 학생 정보는 Student 테이블에 딱 한 번만 저장된다. 과목 정보는 Course 테이블에 한 번만. Enrollment 테이블은 순수하게 "누가 무엇을 수강해서 무슨 성적을 받았는가"만 기록한다. 학생 이름을 바꾸려면 Student 테이블에서 한 행만 업데이트하면 된다.
나는 이걸 경험하면서 "2NF는 복합 키를 쓸 때 주의해야 하는 정규형"이라고 정리했다. Primary Key가 단일 컬럼이면 2NF 위반이 애초에 불가능하다. 부분 종속성이라는 개념 자체가 성립하지 않으니까.
3NF는 나한테 가장 헷갈렸던 개념이었다. "A가 B를 결정하고 B가 C를 결정하면, A가 C를 이행적으로 결정한다"는 설명을 읽었을 때, 수학 시간에 배운 "A=B이고 B=C이면 A=C"라는 추이 관계가 떠올랐다. 그런데 데이터베이스에서 이게 왜 문제가 되는지는 바로 와닿지 않았다.
CREATE TABLE Student_Bad (
student_id INT PRIMARY KEY,
student_name VARCHAR(100),
department VARCHAR(100),
dept_office VARCHAR(100) -- 학과 사무실 위치
);
INSERT INTO Student_Bad VALUES
(101, '홍길동', '컴퓨터공학과', '공학관 301호'),
(102, '김철수', '컴퓨터공학과', '공학관 301호'),
(103, '이영희', '전자공학과', '공학관 201호');
여기서 student_id → department → dept_office 관계가 성립한다. 학번으로 학과를 알 수 있고, 학과로 사무실 위치를 알 수 있다. 그래서 학번으로 사무실 위치를 간접적으로 알 수 있다. 이게 이행적 종속성이다.
문제는 뭘까? '컴퓨터공학과'에 속한 학생이 100명이라고 해보자. 그러면 '공학관 301호'라는 정보가 100번 중복 저장된다. 만약 컴퓨터공학과 사무실이 '공학관 401호'로 이사하면? 100개 행을 모두 업데이트해야 한다. 하나라도 빠뜨리면 같은 학과인데 사무실 위치가 다른 기괴한 상황이 생긴다.
나는 처음에 "그럼 트랜잭션으로 한 번에 업데이트하면 되지 않나?"라고 생각했다. 하지만 이건 근본 문제가 아니다. 사무실 위치는 학과에 종속된 정보인데, 학생 테이블에 저장되어 있는 게 이상한 거다. 학과 정보가 바뀌는 건 학생과 무관한 일인데, 학생 테이블을 건드려야 한다는 게 말이 안 된다.
CREATE TABLE Department (
department VARCHAR(100) PRIMARY KEY,
dept_office VARCHAR(100)
);
CREATE TABLE Student (
student_id INT PRIMARY KEY,
student_name VARCHAR(100),
department VARCHAR(100),
FOREIGN KEY (department) REFERENCES Department(department)
);
INSERT INTO Department VALUES
('컴퓨터공학과', '공학관 301호'),
('전자공학과', '공학관 201호');
INSERT INTO Student VALUES
(101, '홍길동', '컴퓨터공학과'),
(102, '김철수', '컴퓨터공학과'),
(103, '이영희', '전자공학과');
이제 사무실 위치는 Department 테이블에 딱 한 번만 저장된다. 컴퓨터공학과가 이사하면 Department 테이블에서 한 행만 업데이트하면 된다. 학생 100명의 데이터는 전혀 건드릴 필요가 없다.
나는 이걸 통해 "3NF는 키가 아닌 컬럼 간의 종속성을 제거하는 것"이라고 정리했다. department는 Primary Key가 아닌데, dept_office가 거기에 종속되어 있다. 이런 관계를 분리하는 게 3NF다.
BCNF(Boyce-Codd Normal Form)는 3NF보다 한 단계 더 엄격한 정규형이다. 나는 처음에 "3NF면 충분한 거 아닌가?"라고 생각했다. 실제로 대부분의 경우 3NF까지만 적용해도 문제가 없기 때문이다. 하지만 특수한 경우에는 3NF를 만족해도 이상 현상이 남아있을 수 있다.
3NF는 "비주요 속성이 후보 키에 이행적으로 종속되지 않으면 된다"는 조건이다. 하지만 BCNF는 "모든 결정자가 후보 키여야 한다"는 더 강한 조건이다. 나는 이 차이를 완전히 이해하지는 못했지만, "3NF로 해결 안 되는 특수한 케이스가 있구나" 정도로 받아들였다. 실제로 BCNF까지 고려해야 하는 경우는 드물다고 들었다.
이론만 보면 잘 와닿지 않아서, 실제로 이커머스 사이트의 주문 시스템을 설계해봤다. 처음엔 모든 정보를 하나의 테이블에 넣었다.
CREATE TABLE Orders_Bad (
order_id INT PRIMARY KEY,
customer_name VARCHAR(100),
customer_email VARCHAR(100),
customer_address VARCHAR(500),
products VARCHAR(1000), -- "상품A, 상품B, 상품C"
total_price DECIMAL(10, 2),
order_date DATE
);
이 테이블은 1NF도 만족하지 못한다. products 컬럼에 여러 상품이 쉼표로 구분되어 들어간다. "상품A를 주문한 사람"을 찾으려면 LIKE '%상품A%' 같은 쿼리를 써야 한다.
CREATE TABLE Orders (
order_id INT PRIMARY KEY,
customer_name VARCHAR(100),
customer_email VARCHAR(100),
customer_address VARCHAR(500),
order_date DATE
);
CREATE TABLE Order_Items (
order_id INT,
product_id INT,
quantity INT,
price DECIMAL(10, 2),
PRIMARY KEY (order_id, product_id)
);
이제 상품 정보를 명확히 분리했다. 하지만 여전히 문제가 있다. 고객이 10번 주문하면 이름, 이메일, 주소가 10번 중복 저장된다. 고객 이메일이 바뀌면 10개 행을 모두 업데이트해야 한다.
CREATE TABLE Customers (
customer_id INT PRIMARY KEY,
customer_name VARCHAR(100),
customer_email VARCHAR(100),
customer_address VARCHAR(500)
);
CREATE TABLE Products (
product_id INT PRIMARY KEY,
product_name VARCHAR(100),
product_price DECIMAL(10, 2)
);
CREATE TABLE Orders (
order_id INT PRIMARY KEY,
customer_id INT,
order_date DATE,
FOREIGN KEY (customer_id) REFERENCES Customers(customer_id)
);
CREATE TABLE Order_Items (
order_id INT,
product_id INT,
quantity INT,
price DECIMAL(10, 2), -- 주문 당시 가격 (상품 가격이 나중에 바뀔 수 있으므로)
PRIMARY KEY (order_id, product_id),
FOREIGN KEY (order_id) REFERENCES Orders(order_id),
FOREIGN KEY (product_id) REFERENCES Products(product_id)
);
이제 고객 정보는 Customers 테이블에 한 번만 저장된다. 상품 정보도 Products 테이블에 한 번만. 주문 정보는 Orders 테이블에, 주문 상세는 Order_Items 테이블에. 각 정보가 정확히 한 곳에만 존재한다.
나는 이 과정을 직접 겪으면서 "정규화는 정보를 주제별로 분류하는 것"이라고 이해했다. 고객 정보, 상품 정보, 주문 정보는 각각 독립적인 개체다. 이걸 한 테이블에 섞어놓으면 중복과 이상 현상이 발생한다.
정규화를 배우고 나서 나는 "무조건 3NF까지 정규화하는 게 정답이구나"라고 생각했다. 하지만 실제로는 그렇지 않았다. 어떤 쿼리는 너무 많은 JOIN을 필요로 해서 성능이 심각하게 느렸다. 특히 읽기가 많은 서비스에서는 역정규화가 필수였다.
예를 들어 주문 목록을 보여주는 페이지를 생각해보자. 고객 이름, 상품 이름, 주문 날짜, 총액을 보여줘야 한다.
SELECT
o.order_id,
c.customer_name,
p.product_name,
oi.quantity,
oi.price,
o.order_date
FROM Orders o
JOIN Customers c ON o.customer_id = c.customer_id
JOIN Order_Items oi ON o.order_id = oi.order_id
JOIN Products p ON oi.product_id = p.product_id;
이 쿼리는 4개 테이블을 JOIN한다. 주문이 수백만 건이면 상당히 느려질 수 있다. 이럴 때 역정규화를 고려한다.
CREATE TABLE Order_Summary (
order_id INT PRIMARY KEY,
customer_name VARCHAR(100), -- 중복 저장
product_names TEXT, -- 중복 저장
total_price DECIMAL(10, 2),
order_date DATE
);
주문이 생성될 때 이 테이블에도 요약 정보를 함께 저장한다. 그러면 목록 조회 시 JOIN 없이 한 테이블만 읽으면 된다. 읽기 성능이 대폭 향상된다.
단, 고객 이름이 바뀌면? 원본 Customers 테이블뿐만 아니라 Order_Summary 테이블도 업데이트해야 한다. 이게 역정규화의 대가다. 나는 이걸 "편의를 위해 일관성 유지 비용을 지불하는 것"으로 받아들였다.
실제로는 이런 상황에서 애플리케이션 레벨에서 동기화 로직을 넣거나, 아예 읽기 전용 복제본(Read Replica)을 만들어서 별도로 관리하기도 한다. 정답은 없고, 상황에 따라 판단해야 한다.
나는 정규화를 공부하면서 "결국 이거였다"라고 깨달은 순간이 있다. 정규화의 본질은 "같은 정보를 두 번 저장하지 않기"다. 모든 정보는 정확히 한 곳에만 존재해야 한다. 그래야 수정할 때 한 곳만 고치면 되고, 데이터 무결성이 자동으로 유지된다.
1NF, 2NF, 3NF는 각각 다른 유형의 중복을 제거한다.
모두 같은 목적을 향한다. "한 사실은 한 곳에만." 나는 이 원칙을 받아들이고 나서 데이터베이스 설계가 훨씬 명확해졌다. "이 정보는 어디에 저장해야 하나?"를 고민할 때, "이 정보는 무엇에 종속되는가?"를 물어보면 답이 나왔다.
고객 이름은 고객 ID에 종속된다 → Customers 테이블.
상품 가격은 상품 ID에 종속된다 → Products 테이블.
성적은 학생 ID와 과목 ID 조합에 종속된다 → Enrollment 테이블.
이렇게 생각하니 테이블 설계가 자연스럽게 정리됐다.
정규화는 나에게 "데이터베이스는 엑셀이 아니다"라는 깨달음을 줬다. 엑셀에서는 모든 정보를 한 시트에 넣고 필터와 정렬로 관리한다. 하지만 데이터베이스는 다르다. 관계형 데이터베이스는 "관계"를 중심으로 설계해야 한다. 각 엔티티는 독립적으로 존재하고, 외래 키로 연결된다.
나는 정규화를 통해 이상 현상이 왜 발생하는지, 어떻게 방지할 수 있는지 이해했다. 1NF는 관계형 데이터베이스의 기본이고, 2NF와 3NF는 중복을 체계적으로 제거하는 방법이다. 역정규화는 성능을 위해 의도적으로 중복을 허용하는 기법이며, 읽기와 쓰기의 트레이드오프를 고려해야 한다.
실제로는 무조건 3NF까지 정규화하지 않는다. 상황에 따라 2NF에서 멈추기도 하고, 일부 테이블은 역정규화를 적용하기도 한다. 중요한 건 "왜 이렇게 설계했는가"를 설명할 수 있어야 한다는 것. 정규화 원칙을 알고 있으면, 그걸 깰 때도 근거를 가지고 깰 수 있다.
나는 앞으로 데이터베이스를 설계할 때 항상 이상 현상을 먼저 생각하려고 한다. "이 구조에서 데이터를 추가/수정/삭제할 때 문제가 생기지 않는가?" 이 질문에 답할 수 있으면, 설계가 잘된 것이다.