콘텐츠로 이동

DB Modeling

분류: Layer 8 - 데이터베이스 심화

데이터베이스 모델링이란 현실 세계의 데이터를 어떤 구조로 저장할지 설계하는 과정이며, 올바른 모델 선택과 정규화/비정규화 전략이 시스템의 성능·일관성·확장성을 결정한다.


데이터베이스는 애플리케이션의 “기억”이다. 모델링을 잘못하면 다음과 같은 문제가 생긴다.

  • 데이터 중복: 같은 정보가 여러 곳에 저장되어 수정 시 누락이 발생한다.
  • 이상 현상(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'] } 형태로 스토어를 설계할 때 이 원칙이 적용된다.


관계형 DB(RDBMS)는 엑셀 스프레드시트와 같다. 행과 열이 정해진 규격에 맞게 저장되고, 여러 시트(테이블)를 VLOOKUP(JOIN)으로 연결한다. 반면 NoSQL은 메모장 묶음에 가깝다. 형식이 자유롭고, 데이터를 꺼내는 방식도 다양하다.

구분RDBMSNoSQL
스키마고정 (테이블 구조 미리 정의)유연 (스키마리스 또는 동적)
일관성ACID 보장주로 BASE (Basically Available, Soft-state, Eventually consistent)
확장 방식수직 확장 (Scale-up: 더 좋은 서버)수평 확장 (Scale-out: 서버 여러 대)
쿼리 언어SQL각 DB마다 다름
대표 제품PostgreSQL, MySQL, OracleRedis, MongoDB, DynamoDB, Neo4j

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)

정규화는 집 안 물건 정리와 같다. 같은 물건을 여러 방에 두면(중복) 하나를 바꿀 때 다른 방 것도 바꿔야 한다. 정규화는 각 정보를 딱 한 곳에만 두는 규칙이다.

원리: 왜 하는가 — 중복이 만드는 연쇄 문제

섹션 제목: “원리: 왜 하는가 — 중복이 만드는 연쇄 문제”

정규화의 근본 목적은 “하나의 사실은 한 곳에만 저장한다(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_idcustomerproducts
1홍길동사과, 바나나, 포도
2김철수우유

products 컬럼에 여러 값이 쉼표로 묶여 있다. 특정 상품만 검색하거나 수정하기 어렵다.

1NF 적용 후:

order_idcustomerproduct
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_idproductquantitycustomer_namecustomer_address
1사과3홍길동서울시 강남구
1바나나1홍길동서울시 강남구
2우유2김철수부산시 해운대구

customer_namecustomer_addressorder_id에만 종속된다. product와는 무관하다. 이것이 부분 종속이다.

문제: 고객 주소가 바뀌면 해당 고객의 모든 주문 행을 수정해야 한다. 하나라도 빠지면 불일치 발생.

2NF 적용 후:

orders 테이블:

order_idcustomer_id
1101
2102

order_items 테이블:

order_idproductquantity
1사과3
1바나나1
2우유2

customers 테이블:

customer_idcustomer_namecustomer_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_idnamedepartment_iddepartment_namedepartment_location
1홍길동D01개발팀서울
2김철수D01개발팀서울
3이영희D02마케팅팀부산

department_namedepartment_locationemployee_iddepartment_iddepartment_name/location 이행 종속이다.

문제: 개발팀 위치가 부산으로 바뀌면 개발팀 소속 직원 모든 행을 수정해야 한다.

3NF 적용 후:

employees 테이블:

employee_idnamedepartment_id
1홍길동D01
2김철수D01
3이영희D02

departments 테이블:

department_iddepartment_namedepartment_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_location
FROM employees e
JOIN departments d ON e.department_id = d.department_id;
-- 결과:
-- 홍길동 | 개발팀 | 부산
-- 김철수 | 개발팀 | 부산
-- 이영희 | 마케팅팀 | 부산

규칙: 3NF보다 엄격한 형태. 모든 결정자(Determinant)가 후보키(Candidate Key)여야 한다.

3NF를 만족해도 BCNF를 위반하는 경우가 있다. 주로 복합 후보키가 여러 개 있을 때 발생한다.

위반 예시 (학생-과목-교수 배정):

  • 후보키 후보: (학생, 과목), (학생, 교수)
  • 규칙: 교수는 한 과목만 담당하므로 교수 → 과목 관계 성립
학생과목교수
홍길동데이터베이스김교수
김철수데이터베이스김교수
이영희알고리즘박교수

교수 → 과목이 성립하는데, 교수는 후보키가 아니다. BCNF 위반.

BCNF 적용 후:

professor_subject 테이블:

교수과목
김교수데이터베이스
박교수알고리즘

student_professor 테이블:

학생교수
홍길동김교수
김철수김교수
이영희박교수

정규화된 창고는 물건이 정확히 분류되어 있지만, 꺼낼 때마다 여러 창고를 돌아다녀야 한다. 비정규화는 자주 쓰는 물건을 책상 위에 꺼내두는 것이다. 정리는 안 됐지만 꺼내는 속도는 빠르다.

정규화된 스키마는 데이터 일관성은 높지만, 읽기 쿼리에서 다수의 JOIN이 발생한다. 대규모 트래픽 환경에서 JOIN은 CPU와 I/O 비용이 크다. 비정규화는 의도적으로 중복을 허용해 JOIN 없이 데이터를 읽을 수 있게 한다.

  • 읽기 >> 쓰기 패턴: 조회가 압도적으로 많고 수정이 드문 경우
  • 집계 테이블: 매 요청마다 SUM, COUNT를 하는 대신 미리 계산한 결과를 별도 테이블에 저장
  • 리포트/대시보드: OLAP 쿼리처럼 수백만 행을 스캔하는 분석 쿼리
  • 캐시 테이블: 자주 조회되는 복잡한 JOIN 결과를 별도 테이블에 저장

실무 예시: 주문 테이블에 상품명 중복 저장

섹션 제목: “실무 예시: 주문 테이블에 상품명 중복 저장”

정규화된 구조 (JOIN 필요):

-- 주문 내역 조회 시 매번 JOIN
SELECT oi.order_id, oi.quantity, p.product_name, p.price
FROM order_items oi
JOIN products p ON oi.product_id = p.product_id
WHERE 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_price
FROM order_items
WHERE 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 oi
JOIN products p ON oi.product_id = p.product_id
WHERE oi.created_at::date = CURRENT_DATE
GROUP BY oi.product_id, p.product_name;
-- 대시보드 조회 시: 집계 테이블에서 즉시 반환
SELECT product_name, total_revenue
FROM daily_product_sales
WHERE 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 AS
SELECT
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_value
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE 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_summary
WHERE sale_date = CURRENT_DATE - 1
ORDER 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 활용 패턴 포함 (중급)

PostgreSQL MVCC(Multi-Version Concurrency Control), dead tuple, VACUUM 튜닝에 대한 상세 내용은 transaction-basics.md 를 참고하세요.


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):

