DB 모델링, 일관성과 성능의 균형
DB 모델링은 정규화로 데이터 일관성을 지키고, 성능이 필요한 지점에서만 비정규화를 선택하는 작업이다. RDBMS와 NoSQL 선택, 격리 수준, CDC, Materialized View, ORM 운영 실패까지 함께 판단해야 한다.
Script Companion
오디오와 함께 스크립트 보기
- 01
데이터베이스는 애플리케이션의 기억이다. 모델링이 흔들리면 같은 정보가 여러 곳에 중복 저장되고, 삽입·수정·삭제 과정에서 의도하지 않은 손실이나 불일치가 생긴다. 잘못된 스키마는 불필요한 JOIN을 늘리거나, 반대로 중복 데이터 동기화 비용을 키운다. BackOps 엔지니어에게 주문, 정산, 배송 데이터는 단순 저장 대상이 아니라 업무 정확성을 좌우하는 원본이므로, DB 모델링은 조회 성능과 데이터 정확성을 함께 다루는 핵심 역량이다.
- 02
프론트엔드 경험이 있다면 정규화는 낯선 개념만은 아니다. Redux에서 중첩된 users와 posts를 ID 참조로 펼쳐 관리하거나, Zustand에서 byId와 allIds 형태로 스토어를 설계하는 방식은 DB 정규화와 같은 원리를 따른다. 하나의 사실은 한 곳에만 저장하고, 다른 곳에서는 ID로 참조한다. 이 원칙이 깨지면 화면 상태에서도 불일치가 생기듯, 데이터베이스에서도 수정 누락과 이상 현상이 시작된다. DB 모델링은 이 원칙을 테이블, 키, 관계, 트랜잭션 수준으로 확장한 것이다.
- 03
RDBMS와 NoSQL의 차이는 스키마와 일관성, 확장 방식에서 먼저 갈린다. RDBMS는 엑셀 스프레드시트처럼 행과 열의 규격이 정해져 있고, 여러 테이블을 JOIN으로 연결한다. PostgreSQL, MySQL, Oracle이 여기에 속하며, ACID를 보장한다. NoSQL은 메모장 묶음처럼 형식이 더 자유롭고, Redis, MongoDB, DynamoDB, Neo4j처럼 제품마다 저장 방식과 쿼리 방식이 다르다. RDBMS는 주로 더 좋은 서버로 수직 확장하고, NoSQL은 여러 서버로 나누는 수평 확장에 더 자연스럽게 맞는다.
- 04
NoSQL도 하나로 묶어 이해하면 위험하다. Redis 같은 Key-Value Store는 키로 값을 즉시 조회하는 구조라 세션, 캐시, 실시간 카운터, 랭킹 보드에 적합하지만 복잡한 범위 검색이나 관계 조회에는 맞지 않는다. MongoDB 같은 Document Store는 BSON 문서 안에 중첩 구조를 자유롭게 담지만, 단일 문서를 넘는 ACID가 자주 필요하면 비용이 커진다. 원문 기준으로 멀티 도큐먼트 트랜잭션은 1트랜잭션당 수정 도큐먼트 1,000건이나 실행 시간 60초를 넘으면 자동 중단된다. DynamoDB와 Cassandra 같은 Column-Family Store는 파티션 키 설계가 핵심이며, DynamoDB는 파티션당 3,000 RCU/초와 1,000 WCU/초 상한을 넘으면 스로틀링될 수 있다.
- 05
RDBMS가 ACID를 보장하는 방식은 락과 로그, 특히 WAL에 의존한다. 트랜잭션 중에는 같은 데이터에 다른 트랜잭션이 접근하지 못하도록 막고, 변경 전에 로그를 먼저 남겨 정확성을 지킨다. 이 구조는 데이터가 맞아야 하는 주문, 결제, 정산에 강하지만, 여러 서버에 나누어 확장할 때는 락 관리에 네트워크 통신이 들어가 성능 부담이 커진다. 반대로 NoSQL의 BASE 모델은 여러 노드가 독립적으로 요청을 처리해 수평 확장에 유리하지만, 같은 시점에 노드마다 다른 값을 반환하는 Eventual Consistency를 받아들여야 한다.
- 06
정규화의 목적은 중복을 줄이는 것이 아니라, 하나의 사실을 한 곳에만 저장해 이상 현상을 막는 것이다. 주문 테이블에 고객 주소를 직접 저장하면, 고객이 100건의 주문을 했을 때 주소가 100행에 반복된다. 이사가 발생하면 100행을 모두 수정해야 하고, 하나라도 빠지면 서로 다른 주소가 공존한다. 여기서 삽입 이상, 수정 이상, 삭제 이상이 나온다. 새 데이터를 넣기 위해 불필요한 정보까지 입력해야 하거나, 중복된 값을 모두 고쳐야 하거나, 한 행을 지웠는데 보존해야 할 정보까지 사라지는 문제가 생긴다.
- 07
정규형은 중복이 생기는 원인을 단계적으로 제거한다. 1NF는 하나의 셀에 하나의 값만 들어가야 한다는 규칙으로, products 컬럼에 사과, 바나나, 포도를 한꺼번에 넣는 반복 그룹을 분리한다. 2NF는 복합 기본키의 일부에만 종속되는 컬럼을 제거한다. order_id와 product가 함께 키인데 customer_name과 customer_address가 order_id에만 달려 있다면, 주문과 고객 정보를 분리해야 한다. 3NF는 비키 컬럼이 다른 비키 컬럼에 의존하는 이행 종속을 제거한다. employee_id에서 department_id를 거쳐 department_name과 department_location으로 이어진다면 부서 테이블을 따로 둔다.
- 08
BCNF는 3NF보다 더 엄격한 형태로, 모든 결정자가 후보키여야 한다. 원문 예시처럼 학생, 과목, 교수 배정에서 교수는 한 과목만 담당한다는 규칙이 있으면 교수에서 과목이 결정된다. 그런데 교수가 후보키가 아니라면 3NF를 만족하는 것처럼 보여도 BCNF를 위반할 수 있다. 이때 교수와 과목을 담는 professor_subject, 학생과 교수를 담는 student_professor로 나누면 결정 관계가 더 명확해진다. 핵심은 테이블을 많이 나누는 것이 아니라, 어떤 값이 어떤 값을 결정하는지 정확히 드러내는 것이다.
- 09
비정규화는 정규화의 반대편에 있는 성능 선택이다. 정규화된 창고는 물건이 정확히 분류되어 있지만, 자주 꺼내려면 여러 창고를 돌아야 한다. 비정규화는 자주 쓰는 물건을 책상 위에 꺼내두는 것과 같다. 데이터 일관성은 직접 관리해야 하지만, JOIN 없이 빠르게 읽을 수 있다. 조회가 쓰기보다 압도적으로 많고 수정이 드문 경우, 매번 SUM이나 COUNT를 하기보다 미리 계산한 집계 테이블이 필요한 경우, 리포트와 대시보드처럼 많은 행을 스캔하는 경우에 선택적으로 사용한다.
- 10
현대적 비정규화에서 중요한 축은 CDC, Change Data Capture 기반 자동 동기화다. 전통적으로는 배치 스크립트나 트리거로 중복 데이터를 맞췄지만, CDC는 WAL 기반으로 변경을 읽어 준실시간으로 검색용 Elasticsearch나 대시보드용 집계 테이블에 반영할 수 있다. 원본 DB에 직접 부하를 주지 않고, 실패해도 Kafka 오프셋으로 재처리할 수 있다는 장점이 있다. 다만 Debezium 스냅샷 도중 비정상 종료되면 중복 이벤트가 발행될 수 있어 Consumer 측에서 PK 기반 UPSERT 같은 멱등 처리가 필요하다. Kafka 커밋 전 커넥터가 크래시되면 at-least-once 중복 이벤트가 생길 수 있고, Consumer 처리 속도가 느리면 Kafka Consumer Lag로 동기화 지연이 누적된다.
- 11
비정규화는 일단 해두는 최적화가 아니라 측정 후 판단하는 최후 수단에 가깝다. 쓰기와 읽기가 비슷하게 많은 OLTP 테이블에서는 중복 데이터 동기화 비용이 조회 이득보다 커질 수 있다. 결제 금액이나 정산 데이터처럼 일관성이 비즈니스상 절대적인 경우도 조심해야 한다. 대안으로 PostgreSQL Materialized View는 쿼리 결과를 물리적으로 저장한 뷰로, 선언적 SQL 정의와 REFRESH 명령을 통해 집계 테이블보다 낮은 복잡도로 운영할 수 있다. CONCURRENTLY 옵션을 쓰면 갱신 중 읽기도 가능하다는 점이 원문에서 강조된다.
- 12
격리 수준은 모델링과 분리된 주제가 아니라, 데이터가 동시에 바뀌는 업무에서 함께 선택해야 하는 조건이다. 대부분의 웹 API는 PostgreSQL 기본값인 READ COMMITTED로 커밋된 데이터만 읽어 Dirty Read를 막는 수준이 적합하다. 재고 차감이나 좌석 예약처럼 동시성 충돌이 중요한 경우에는 REPEATABLE READ 또는 SELECT FOR UPDATE 같은 비관적 락을 고려한다. 금융 정산 배치는 성능 희생을 감수하고 SERIALIZABLE을 선택할 수 있다. BackOps 관점에서 정산 금액은 ACID 트랜잭션이 필수이고, REPEATABLE READ 이상을 써야 집계 오류를 줄일 수 있다.
- 13
ORM을 사용할 때도 관계 모델링 이해가 필요하다. TypeORM의 ManyToOne과 OneToMany 데코레이터는 정규화된 관계를 코드로 표현하지만, 관계를 모르면 쿼리 최적화도 어렵다. 프로덕션 RDS에 DDL을 직접 실행하면 Table Lock이 생길 수 있고, 수백만 행 이상의 대규모 테이블에서는 pg_repack이나 AWS RDS Blue/Green Deployment 같은 무중단 스키마 변경을 고려해야 한다. Prisma는 2024~2025년 NestJS 생태계에서 TypeORM 대안으로 채택되고 있으며, 신규 NestJS와 Aurora 프로젝트에서는 타입 안전성과 자동완성이 장점이다. 반면 기존 TypeORM 프로젝트는 마이그레이션 비용이 크고, 복잡한 상속 구조는 TypeORM이 더 적합하다.
- 14
트러블슈팅에서 자주 보이는 실패는 설계 원칙이 운영 조건과 어긋날 때 나온다. 과도한 정규화는 단일 조회에 다중 JOIN을 만들고, 대량 데이터 환경에서 인덱스 스캔과 해시 조인 비용을 키운다. 반대로 비정규화 후에는 집계 배치가 돌기 전 신규 주문이 반영되지 않거나, product_name 업데이트 로직이 누락되어 데이터 불일치가 생길 수 있다. TypeORM이나 Sequelize에서 Lazy Loading으로 연관 엔티티를 조회하면 정규화된 스키마 위에서 N+1 쿼리가 반복될 수 있다. UPDATE와 DELETE가 많은 PostgreSQL 테이블에서는 autovacuum이 dead tuple 생성 속도를 따라가지 못해 Bloat가 발생하며, 기본 autovacuum_vacuum_scale_factor=0.2는 대규모 테이블에서 너무 느슨할 수 있다.
- 15
운영 설정 실수도 모델링 실패와 같은 결과를 만든다. TypeORM synchronize true 설정은 앱 시작 시 엔티티와 DB 스키마를 자동으로 맞추는데, 운영 DB에서 컬럼 이름 오타나 필드 삭제가 있으면 DROP COLUMN으로 데이터가 사라질 수 있다. Aurora Serverless v2에서는 Lambda나 Fargate 인스턴스가 늘어날 때 각 인스턴스가 독립 연결 풀을 만들고, 스케일 아웃 수에 connectionLimit가 곱해져 too many clients already 오류로 이어질 수 있다. 정리하면 DB 모델링은 정규화로 일관성을 확보하고, 성능이 필요한 곳에서만 비정규화를 적용하며, 격리 수준과 운영 방식을 업무 특성에 맞게 고르는 균형의 기술이다.
같은 레이어
L8에서 이어 듣기
- 오디오 파일
- /podcasts/l8-db-modeling.mp3