본문 바로가기

Programming/Technical Writing

주니어는 모르는 ERD 설계의 함정들

TL;DR: ERD는 ERD는 ‘데이터 구조’가 아니라 ‘변경 이력의 지도’다.

 

 

ERD란 무엇일까? 주니어 개발자로서 프로젝트를 하면서 ERD를 설계하는 일은 사실 상사들의 몫이었다. 내가 설계를 하게 되니 ERD란 단순히 DB를 논리적으로 옮겨 둔 것뿐만이 아니라 개발의 토대라고 말할 수 있었다. 프로젝트의 성능이 달려 있고 비즈니스 모델의 중심을 정하는, 정렬을 우선으로 할지 정합성을 우선으로 할지 등을 판단해야 하는 일이었다.

 

주니어 단계에서는 ERD를 이렇게 본다. 엔티티가 맞는가? 관계가 자연스러운가? 정규화가 되어 있는가?

시니어는 달라야 한다. 이 데이터는 언제 바뀌는가? 누가 바꾸는가? 왜 바뀌는가?

 

현재 요구사항을 만족하는 구조가 아니라, 미래 변경과 운영을 견디는 구조로 생각해야 한다. '지금은 안 필요'하다는 이유로 변경 이력을 담을 공간 자체를 만들지 않는 것은 놓치기 쉬운 포인트다. 상태 테이블을 분리하고, 이력 테이블을 생성하고, 이벤트/로그성 데이터를 고려해야 한다.

 

 

1. 정규화와 비정규화의 균형

 

정규화는 데이터 설계의 기본 원칙이다. 중복을 제거하고, 갱신 이상을 방지하고, 데이터의 단일 진실 원천(Single Source of Truth)을 유지하는 것이다. 교과서적으로는 3정규형(3NF)을 달성하면 대부분의 이상 현상이 해결된다. 하지만 운영 환경에서 3NF를 무조건 고수하면, 정합성을 지키려다 오히려 시스템을 무너뜨리는 상황이 발생한다.

 

3정규형이 항상 정답은 아니다. 정규화를 기본으로 하되, 읽기 성능이 중요한 영역에서는 의도적 비정규화를 적용해야 한다. 융통성 없이 정규화를 고집하다 보면 정합성 유지 비용이 증가하고, 조용히 버그가 생긴다. 스냅샷 데이터를 분리하는 기준이 있어야 한다. 핵심은 왜 비정규화했는지를 문서화하는 것이다.

 

정규화의 비용은 조용히 쌓인다

정규화가 깊어질수록 테이블 수가 늘어나고, 조회 시 JOIN이 많아진다. JOIN이 3~4단계를 넘어가면 쿼리 플랜이 복잡해지고, 옵티마이저가 예측하지 못한 실행 계획을 선택하는 빈도가 높아진다. 느린 쿼리가 간헐적으로 발생하고, 원인을 추적하면 결국 과도한 정규화에서 출발한 다단계 JOIN인 경우가 적지 않다.

더 위험한 것은 정합성 유지 비용이다. 정규화된 구조에서 하나의 비즈니스 데이터를 갱신하려면 여러 테이블을 트랜잭션으로 묶어야 한다. 트랜잭션 범위가 넓어지면 락 경합이 발생하고, 동시성이 떨어진다. 이를 피하려고 트랜잭션을 분리하면, 중간에 실패했을 때 테이블 간 데이터가 어긋나는 정합성 버그가 생긴다. 정규화를 지키려고 정합성이 깨지는 역설이 발생하는 것이다.

의도적 비정규화의 기준

비정규화는 "정규화를 포기하는 것"이 아니라, 정규화의 비용이 이점을 초과하는 지점에서 의식적으로 트레이드오프를 선택하는 것이다. 이 판단에는 명확한 기준이 필요하다.

읽기 비율이 쓰기보다 압도적으로 높은 테이블은 비정규화 후보다. 상품 목록 조회처럼 초당 수천 건의 SELECT가 발생하는데 상품 정보 갱신은 하루에 몇 번뿐이라면, 조회 최적화를 위해 카테고리명이나 브랜드명을 상품 테이블에 중복 저장하는 것이 합리적이다.

시점 데이터가 중요한 경우에도 비정규화가 필요하다. 주문 시점의 상품 가격이 대표적이다. 주문 테이블에서 상품 테이블을 FK로 참조만 하면, 상품 가격이 변경될 때 과거 주문의 금액이 소급 변경되는 문제가 생긴다. 주문 시점의 가격, 상품명, 할인율을 주문 테이블에 스냅샷으로 저장하는 것은 중복이 아니라 비즈니스 요구사항이다. 스냅샷 분리의 기준은 명확하다. "이 값이 원본에서 바뀌었을 때, 과거 기록도 따라 바뀌어야 하는가?" 답이 아니오라면 스냅샷이 맞다.