Terminal window
# 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) 설정 방법 (중급)


  • 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 (성능 희생을 감수하고 정확성 우선)

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; // 가격 변경에 영향받지 않음
}

기준정규화비정규화
목적데이터 일관성읽기 성능
중복최소화의도적 허용
쓰기 비용낮음높음 (여러 곳 동기화)
읽기 비용높음 (JOIN)낮음 (단일 테이블)
적합한 환경OLTP (트랜잭션 처리)OLAP (분석/리포트)
기준SQL JOINNoSQL 문서 내포
방식별도 테이블을 키로 연결하나의 문서 안에 중첩
일관성강함 (FK 제약)약함 (애플리케이션 책임)
읽기느림 (JOIN)빠름 (단일 조회)
유연성낮음 (스키마 고정)높음 (스키마리스)
속성ACID (RDBMS)BASE (NoSQL)
일관성항상 일관결국 일관 (Eventually Consistent)
가용성일관성 우선가용성 우선
파티션제한적자연스러운 분산

문제 1: 과도한 정규화로 인한 JOIN 폭발

섹션 제목: “문제 1: 과도한 정규화로 인한 JOIN 폭발”

증상:

-- 슬로우 쿼리 로그에 지속적으로 등장
[SLOW QUERY] execution time: 8.432s
SELECT o.*, c.name, c.address, p.product_name, p.category, cat.name, sup.supplier_name
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
JOIN categories cat ON p.category_id = cat.id
JOIN suppliers sup ON p.supplier_id = sup.id
WHERE o.status = 'PENDING';

원인:

  • 5~6단계 정규화로 모든 테이블이 분리되어 있어 단일 조회에 다중 JOIN 필요
  • 각 JOIN마다 인덱스 스캔과 해시 조인 비용 발생
  • 대량 데이터 환경(수백만 행)에서 기하급수적 성능 저하

