<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Denormalization on kastori</title><link>http://blog.kastori.dev/tags/denormalization/</link><description>Recent content in Denormalization on kastori</description><generator>Hugo -- gohugo.io</generator><language>ko-kr</language><lastBuildDate>Fri, 29 May 2026 00:00:00 +0900</lastBuildDate><atom:link href="http://blog.kastori.dev/tags/denormalization/index.xml" rel="self" type="application/rss+xml"/><item><title>[DB 완전 정복 #4] 정규화는 언제 깨야 하나 — 1NF부터 3NF, 실무 비정규화까지</title><link>http://blog.kastori.dev/tech/2026-05-29-db-04-normalization/</link><pubDate>Fri, 29 May 2026 00:00:00 +0900</pubDate><guid>http://blog.kastori.dev/tech/2026-05-29-db-04-normalization/</guid><description>&lt;h2 id="테이블-하나에-다-넣으면-안-되나요"&gt;&lt;a href="#%ed%85%8c%ec%9d%b4%eb%b8%94-%ed%95%98%eb%82%98%ec%97%90-%eb%8b%a4-%eb%84%a3%ec%9c%bc%eb%a9%b4-%ec%95%88-%eb%90%98%eb%82%98%ec%9a%94" class="header-anchor"&gt;&lt;/a&gt;테이블 하나에 다 넣으면 안 되나요?
&lt;/h2&gt;&lt;p&gt;DB를 처음 설계할 때 이런 생각을 한 적이 있을 것이다. 그냥 필요한 정보를 테이블 하나에 다 넣으면 조인도 없고 편하지 않을까?&lt;/p&gt;
&lt;p&gt;실제로 해보면 금방 문제가 생긴다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="이상-현상--설계가-나쁠-때-생기는-일"&gt;&lt;a href="#%ec%9d%b4%ec%83%81-%ed%98%84%ec%83%81--%ec%84%a4%ea%b3%84%ea%b0%80-%eb%82%98%ec%81%a0-%eb%95%8c-%ec%83%9d%ea%b8%b0%eb%8a%94-%ec%9d%bc" class="header-anchor"&gt;&lt;/a&gt;이상 현상 — 설계가 나쁠 때 생기는 일
&lt;/h2&gt;&lt;p&gt;이런 테이블이 있다고 해보자.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;주문_정보 (비정규화)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| order_id | member_id | member_name | member_email | product_id | product_name | price |
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;|----------|-----------|-------------|---------------|------------|--------------|-------|
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| 1 | 100 | 홍길동 | hong@test.com | 200 | 노트북 | 1500 |
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| 2 | 100 | 홍길동 | hong@test.com | 201 | 마우스 | 30 |
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| 3 | 101 | 김철수 | kim@test.com | 200 | 노트북 | 1500 |
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;삽입 이상&lt;/strong&gt;: 주문이 없는 신규 회원을 등록하고 싶다. &lt;code&gt;order_id&lt;/code&gt;가 없으니 행을 추가할 수 없다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;삭제 이상&lt;/strong&gt;: 주문 1번을 삭제한다. 그런데 홍길동의 이메일 정보가 주문 2번에만 남는 게 아니라, 만약 주문이 1번 하나뿐이었다면 회원 정보 자체가 사라진다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;수정 이상&lt;/strong&gt;: 홍길동이 이메일을 변경했다. &lt;code&gt;order_id = 1&lt;/code&gt;과 &lt;code&gt;order_id = 2&lt;/code&gt; 두 군데를 모두 수정해야 한다. 하나만 고치면 같은 사람의 이메일이 두 개가 된다.&lt;/p&gt;
&lt;p&gt;이것이 **이상 현상(Anomaly)**이다. 정규화는 이 문제를 해결하기 위한 설계 원칙이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="1nf--원자값-원칙"&gt;&lt;a href="#1nf--%ec%9b%90%ec%9e%90%ea%b0%92-%ec%9b%90%ec%b9%99" class="header-anchor"&gt;&lt;/a&gt;1NF — 원자값 원칙
&lt;/h2&gt;
 &lt;blockquote&gt;
 &lt;p&gt;모든 컬럼의 값이 더 이상 분리할 수 없는 원자적인 값이어야 한다.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;❌ 1NF 위반
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| order_id | products |
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;|----------|------------------|
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| 1 | 노트북, 마우스 | ← 여러 값이 한 컬럼에
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✅ 1NF 준수
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| order_id | product_id | product_name |
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;|----------|------------|--------------|
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| 1 | 200 | 노트북 |
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| 1 | 201 | 마우스 |
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;컬럼에 여러 값을 콤마로 넣거나, 배열처럼 넣는 것이 대표적인 위반이다. 이렇게 하면 &lt;code&gt;WHERE products LIKE '%마우스%'&lt;/code&gt;처럼 검색해야 하고, 인덱스도 제대로 못 탄다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="2nf--부분-함수-종속-제거"&gt;&lt;a href="#2nf--%eb%b6%80%eb%b6%84-%ed%95%a8%ec%88%98-%ec%a2%85%ec%86%8d-%ec%a0%9c%ea%b1%b0" class="header-anchor"&gt;&lt;/a&gt;2NF — 부분 함수 종속 제거
&lt;/h2&gt;
 &lt;blockquote&gt;
 &lt;p&gt;기본키가 복합키일 때, 모든 일반 컬럼이 복합키 전체에 종속되어야 한다. (기본키의 일부에만 종속되면 안 된다)&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;order_items 테이블 (1NF)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;기본키: (order_id, product_id)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| order_id | product_id | product_name | qty |
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;|----------|------------|--------------|-----|
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| 1 | 200 | 노트북 | 1 |
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| 1 | 201 | 마우스 | 2 |
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;문제: product_name은 product_id에만 종속 (order_id와는 무관)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; → 노트북 이름이 바뀌면 여러 주문 행을 모두 수정해야 함
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✅ 2NF 준수 (분리)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;order_items: (order_id, product_id, qty)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;products: (product_id, product_name, price)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;hr&gt;
&lt;h2 id="3nf--이행적-함수-종속-제거"&gt;&lt;a href="#3nf--%ec%9d%b4%ed%96%89%ec%a0%81-%ed%95%a8%ec%88%98-%ec%a2%85%ec%86%8d-%ec%a0%9c%ea%b1%b0" class="header-anchor"&gt;&lt;/a&gt;3NF — 이행적 함수 종속 제거
&lt;/h2&gt;
 &lt;blockquote&gt;
 &lt;p&gt;기본키가 아닌 일반 컬럼 간의 종속이 없어야 한다.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;members 테이블 (2NF)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;기본키: member_id
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| member_id | zip_code | city | district |
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;|-----------|----------|--------|----------|
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| 1 | 06130 | 서울 | 강남구 |
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| 2 | 06130 | 서울 | 강남구 |
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;문제: city와 district는 zip_code에 종속 (member_id → zip_code → city/district)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; → 06130 우편번호의 시/구가 바뀌면 모든 행을 수정해야 함
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✅ 3NF 준수 (분리)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;members: (member_id, zip_code)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;zip_codes: (zip_code, city, district)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;hr&gt;
&lt;h2 id="정규화-vs-비정규화--트레이드오프"&gt;&lt;a href="#%ec%a0%95%ea%b7%9c%ed%99%94-vs-%eb%b9%84%ec%a0%95%ea%b7%9c%ed%99%94--%ed%8a%b8%eb%a0%88%ec%9d%b4%eb%93%9c%ec%98%a4%ed%94%84" class="header-anchor"&gt;&lt;/a&gt;정규화 vs 비정규화 — 트레이드오프
&lt;/h2&gt;&lt;p&gt;정규화를 하면 데이터 무결성이 좋아지지만, 조회 시 조인이 많아진다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;정규화의 장점:
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ✅ 데이터 중복 없음 → 수정 이상 없음
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ✅ 삽입/삭제 이상 없음
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ✅ 저장 공간 절약
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;정규화의 단점:
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ❌ 조회 시 JOIN 필요 → 쿼리 복잡
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ❌ 성능이 중요한 읽기 위주 서비스에서 조인 비용
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;실무에서는 &lt;strong&gt;의도적으로 정규화를 깨는&lt;/strong&gt; 경우도 있다.&lt;/p&gt;
&lt;h3 id="비정규화가-정당한-경우"&gt;&lt;a href="#%eb%b9%84%ec%a0%95%ea%b7%9c%ed%99%94%ea%b0%80-%ec%a0%95%eb%8b%b9%ed%95%9c-%ea%b2%bd%ec%9a%b0" class="header-anchor"&gt;&lt;/a&gt;비정규화가 정당한 경우
&lt;/h3&gt;&lt;p&gt;&lt;strong&gt;1. 집계 결과를 미리 저장&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- 매번 COUNT 집계 쿼리 실행
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;member_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- 비정규화: members 테이블에 post_count 컬럼 추가
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- 게시글 INSERT/DELETE 시 post_count를 함께 업데이트
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;post_count&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 빠름
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;2. 자주 함께 조회되는 컬럼 중복 저장&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- orders 테이블에 member_name을 중복 저장
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- 주문 목록 조회 시 members 테이블 JOIN 불필요
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;member_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;member_name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total_amount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;주문 당시의 회원명을 보존한다는 의미도 있다(회원이 탈퇴해도 주문 이력 유지).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. 읽기 전용 분석 테이블&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;OLAP(분석 쿼리) 용도의 테이블은 처음부터 비정규화로 설계한다. 수십 개 테이블을 조인하는 분석 쿼리보다 평탄화된 넓은 테이블이 훨씬 빠르다.&lt;/p&gt;
&lt;h3 id="jpa와-정규화의-관계"&gt;&lt;a href="#jpa%ec%99%80-%ec%a0%95%ea%b7%9c%ed%99%94%ec%9d%98-%ea%b4%80%ea%b3%84" class="header-anchor"&gt;&lt;/a&gt;JPA와 정규화의 관계
&lt;/h3&gt;&lt;p&gt;JPA 연관관계(&lt;code&gt;@OneToMany&lt;/code&gt;, &lt;code&gt;@ManyToOne&lt;/code&gt;)는 정규화된 테이블을 객체로 매핑하는 방식이다. 정규화가 잘 되어 있으면 JPA 설계도 자연스럽게 따라온다.&lt;/p&gt;
&lt;p&gt;비정규화를 하면 JPA 매핑이 어색해질 수 있다. 그래서 비정규화는 신중하게, 이유가 명확할 때만 해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="마치며"&gt;&lt;a href="#%eb%a7%88%ec%b9%98%eb%a9%b0" class="header-anchor"&gt;&lt;/a&gt;마치며
&lt;/h2&gt;&lt;p&gt;정규화는 목표가 아니라 도구다. &lt;strong&gt;이상 현상을 없애기 위해 필요한 만큼&lt;/strong&gt; 하고, 성능이 중요하고 이유가 명확한 경우에만 의도적으로 비정규화를 선택한다. &amp;ldquo;정규화를 깨는 이유가 뭔가요?&amp;ldquo;라는 질문에 답할 수 있다면 충분하다.&lt;/p&gt;
&lt;p&gt;다음 편에서는 DB 연결 자체의 비용을 다루는 커넥션 풀(HikariCP)을 다룬다.&lt;/p&gt;</description></item></channel></rss>