집계/통계 데이터도 마찬가지다. 게시글의 댓글 수를 매번 COUNT(*)로 계산하는 것과, comment_count 컬럼을 두고 댓글 생성/삭제 시 갱신하는 것은 트래픽 규모에 따라 판단이 달라진다. 소규모 서비스에서는 실시간 COUNT가 정확하고 단순하지만, 대규모 서비스에서는 비정규화된 카운트 컬럼이 사실상 필수다.

Materialized View와 CQRS

비정규화를 테이블 레벨에서 직접 하는 것이 부담스럽다면, Materialized View가 중간 지점이 된다. 원본 테이블은 정규화를 유지하면서, 조회 전용 뷰를 별도로 만들어 갱신 주기를 제어할 수 있다. 다만 Materialized View는 실시간 반영이 아니라는 점을 감안해야 하므로, 수 초~수 분의 지연이 허용되는 대시보드나 리포트성 조회에 적합하다.

이 개념을 아키텍처 레벨로 확장하면 CQRS(Command Query Responsibility Segregation)가 된다. 쓰기 모델은 정규화된 구조로 정합성을 유지하고, 읽기 모델은 비정규화된 구조로 조회 성능을 최적화하는 분리 전략이다. OLTP와 OLAP 영역을 분리 설계하는 것도 같은 맥락이다. 트랜잭션 처리와 분석 조회가 같은 테이블을 두고 경합하면, 어느 쪽도 만족스러운 성능을 내기 어렵다.

비정규화의 유일한 조건: 문서화

비정규화 자체는 문제가 아니다. 문제는 왜 비정규화했는지 아무도 모르는 상태다. 6개월 뒤에 새로운 팀원이 "이 컬럼은 왜 중복이지?"라고 물었을 때, "조회 성능 때문에 의도적으로 중복 저장했고, 원본은 A 테이블이며, 동기화는 B 이벤트에서 처리한다"는 맥락이 남아 있어야 한다. 이 기록이 없으면 누군가는 중복을 제거하려다 장애를 내고, 또 누군가는 두 곳의 데이터를 각각 신뢰하다가 정합성 버그를 만든다.

ERD 다이어그램 위에 주석을 남기든, 별도의 ADR(Architecture Decision Record)에 기록하든, 비정규화에는 반드시 의사결정의 근거가 따라붙어야 한다.

 

 

 

2. 확장성과 유연성

요구사항 변경이 잦은 영역과 안정적인 영역 분리 사이에서 균형을 맞춰야 한다. 데이터는 깨끗하지 않다. 재처리, 롤백, 정합성 복구를 고려해야 한다. ERD에는 없지만 때때로 반드시 필요한 테이블이 있는데, 로그성 데이터와 도메인 데이터를 분리한 것이 그것이다. Event Sourcing이 필요한 도메인인지, 단순 스냅샷 이력으로 충분한지 판단이 필요하다.

 

변경 빈도에 따른 영역 분리

모든 테이블이 같은 속도로 변하지 않는다. 사용자 정보처럼 상대적으로 안정적인 영역이 있고, 프로모션이나 정산 정책처럼 비즈니스 변화에 민감한 영역이 있다. 이 둘을 같은 밀도로 설계하면 안정적인 영역까지 불필요한 변경에 노출된다.

예를 들어 Product 테이블에 할인 정책 컬럼을 직접 넣으면, 프로모션 로직이 바뀔 때마다 상품 테이블을 건드려야 한다. 할인 정책을 별도 테이블로 분리하면 상품 데이터는 안정적으로 유지하면서 정책만 독립적으로 변경할 수 있다. ERD 설계 시점에 "이 영역은 얼마나 자주 바뀔 것인가?"를 기준으로 테이블 그룹을 나누는 것이 첫 번째 판단이다.

데이터는 깨끗하지 않다는 전제에서 출발하라

운영 환경에서 데이터는 예외적인 상태를 반드시 포함한다. 결제는 됐는데 주문 상태가 갱신되지 않은 레코드, 외부 API 장애로 절반만 채워진 행, 배치 처리 중 실패한 중간 상태. 이런 데이터는 ERD에 표현되지 않지만 반드시 존재한다.

