『SQL 코딩의 기술』을 읽으며 기본키와 정규화를 정리한 메모를 다시 검토했다. 당시에는 “모든 테이블에는 기본키가 있어야 한다”, “복합키는 느리다”, “대리키를 쓰면 조인이 줄어든다”처럼 결론을 너무 단순하게 적었다.
실제 설계에서는 키의 종류와 DBMS 동작을 나눠 봐야 한다. 핵심은 행을 안정적으로 식별하고, 업무 규칙의 유일성을 별도로 보존하며, 함수 종속성 때문에 생기는 변경 이상을 줄이는 것이다.
후보키와 기본키부터 구분한다
후보키(candidate key)는 한 행을 유일하게 식별하는 최소 속성 집합이다. 후보키가 여러 개라면 그중 하나를 기본키(primary key)로 선택하고, 나머지는 대체키(alternate key)로 볼 수 있다.
SQL의 기본키 제약 조건은 다음 두 성질을 함께 보장한다.
- 값 또는 값의 조합이 유일하다.
- 기본키에 포함된 열은 NULL일 수 없다.
테이블에 기본키가 없으면 문법 오류가 난다는 뜻은 아니다. PostgreSQL을 포함한 DBMS는 기본키 없는 테이블도 허용한다. 외래키도 DBMS가 허용하는 고유 제약이나 고유 인덱스를 참조할 수 있다.
그렇더라도 일반적인 운영 테이블에는 안정적인 행 식별자가 있는 편이 좋다. 수정·삭제 대상을 특정하고, 중복 행을 방지하며, 다른 테이블에서 참조하기 쉬워지기 때문이다. 로그 적재나 임시 staging 테이블처럼 수명과 용도가 다른 테이블은 예외가 될 수 있다.
자연키와 대리키는 어느 쪽이 항상 우월하지 않다
자연키(natural key)는 이메일, 국가 코드처럼 업무 영역에서 이미 의미가 있는 값이다. 대리키(surrogate key)는 customer_id 같은 인위적인 식별자다.
대리키의 장점은 참조 열을 짧고 안정적으로 만들기 쉽다는 데 있다. 그러나 대리키를 추가했다고 업무상 중복이 자동으로 막히지는 않는다.
CREATE TABLE customers (
customer_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL,
CONSTRAINT uq_customers_email UNIQUE (email)
);
customer_id는 행을 참조하기 편하게 하고, UNIQUE(email)은 같은 이메일을 두 번 등록할 수 없다는 업무 규칙을 지킨다. 대리키만 만들고 자연 후보키의 고유 제약을 빼면, 기술적으로 다른 ID를 가진 중복 고객이 생길 수 있다.
대리키도 조인을 없애 주지 않는다. 고객 정보를 주문과 함께 읽으려면 여전히 customer_id로 조인한다. 다만 넓거나 자주 바뀌는 자연키를 여러 테이블에 반복 저장하지 않도록 해 참조 구조를 단순하게 만들 수 있다.
자연키가 짧고 안정적이며 규칙이 분명하다면 그대로 기본키로 쓸 수도 있다. 값이 바뀔 가능성, 개인정보 노출, 크기, 여러 시스템 사이의 의미가 애매하다면 대리키와 고유 제약을 함께 고려한다.
복합키는 연결 테이블에서 자연스러운 선택이다
복합키가 단일 열 키보다 무조건 느린 것은 아니다. 주문과 상품의 다대다 관계를 나타내는 연결 테이블에서는 두 외래키의 조합 자체가 행의 정체성이 될 수 있다.
CREATE TABLE order_items (
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INTEGER NOT NULL,
PRIMARY KEY (order_id, product_id),
FOREIGN KEY (order_id) REFERENCES orders(order_id),
FOREIGN KEY (product_id) REFERENCES products(product_id)
);
이 설계는 같은 주문에 같은 상품 행이 중복되는 것을 막는다. 별도 order_item_id를 두더라도 업무 규칙이 같다면 (order_id, product_id)에 UNIQUE가 필요하다.
복합키의 실제 비용은 열의 폭, 인덱스 구조, 참조하는 외래키의 폭, 주로 사용하는 조회 조건에 따라 달라진다. 인덱스 선두 열 규칙도 DBMS와 쿼리 모양에 맞춰 봐야 한다. “열이 두 개라서 느리다”가 아니라 저장 공간과 조인·조회 패턴을 측정해 선택해야 한다.
외래키와 인덱스는 DBMS마다 함께 움직이지 않는다
외래키는 자식 행이 존재하지 않는 부모를 참조하지 않도록 관계 무결성을 지킨다. 하지만 외래키를 선언했을 때 자식 쪽 인덱스까지 자동으로 생기는지는 DBMS마다 다르다.
- PostgreSQL은 참조되는 기본키·고유키에는 인덱스가 있지만, 참조하는 자식 열에는 외래키 선언만으로 인덱스를 자동 생성하지 않는다.
- MySQL InnoDB는 외래키 검사에 필요한 자식 쪽 인덱스가 없으면 자동으로 만든다.
따라서 “외래키를 만들면 인덱스도 생긴다”는 문장을 제품 구분 없이 쓰면 틀린다. PostgreSQL에서는 부모 행 삭제·키 변경 시 자식 행을 찾는 쿼리와 평소 조인 패턴을 보고 자식 외래키 인덱스를 따로 설계하는 경우가 많다.
정규화는 중복을 전부 없애는 작업이 아니다
정규화는 함수 종속성을 기준으로 데이터를 나눠 삽입·수정·삭제 이상을 줄이는 과정이다. 같은 값이 여러 행에 보인다는 이유만으로 무조건 분리하는 작업은 아니다.
학습 단계에서는 다음처럼 기억할 수 있다. 엄밀한 정의는 후보키와 prime attribute까지 포함해 더 세밀하다.
- 1NF: 반복 그룹을 분리하고 각 행·열 교차점에 관계의 도메인에 맞는 한 값을 둔다.
- 2NF: 복합 후보키의 일부에만 의존하는 비키 속성을 분리한다.
- 3NF: 비키 속성이 키가 아닌 다른 비키 속성에 의존해 사실을 중복 저장하는 구조를 줄인다.
예를 들어 주문 행마다 고객 이름과 주소를 반복 저장하면 고객 주소 변경 시 여러 주문 행을 동시에 수정해야 한다. 고객 사실은 customers, 주문 사실은 orders, 상품별 주문 사실은 order_items로 나누면 각 사실의 주인이 분명해진다.
CREATE TABLE orders (
order_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(customer_id),
ordered_at TIMESTAMP NOT NULL
);
과거 주문 당시의 배송 주소처럼 시점의 사실을 보존해야 하는 값은 현재 고객 주소와 별도로 주문에 저장하는 것이 맞을 수 있다. 겉으로 중복처럼 보여도 의미가 다른 데이터다. 정규화는 업무 의미를 지우는 규칙이 아니다.
비정규화는 측정 뒤에 일관성 비용까지 설계한다
조회 성능이나 분석 편의를 위해 요약 값과 중복 열을 둘 수 있다. 그 결정은 “조인이 느릴 것 같다”는 추측보다 실제 실행 계획과 부하 측정 뒤에 해야 한다.
비정규화했다면 함께 답해야 할 질문이 있다.
- 원본 데이터의 주인은 어느 테이블인가?
- 중복 값을 트랜잭션, trigger, batch, CDC 중 무엇으로 갱신하는가?
- 갱신 실패와 지연을 어떻게 감지하고 복구하는가?
- 값이 어긋났을 때 어느 쪽을 기준으로 다시 만드는가?
키 선택과 정규화의 목적은 정답 모양을 만드는 데 있지 않다. 데이터가 바뀔 때 모순을 막고, 쿼리와 운영 비용을 설명 가능한 상태로 만드는 데 있다. 인덱스 열 순서와 조회 조건은 복합 인덱스 설계 기준에서 이어서 볼 수 있다.
참고 자료
'배움과 성장 > 백엔드·데이터' 카테고리의 다른 글
| SQL 학습 메모: NULL 안전한 안티 조인과 날짜 범위 조건 (0) | 2022.07.14 |
|---|---|
| Django QuerySet 학습노트: Lazy Evaluation·조회·Slicing·F Expression (0) | 2022.07.05 |
| Spring MVC HTTP 요청·응답 학습노트: Query·Form·JSON과 Servlet API (0) | 2022.05.10 |
| MySQL 8.4 쿼리 문법: ALL·INSERT SELECT·CASE·NULL 처리 (0) | 2022.05.10 |
| MySQL 조회 조건과 집합 결합: LIKE·JOIN·GROUP BY·EXISTS·ANY (0) | 2022.05.07 |
댓글