엔진과 무관한 관계형 설계를 다룰 때 사용한다 — 스키마 정규화·키·NULL 의미·DB 제약·소프트 삭제·인덱스 선택 원칙·쿼리 규칙(N+1·플레이스홀더·커서 페이징)·트랜잭션 경계·마이그레이션 단계. PostgreSQL 고유 주제는 yd-pg-expert, Oracle 고유 주제는 yd-oracle-expert, SQLite 고유 주제는 yd-sqlite-expert 를 쓴다.
Installs into .claude/skills of the current project.
Are you the author of Yd Db Expert?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/leeyudok-yd-db-expert)
---
name: yd-db-expert
description: 엔진과 무관한 관계형 설계를 다룰 때 사용한다 — 스키마 정규화·키·NULL 의미·DB 제약·소프트 삭제·인덱스 선택 원칙·쿼리 규칙(N+1·플레이스홀더·커서 페이징)·트랜잭션 경계·마이그레이션 단계. PostgreSQL 고유 주제는 yd-pg-expert, Oracle 고유 주제는 yd-oracle-expert, SQLite 고유 주제는 yd-sqlite-expert 를 쓴다.
---
# yd-db-expert
엔진과 무관한 관계형 설계가 대상이다. 잠금·테이블 재작성·실행계획 읽는 법·타입 세부처럼
엔진마다 다른 동작은 엔진 스킬이 맡는다 — PostgreSQL 은 `yd-pg-expert`, Oracle 은
`yd-oracle-expert`, SQLite 파일은 `yd-sqlite-expert`. 애플리케이션 코드는 각 언어 스킬이 맡는다.
아래 단언 가운데 SQL 의미론에 관한 것은 `scripts/verify_db_claims.py` 가 SQLite 로
재현한다(8장). 같은 의미론은 표준 SQL 이라 PostgreSQL·Oracle 에서도 같다. 엔진마다 갈리는
지점은 본문에 적었다.
## 1. 스키마 설계 — 판단 기준
정규화는 목적이 아니라 **이상현상(anomaly)을 없애는 수단**이다. 3NF 를 기본으로 두고,
역정규화는 **측정된 병목**이 있을 때만, 그리고 **갱신 경로를 하나로 유지**할 수 있을 때만.
설계하기 전에 스스로 답한다:
1. **이 테이블의 한 행은 무엇 하나인가** — 한 문장으로 안 되면 쪼갤 신호다.
2. **자연키인가 대리키인가** — 사업자번호·사번처럼 외부가 소유한 값은 바뀐다.
대리키(식별자)를 두고 자연키에는 유니크 제약을 건다.
3. **이 컬럼이 NULL 일 수 있는 실제 상황은 무엇인가** — 답이 없으면 `NOT NULL`.
NULL 은 "모름"이지 "없음"이나 "0"이 아니다.
4. **삭제하면 무엇이 같이 사라져야 하는가** — FK 의 `ON DELETE` 를 의도적으로 정한다.
기본값에 맡기지 않는다.
### 제약은 애플리케이션이 아니라 DB 에 건다
`NOT NULL`·`UNIQUE`·`CHECK`·`FOREIGN KEY` 는 마지막 방어선이다. 애플리케이션 검증은
사용자 경험용이고, 데이터 무결성은 DB 가 보장한다. **버그·수동 작업·다른 클라이언트는
애플리케이션을 우회한다.** 제약이 없는 테이블에 수동 SQL 세 줄을 넣으면 NULL·중복·음수 잔액이
그대로 들어가고, 제약이 있는 테이블은 셋 다 거부한다.
### NULL 의 의미론
- **`NULL = NULL` 은 참이 아니라 NULL 이다.** `WHERE col = NULL` 은 아무 행도 고르지 못한다.
`IS NULL` 을 쓴다. `col <> 'x'` 도 NULL 인 행을 고르지 않는다.
- **`NOT IN` 서브쿼리에 NULL 이 하나라도 있으면 결과가 통째로 빈다.** 오류도 경고도 없다.
서브쿼리 컬럼이 NULL 을 허용하면 `NOT EXISTS` 를 쓴다.
- **`UNIQUE` 는 NULL 끼리 같다고 보지 않는다.** NULL 행이 여러 개여도 통과한다. "값이 하나뿐"
이 규칙이면 `NOT NULL` 을 함께 건다. NULL 도 하나만 허용하는 방법은 엔진마다 다르다.
### 시간과 통화
- 시각은 타임존이 붙은 형식으로 저장한다. 저장은 UTC, 표시에서 변환한다. 엔진별 타입 선택은
엔진 스킬이 맡는다. 사용자 표기는 `YYYY-MM-DD HH:MM:SS.mmm` (KST 가정).
- 돈은 고정소수점(`numeric`/`decimal`)이나 최소 단위 정수(원·센트)로 저장한다. 부동소수점 금지.
### 소프트 삭제
`deleted_at` 을 도입하면 **모든 조회에 조건이 붙는다.** 빠뜨린 한 곳이 사고가 된다.
정말 필요하면 뷰나 행 수준 보안으로 강제하고, 아니면 이력 테이블로 옮기는 편이 낫다.
**일반 `UNIQUE` 는 탈퇴한 이메일의 재가입을 막는다.** 삭제 표시만 한 행이 값을 쥐고 있어서다.
살아 있는 행에만 거는 부분 유니크 인덱스(`WHERE deleted_at IS NULL`)는 재가입을 허용하고
활성 중복은 막는다. 부분 인덱스가 없는 엔진은 엔진 스킬에서 대안을 찾는다.
## 2. 인덱스
- **WHERE·JOIN·ORDER BY 에 쓰이는 컬럼**이 후보다. 전부 만들지 않는다 — 인덱스는
쓰기 비용과 저장공간을 먹는다.
- 복합 인덱스는 앞 컬럼부터 쓰인다. 등호 조건 컬럼을 앞에, 범위 조건 컬럼을 뒤에 둔다.
- **FK 를 선언해도 자식 컬럼 인덱스는 생기지 않는다** (PostgreSQL·SQLite 실측). 부모를
지우거나 부모 기준으로 자식을 찾을 때 자식 테이블 전체를 훑으므로 직접 만든다.
- **함수를 씌운 조건은 컬럼 인덱스를 못 탄다.** `WHERE lower(name) = ...` 는 `name` 인덱스를
쓰지 않는다. 쿼리를 바꾸거나 표현식 인덱스를 만든다.
- 인덱스를 추가하기 전에 **쿼리를 고칠 수 있는지** 먼저 본다. 판단은 추측이 아니라 실행계획으로
하고, 실행계획 읽는 법은 엔진 스킬을 따른다.
## 3. 쿼리
- `SELECT *` 를 애플리케이션 쿼리에 쓰지 않는다. 컬럼이 늘면 전송량이 늘고,
의도치 않은 필드가 새어나간다.
- **N+1 을 의심한다.** 고객 50명의 주문 수를 건마다 세면 문이 51개 나간다. `IN` 한 번이면
2개로 끝나고 결과는 같다. 목록을 돌며 조회하는 코드는 조인이나 `IN` 으로 바꾼다.
- **오프셋 페이징은 페이지 사이에 행이 들어오면 같은 행을 두 번 보여준다.** 큰 오프셋에서는
느리기도 하다. 정렬 키 기준 커서(`WHERE id < ?`)를 쓴다.
- **커서의 정렬 키가 유일하지 않으면 동률 행을 건너뛴다.** 시각처럼 겹칠 수 있는 키는
`(at, id) > (?, ?)` 처럼 유일한 키를 덧붙여 비교한다.
- 문자열 조립 금지. **값은 언제나 플레이스홀더.** 이어 붙인 `x' OR '1'='1` 은 전 행을 돌려주고,
`O'Brien` 같은 정상 입력도 문법 오류를 낸다.
- **플레이스홀더는 값 자리에만 쓸 수 있다.** 테이블·컬럼 이름 자리에 넣으면 문법 오류다.
식별자를 동적으로 넣어야 하면 화이트리스트로 검증하고 인용한다.
## 4. 트랜잭션
- **경계를 명시적으로 정한다.** "이 작업들이 전부 되거나 전부 안 돼야 한다"가 기준이다.
이체의 입금과 출금을 따로 커밋하면 출금이 실패할 때 입금만 남아 돈이 생긴다.
- 트랜잭션 안에서 **외부 호출(HTTP·메일)을 하지 않는다.** 락을 잡은 채 네트워크를 기다린다.
- 격리수준은 기본으로 두고, 필요한 경우에만 올린다. 올리면 직렬화 실패 재시도가 짝이다.
엔진마다 기본값과 오류 코드가 다르므로 엔진 스킬을 따른다.
- 락 순서를 일정하게 유지해 교착을 피한다.
- 긴 트랜잭션을 만들지 않는다. 배치는 잘라서 커밋한다. 엔진마다 대가는 다르다 — 정리 작업
지연, 잠금 대기, 언두 공간 부족.
## 5. 마이그레이션
- **되돌릴 수 있게** 쓴다. 되돌릴 수 없으면(데이터 삭제) PR 본문에 명시한다.
- 운영 중 스키마 변경은 **잠금 시간**이 관건이다. 어떤 변경이 테이블을 다시 쓰고 무엇을
막는지는 엔진과 버전마다 다르므로 엔진 스킬의 표로 확인한다. 기억으로 판단하지 않는다.
- 큰 테이블은 단계를 나눈다: 컬럼 추가 → 백필(배치) → 제약 추가 → 구 컬럼 제거.
각 단계가 따로 배포되어도 애플리케이션이 동작해야 한다.
- 적용 전 **백업 또는 되돌릴 계획**을 확인한다.
## 6. 하지 않는 것
- 특정 엔진의 운영(접속·커넥션 상한·진단 쿼리)은 다루지 않는다. doksam pig 공유 PostgreSQL
클러스터 운영은 `yd-pg-expert` 가 맡는다.
- 엔진별 타입·잠금·실행계획 세부를 여기에 적지 않는다. 적으면 엔진 스킬과 어긋난다.
## 7. 완료 조건
- 새 테이블·컬럼에 적절한 제약(`NOT NULL`·FK·`UNIQUE`)이 있고, NULL 허용은 근거가 있다.
- NULL 을 허용하는 컬럼을 `NOT IN`·`=` 로 비교하는 곳이 없다.
- FK 컬럼과 조회 조건에 인덱스가 있고, 느린 쿼리는 엔진 스킬대로 실행계획을 확인했다.
- 목록 조회에 N+1 이 없고, 페이징은 유일한 키 기준 커서다.
- 마이그레이션이 되돌릴 수 있거나, 불가능함을 명시했다. 잠금 영향은 엔진 스킬로 확인했다.
- 시크릿이 출력·로그·이슈에 노출되지 않았다.
## 8. 이 문서의 단언 재현
```bash
python3 skills/yd-db-expert/scripts/verify_db_claims.py
```
NULL 비교, `NOT IN`, `UNIQUE` 와 NULL, DB 제약, FK 인덱스, 함수 조건, N+1, 오프셋·커서
페이징, 원자성, 플레이스홀더, 소프트 삭제 유니크를 stdlib `sqlite3` 로 재현한다. 하나라도
문서와 다르면 exit 1 이다. 단언과 테스트의 대응은 `tests/claims.json` 이 갖는다.