이를 대비하려면 재처리, 롤백, 정합성 복구가 가능한 구조를 설계 단계에서 고려해야 한다. 구체적으로는 멱등성을 보장할 수 있는 유니크 키 설계, 상태 전이를 추적할 수 있는 이력 컬럼, 원본 데이터를 훼손하지 않고 재처리할 수 있는 구조가 이에 해당한다.

도메인 데이터와 운영 데이터의 분리

ERD에는 없지만 운영에서 반드시 필요한 테이블들이 있다. 대표적인 것이 로그성 테이블과 이벤트 테이블이다.

도메인 데이터는 비즈니스 상태를 표현한다. "이 주문은 배송 중이다." 운영 데이터는 시스템의 동작을 기록한다. "이 주문의 상태가 14시 23분에 '결제완료'에서 '배송중'으로 바뀌었다." 이 둘을 같은 테이블에 담으면, 도메인 테이블이 비대해지고 조회 성능이 떨어진다. 상태 변경 이력, 알림 발송 기록, API 호출 로그 같은 운영 데이터는 별도 테이블로 분리하는 것이 원칙이다.

 

여기서 한 단계 더 나아가면 Event Sourcing 도입 여부를 판단해야 한다. 모든 변경을 이벤트로 저장하고 현재 상태를 이벤트의 합으로 도출하는 Event Sourcing은 강력하지만, 구현 복잡도와 조회 성능에 비용이 따른다. 대부분의 도메인에서는 현재 상태를 메인 테이블에 두고, 변경 이력을 별도 히스토리 테이블에 스냅샷으로 저장하는 방식이면 충분하다. Event Sourcing은 금융 거래, 재고 관리처럼 "왜 이 상태가 되었는가"의 추적이 비즈니스 핵심인 도메인에 한정하는 것이 현실적이다.

스키마 유연성의 선택지

요구사항이 확정되지 않았거나, 엔티티마다 속성이 다른 경우가 있다. 상품 카테고리별로 필요한 속성이 다른 경우가 대표적이다. 의류에는 사이즈와 색상이 필요하고, 전자제품에는 전압과 무게가 필요하다.

이때 선택지는 크게 세 가지다.

EAV 패턴은 속성을 행으로 저장하는 방식이다. 유연성은 극대화되지만, 단일 엔티티를 조회할 때도 여러 행을 피벗해야 하고, 타입 안전성이 없으며, 인덱스 설계가 까다롭다. 속성의 종류가 수십~수백 개이고 자주 바뀌는 경우에만 고려할 만하다.

JSON 컬럼은 EAV보다 조회가 간편하고, MySQL 8.0+나 PostgreSQL에서는 JSON 내부 필드에 인덱스도 걸 수 있다. 다만 JSON 내부 데이터에 대한 제약 조건(NOT NULL, UNIQUE 등)을 DB 레벨에서 강제할 수 없으므로, 애플리케이션에서의 검증 책임이 커진다. 구조가 느슨하지만 조회 빈도가 낮은 부가 속성에 적합하다.

테이블 상속(STI 또는 CTI)은 카테고리별로 테이블을 분리하거나, 공통 컬럼을 부모 테이블에 두고 카테고리별 테이블을 자식으로 두는 방식이다. 타입 안전성과 인덱스 활용이 가능하지만, 카테고리가 추가될 때마다 DDL 변경이 필요하다. 어떤 방식을 선택하든, "이 속성은 검색 조건으로 쓰이는가?"가 핵심 판단 기준이다. 검색 조건이 되는 속성은 정규 컬럼으로, 단순 표시용 속성은 JSON으로 분리하는 혼합 전략이 실무에서 가장 균형 잡힌 접근이다.

멀티테넌시와 파티셔닝

SaaS 서비스라면 멀티테넌시 전략을 ERD 설계 초기에 확정해야 한다. 이 결정은 나중에 바꾸기 가장 어려운 아키텍처 결정 중 하나이기 때문이다.

tenant_id를 모든 테이블에 넣는 Row-Level 분리는 구현이 단순하지만, 쿼리마다 WHERE tenant_id = ?를 빠뜨리면 데이터 유출이 발생한다. 스키마 분리는 테넌트 간 격리가 강하지만 마이그레이션이 테넌트 수만큼 반복된다. DB 인스턴스 분리는 가장 안전하지만 운영 비용이 가장 높다. 초기 테넌트 규모와 데이터 격리 요구 수준에 따라 판단하되, Row-Level에서 시작해 필요에 따라 격리 수준을 올리는 방향이 일반적이다. 파티셔닝 역시 "나중에 하자"가 통하지 않는 영역이다. 수천만 건 이상이 예상되는 테이블은 설계 시점에 파티션 키를 정해야 한다. 시계열 데이터는 날짜 기반 Range 파티셔닝이 자연스럽고, 테넌트별 데이터는 tenant_id 기반 Hash 파티셔닝이 적합하다. 파티션 키는 대부분의 쿼리에서 WHERE 조건에 포함되는 컬럼이어야 한다는 점도 잊지 말아야 한다. 파티션 키가 빠진 쿼리는 전체 파티션을 스캔하게 되어, 파티셔닝의 이점을 완전히 상쇄한다.

 

 

 