해결 방법:

  1. EXPLAIN ANALYZE로 어떤 JOIN이 병목인지 파악

    EXPLAIN ANALYZE
    SELECT ... (위 쿼리);
    -- Hash Join, Seq Scan이 많으면 인덱스 또는 비정규화 필요
  2. 자주 함께 조회되는 데이터를 비정규화하여 별도 뷰(View) 또는 테이블로 관리

  3. Materialized View 활용 (PostgreSQL):

    CREATE MATERIALIZED VIEW order_summary AS
    SELECT 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) 업데이트 로직이 누락됨

해결 방법:

  1. 집계 테이블에 last_synced_at 컬럼 추가 → UI에 “마지막 업데이트 시각” 표시
  2. 중요한 집계는 캐시 테이블 + Redis 이중 저장으로 정합성 체크
  3. 비정규화 컬럼 업데이트는 트랜잭션 내에서 원본과 함께 처리:
    BEGIN;
    UPDATE products SET product_name = '신상품명' WHERE id = 1;
    -- 비정규화된 컬럼도 동시 업데이트 (미래 주문 기준)
    -- 과거 주문은 의도적으로 유지 (스냅샷 정책)
    COMMIT;

문제 3: Phantom Read로 인한 재고 이중 차감

섹션 제목: “문제 3: Phantom Read로 인한 재고 이중 차감”

증상:

-- 동시에 2명이 마지막 재고 1개를 주문
-- 두 트랜잭션 모두 재고 1개 확인 후 차감 → 재고가 -1이 됨
stock: -1 -- 불가능해야 할 값

원인:

-- 두 트랜잭션이 동시에 실행됨
-- Tx1
SELECT stock FROM items WHERE id=1; -- 1 확인
-- Tx2
SELECT stock FROM items WHERE id=1; -- 동시에 1 확인
-- Tx1
UPDATE items SET stock = stock - 1 WHERE id=1; -- stock=0
-- Tx2
UPDATE items SET stock = stock - 1 WHERE id=1; -- stock=-1

해결 방법:

  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;
  2. 낙관적 락(Optimistic Lock) 사용 (TypeORM):
    @VersionColumn()
    version: number;
    // 버전이 다르면 업데이트 실패 → 재시도 로직
  3. 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로 직접 JOIN
const 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_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY dead_pct DESC;
-- VACUUM을 블록하는 장시간 트랜잭션 확인
SELECT pid, now() - xact_start AS duration, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND xact_start < now() - interval '10 minutes'
ORDER BY duration DESC;

해결 방법:

-- 1. 즉시 조치: 해당 테이블 수동 VACUUM
VACUUM 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}"],
});

해결 방법:

Terminal window
# 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_event
FROM pg_stat_activity
GROUP BY state, wait_event_type, wait_event
ORDER 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를 사용해 연결 고갈을 방지하는 설정 가이드 (입문~중급)


  • 스키마 확정 전 최소 3NF까지 정규화 검토했는가?
  • 자주 함께 조회될 데이터끼리 테이블 관계를 올바르게 설계했는가?
  • 외래키(FK) 제약을 설정해 참조 무결성을 보장했는가?
  • 자주 WHERE 조건에 쓰이는 컬럼에 인덱스를 추가했는가?
  • 슬로우 쿼리 발생 시 EXPLAIN ANALYZE로 병목을 확인했는가?
  • 비정규화 적용 전 “읽기 vs 쓰기 비율”을 측정했는가?
  • 비정규화된 데이터 동기화 전략(배치/트리거/이벤트)을 정의했는가?
  • 집계 테이블에 마지막 갱신 시각을 기록하고 있는가?
  • 현재 사용 중인 격리 수준을 파악하고 있는가?
  • 재고 차감, 잔액 변경 등 동시성 충돌이 가능한 쿼리에 락을 적용했는가?
  • ORM의 Lazy Loading이 N+1 문제를 유발하지 않는지 확인했는가?

키워드설명
ACIDAtomicity(원자성), 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비관적 락: 해당 행 읽기 시 배타 락 획득


-- 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 | 김철수 | 우유
-- 현재 격리 수준 확인
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;
-- JOIN이 많은 쿼리 실행 계획 확인
EXPLAIN ANALYZE
SELECT o.id, c.name, SUM(oi.quantity)
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON o.id = oi.order_id
GROUP 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 -- 이 값이 크면 최적화 필요
-- 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_pct
FROM pg_stat_user_tables
WHERE 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_tup
FROM pg_stat_user_tables
WHERE 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 (두 세션이 순차적으로 처리되어 정확한 값)

데이터 모델링은 정규화로 일관성을 확보하고, 성능이 필요한 곳에서만 비정규화를 적용하며, 격리 수준을 업무 특성에 맞게 선택하는 균형의 기술이다.