정규화 직관 —
왜 테이블을 나누는가
지금까지는 이미 나뉘어 있는 다섯 테이블을 읽었습니다. 이번에는 방향을 뒤집어 볼게요. 회원 · 주문 · 상품이 한 장의 넓은 표에 전부 들어 있다면 무슨 일이 벌어질까요? 그 표에서 각 정보의 주인을 찾아내는 일이 정규화입니다.
이번 섹션의 여정
완료 ✓
완료 ✓
지금 여기
실무 ERD · 종합 실습
만약 테이블이 나뉘어 있지 않았다면
4-1에서 “거대한 표 하나로 만들면 안 될까요” 라는 질문을 잠깐 던지고 지나갔습니다. 오늘은 그 표를 실제로 만들어 놓고 하나씩 뜯어보겠습니다.
우리 shop의 값을 그대로 쓰되, 다섯 테이블을 한 장으로 합쳤다고 상상해 볼게요.
아래 표는 shop 에 존재하는 테이블이 아닙니다. 실제 값을 한 장에 합쳤다고
가정한 비교용 표예요. 진짜 shop 은 이미 다섯 테이블로 나뉘어 있습니다.
| order_id | ordered_at | member_name | product_name | category_name | price | quantity |
|---|---|---|---|---|---|---|
| 1 | 2025-02-01 | 김철수 | 라운드 티셔츠 | 의류 | 19,000 | 1 |
| 1 | 2025-02-01 | 김철수 | 프리미엄 그래놀라 | 식품 | 12,000 | 2 |
| 6 | 2025-04-10 | 김철수 | 무선 이어폰 | 전자기기 | 89,000 | 1 |
| 7 | 2025-04-20 | 강서연 | 라운드 티셔츠 | 의류 | 19,000 | 2 |
| 7 | 2025-04-20 | 강서연 | 니트 | 의류 | 48,000 | 1 |
한 행은 주문에 담긴 상품 한 종류입니다. 그래서 주문 1번은 두 줄, 주문 7번도 두 줄이 되죠.
보기에는 꽤 편해 보입니다. 조회할 때 다른 테이블을 찾아갈 필요도 없고요. 그런데 이 표에는 이미 문제가 심어져 있습니다.
한 줄 안에 다섯 가지 사실이 섞여 있습니다
첫 두 줄을 다시 보세요. 김철수와 2025-02-01은 똑같이 반복되는데, 상품명 · 카테고리 · 가격 · 수량은 서로 다릅니다.
같은 행에 있다고 해서 같은 성격의 값이 아니라는 뜻이에요. 한 줄을 성격별로 갈라 보면 이렇게 됩니다.
| 이 값은 | 누구의 사실인가 | 주인이 될 테이블 |
|---|---|---|
| 회원 이름 · 성별 · 나이 · 가입일 | 회원 한 명 | members |
| 주문일 · 주문한 회원 | 주문 한 건 | orders |
| 상품 이름 · 현재 가격 | 상품 한 개 | products |
| 카테고리 이름 | 카테고리 하나 | categories |
| 담긴 상품과 수량 | 주문과 상품의 만남 | order_items |
3-1에서 배운 “이 테이블에서 한 행의 주인공은 무엇이지” 라는 질문을 이 표에 던져 보세요. 답이 하나로 나오지 않습니다. 주문의 표 같기도 하고, 상품의 표 같기도 하고, 회원의 표 같기도 하죠.
정규화는 표를 많이 만드는 기술이 아니라, 같은 사실을 한 곳에서 책임지게 만드는 정리 방법입니다.
값이 반복되는 것 자체는 문제가 아닙니다
여기서 흔한 오해 하나를 먼저 지우고 갑니다. 같은 값이 여러 줄에 보인다고 무조건 잘못된 설계가 아닙니다.
실제 shop의 orders 테이블에도 member_id = 1은 여섯 번 나옵니다. 김철수 회원의 주문이 6건이니까요. 이건 관계를 저장하기 위한 정상적인 반복입니다.
| 어떤 반복인가 | 예 | 문제인가 |
|---|---|---|
| 관계를 가리키는 번호가 반복 | orders 여섯 줄에 member_id = 1 | × 정상 — 관계를 저장하려면 필요하다 |
| 같은 사실을 여러 줄에 복사해 따로 관리 | 넓은 표 열 줄에 "김철수"라는 이름 자체를 반복 저장 | ○ 문제 — 고칠 곳이 열 군데가 된다 |
외래키 값이 반복되는 것은 “이 행이 누구에게 속하는지” 를 가리키는 표지입니다. 문제는 사실 그 자체를 여러 곳에 복사해 놓고 각자 관리하는 상태예요.
이제 그 상태에서 평범한 작업 세 가지를 해보겠습니다.
상황 하나 — 이름 하나 바꾸는 데 열다섯 줄
기획팀에서 카테고리 이름을 “의류” 에서 “패션” 으로 바꾸기로 했습니다. 실제 shop이라면 categories 테이블의 한 줄만 고치면 끝이에요.
그런데 넓은 표에서는 의류 상품이 담긴 주문상세 줄을 전부 찾아야 합니다.
| 상품 | 몇 개 주문에 담겼나 |
|---|---|
| 라운드 티셔츠 | 4 |
| 패딩 점퍼 | 4 |
| 니트 | 3 |
| 카디건 | 3 |
| 면 셔츠 | 1 |
| 합계 | 15 |
열다섯 줄을 고치는 것 자체보다 무서운 건 한 줄을 놓쳤을 때 입니다. 열네 줄은 “패션”, 한 줄은 “의류”가 되죠. 그러면 같은 카테고리인데 이름이 두 개인 상태가 됩니다. DB는 그 한 줄이 틀렸다는 걸 알 방법이 없어요 — 어느 쪽이 진짜인지 판단할 근거가 표 안에 없으니까요.
상품 쪽도 똑같습니다. 라운드 티셔츠 가격이 19,000원에서 21,000원으로 오르면 네 줄을 모두 찾아 고쳐야 합니다.
“가격을 고치면 지난 주문의 금액까지 바뀌는 것 아닌가요?” — 아주 좋은 질문이고, 실무에서는 주문 당시 가격을 따로 남기는 설계를 씁니다. 다만 그건 가격 이력 이라는 별도 주제예요. 오늘은 사실의 주인을 찾는 감각에 집중하겠습니다.
상황 둘 — 아직 팔리지 않은 것은 적을 자리가 없습니다
이번에는 새 데이터를 넣어 봅니다. 넓은 표의 한 행은 주문에 담긴 상품 한 종류였죠. 그래서 주문이 없으면 행 자체가 생기지 않습니다.
| 넣고 싶은 것 | shop 의 실제 개수 | 넓은 표에서는 |
|---|---|---|
| 아직 주문이 없는 회원 | 4명 (17 ~ 20번, 백서윤 포함) | 넣을 행이 없다 |
| 아직 한 번도 팔리지 않은 상품 | 10개 (슬림핏 청바지 · 다크 초콜릿 등) | 넣을 행이 없다 |
| 상품이 아직 없는 새 카테고리 | 지금은 없지만 언제든 생긴다 | 넣을 행이 없다 |
가입만 하고 아직 주문하지 않은 백서윤 회원은 존재하지 않는 회원이 됩니다. 창고에 쌓여 있는 다크 초콜릿도 없는 상품이 되고요.
억지로 넣을 방법이 없는 건 아닙니다. 주문번호 · 상품명 · 수량을 비워둔 유령 행을 하나 만들면 되니까요. 하지만 그 순간 “한 행은 주문에 담긴 상품 한 종류” 라는 약속이 깨집니다. 이후 모든 집계가 그 유령 행을 걸러내는 조건을 달고 다녀야 해요.
행을 넣기 위해 한 행의 의미를 흐려야 한다면 — 그 표는 서로 다른 사실을 너무 많이 안고 있는 것입니다.
상황 셋 — 주문을 지웠더니 상품이 사라졌습니다
마지막은 삭제입니다. 주문 12번을 취소해서 관련 줄을 지운다고 해봅시다. 실제 shop의 주문 12번에는 두 상품이 들어 있어요.
| order_id | product_id | product_name | price | quantity |
|---|---|---|---|---|
| 12 | 9 | 무선 이어폰 | 89,000 | 1 |
| 12 | 12 | USB-C 케이블 | 8,000 | 3 |
무선 이어폰은 주문 3 · 6 · 12 · 19 · 22 · 27번에 담겨 있어서, 12번 줄이 사라져도 다른 줄에 이름과 가격이 남습니다. 그런데 USB-C 케이블은 40건의 주문 중 12번에만 들어 있어요.
넓은 표에서 그 줄을 지우는 순간 “USB-C 케이블이라는 상품이 8,000원에 팔리고 있다” 는 사실까지 함께 지워집니다. 우리는 주문 하나를 취소했을 뿐인데 상품 목록에서 상품이 하나 사라진 겁니다.
수정 · 삽입 · 삭제는 어느 서비스에서나 매일 일어나는 평범한 작업입니다. 그런 평범한 작업이 관계없는 다른 정보까지 흔든다면 — 구조를 의심해야 합니다.
해결은 하나 — 사실을 주인에게 돌려보내기
문제의 원인이 “한 표가 다섯 주인의 사실을 떠안고 있다” 였으니, 해결은 단순합니다. 각 사실을 자기 주인에게 돌려보내는 것.
| 테이블 | 한 행이 뜻하는 것 | 책임지는 사실 |
|---|---|---|
| members | 회원 한 명 | 이름 · 성별 · 나이 · 가입일 |
| orders | 주문 한 건 | 주문일 · 주문한 회원 번호 |
| products | 상품 한 개 | 상품 이름 · 현재 가격 · 카테고리 번호 |
| categories | 카테고리 하나 | 카테고리 이름 |
| order_items | 주문에 담긴 상품 한 종류 | 수량 |
이렇게 나누면 아까의 세 상황이 전부 조용해집니다.
같은 작업, 나누기 전과 나눈 뒤
카테고리 이름 변경열다섯 줄을 찾아 고치고, 하나만 놓쳐도 이름이 갈라진다
주문 없는 회원 등록넣을 행이 없어 유령 행을 만들어야 한다
주문 12번 취소USB-C 케이블이라는 상품 자체가 사라진다
카테고리 이름 변경categories 의 한 줄만 고치면 끝난다
주문 없는 회원 등록members 에 한 줄 넣으면 끝난다
주문 12번 취소order_items · orders 만 지워지고 products 는 그대로
테이블을 나눈 진짜 이유는 저장 공간을 아끼는 것도, 표를 예쁘게 만드는 것도 아닙니다 — 고칠 곳을 한 군데로 만들기 위해서입니다.
나뉜 테이블을 다시 이어주는 것이 외래키이고, 그 연결을 그림으로 읽는 방법이 ERD 였습니다. 정규화는 나누는 쪽, 외래키는 잇는 쪽 — 둘은 한 세트로 움직입니다.
수량의 주인은 주문과 상품의 만남입니다
회원 · 주문 · 상품 · 카테고리는 주인을 찾기 쉬웠습니다. 그런데 하나가 애매하게 남습니다. 수량은 누구의 사실일까요?
먼저 상품의 사실인지 확인해 봅니다. 프리미엄 그래놀라의 수량이 정해져 있다면 products 테이블에 두면 되겠죠.
| order_id | product_id | quantity |
|---|---|---|
| 1 | 16 | 2 |
| 10 | 16 | 3 |
그럼 주문의 사실일까요? 주문 1번의 수량을 하나로 적으려면 곤란해집니다.
| order_id | product_id | product_name | quantity |
|---|---|---|---|
| 1 | 1 | 라운드 티셔츠 | 1 |
| 1 | 16 | 프리미엄 그래놀라 | 2 |
수량은 상품만으로도, 주문만으로도 정해지지 않습니다. 주문 1번과 상품 16번이 함께 지목되어야 비로소 2라는 값이 정해져요.
수량은 특정 주문과 특정 상품이 만났을 때 비로소 생기는 사실입니다. 그래서 주문도 상품도 아닌 — 둘이 만나는 자리, order_items 에 둡니다.
order_items의 기본키가 (order_id, product_id) 복합키인 것도 같은 이유입니다. 이 행을 특정하려면 두 번호가 모두 필요하니까요. 4-3에서 본 quantity = 2가 “그래놀라는 언제나 2개” 가 아니라 “주문 1번에 그래놀라가 2개” 였던 이유가 여기 있습니다.
헷갈릴 때 던지는 한 문장
새 컬럼을 어디에 둘지 고민될 때는 이 순서로 물어보세요.
테이블을 잘게 나눌수록 조회할 때 이어 붙일 곳이 늘어납니다. 실무에서는 조회 성능 때문에 일부러 합쳐 두는 판단도 합니다. 다만 그건 나누는 이유를 정확히 안 다음에 내리는 선택이에요. 오늘 잡을 것은 그 기준선입니다.
한눈에 정리
| 넓은 표 한 장에서 벌어지는 일 | 원인 | 나눈 뒤 |
|---|---|---|
| 카테고리 이름을 열다섯 줄에서 고쳐야 한다 | 같은 사실이 여러 줄에 복사돼 있다 | categories 한 줄만 고친다 |
| 주문 없는 회원 · 안 팔린 상품을 못 넣는다 | 한 행이 주문상세를 뜻해서 | members · products 에 따로 넣는다 |
| 주문을 지우면 상품 정보까지 사라진다 | 서로 다른 사실이 한 줄에 묶여 있다 | 각 테이블이 독립적으로 남는다 |
| 사실 | 주인 | 테이블 |
|---|---|---|
| 이름 · 성별 · 나이 · 가입일 | 회원 한 명 | members |
| 주문일 · 주문한 회원 | 주문 한 건 | orders |
| 상품 이름 · 현재 가격 | 상품 한 개 | products |
| 카테고리 이름 | 카테고리 하나 | categories |
| 수량 | 주문과 상품의 만남 | order_items |
값이 반복된다는 이유만으로 표를 나누는 것이 아닙니다. 같은 사실을 여러 줄에서 따로 관리하고 있다면 그 사실을 한 명의 주인에게 돌려보내는 것 — 그게 정규화입니다.
이제 다섯 테이블이 왜 다섯 개인지 알았습니다. 다음 강의에서는 이렇게 나뉜 테이블을 실제로 따라가 볼 차례입니다.