3. FK의 존재 이유

FK의 ‘존재 이유’를 설명할 수 있어야 한다. 관계가 있으니까 FK라는 기계적인 사고는 금물이다.

ERD에서 선 하나는 외래키 이상의 의미를 가진다. 이 관계는 정책인가, 편의인가? 끊어져도 시스템은 의미를 유지하는가? 그래서 종종 느슨한 참조 (ID만 저장), FK를 일부러 안 거는 구조가 등장한다.

 

FK는 제약이자 계약이다. FK를 걸면 두 테이블은 라이프사이클이 결합된다. 부모 레코드를 삭제하려면 자식을 먼저 처리해야 하고, 마이그레이션할 때도 순서가 생긴다. 즉 FK는 단순한 참조가 아니라 "이 데이터는 저 데이터 없이는 존재할 수 없다"는 비즈니스 계약이다. 주문과 주문상품은 이 계약이 맞지만, 주문과 쿠폰은 어떨까? 쿠폰이 삭제되었다고 주문 이력이 깨져야 할까?

 

CASCADE의 위험성도 고려해야 한다. FK를 걸 때 흔히 ON DELETE CASCADE를 함께 설정하는데, 이건 "부모가 죽으면 자식도 같이 죽는다"는 꽤 과격한 정책이다. 운영 중에 실수로 부모 레코드 하나를 지웠을 때 수만 건의 자식 데이터가 연쇄 삭제되는 사고가 실제로 발생한다. FK를 건다면 CASCADE 정책까지 의식적으로 선택해야 하고, 대부분의 경우 RESTRICT가 더 안전하다.

 

FK가 만드는 운영 비용이 있다. 대용량 환경에서 FK는 INSERT/UPDATE 시마다 부모 테이블을 조회해 존재 여부를 확인한다. 이 과정에서 공유 잠금(Shared Lock)이 걸리고, 트래픽이 몰리면 병목이 된다. 그래서 트래픽이 높은 시스템일수록 FK를 DB에서 빼고 애플리케이션 레벨에서 정합성을 관리하는 경우가 많다. 이건 "FK가 필요 없다"가 아니라 "FK의 책임을 어디에 둘 것인가"의 문제이다.

 

설계할 때 각 FK에 대해 이 질문을 던져보면 좋다. 사용자가 탈퇴해도 게시글은 남아야 한다면, user_id는 FK보다 논리적 참조가 맞다. 반면 장바구니 아이템에서 상품이 사라지면 의미가 없으므로 FK가 맞다. 결국 데이터의 독립 생존 가능성이 FK 여부를 결정하는 핵심 기준이다.

 

 

 

4. ERD와 시퀀스 다이어그램

질문 ERD 시퀀스
데이터는 언제 생기나? 테이블 생성 흐름
누가 책임지나? 소유 엔티티 호출 주체
변경 비용은? 컬럼/관계 트랜잭션
실패하면? 상태/이력 예외 흐름

"이 데이터는 언제 생기나?"

ERD만 보면 테이블과 관계만 보인다. 하지만 생성 시점을 질문하면 숨겨진 순서가 드러난다.

주문 시스템을 예로 들면, Order와 Payment가 1:1 관계로 그려져 있을 때, 이 질문을 던지면 "주문이 먼저 생기고, 결제는 나중에 생긴다"는 시간차가 보인다. 그러면 자연스럽게 "결제가 아직 없는 주문은 어떤 상태인가?"라는 질문이 따라오고, Order에 status 컬럼이 필요하다는 결론에 도달한다. ERD의 선은 동시에 존재하는 것처럼 보이지만, 실제로는 시차를 가진 의존인 경우가 대부분이다. 시퀀스 다이어그램에서는 이게 더 명확해진다. 어떤 API 호출이 데이터를 만드는지, 그 시점에 필요한 선행 데이터가 이미 존재하는지를 검증할 수 있다. "이 INSERT가 실행되는 시점에 참조하는 FK 대상이 반드시 존재하는가?"를 시퀀스에서 추적하면, 설계 단계에서 정합성 버그를 잡을 수 있다.

"누가 책임지나?"

