DB Modeling
분류: Layer 8 - 데이터베이스 심화
DB 모델링 (DB Modeling)
섹션 제목: “DB 모델링 (DB Modeling)”1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”데이터베이스 모델링이란 현실 세계의 데이터를 어떤 구조로 저장할지 설계하는 과정이며, 올바른 모델 선택과 정규화/비정규화 전략이 시스템의 성능·일관성·확장성을 결정한다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”데이터베이스는 애플리케이션의 “기억”이다. 모델링을 잘못하면 다음과 같은 문제가 생긴다.
- 데이터 중복: 같은 정보가 여러 곳에 저장되어 수정 시 누락이 발생한다.
- 이상 현상(Anomaly): 삽입·수정·삭제 시 의도치 않은 데이터 손실이나 불일치가 일어난다.
- 성능 저하: 잘못된 스키마는 불필요한 JOIN을 유발하거나, 반대로 중복 데이터 동기화 비용을 높인다.
- 트랜잭션 이상: 격리 수준을 이해하지 못하면 Dirty Read, Phantom Read 같은 동시성 버그가 운영 환경에서 발생한다.
BackOps 엔지니어 관점에서는 주문·정산·배송 같은 비즈니스 데이터를 정확히 저장하고 빠르게 조회하는 것이 핵심이기 때문에 DB 모델링 역량은 필수적이다.
프론트엔드 개발자를 위한 브릿지
섹션 제목: “프론트엔드 개발자를 위한 브릿지”Redux에서 중첩된 상태(예: { users: [{ id: 1, posts: [{ id: 10, ... }] }] })를 ID 참조로 펼쳐서 관리하는 것(normalizr 패턴)이 바로 DB 정규화와 같은 원리다. 중복 데이터를 한 곳에만 저장하고, 다른 곳에서는 ID로 참조하는 방식으로 “하나의 사실은 한 곳에만(Single Source of Truth)“을 지킨다. Zustand에서 { byId: { '1': {...} }, allIds: ['1', '2'] } 형태로 스토어를 설계할 때 이 원칙이 적용된다.
3. 핵심 개념
섹션 제목: “3. 핵심 개념”3-1. RDBMS vs NoSQL
섹션 제목: “3-1. RDBMS vs NoSQL”관계형 DB(RDBMS)는 엑셀 스프레드시트와 같다. 행과 열이 정해진 규격에 맞게 저장되고, 여러 시트(테이블)를 VLOOKUP(JOIN)으로 연결한다. 반면 NoSQL은 메모장 묶음에 가깝다. 형식이 자유롭고, 데이터를 꺼내는 방식도 다양하다.
| 구분 | RDBMS | NoSQL |
|---|---|---|
| 스키마 | 고정 (테이블 구조 미리 정의) | 유연 (스키마리스 또는 동적) |
| 일관성 | ACID 보장 | 주로 BASE (Basically Available, Soft-state, Eventually consistent) |
| 확장 방식 | 수직 확장 (Scale-up: 더 좋은 서버) | 수평 확장 (Scale-out: 서버 여러 대) |
| 쿼리 언어 | SQL | 각 DB마다 다름 |
| 대표 제품 | PostgreSQL, MySQL, Oracle | Redis, MongoDB, DynamoDB, Neo4j |
NoSQL 유형별 특성
섹션 제목: “NoSQL 유형별 특성”Key-Value Store (Redis)
- 원리: 사전처럼 키(key)로 값(value)을 즉시 조회한다. 모든 데이터를 메모리에 올려 마이크로초 수준의 응답 속도를 낸다.
- 적합한 용도: 세션 저장, 캐시, 실시간 카운터, 랭킹 보드
- 한계: 복잡한 쿼리(범위 검색, 관계 조회) 불가
Document Store (MongoDB)
- 원리: JSON과 유사한 BSON 형식으로 문서(Document)를 저장한다. 한 문서 안에 중첩 구조를 자유롭게 담을 수 있다.
- 적합한 용도: 콘텐츠 관리 시스템, 빠르게 변하는 스키마, 카탈로그
- 한계: 강한 일관성(ACID) 보장이 약하며 JOIN이 비효율적
Column-Family Store (DynamoDB, Cassandra)
- 원리: 행(Row)과 열(Column)의 개념을 가지지만 열을 동적으로 추가할 수 있다. 파티션 키 기반 분산으로 수평 확장에 최적화되어 있다.
- 적합한 용도: 시계열 데이터, 이벤트 로그, 대용량 쓰기 워크로드
- 한계: 파티션 키 설계가 잘못되면 핫스팟(Hot Partition) 발생
Graph DB (Neo4j)
- 원리: 노드(Node)와 엣지(Edge)로 데이터 관계를 표현한다. 관계 탐색이 핵심인 경우 RDBMS의 다단계 JOIN보다 훨씬 빠르다.
- 적합한 용도: SNS 친구 관계, 추천 엔진, 사기 탐지
왜 이렇게 동작하는가 — ACID vs BASE의 근본 트레이드오프
섹션 제목: “왜 이렇게 동작하는가 — ACID vs BASE의 근본 트레이드오프”RDBMS가 ACID를 보장하는 방법은 락(Lock)과 로그(WAL) 에 의존한다. 트랜잭션 중 다른 트랜잭션이 같은 데이터에 접근하지 못하게 락을 걸고, 변경 전에 로그를 먼저 기록한다. 이 구조는 데이터 정확성을 보장하지만, 여러 서버에 분산하기 어렵다. 락을 분산 환경에서 관리하려면 네트워크 통신이 필요하고, 이는 성능 저하로 직결된다.
반면 NoSQL의 BASE 모델은 “지금 당장 완벽하지 않아도 괜찮다, 결국에는 맞을 것이다” 라는 철학이다. 락 없이 여러 노드가 독립적으로 요청을 처리하므로 수평 확장이 자연스럽다. 대신 같은 시점에 노드마다 다른 값을 반환할 수 있다(Eventual Consistency).
ACID (RDBMS): "모든 창구가 동일한 잔액을 보여준다" → 정확하지만 확장 어려움BASE (NoSQL): "일부 창구가 잠깐 다른 잔액을 보여줄 수 있다" → 덜 정확하지만 확장 쉬움
실무 판단 기준:"이 데이터가 1초간 불일치해도 비즈니스에 문제가 없는가?"→ YES: NoSQL 고려 가능 (상품 카탈로그, SNS 피드, 로그)→ NO: RDBMS 필수 (결제, 재고, 정산)📖 더 보기: Amazon DynamoDB 개발자 가이드 - 관계형 DB vs DynamoDB — AWS 환경에서 RDBMS와 DynamoDB를 언제 선택할지 공식 가이드 (입문)
선택 기준
섹션 제목: “선택 기준”데이터 구조가 고정적이고 관계가 복잡한가? → RDBMS (PostgreSQL)스키마가 자주 변하고 문서 형태인가? → Document DB (MongoDB)초고속 캐시/세션이 필요한가? → Key-Value (Redis)AWS 환경에서 대용량 단순 쿼리가 필요한가? → DynamoDB관계 탐색이 핵심인가? → Graph DB (Neo4j)3-2. 정규화 (Normalization)
섹션 제목: “3-2. 정규화 (Normalization)”정규화는 집 안 물건 정리와 같다. 같은 물건을 여러 방에 두면(중복) 하나를 바꿀 때 다른 방 것도 바꿔야 한다. 정규화는 각 정보를 딱 한 곳에만 두는 규칙이다.
원리: 왜 하는가 — 중복이 만드는 연쇄 문제
섹션 제목: “원리: 왜 하는가 — 중복이 만드는 연쇄 문제”정규화의 근본 목적은 “하나의 사실은 한 곳에만 저장한다(Single Source of Truth)” 를 보장하는 것이다. 이것이 왜 중요한지 실무 예시로 설명하면, 주문 테이블에 고객 주소를 직접 저장하는 경우(비정규화) 고객이 100건의 주문을 했다면 주소가 100행에 중복 저장된다. 고객이 이사하면 100행을 모두 UPDATE해야 하고, 한 행이라도 놓치면 시스템 내에 “서로 다른 주소”가 공존하게 된다. 이것이 데이터 불일치의 시작점이다.
데이터 중복은 세 가지 이상 현상(Anomaly)을 유발한다.
- 삽입 이상(Insert Anomaly): 새 데이터를 넣으려면 불필요한 다른 데이터도 함께 입력해야 한다.
- 수정 이상(Update Anomaly): 중복된 데이터를 모두 찾아 수정해야 하며, 하나라도 누락하면 불일치가 생긴다.
- 삭제 이상(Delete Anomaly): 한 행을 지웠을 때 의도치 않게 다른 정보도 사라진다.
1NF (제1정규형): 원자값 (Atomic Values)
섹션 제목: “1NF (제1정규형): 원자값 (Atomic Values)”규칙: 하나의 셀(컬럼)에는 하나의 값만 들어가야 한다. 반복 그룹(Repeating Group)이 없어야 한다.
위반 예시:
| order_id | customer | products |
|---|---|---|
| 1 | 홍길동 | 사과, 바나나, 포도 |
| 2 | 김철수 | 우유 |
products 컬럼에 여러 값이 쉼표로 묶여 있다. 특정 상품만 검색하거나 수정하기 어렵다.
1NF 적용 후:
| order_id | customer | product |
|---|---|---|
| 1 | 홍길동 | 사과 |
| 1 | 홍길동 | 바나나 |
| 1 | 홍길동 | 포도 |
| 2 | 김철수 | 우유 |
각 행이 하나의 원자값을 갖는다. 이제 특정 상품 검색이 가능해진다.
-- 1NF 위반: products 컬럼에 다중값SELECT * FROM orders WHERE products LIKE '%사과%'; -- 비효율적
-- 1NF 준수 후: 정확한 검색 가능SELECT * FROM order_items WHERE product = '사과';-- 결과: order_id=1, customer=홍길동, product=사과2NF (제2정규형): 부분 종속 제거 (Partial Dependency Elimination)
섹션 제목: “2NF (제2정규형): 부분 종속 제거 (Partial Dependency Elimination)”규칙: 1NF를 만족하면서, 복합 기본키(Composite PK)의 일부에만 종속되는 컬럼이 없어야 한다.
위반 예시 (기본키: order_id + product):
| order_id | product | quantity | customer_name | customer_address |
|---|---|---|---|---|
| 1 | 사과 | 3 | 홍길동 | 서울시 강남구 |
| 1 | 바나나 | 1 | 홍길동 | 서울시 강남구 |
| 2 | 우유 | 2 | 김철수 | 부산시 해운대구 |
customer_name과 customer_address는 order_id에만 종속된다. product와는 무관하다. 이것이 부분 종속이다.
문제: 고객 주소가 바뀌면 해당 고객의 모든 주문 행을 수정해야 한다. 하나라도 빠지면 불일치 발생.
2NF 적용 후:
orders 테이블:
| order_id | customer_id |
|---|---|
| 1 | 101 |
| 2 | 102 |
order_items 테이블:
| order_id | product | quantity |
|---|---|---|
| 1 | 사과 | 3 |
| 1 | 바나나 | 1 |
| 2 | 우유 | 2 |
customers 테이블:
| customer_id | customer_name | customer_address |
|---|---|---|
| 101 | 홍길동 | 서울시 강남구 |
| 102 | 김철수 | 부산시 해운대구 |
-- 고객 주소 변경 시 단 1행만 수정UPDATE customers SET customer_address = '서울시 마포구' WHERE customer_id = 101;-- 결과: 1 row affected (이전에는 order_items 행 수만큼 수정해야 했음)3NF (제3정규형): 이행 종속 제거 (Transitive Dependency Elimination)
섹션 제목: “3NF (제3정규형): 이행 종속 제거 (Transitive Dependency Elimination)”규칙: 2NF를 만족하면서, 비키 컬럼 → 비키 컬럼 종속(이행 종속)이 없어야 한다. 즉, A → B → C 관계에서 C가 A를 통해서만 B에 접근해야 한다면, C는 B에 직접 종속된 것이므로 분리해야 한다.
위반 예시 (기본키: employee_id):
| employee_id | name | department_id | department_name | department_location |
|---|---|---|---|---|
| 1 | 홍길동 | D01 | 개발팀 | 서울 |
| 2 | 김철수 | D01 | 개발팀 | 서울 |
| 3 | 이영희 | D02 | 마케팅팀 | 부산 |
department_name과 department_location은 employee_id → department_id → department_name/location 이행 종속이다.
문제: 개발팀 위치가 부산으로 바뀌면 개발팀 소속 직원 모든 행을 수정해야 한다.
3NF 적용 후:
employees 테이블:
| employee_id | name | department_id |
|---|---|---|
| 1 | 홍길동 | D01 |
| 2 | 김철수 | D01 |
| 3 | 이영희 | D02 |
departments 테이블:
| department_id | department_name | department_location |
|---|---|---|
| D01 | 개발팀 | 서울 |
| D02 | 마케팅팀 | 부산 |
-- 부서 위치 변경 시 단 1행만 수정UPDATE departments SET department_location = '부산' WHERE department_id = 'D01';-- 결과: 1 row affected
-- JOIN으로 전체 정보 조회SELECT e.name, d.department_name, d.department_locationFROM employees eJOIN departments d ON e.department_id = d.department_id;-- 결과:-- 홍길동 | 개발팀 | 부산-- 김철수 | 개발팀 | 부산-- 이영희 | 마케팅팀 | 부산BCNF (Boyce-Codd Normal Form)
섹션 제목: “BCNF (Boyce-Codd Normal Form)”규칙: 3NF보다 엄격한 형태. 모든 결정자(Determinant)가 후보키(Candidate Key)여야 한다.
3NF를 만족해도 BCNF를 위반하는 경우가 있다. 주로 복합 후보키가 여러 개 있을 때 발생한다.
위반 예시 (학생-과목-교수 배정):
- 후보키 후보:
(학생, 과목),(학생, 교수) - 규칙: 교수는 한 과목만 담당하므로
교수 → 과목관계 성립
| 학생 | 과목 | 교수 |
|---|---|---|
| 홍길동 | 데이터베이스 | 김교수 |
| 김철수 | 데이터베이스 | 김교수 |
| 이영희 | 알고리즘 | 박교수 |
교수 → 과목이 성립하는데, 교수는 후보키가 아니다. BCNF 위반.
BCNF 적용 후:
professor_subject 테이블:
| 교수 | 과목 |
|---|---|
| 김교수 | 데이터베이스 |
| 박교수 | 알고리즘 |
student_professor 테이블:
| 학생 | 교수 |
|---|---|
| 홍길동 | 김교수 |
| 김철수 | 김교수 |
| 이영희 | 박교수 |
3-3. 비정규화 (Denormalization)
섹션 제목: “3-3. 비정규화 (Denormalization)”정규화된 창고는 물건이 정확히 분류되어 있지만, 꺼낼 때마다 여러 창고를 돌아다녀야 한다. 비정규화는 자주 쓰는 물건을 책상 위에 꺼내두는 것이다. 정리는 안 됐지만 꺼내는 속도는 빠르다.
정규화된 스키마는 데이터 일관성은 높지만, 읽기 쿼리에서 다수의 JOIN이 발생한다. 대규모 트래픽 환경에서 JOIN은 CPU와 I/O 비용이 크다. 비정규화는 의도적으로 중복을 허용해 JOIN 없이 데이터를 읽을 수 있게 한다.
언제 사용하는가
섹션 제목: “언제 사용하는가”- 읽기 >> 쓰기 패턴: 조회가 압도적으로 많고 수정이 드문 경우
- 집계 테이블: 매 요청마다
SUM,COUNT를 하는 대신 미리 계산한 결과를 별도 테이블에 저장 - 리포트/대시보드: OLAP 쿼리처럼 수백만 행을 스캔하는 분석 쿼리
- 캐시 테이블: 자주 조회되는 복잡한 JOIN 결과를 별도 테이블에 저장
실무 예시: 주문 테이블에 상품명 중복 저장
섹션 제목: “실무 예시: 주문 테이블에 상품명 중복 저장”정규화된 구조 (JOIN 필요):
-- 주문 내역 조회 시 매번 JOINSELECT oi.order_id, oi.quantity, p.product_name, p.priceFROM order_items oiJOIN products p ON oi.product_id = p.product_idWHERE oi.order_id = 1001;비정규화된 구조 (JOIN 불필요):
-- order_items 테이블에 product_name을 직접 저장-- 스키마: order_id, product_id, product_name, quantity, unit_price
SELECT order_id, product_name, quantity, unit_priceFROM order_itemsWHERE order_id = 1001;-- 결과: JOIN 없이 즉시 반환 → 응답 속도 향상
-- 단점: 상품명이 바뀌면 기존 주문 데이터는 예전 이름 그대로 유지-- (이는 실제로 비즈니스 관점에서 맞는 경우도 있음: 주문 당시의 상품명 보존)집계 테이블 예시:
-- 매일 밤 배치로 실행: 상품별 일일 판매 집계INSERT INTO daily_product_sales (sale_date, product_id, product_name, total_quantity, total_revenue)SELECT CURRENT_DATE, oi.product_id, p.product_name, SUM(oi.quantity), SUM(oi.quantity * oi.unit_price)FROM order_items oiJOIN products p ON oi.product_id = p.product_idWHERE oi.created_at::date = CURRENT_DATEGROUP BY oi.product_id, p.product_name;
-- 대시보드 조회 시: 집계 테이블에서 즉시 반환SELECT product_name, total_revenueFROM daily_product_salesWHERE sale_date = '2026-04-01'ORDER BY total_revenue DESC;-- 결과: COUNT/SUM 연산 없이 미리 계산된 값 즉시 반환2025년 현대적 비정규화: CDC 기반 자동 동기화
섹션 제목: “2025년 현대적 비정규화: CDC 기반 자동 동기화”전통적인 비정규화는 배치 스크립트나 트리거로 중복 데이터를 수동 동기화했다. 2025년에는 CDC(Change Data Capture) 기반 자동화 파이프라인이 표준으로 자리잡았다.
CDC 비정규화 파이프라인 구조:
원본 테이블 (정규화 소스) │ │ WAL(PostgreSQL) 또는 binlog(MySQL) 변경 감지 ▼Debezium (CDC 커넥터) │ ▼Kafka 토픽 (변경 이벤트 스트림) │ ├──▶ 비정규화 집계 테이블 (UPSERT) └──▶ Elasticsearch 검색 인덱스 (동기화)왜 이 방식이 더 나은가:
- 배치 주기(자정 실행 등) 없이 준실시간 동기화 가능
- 원본 DB에 직접 부하를 주지 않음 (WAL 기반 비침습적 읽기)
- 실패 시 Kafka 오프셋으로 정확히 재처리 가능
AWS 환경에서의 CDC 구성 예시:
PostgreSQL (RDS) → DMS(Database Migration Service) → Kinesis Data Streams → Lambda → DynamoDB/Elasticsearch
또는 (오픈소스 스택):PostgreSQL (RDS) → Debezium (EC2/ECS) → MSK(Managed Kafka) → Consumer → 비정규화 테이블DMS는 AWS 관리형 CDC 서비스로, Debezium보다 설정이 간단하지만 커스터마이징이 제한적이다. Debezium은 오픈소스이므로 변환 로직을 자유롭게 추가할 수 있다. BackOps 환경에서는 주문 데이터를 정규화된 PostgreSQL에 저장하고, CDC로 검색용 Elasticsearch나 대시보드용 집계 테이블에 자동 동기화하는 패턴이 일반적이다.
📖 더 보기: PostgreSQL: To normalize or not to normalize? — 정규화와 비정규화 트레이드오프를 실제 사례 중심으로 분석 (CYBERTEC PostgreSQL)
📖 더 보기: Mastering Postgres CDC: Complete Tech Guide — PostgreSQL CDC의 방식별(트리거, WAL, logical decoding) 비교와 실무 선택 가이드 (2025, 중급)
3-4. 격리 수준 (Isolation Level) — 참조
섹션 제목: “3-4. 격리 수준 (Isolation Level) — 참조”격리 수준(Dirty Read, Non-Repeatable Read, Phantom Read)과 MVCC/VACUUM 상세 내용은 transaction-basics.md 를 참고하세요.
격리 수준 실무 적용 요약:
- 대부분의 웹 API: READ COMMITTED (PostgreSQL 기본값)
- 재고 차감, 좌석 예약 등 동시성 충돌이 중요한 경우: REPEATABLE READ 또는
SELECT FOR UPDATE - 금융 정산 배치: SERIALIZABLE (성능 희생을 감수하고 정확성 우선)
2025 현대적 비정규화: Materialized View와 자동화 전략
섹션 제목: “2025 현대적 비정규화: Materialized View와 자동화 전략”단순한 집계 테이블 방식 외에, 2025년에는 Materialized View(구체화 뷰) 와 자동 갱신 파이프라인 이 표준 비정규화 도구로 자리 잡았다.
비유: 도서관 서가 목록(Materialized View)을 한 번 만들어두면 매번 서가를 직접 뒤지지 않아도 된다. 주기적으로 목록을 갱신(REFRESH)하면 충분히 최신 상태를 유지할 수 있다.
PostgreSQL Materialized View 실전 사용법:
-- 비용이 높은 집계 쿼리를 Materialized View로 대체CREATE MATERIALIZED VIEW order_daily_summary ASSELECT o.created_at::date AS sale_date, c.region, COUNT(o.id) AS order_count, SUM(o.total_amount) AS total_revenue, AVG(o.total_amount) AS avg_order_valueFROM orders oJOIN customers c ON o.customer_id = c.idWHERE o.status = 'COMPLETED'GROUP BY o.created_at::date, c.region;
-- 인덱스 추가 (Materialized View도 인덱스 지원)CREATE INDEX ON order_daily_summary (sale_date, region);
-- 조회: 집계 없이 즉시 반환 (10-15x 성능 향상)SELECT * FROM order_daily_summaryWHERE sale_date = CURRENT_DATE - 1ORDER BY total_revenue DESC;-- 예상 출력:-- sale_date | region | order_count | total_revenue | avg_order_value-- 2026-04-07 | 서울 | 1,245 | 89,250,000 | 71,687.5-- 2026-04-07 | 부산 | 387 | 22,400,000 | 57,883.2
-- 주기적 갱신 (데이터 변경 없이 락 없이 갱신)REFRESH MATERIALIZED VIEW CONCURRENTLY order_daily_summary;-- CONCURRENTLY: 갱신 중에도 읽기 가능 (구 버전 유지하며 교체)-- 단, CONCURRENTLY는 UNIQUE 인덱스가 있어야 사용 가능
-- NestJS에서 주기적 갱신 (스케줄러 활용)// @Cron('0 0 * * *') // 매일 자정// async refreshMaterializedView() {// await this.dataSource.query('REFRESH MATERIALIZED VIEW CONCURRENTLY order_daily_summary');// }Materialized View vs 집계 테이블 비교:
| 기준 | Materialized View | 별도 집계 테이블 |
|---|---|---|
| 정의 방법 | SQL 쿼리로 선언적 정의 | 배치 스크립트로 직접 구현 |
| 갱신 방식 | REFRESH 명령 한 번 | INSERT/UPDATE 로직 직접 작성 |
| 인덱스 지원 | 지원 | 지원 |
| 복잡도 | 낮음 (선언적) | 높음 (명령형) |
| 갱신 중 읽기 | CONCURRENTLY 옵션으로 가능 | 로직에 따라 다름 |
📖 더 보기: CYBERTEC PostgreSQL - To normalize or not to normalize — 정규화와 비정규화 트레이드오프 실제 사례 분석. Materialized View 활용 패턴 포함 (중급)
3-5. MVCC / VACUUM — 참조
섹션 제목: “3-5. MVCC / VACUUM — 참조”PostgreSQL MVCC(Multi-Version Concurrency Control), dead tuple, VACUUM 튜닝에 대한 상세 내용은 transaction-basics.md 를 참고하세요.
3-5. 인덱스 전략 — 참조
섹션 제목: “3-5. 인덱스 전략 — 참조”B-Tree 인덱스, 복합 인덱스, 커버링 인덱스, 부분 인덱스 등 인덱스 설계와 최적화에 대한 상세 내용은 db-index-query-optimization.md 를 참고하세요.
3-6. TypeORM + AWS RDS/Aurora 실전 모델링 패턴
섹션 제목: “3-6. TypeORM + AWS RDS/Aurora 실전 모델링 패턴”TypeORM 엔티티 설계와 정규화 관계 매핑
섹션 제목: “TypeORM 엔티티 설계와 정규화 관계 매핑”TypeORM의 데코레이터가 정규화된 관계를 코드로 어떻게 표현하는지 실무 예시를 통해 확인한다. 아래는 주문-고객-상품 관계를 3NF 기준으로 설계하고 TypeORM으로 표현한 예시다.
// customer.entity.ts — customers 테이블 (3NF: 고객 정보 단일 보관)import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from "typeorm";
@Entity("customers")export class Customer { @PrimaryGeneratedColumn() id: number;
@Column({ length: 100 }) name: string;
@Column({ length: 255 }) address: string;
@OneToMany(() => Order, (order) => order.customer) orders: Order[];}
// order.entity.ts — orders 테이블@Entity("orders")export class Order { @PrimaryGeneratedColumn() id: number;
@ManyToOne(() => Customer, (customer) => customer.orders, { nullable: false }) @JoinColumn({ name: "customer_id" }) customer: Customer;
@Column({ type: "enum", enum: ["PENDING", "PAID", "SHIPPED", "DELIVERED"] }) status: string;
@OneToMany(() => OrderItem, (item) => item.order, { cascade: true }) items: OrderItem[];
@CreateDateColumn() createdAt: Date;}
// order-item.entity.ts — order_items 테이블 (비정규화: 주문 당시 스냅샷 저장)@Entity("order_items")export class OrderItem { @PrimaryGeneratedColumn() id: number;
@ManyToOne(() => Order, (order) => order.items) order: Order;
@Column() productId: number; // products 테이블 FK
// 비정규화 컬럼: 주문 당시의 상품명·가격을 스냅샷으로 저장 // 이후 상품 정보가 변경돼도 주문 내역은 당시 값을 보존 @Column({ length: 200 }) productName: string;
@Column("decimal", { precision: 10, scale: 2 }) unitPrice: number;
@Column() quantity: number;}TypeORM 마이그레이션으로 스키마 안전하게 관리 (AWS RDS):
# 1. 엔티티 변경 후 마이그레이션 파일 자동 생성npx typeorm migration:generate src/migrations/AddOrderItemSnapshot \ -d src/data-source.ts
# 2. 생성된 마이그레이션 확인 (반드시 검토 후 실행)# src/migrations/1712345678901-AddOrderItemSnapshot.ts
# 3. RDS에 마이그레이션 적용npx typeorm migration:run -d src/data-source.ts
# 예상 출력:# query: ALTER TABLE "order_items" ADD "product_name" character varying(200) NOT NULL# query: ALTER TABLE "order_items" ADD "unit_price" numeric(10,2) NOT NULL# Migration AddOrderItemSnapshot has been executed successfully.프로덕션 RDS에 DDL을 직접 실행하면 Table Lock이 발생할 수 있다. 대규모 테이블(수백만 행 이상)에서는
pg_repack또는 AWS RDS Blue/Green Deployment로 무중단 스키마 변경을 고려한다.
Aurora PostgreSQL + Prisma 비교 (TypeORM 대안)
섹션 제목: “Aurora PostgreSQL + Prisma 비교 (TypeORM 대안)”Prisma는 TypeORM의 대안으로 2024~2025년 NestJS 생태계에서 빠르게 채택되고 있다. 두 ORM의 Aurora 연동 방식 차이를 알아두면 프로젝트 선택에 도움이 된다.
TypeORM vs Prisma — Aurora 연동 핵심 차이:
TypeORM: - 엔티티 클래스에 @Column, @ManyToOne 데코레이터로 스키마 정의 - replication: { master, slaves } 설정으로 읽기/쓰기 자동 분리 - 마이그레이션: migration:generate → migration:run
Prisma: - schema.prisma 파일에 스키마 정의 (데코레이터 불필요) - datasource에 url(읽기/쓰기)과 directUrl(마이그레이션 전용) 분리 - 마이그레이션: prisma migrate deploy
Prisma의 Aurora 읽기/쓰기 분리 예시 (schema.prisma): datasource db { provider = "postgresql" url = env("DATABASE_URL") // Aurora Reader Endpoint (읽기) directUrl = env("DIRECT_URL") // Aurora Writer Endpoint (쓰기/마이그레이션) }언제 어떤 ORM을 선택하는가:
| 상황 | 권장 선택 |
|---|---|
| 기존 TypeORM 프로젝트 유지 | TypeORM 유지 (마이그레이션 비용 큼) |
| 신규 프로젝트 (NestJS + Aurora) | Prisma (타입 안전성, 자동완성 우수) |
| 복잡한 상속 구조(STI, CTI) | TypeORM (Prisma는 테이블 상속 미지원) |
| 팀이 SQL에 친숙 | TypeORM QueryBuilder |
AWS RDS Parameter Group으로 격리 수준 설정
섹션 제목: “AWS RDS Parameter Group으로 격리 수준 설정”-- RDS PostgreSQL 인스턴스의 기본 격리 수준 확인SHOW default_transaction_isolation;-- 출력: read committed (PostgreSQL 기본값)
-- BackOps 환경 권장 설정:-- 일반 OLTP API: READ COMMITTED (기본값 유지)-- 정산/배치: REPEATABLE READ 또는 SERIALIZABLE
-- TypeORM에서 트랜잭션별 격리 수준 지정await dataSource.transaction("REPEATABLE READ", async (manager) => { // 이 트랜잭션 내에서는 REPEATABLE READ 적용 const balance = await manager.findOne(Account, { where: { id: accountId } }); // Non-Repeatable Read 방지: 트랜잭션 내에서 같은 행을 두 번 읽어도 동일한 값 보장 await manager.update(Account, accountId, { balance: balance.balance - amount });});
// Aurora RDS Parameter Group에서 DB 레벨로 기본값 변경 (AWS CLI):// aws rds modify-db-cluster-parameter-group \// --db-cluster-parameter-group-name my-aurora-pg \// --parameters "ParameterName=default_transaction_isolation,ParameterValue=repeatable read,ApplyMethod=pending-reboot"예상 출력 (REPEATABLE READ 트랜잭션):
[TypeORM] START TRANSACTION ISOLATION LEVEL REPEATABLE READ[TypeORM] SELECT ... FROM "accounts" WHERE "id" = $1 -- balance: 100,000[TypeORM] UPDATE "accounts" SET "balance" = $1 WHERE "id" = $2 -- balance: 90,000[TypeORM] COMMIT
-- 같은 트랜잭션 내에서 accounts를 다시 SELECT해도 여전히 100,000 반환-- (다른 트랜잭션이 중간에 변경했더라도 격리 수준이 보호)📖 더 보기: Prisma and Aurora PostgreSQL with connection pooling — Prisma Blog — Aurora Serverless v2와 Prisma 연동 시 연결 풀링(PgBouncer/RDS Proxy) 설정 방법 (중급)
4. 실무에서 어떻게 쓰이나
섹션 제목: “4. 실무에서 어떻게 쓰이나”RDBMS vs NoSQL 선택
섹션 제목: “RDBMS vs NoSQL 선택”- PostgreSQL: 주문, 결제, 정산처럼 ACID가 필수인 핵심 도메인
- Redis: 세션, API 응답 캐시, 실시간 랭킹
- DynamoDB: AWS 환경에서 대용량 이벤트 로그, 사용자 활동 기록
정규화 실무 적용
섹션 제목: “정규화 실무 적용”- 설계 초기에는 3NF 기준으로 정규화 → 데이터 일관성 확보
- 운영 단계에서 느린 쿼리 발견 시 선택적으로 비정규화 적용
- 주문 완료 시점의 상품명/가격은 비정규화로 “스냅샷”으로 저장하는 것이 일반적 (상품 정보 변경 후에도 당시 주문 내역 보존)
2025년 비정규화 의사결정 프레임워크
섹션 제목: “2025년 비정규화 의사결정 프레임워크”2025년 현업 기준으로, 비정규화는 “일단 해두는” 것이 아니라 측정 후 판단하는 최후 수단이다.
Step 1: EXPLAIN ANALYZE로 느린 쿼리 확인 → Seq Scan? Index Scan? Hash Join?
Step 2: 인덱스 추가로 해결 가능한가? → YES → 인덱스 추가 (비정규화 불필요) → NO → 다음 단계
Step 3: Materialized View로 집계 가능한가? → YES → Materialized View 생성 (선언적, 유지보수 쉬움) → NO → 다음 단계 (JOIN 구조가 너무 복잡한 경우)
Step 4: 비정규화 컬럼 추가 (마지막 수단) → 동기화 전략(트리거/CDC/배치) 반드시 함께 설계 → 어떤 컬럼을 중복 허용할지 문서화비정규화를 하면 안 되는 경우:
- 쓰기(INSERT/UPDATE)가 읽기만큼 많은 OLTP 테이블 — 중복 데이터 동기화 비용이 오히려 더 크다
- 데이터 일관성이 비즈니스상 절대적인 경우 (결제 금액, 정산 데이터)
- 팀이 동기화 로직을 신뢰있게 유지할 수 없는 경우
격리 수준 실무 적용
섹션 제목: “격리 수준 실무 적용”- 대부분의 웹 애플리케이션: READ COMMITTED (PostgreSQL 기본값)
- 재고 차감, 좌석 예약 등 동시성 충돌이 중요한 경우: REPEATABLE READ 또는 비관적 락(
SELECT FOR UPDATE) - 금융 정산 배치: SERIALIZABLE (성능 희생을 감수하고 정확성 우선)
5. 내 업무와의 연결고리
섹션 제목: “5. 내 업무와의 연결고리”BackOps 엔지니어의 핵심 업무에서 DB 모델링이 어떻게 연결되는지:
- 주문/정산 데이터: 정산 금액은 ACID 트랜잭션이 필수. 격리 수준을 REPEATABLE READ 이상으로 설정해야 집계 오류를 방지한다.
- 배송 상태 추적: 상태 변경 이력을 별도 테이블(이력 테이블)로 관리하면 3NF를 만족하면서 추적이 용이하다.
- 집계 대시보드: 관리자 페이지의 매출 집계는 비정규화된 집계 테이블로 분리하면 실시간 부하를 줄일 수 있다.
- Nest.js + TypeORM:
@ManyToOne,@OneToMany데코레이터가 정규화된 관계를 코드로 표현한다. 관계 이해 없이는 쿼리 최적화가 불가능하다.
// TypeORM에서 비정규화 적용 예시@Entity()export class OrderItem { @PrimaryGeneratedColumn() id: number;
@ManyToOne(() => Order) order: Order;
@Column() productId: number;
// 비정규화: 주문 당시의 상품명과 가격을 스냅샷으로 저장 @Column() productName: string; // products 테이블 JOIN 없이 조회 가능
@Column("decimal") unitPrice: number; // 가격 변경에 영향받지 않음}6. 비슷한 개념과 비교
섹션 제목: “6. 비슷한 개념과 비교”정규화 vs 비정규화
섹션 제목: “정규화 vs 비정규화”| 기준 | 정규화 | 비정규화 |
|---|---|---|
| 목적 | 데이터 일관성 | 읽기 성능 |
| 중복 | 최소화 | 의도적 허용 |
| 쓰기 비용 | 낮음 | 높음 (여러 곳 동기화) |
| 읽기 비용 | 높음 (JOIN) | 낮음 (단일 테이블) |
| 적합한 환경 | OLTP (트랜잭션 처리) | OLAP (분석/리포트) |
SQL JOIN vs NoSQL Embedding
섹션 제목: “SQL JOIN vs NoSQL Embedding”| 기준 | SQL JOIN | NoSQL 문서 내포 |
|---|---|---|
| 방식 | 별도 테이블을 키로 연결 | 하나의 문서 안에 중첩 |
| 일관성 | 강함 (FK 제약) | 약함 (애플리케이션 책임) |
| 읽기 | 느림 (JOIN) | 빠름 (단일 조회) |
| 유연성 | 낮음 (스키마 고정) | 높음 (스키마리스) |
ACID vs BASE
섹션 제목: “ACID vs BASE”| 속성 | ACID (RDBMS) | BASE (NoSQL) |
|---|---|---|
| 일관성 | 항상 일관 | 결국 일관 (Eventually Consistent) |
| 가용성 | 일관성 우선 | 가용성 우선 |
| 파티션 | 제한적 | 자연스러운 분산 |
6.5. 트러블슈팅
섹션 제목: “6.5. 트러블슈팅”문제 1: 과도한 정규화로 인한 JOIN 폭발
섹션 제목: “문제 1: 과도한 정규화로 인한 JOIN 폭발”증상:
-- 슬로우 쿼리 로그에 지속적으로 등장[SLOW QUERY] execution time: 8.432sSELECT o.*, c.name, c.address, p.product_name, p.category, cat.name, sup.supplier_nameFROM orders oJOIN customers c ON o.customer_id = c.idJOIN order_items oi ON o.id = oi.order_idJOIN products p ON oi.product_id = p.idJOIN categories cat ON p.category_id = cat.idJOIN suppliers sup ON p.supplier_id = sup.idWHERE o.status = 'PENDING';원인:
- 5~6단계 정규화로 모든 테이블이 분리되어 있어 단일 조회에 다중 JOIN 필요
- 각 JOIN마다 인덱스 스캔과 해시 조인 비용 발생
- 대량 데이터 환경(수백만 행)에서 기하급수적 성능 저하
해결 방법:
-
EXPLAIN ANALYZE로 어떤 JOIN이 병목인지 파악EXPLAIN ANALYZESELECT ... (위 쿼리);-- Hash Join, Seq Scan이 많으면 인덱스 또는 비정규화 필요 -
자주 함께 조회되는 데이터를 비정규화하여 별도 뷰(View) 또는 테이블로 관리
-
Materialized View 활용 (PostgreSQL):
CREATE MATERIALIZED VIEW order_summary ASSELECT o.id, o.status, c.name AS customer_name, ...FROM orders o JOIN customers c ON ...;-- 주기적 갱신REFRESH MATERIALIZED VIEW order_summary;
문제 2: 비정규화 후 데이터 불일치
섹션 제목: “문제 2: 비정규화 후 데이터 불일치”증상:
-- 상품명 변경 후 일부 주문 내역에는 예전 이름이 표시됨-- 대시보드 매출 집계와 실시간 합계가 다름SELECT SUM(total_revenue) FROM daily_sales WHERE sale_date = '2026-04-01';-- 결과: 10,500,000
SELECT SUM(amount) FROM orders WHERE created_at::date = '2026-04-01';-- 결과: 10,700,000 -- 불일치!원인:
- 집계 배치가 자정에 실행되기 전, 당일 신규 주문이
daily_sales에 반영되지 않음 - 비정규화된 컬럼(
product_name) 업데이트 로직이 누락됨
해결 방법:
- 집계 테이블에
last_synced_at컬럼 추가 → UI에 “마지막 업데이트 시각” 표시 - 중요한 집계는 캐시 테이블 + Redis 이중 저장으로 정합성 체크
- 비정규화 컬럼 업데이트는 트랜잭션 내에서 원본과 함께 처리:
BEGIN;UPDATE products SET product_name = '신상품명' WHERE id = 1;-- 비정규화된 컬럼도 동시 업데이트 (미래 주문 기준)-- 과거 주문은 의도적으로 유지 (스냅샷 정책)COMMIT;
문제 3: Phantom Read로 인한 재고 이중 차감
섹션 제목: “문제 3: Phantom Read로 인한 재고 이중 차감”증상:
-- 동시에 2명이 마지막 재고 1개를 주문-- 두 트랜잭션 모두 재고 1개 확인 후 차감 → 재고가 -1이 됨stock: -1 -- 불가능해야 할 값원인:
-- 두 트랜잭션이 동시에 실행됨-- Tx1SELECT stock FROM items WHERE id=1; -- 1 확인-- Tx2SELECT stock FROM items WHERE id=1; -- 동시에 1 확인-- Tx1UPDATE items SET stock = stock - 1 WHERE id=1; -- stock=0-- Tx2UPDATE items SET stock = stock - 1 WHERE id=1; -- stock=-1해결 방법:
- 비관적 락(Pessimistic Lock) 사용:
BEGIN;SELECT stock FROM items WHERE id=1 FOR UPDATE;-- FOR UPDATE: 해당 행에 배타 락 → 다른 트랜잭션이 대기UPDATE items SET stock = stock - 1 WHERE id=1 WHERE stock > 0;COMMIT;
- 낙관적 락(Optimistic Lock) 사용 (TypeORM):
@VersionColumn()version: number;// 버전이 다르면 업데이트 실패 → 재시도 로직
WHERE stock > 0조건 + 영향 행 수 확인:UPDATE items SET stock = stock - 1 WHERE id=1 AND stock > 0;-- affected rows = 0이면 재고 부족으로 처리
문제 4: N+1 쿼리 문제 (정규화된 스키마에서 ORM 사용 시)
섹션 제목: “문제 4: N+1 쿼리 문제 (정규화된 스키마에서 ORM 사용 시)”증상:
-- 100개 주문 목록을 불러올 때 쿼리가 101번 실행됨SELECT * FROM orders; -- 1번SELECT * FROM customers WHERE id=1; -- 1번SELECT * FROM customers WHERE id=2; -- 1번... (100번 반복)원인:
- TypeORM/Sequelize에서 연관 엔티티를 Lazy Loading으로 조회할 때
- 정규화된 스키마에서 ORM이 개별 조회를 반복 실행
해결 방법:
// TypeORM에서 Eager Loading으로 JOIN 한 번에 처리const orders = await orderRepository.find({ relations: ["customer", "orderItems"], // SQL: SELECT ... FROM orders LEFT JOIN customers ... LEFT JOIN order_items ...});
// 또는 QueryBuilder로 직접 JOINconst orders = await orderRepository .createQueryBuilder("order") .leftJoinAndSelect("order.customer", "customer") .leftJoinAndSelect("order.orderItems", "item") .getMany();문제 5: Autovacuum이 따라가지 못해 테이블 Bloat 발생
섹션 제목: “문제 5: Autovacuum이 따라가지 못해 테이블 Bloat 발생”증상:
-- 테이블 크기가 실제 데이터 대비 비정상적으로 큼SELECT pg_size_pretty(pg_total_relation_size('orders'));-- 결과: 12 GB (실제 live 데이터는 3 GB 수준)
-- 쿼리 성능이 점진적으로 느려짐-- EXPLAIN ANALYZE의 Seq Scan에서 rows=500000이지만 실제 live rows는 200000-- → dead tuple이 디스크 페이지를 차지해 불필요한 I/O 발생원인:
- UPDATE/DELETE가 빈번한 테이블에서 autovacuum이 dead tuple 생성 속도를 따라가지 못함
- 기본
autovacuum_vacuum_scale_factor=0.2은 “전체 행의 20%가 dead tuple이 되면 실행”이므로 대규모 테이블에서는 트리거 조건이 너무 느슨함 - 장시간 실행되는 트랜잭션(Long-running Transaction)이 VACUUM을 블록하는 경우도 있음
확인 방법:
-- dead tuple 비율과 마지막 autovacuum 시간 확인SELECT schemaname, tablename, n_live_tup, n_dead_tup, ROUND(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 2) AS dead_pct, last_autovacuum, last_autoanalyzeFROM pg_stat_user_tablesWHERE n_dead_tup > 10000ORDER BY dead_pct DESC;
-- VACUUM을 블록하는 장시간 트랜잭션 확인SELECT pid, now() - xact_start AS duration, queryFROM pg_stat_activityWHERE state = 'idle in transaction' AND xact_start < now() - interval '10 minutes'ORDER BY duration DESC;해결 방법:
-- 1. 즉시 조치: 해당 테이블 수동 VACUUMVACUUM ANALYZE orders;
-- 2. 장기 조치: 테이블별 autovacuum 튜닝ALTER TABLE orders SET ( autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_threshold = 100);
-- 3. 극단적 bloat: pg_repack으로 락 없이 테이블 재구성-- pg_repack --table orders --no-superuser-check -d mydb
-- 4. 장시간 트랜잭션 정리 (원인 제거)-- idle in transaction 상태가 10분 이상이면 자동 종료 설정-- postgresql.conf: idle_in_transaction_session_timeout = '10min'📖 더 보기: Understanding autovacuum in Amazon RDS for PostgreSQL — RDS 환경에서 autovacuum 파라미터 튜닝 가이드 (AWS 공식, 중급)
문제 6: TypeORM synchronize: true로 인한 운영 DB 컬럼 삭제
섹션 제목: “문제 6: TypeORM synchronize: true로 인한 운영 DB 컬럼 삭제”증상:
ERROR: column "user_email" of relation "users" does not exist (서비스 배포 후 기존 기능이 갑자기 500 에러)
-- DB에서 확인:SELECT column_name FROM information_schema.columns WHERE table_name = 'users';-- user_email 컬럼이 없어짐!원인:
TypeORM synchronize: true 설정은 앱 시작 시 엔티티와 DB 스키마를 자동으로 맞춘다. 개발자가 엔티티에서 컬럼을 @Column() 제거 없이 이름을 오타로 수정하거나, 실수로 필드를 삭제하면 TypeORM이 해당 컬럼을 DB에서 삭제(DROP COLUMN)한다. 데이터도 함께 사라진다.
확인 방법:
// 위험한 설정 (개발 환경에서만 사용해야 함)TypeOrmModule.forRoot({ synchronize: true, // ← 절대 프로덕션 RDS에 사용 금지});
// 안전한 프로덕션 설정TypeOrmModule.forRoot({ synchronize: false, // 반드시 false migrationsRun: true, // 시작 시 대기 중인 마이그레이션 자동 실행 migrations: [__dirname + "/migrations/**{.ts,.js}"],});해결 방법:
# 1. 즉시 롤백 — 직전 마이그레이션으로 되돌리기npx typeorm migration:revert -d src/data-source.ts# → migrations 테이블에서 마지막 마이그레이션을 찾아 down() 실행
# 2. 데이터 복구 — RDS 자동 백업에서 PITR# AWS Console → RDS → 해당 인스턴스 → Actions → Restore to point in time# → 컬럼 삭제 직전 시각으로 복원 (RDS 자동 백업 보존 기간 내)
# 3. 재발 방지 — 환경별 설정 분리# .env.development: DB_SYNCHRONIZE=true# .env.production: DB_SYNCHRONIZE=false
# 4. CI/CD에서 마이그레이션 자동 검증# GitHub Actions: typeorm migration:run --check (실행 없이 대기 중인 마이그레이션 확인)예방 규칙:
프로덕션 RDS 사용 체크리스트: ✅ synchronize: false ✅ migrationsRun: true ✅ 마이그레이션 파일 git에 커밋 ✅ 배포 전 staging DB에서 마이그레이션 사전 검증 ❌ 절대 git에 .env 파일 포함 금지문제 7: Aurora Serverless v2 연결 풀 고갈 — “too many clients already”
섹션 제목: “문제 7: Aurora Serverless v2 연결 풀 고갈 — “too many clients already””증상:
error: sorry, too many clients already at Connection.parseErrorMessage (/app/node_modules/pg/lib/connection.js)
-- RDS CloudWatch 메트릭:DatabaseConnections: 현재 450 / 최대 500→ Lambda 함수 인스턴스가 각각 DB 연결을 유지해 급속도로 연결 수 증가원인:
Lambda/Fargate처럼 인스턴스가 자동으로 늘어나는 환경에서 TypeORM(또는 pg 드라이버)은 각 인스턴스마다 독립적인 연결 풀을 생성한다. Lambda 인스턴스가 100개로 스케일 아웃되면 connectionLimit × 100개의 DB 연결이 동시에 생성된다. Aurora Serverless v2의 최대 연결 수는 인스턴스 크기(ACU)에 따라 제한된다.
확인 방법:
-- 현재 DB 연결 수 확인 (Aurora PostgreSQL)SELECT count(*), state, wait_event_type, wait_eventFROM pg_stat_activityGROUP BY state, wait_event_type, wait_eventORDER BY count DESC;
-- 예상 출력 (문제 상황):-- count | state | wait_event_type | wait_event-- 380 | idle | Client | ClientRead ← 대부분 idle 상태로 연결만 점유-- 45 | active | Lock | relation해결 방법:
// 방법 1: RDS Proxy 사용 (AWS 권장 — 서버리스 환경 표준)// RDS Proxy가 연결 풀을 중앙 관리 → DB 연결 수를 1/10 이하로 감소TypeOrmModule.forRoot({ host: process.env.RDS_PROXY_ENDPOINT, // ← RDS Proxy 엔드포인트로 변경 // 나머지 설정 동일 extra: { max: 5, // Proxy 사용 시 인스턴스당 연결 수 최소화 idleTimeoutMillis: 10000, },});
// 방법 2: connectionLimit 축소 (임시 조치)TypeOrmModule.forRoot({ extra: { max: 2, // Lambda 인스턴스당 최대 2개 연결 min: 0, // 유휴 시 연결 0개 유지 (서버리스 특성) idleTimeoutMillis: 5000, // 5초 유휴 시 연결 해제 },});
// 방법 3: pg-pool을 별도로 관리 (고급)// 서버리스 환경에서는 연결을 매 요청마다 생성/해제하는 방식도 고려RDS Proxy 적용 효과:
적용 전: Lambda 인스턴스 100개 × 연결 풀 10개 = DB 연결 1,000개 Aurora max_connections: 500 → 연결 거부 발생
적용 후: Lambda → RDS Proxy (연결 풀 50개 유지) → Aurora Aurora 실제 연결: 50개 → 여유 충분📖 더 보기: Using Amazon RDS Proxy with AWS Lambda — AWS 공식 — Lambda 함수에서 RDS Proxy를 사용해 연결 고갈을 방지하는 설정 가이드 (입문~중급)
7. 체크리스트
섹션 제목: “7. 체크리스트”DB 설계 시
섹션 제목: “DB 설계 시”- 스키마 확정 전 최소 3NF까지 정규화 검토했는가?
- 자주 함께 조회될 데이터끼리 테이블 관계를 올바르게 설계했는가?
- 외래키(FK) 제약을 설정해 참조 무결성을 보장했는가?
- 자주 WHERE 조건에 쓰이는 컬럼에 인덱스를 추가했는가?
정규화/비정규화
섹션 제목: “정규화/비정규화”- 슬로우 쿼리 발생 시
EXPLAIN ANALYZE로 병목을 확인했는가? - 비정규화 적용 전 “읽기 vs 쓰기 비율”을 측정했는가?
- 비정규화된 데이터 동기화 전략(배치/트리거/이벤트)을 정의했는가?
- 집계 테이블에 마지막 갱신 시각을 기록하고 있는가?
트랜잭션
섹션 제목: “트랜잭션”- 현재 사용 중인 격리 수준을 파악하고 있는가?
- 재고 차감, 잔액 변경 등 동시성 충돌이 가능한 쿼리에 락을 적용했는가?
- ORM의 Lazy Loading이 N+1 문제를 유발하지 않는지 확인했는가?
8. 핵심 키워드
섹션 제목: “8. 핵심 키워드”| 키워드 | 설명 |
|---|---|
ACID | Atomicity(원자성), Consistency(일관성), Isolation(격리성), Durability(지속성) |
1NF | 원자값만 허용, 반복 그룹 제거 |
2NF | 부분 종속 제거 (복합 PK의 일부에만 종속된 컬럼 분리) |
3NF | 이행 종속 제거 (비키 → 비키 종속 분리) |
BCNF | 모든 결정자가 후보키 |
Dirty Read | 미커밋 데이터 읽기 |
Non-Repeatable Read | 같은 행 두 번 읽을 때 값 변화 |
Phantom Read | 같은 조건 두 번 조회 시 행 수 변화 |
READ COMMITTED | 커밋된 데이터만 읽음 (Dirty Read 방지) |
REPEATABLE READ | 트랜잭션 내 동일 행 보장 |
SERIALIZABLE | 모든 이상 현상 방지, 최고 격리 수준 |
Denormalization | 성능을 위해 의도적으로 중복 허용 |
Materialized View | 쿼리 결과를 물리적으로 저장한 뷰 |
FOR UPDATE | 비관적 락: 해당 행 읽기 시 배타 락 획득 |
8.5. 추천 리소스
섹션 제목: “8.5. 추천 리소스”📚 추천 리소스
섹션 제목: “📚 추천 리소스”- 📖 Database Normalization 1NF 2NF 3NF 예시 — FreeCodeCamp — 레스토랑 관리 앱을 예시로 각 정규형 변환 과정을 시각적으로 설명 (입문)
- 📖 Amazon DynamoDB 개발자 가이드 - 관계형 DB vs DynamoDB — AWS 환경에서 RDBMS와 DynamoDB를 언제 선택할지 공식 가이드 (입문)
- 📖 Data Normalization in PostgreSQL — CYBERTEC — PostgreSQL 실무자 관점에서 정규화를 언제, 어떻게 적용하는지 실제 쿼리 예시와 함께 설명 (입문~중급)
- 📖 PostgreSQL MVCC Internals: xmin/xmax to Isolation Levels — DEV Community — MVCC 내부 구조(xmin/xmax), dead tuple, VACUUM의 관계를 깊이 있게 분석 (중급)
- 📖 Understanding autovacuum in Amazon RDS for PostgreSQL — AWS Blog — RDS 환경에서 autovacuum 파라미터를 실무에 맞게 튜닝하는 AWS 공식 가이드 (중급)
9. 직접 확인해보기
섹션 제목: “9. 직접 확인해보기”정규화 단계별 확인 (PostgreSQL)
섹션 제목: “정규화 단계별 확인 (PostgreSQL)”-- 1. 1NF 위반 테이블 생성CREATE TABLE orders_bad ( order_id INT PRIMARY KEY, customer VARCHAR(50), products TEXT -- '사과,바나나,포도' 같은 다중값);
INSERT INTO orders_bad VALUES (1, '홍길동', '사과,바나나,포도');INSERT INTO orders_bad VALUES (2, '김철수', '우유');
-- 1NF 준수 테이블CREATE TABLE order_items ( order_id INT, customer VARCHAR(50), product VARCHAR(50), PRIMARY KEY (order_id, product));
INSERT INTO order_items VALUES (1, '홍길동', '사과'), (1, '홍길동', '바나나'), (1, '홍길동', '포도'), (2, '김철수', '우유');
SELECT * FROM order_items;-- 예상 출력:-- order_id | customer | product-- ---------+----------+----------- 1 | 홍길동 | 사과-- 1 | 홍길동 | 바나나-- 1 | 홍길동 | 포도-- 2 | 김철수 | 우유격리 수준 확인 (PostgreSQL)
섹션 제목: “격리 수준 확인 (PostgreSQL)”-- 현재 격리 수준 확인SHOW transaction_isolation;-- 예상 출력: read committed
-- Non-Repeatable Read 재현 (두 개의 세션 필요)-- Session 1:BEGIN;SET TRANSACTION ISOLATION LEVEL READ COMMITTED;SELECT amount FROM accounts WHERE id = 1; -- 100 출력
-- Session 2: (별도 터미널)UPDATE accounts SET amount = 500 WHERE id = 1;COMMIT;
-- Session 1: (다시)SELECT amount FROM accounts WHERE id = 1; -- 500 출력 (값이 바뀜!)COMMIT;
-- REPEATABLE READ로 변경하면 같은 값 유지BEGIN;SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;SELECT amount FROM accounts WHERE id = 1; -- 100
-- Session 2에서 UPDATE + COMMIT 후에도SELECT amount FROM accounts WHERE id = 1; -- 여전히 100 (스냅샷 유지)COMMIT;EXPLAIN으로 JOIN 비용 확인
섹션 제목: “EXPLAIN으로 JOIN 비용 확인”-- JOIN이 많은 쿼리 실행 계획 확인EXPLAIN ANALYZESELECT o.id, c.name, SUM(oi.quantity)FROM orders oJOIN customers c ON o.customer_id = c.idJOIN order_items oi ON o.id = oi.order_idGROUP BY o.id, c.name;
-- 예상 출력 (요약):-- HashAggregate (cost=...)-- -> Hash Join (cost=...)-- -> Hash Join (cost=...)-- -> Seq Scan on orders (actual time=...)-- -> Hash (cost=...)-- -> Seq Scan on customers-- -> Hash (cost=...)-- -> Seq Scan on order_items-- Planning Time: 1.2 ms-- Execution Time: 45.8 ms -- 이 값이 크면 최적화 필요Dead Tuple과 Bloat 확인 (PostgreSQL)
섹션 제목: “Dead Tuple과 Bloat 확인 (PostgreSQL)”-- 1. 테스트 테이블 생성 및 데이터 삽입CREATE TABLE bloat_test (id SERIAL PRIMARY KEY, value TEXT);INSERT INTO bloat_test (value) SELECT 'row-' || generate_series(1, 10000);
-- 2. 대량 UPDATE로 dead tuple 생성UPDATE bloat_test SET value = 'updated-' || id;
-- 3. dead tuple 확인SELECT tablename, n_live_tup, n_dead_tup, ROUND(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 2) AS dead_pctFROM pg_stat_user_tablesWHERE tablename = 'bloat_test';
-- 예상 출력:-- tablename | n_live_tup | n_dead_tup | dead_pct-- -----------+------------+------------+----------- bloat_test | 10000 | 10000 | 50.00
-- 4. VACUUM 실행 후 다시 확인VACUUM ANALYZE bloat_test;
SELECT tablename, n_live_tup, n_dead_tupFROM pg_stat_user_tablesWHERE tablename = 'bloat_test';
-- 예상 출력:-- tablename | n_live_tup | n_dead_tup-- -----------+------------+------------- bloat_test | 10000 | 0
-- 정리DROP TABLE bloat_test;비관적 락 vs 낙관적 락 동작 확인 (PostgreSQL)
섹션 제목: “비관적 락 vs 낙관적 락 동작 확인 (PostgreSQL)”-- 비관적 락(FOR UPDATE) 동작 확인 — 두 세션 필요
-- Session 1:BEGIN;SELECT stock FROM items WHERE id = 1 FOR UPDATE;-- 결과: stock = 10 (행에 배타 락 획득)
-- Session 2: (별도 터미널, Session 1이 COMMIT하기 전)BEGIN;SELECT stock FROM items WHERE id = 1 FOR UPDATE;-- → 대기 상태 (Session 1이 락을 잡고 있으므로 블록됨)
-- Session 1:UPDATE items SET stock = stock - 1 WHERE id = 1;COMMIT;-- → Session 2가 즉시 실행됨, stock = 9 반환
-- Session 2:UPDATE items SET stock = stock - 1 WHERE id = 1;COMMIT;-- → stock = 8 (두 세션이 순차적으로 처리되어 정확한 값)10. 한 줄 요약
섹션 제목: “10. 한 줄 요약”데이터 모델링은 정규화로 일관성을 확보하고, 성능이 필요한 곳에서만 비정규화를 적용하며, 격리 수준을 업무 특성에 맞게 선택하는 균형의 기술이다.