[UMC] ERD 설계와 정규화

Backend·2025-09-16·by 1000hyehyang

디자인으로만 참여했던 UMC에서 스프링 챌린저로 참여하게 되었다. 패기있게 시니어 과제도 하겠다! 라고 말해서... 공부할 게 너무 많아졌다🥹 그래도 여러 지점을 생각해볼 수 있는 미션들이어서 한층 성장할 수 있을 것 같다!

0주차 미션은 피그마 자료를 보고 ERD를 설계한 뒤, 제1·2·3 정규화를 거쳐 정규형을 만들어보는 과제였다. 단순히 결과물을 내는 게 아니라, 내가 어디서 정규화를 자연스럽게 적용했고, 어디서는 놓쳤다가 뒤늦게 깨달았는지를 정리해보자.

 

정규화란?

정규화는 데이터베이스 설계에서 중복을 줄이기 위해 테이블을 나누고 속성을 정리하는 과정이다.

  • 제1정규형(1NF): 컬럼에 다중 값이 들어가지 않고, 모든 속성은 원자적이어야 한다.
  • 제2정규형(2NF): 부분 종속을 없애야 한다. 복합키의 일부에만 종속된 속성은 따로 빼낸다.
  • 제3정규형(3NF): 이행 종속을 없애야 한다. 기본키가 아닌 속성이 또 다른 속성에 종속되어서는 안 된다.

정규화는 이상적으로는 끝없이 나눌 수도 있지만, 보통 3NF까지 맞추는 걸 기본으로 삼는다. 이후는 성능 요구에 따라 역정규화를 선택적으로 한다.

 

내가 설계하면서 자연스럽게 했던 정규화는...

처음 ERD를 만들 때부터 완전히 무지성으로 한 건 아니다. 예를 들어 리뷰 이미지나 가게 이미지는 다중 업로드가 필요하다는 걸 캐치해서, 따로 테이블을 뺐다. 만약 image1, image2 같은 컬럼을 늘려서 저장했다면 제1정규형을 위반했을 것이다. 이미지를 한두 개만 허용한다면 그냥 컬럼을 합쳐도 됐을지 모르겠지만, 확장성을 고려하면 따로 빼는 게 맞다. (물론 테이블이 많아지면 조인 비용이 늘어난다는 단점도 있다.)

또 하나는 유저 선호. "한식, 중식, 양식" 같은 문자열로 저장할 수도 있었지만, 선택형이 여러 개라는 걸 보고 user_preference 테이블을 별도로 뒀다. 이 역시 제1정규형을 지킨 결과다.

👉 만약 한식·중식·양식을 그냥 "한식,중식,양식" 문자열로 저장했다면? 나중에 “중식 좋아하는 사람”만 빠르게 조회하려면 문자열 파싱을 해야 하고, 인덱싱이나 집계가 사실상 불가능하다. 정규화라는 게 거창한 게 아니라, 이런 선택 하나가 나중에 쿼리 성능과 유지보수성에 직결된다는 걸 체감했다.

 

놓친 정규화를 해보자

반대로, 크게 놓쳤던 부분도 있다. 대표적인 게 미션과 유저 상태다. 처음엔 mission 테이블 하나에 user_id, status를 넣어서 끝내려 했다. 그런데 미션이라는 건 본질적으로 공통 정의인데, 유저별 진행 상황은 동적인 값이라 이 둘을 한데 묶으면 미션이 유저 수만큼 중복된다.

여기서 발생하는 문제는 단순한 중복을 넘어서 부분 종속이다. mission_id 하나로는 행이 유일하지 않고, mission_id + user_id라는 복합키가 필요해진다. 그런데 status 같은 속성은 mission_id 전체가 아니라 user_id에 종속되기 때문에, 이는 2NF 위반이다.

결국 mission과 user_mission으로 나눠서 문제를 해결했다. 이렇게 분리하면 mission은 공통 정의만, user_mission은 개별 상태만 담당하므로 중복도 줄고 정규화 원칙도 충족한다.

👉 여기서 깨달은 건, 단순히 “테이블 수를 줄이는 게 깔끔하다”는 생각이 정답은 아니라는 거다. 오히려 정규화를 지키려면 관계형 데이터베이스의 본질을 인정하고, 필요한 중간 테이블을 과감하게 두는 게 맞다.

 

제3정규형에서 고민했던 부분

가게 테이블에 avg_rating을 넣을지 말지 고민했다. 이 값은 리뷰 평점의 평균이라 사실상 계산으로 구할 수 있다.

store 테이블의 기본키는 store_id. 그런데 avg_rating은 단순히 store_id에 직접적으로 의존하는 값이 아니다. 실제로는 store_id → review.rating → avg_rating처럼, 리뷰 테이블의 속성을 거쳐 결정되는 이행 종속이다. 따라서 제3정규형 위반이 맞다.

원칙대로라면 avg_rating은 빼고, 필요할 때마다 AVG(review.rating)을 계산하는 게 정석이다. 그렇지 않으면 리뷰 수정 시 값이 엇갈리며 동기화 문제가 생긴다.

하지만 동시에 서비스 성격상 "리스트 화면에서 평점 노출" 같은 기능이 빈번하게 필요하다. 매번 조인과 평균 계산을 하다 보면 성능이 떨어질 수 있다. 그래서 원칙은 3NF를 따르되, 실무적 트레이드오프를 인정해서 캐시 컬럼으로 두기로 했다.

이건 정규화를 그대로 지켰다고 보긴 어렵지만, 정규화 원칙을 의식한 상태에서 의도적으로 예외를 둔 것이라 기록해둘 만하다고 생각했다.

 

정규화 전후 중복 데이터 변화

실제로 정규화를 하기 전과 후를 비교하면 체감이 확 다르다.

  • 미션 1개를 1만 명이 수행하는 상황을 생각해보면, 정규화 전에는 mission 테이블에 같은 행이 1만 개 생긴다. 정규화 후에는 미션 정의는 딱 한 줄만 남고, 진행 상태만 user_mission에 1만 줄이 생긴다.
  • 리뷰 이미지도 마찬가지다. 정규화 전에는 리뷰 테이블에 이미지 컬럼을 늘려야 하고, 확장성에 한계가 있다. 정규화 후에는 이미지가 몇 장이든 review_image에 자유롭게 들어간다.
  • 유저 선호 역시 문자열 파싱 없이 곧바로 인덱싱과 집계가 가능해졌다.

즉, 정규화를 거치면서 중복이 줄고, 구조가 훨씬 유연해졌다.

👉 정리하자면, 현재 내 ERD는 큰 틀에서 제1·2·3 정규형을 모두 충족한다. avg_rating처럼 일부러 넣은 캐시 컬럼 외에는 “위반”이라 부를 만한 부분이 없다. 오히려 이번 과정을 거치면서, 정규화를 기계적으로 따르기보다 “어디까지 지키고 어디서 의도적으로 풀어야 하는지”를 판단하는 감각이 조금 생긴 것 같다.

댓글 로딩 중...