ERD에서 테이블 하나는 소유자가 모호한 경우가 많다. user_address 테이블이 있을 때, 이걸 User 서비스가 관리하나, Order 서비스가 배송지로 관리하나? 책임 소재를 질문하면 테이블이 쪼개져야 하는 순간이 보인다. User 서비스의 address와 Order 서비스의 shipping_address는 같은 데이터지만 책임이 다르고, 변경 주기도 다르다.

시퀀스 다이어그램에서 이 질문은 더 강력하다. 하나의 플로우에서 세 개의 서비스가 같은 테이블에 쓰기를 한다면, 그건 책임이 분산된 것이 아니라 혼재된 것이다. 시퀀스의 화살표가 하나의 테이블로 여러 곳에서 향하고 있다면, 그 테이블의 오너십을 다시 정의해야 한다.

이건 곧 "이 데이터의 정합성이 깨졌을 때, 누가 고치나?"라는 운영 질문과도 직결된다.

"변경 비용은?"

ERD에서 FK로 강하게 묶인 테이블 클러스터는 하나를 바꾸면 연쇄적으로 영향이 퍼지는 구조이다. 예를 들어 Product 테이블의 PK 타입을 INT에서 UUID로 바꾸고 싶다면, FK로 연결된 OrderItem, CartItem, Review, Wishlist 전부를 동시에 마이그레이션해야 한다.

이 질문을 던지면 ERD 위에 변경 전파 반경이 그려진다. FK가 많이 연결된 테이블일수록 변경 비용이 높고, 이런 테이블은 설계 초기에 더 신중해야 한다. 반대로 논리적 참조(ID만 저장)로 되어 있다면 변경이 해당 테이블에서 끝난다. 느슨한 결합의 실질적 이점이 바로 여기서 체감된다. 시퀀스 다이어그램에서는 "이 API의 응답 스펙을 바꾸면 누가 영향받나?"로 변환된다. 한 서비스의 응답을 세 곳에서 파싱하고 있다면, 그 변경 비용은 ERD만으로는 보이지 않는다. 시퀀스의 화살표 개수가 곧 변경 비용의 지표이다.

"실패하면?"

이 질문이 가장 강력하다. ERD는 성공 시나리오만 표현하기 때문이다.

주문 생성 플로우에서 Order INSERT는 성공했는데 Payment INSERT가 실패하면? 이때 ERD만 보면 답이 없다. 시퀀스 다이어그램에서 이 실패 지점을 표시하면, 보상 트랜잭션(Saga), 재시도 정책, 중간 상태 관리가 필요한 곳이 드러난다.

이 질문은 ERD에도 영향을 준다. 실패 가능성을 고려하면 status, retry_count, failed_at, error_reason 같은 컬럼이 추가되고, 중간 상태를 추적하는 이벤트 테이블이 필요해진다. "실패하면?"이라는 질문 하나가 테이블 두세 개를 만들어내는 셈이다. 특히 외부 시스템 연동이 있는 시퀀스에서는 모든 화살표가 실패 후보이다. PG사 결제 API가 타임아웃 나면? 알림 발송이 실패하면? 이 질문을 시퀀스의 매 단계마다 던지면, 설계의 견고함이 달라진다.

 

이 질문들은 개별로도 강력하지만, 겹치면 설계의 빈틈이 정확히 드러난다.

이렇게 하나의 테이블, 하나의 관계에 네 질문을 모두 통과시키면, 그 설계가 정말 견고한지 바로 판단할 수 있다. ERD는 구조를 그리는 도구이고, 시퀀스는 흐름을 그리는 도구인데, 이 네 질문이 두 다이어그램 사이의 빈 공간을 채워주는 역할을 한다.

 

 

결론: ERD는 완성되는 것이 아니라 합의되는 것이다.

테이블을 그리고 선을 잇는 건 누구나 할 수 있다. 하지만 그 선 하나에 "왜 FK인가, 왜 FK가 아닌가", 그 컬럼 하나에 "이건 언제 바뀌고, 실패하면 어떻게 되는가"라는 질문이 담겨 있는지가 주니어와 시니어의 차이다.

 

결국 좋은 ERD란 예쁜 다이어그램이 아니라, 팀이 내린 의사결정의 지도다. '정규화를 풀었다면 왜 풀었는지, FK를 빼었다면 정합성을 누가 책임지는지, 이력 테이블을 뒀다면 어떤 실패를 대비한 건지' 이 맥락이 읽히는 ERD가 좋은 ERD다. 다이어그램은 시간이 지나면 낡지만, 거기에 담긴 판단의 근거는 낡지 않는다.