자연 키와 인조 키의 장단점
자연 키와 인조 키의 장단점
변하지 않고 짧으며 확실히 유일한 자연 키라면 기본 키 후보가 된다. 의미가 바뀌거나 여러 컬럼이 필요한 경우 인조 키와 별도 unique constraint 조합이 실용적이다.
목차
- #문제가 되는 상황
- #자연 키와 인조 키를 구분하는 기준
- #자연 키가 좋은 후보가 되는 조건
- #이메일이 기본 키로 불편한 이유
- #인조 키를 추가해도 업무 중복은 남는다
- #유일성의 범위는 tenant와 시간을 포함한다
- #복합 자연 키와 참조 비용
- #외부 시스템 식별자를 다루는 방법
- #동시 생성과 upsert
- #선택 시나리오 비교
- #실전 점검 목록
- #결론
- #관련 노트
문제가 되는 상황
상품 테이블에 숫자 id를 추가하면 기본 키 선택은 끝난 것처럼 보인다. 하지만 같은 판매처에서 같은 SKU를 두 번 등록할 수 있다면 업무 중복은 그대로 남는다. 반대로 SKU를 기본 키로 사용하면 판매처가 코드 체계를 바꾸거나 여러 지역에서 같은 코드를 사용할 때 모든 참조가 흔들린다.
자연 키와 인조 키는 서로 하나만 택하면 다른 하나가 사라지는 관계가 아니다. 인조 키가 행의 안정적인 identity를 담당하고, 자연 키에 해당하는 컬럼 조합이 업무 유일성을 보장하는 구성이 흔하다. 중요한 것은 각 키가 어떤 규칙을 책임지는지 구분하는 것이다.
상품·고객·외부 공급자 schema는 키 선택을 설명하기 위한 가상 예제다. 실제 서비스의 코드 체계나 고객 데이터를 사용하지 않았다.
자연 키와 인조 키를 구분하는 기준
자연 키는 도메인에 이미 존재하는 속성으로 행을 식별한다. 주민번호, 국가 코드, 공급자 상품 코드 조합 등이 후보가 될 수 있다. 인조 키는 업무 의미와 별개로 시스템이 생성한 sequence, UUID 같은 값이다.
-- 자연 키 후보를 기본 키로 사용한 예
CREATE TABLE countries (
code CHAR(2) PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
-- 인조 키와 업무 unique key를 함께 사용한 예
CREATE TABLE products (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
merchant_id BIGINT UNSIGNED NOT NULL,
sku VARCHAR(80) NOT NULL,
name VARCHAR(200) NOT NULL,
UNIQUE KEY uq_products_merchant_sku (merchant_id, sku)
);
두 번째 테이블에서 id는 상품 행의 identity이고 (merchant_id, sku)는 같은 판매처 안에 동일 SKU 상품이 하나만 있다는 업무 규칙이다.
자연 키가 좋은 후보가 되는 조건
자연 키를 기본 키로 사용하려면 다음 질문에 모두 자신 있게 답할 수 있어야 한다.
- 업무 규칙상 항상 존재하는가?
- 현재뿐 아니라 미래에도 유일한가?
- 행 생명주기 동안 바뀌지 않는가?
- 짧고 비교하기 쉬운가?
- 개인정보나 내부 의미를 과도하게 노출하지 않는가?
- 다른 시스템과 동일한 정규화 규칙을 적용할 수 있는가?
국가 코드처럼 짧고 표준화되어 있으며 참조 의미가 분명한 값은 자연 키 후보가 될 수 있다. 다만 표준도 변경·폐기될 수 있으므로 “공식 코드니까 영원히 불변”이라고 단정하지 않는다.
CREATE TABLE tax_rates (
country_code CHAR(2) NOT NULL,
category_code VARCHAR(20) NOT NULL,
rate_basis_points INT NOT NULL,
PRIMARY KEY (country_code, category_code),
FOREIGN KEY (country_code) REFERENCES countries(code)
);
이 복합 키는 국가와 과세 유형 조합 자체가 행 identity라면 자연스럽다. 반대로 세율 기록의 이력과 승인 상태가 생기면 별도 ID와 유효 기간이 필요한 entity로 바뀔 수 있다.
이메일이 기본 키로 불편한 이유
이메일은 가입 시 유일하게 보이지만 다음 문제가 있다.
- 사용자가 주소를 변경할 수 있다.
- 대소문자와 Unicode·도메인 정규화 규칙이 복잡하다.
- 여러 계정에서 재사용을 허용할지 제품 정책이 바뀔 수 있다.
- 외래 키와 로그에 개인정보가 반복된다.
- 삭제 후 같은 이메일로 재가입할 때 identity가 섞일 수 있다.
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email_original VARCHAR(255) NOT NULL,
email_normalized VARCHAR(255) NOT NULL,
UNIQUE KEY uq_users_email_normalized (email_normalized)
);
참조 테이블은 작은 user_id를 사용하고 이메일 변경은 users 행 한 곳에서 처리한다.
CREATE TABLE user_sessions (
id BINARY(16) PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
expires_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
이메일 unique 규칙도 단순하지 않다. soft delete 사용자와 재가입을 어떻게 처리할지, 조직별로 같은 이메일을 허용할지, 인증 전 pending 계정까지 유일성에 포함할지 정해야 한다.
인조 키를 추가해도 업무 중복은 남는다
다음 테이블은 DB 관점에서 모든 행이 다른 ID를 가지지만 업무상 같은 SKU가 중복될 수 있다.
CREATE TABLE products (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
merchant_id BIGINT UNSIGNED NOT NULL,
sku VARCHAR(80) NOT NULL
);
id=101, merchant=7, sku=BOOK-001
id=102, merchant=7, sku=BOOK-001
애플리케이션에서 먼저 조회한 뒤 insert하는 코드는 동시 요청에 취약하다.
// 경쟁 조건이 있는 예
const found = await productRepository.findByMerchantAndSku(merchantId, sku);
if (!found) {
await productRepository.insert({ merchantId, sku });
}
두 transaction이 동시에 found = null을 보고 insert할 수 있다. DB unique constraint를 최종 방어선으로 두고 충돌을 도메인 오류로 변환한다.
ALTER TABLE products
ADD CONSTRAINT uq_products_merchant_sku UNIQUE (merchant_id, sku);
인조 키는 identity를 단순하게 만들지만 후보 키의 업무 규칙을 삭제하지 않는다.
유일성의 범위는 tenant와 시간을 포함한다
“SKU는 유일하다”보다 “한 merchant의 활성 상품 중 SKU가 유일하다”가 더 정확한 규칙일 수 있다.
scope: merchant_id
value: normalized_sku
time: active period
tenant가 있는 시스템에서 UNIQUE (sku)만 두면 서로 다른 판매처가 같은 코드를 사용할 수 없다. 반대로 tenant ID를 빠뜨리면 조회와 unique 모두 데이터 격리를 잘못 표현한다.
시간 범위가 들어가면 단순 unique index만으로 표현하기 어려울 수 있다. 삭제된 상품의 SKU 재사용을 허용한다면 다음 선택을 검토한다.
- hard delete 후 unique key 재사용
- 활성 행만 대상으로 하는 partial/filtered unique index
active_keygenerated column 또는 상태를 포함한 제약- SKU를 재사용하지 않고 새 버전·별도 entity로 모델링
DB가 지원하는 제약과 NULL semantics가 다르므로 실제 엔진에서 검증한다. soft delete를 이유로 (merchant_id, sku, deleted_at) unique를 추가하면 여러 NULL 처리 방식 때문에 기대와 다르게 중복을 허용할 수 있다.
복합 자연 키와 참조 비용
주문 항목을 (order_id, line_number)로 식별하면 의미가 분명하다.
CREATE TABLE order_items (
order_id BIGINT UNSIGNED NOT NULL,
line_number SMALLINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
quantity INT UNSIGNED NOT NULL,
PRIMARY KEY (order_id, line_number)
);
이 관계를 다른 테이블이 참조하면 외래 키도 두 컬럼이 된다.
CREATE TABLE shipment_items (
shipment_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
line_number SMALLINT UNSIGNED NOT NULL,
FOREIGN KEY (order_id, line_number)
REFERENCES order_items(order_id, line_number)
);
복합 키가 두세 단계 전파되면 query와 ORM mapping이 복잡해진다. order item 자체를 여러 context에서 자주 참조한다면 order_item_id를 추가하되 (order_id, line_number) unique는 유지하는 조합이 실용적일 수 있다.
외부 시스템 식별자를 다루는 방법
외부 공급자의 customer ID를 우리 테이블 기본 키로 사용하면 공급자 추가와 ID 재발급에 묶인다. 내부 ID와 공급자 mapping을 분리한다.
CREATE TABLE customers (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
display_name VARCHAR(100) NOT NULL
);
CREATE TABLE customer_external_ids (
customer_id BIGINT UNSIGNED NOT NULL,
provider VARCHAR(40) NOT NULL,
external_id VARCHAR(120) NOT NULL,
PRIMARY KEY (provider, external_id),
UNIQUE KEY uq_customer_provider (customer_id, provider),
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
한 고객이 여러 provider ID를 가질 수 있고 공급자를 교체해도 내부 참조는 유지된다. 외부 ID의 대소문자와 공백 정규화는 provider 계약에 맞춰야 한다. 모든 공급자 ID를 같은 규칙으로 lower-case 처리하면 case-sensitive ID가 충돌할 수 있다.
동시 생성과 upsert
외부 event 두 개가 같은 고객 mapping을 동시에 만들 수 있다. unique constraint 위에서 idempotent한 생성 흐름을 만든다.
async function findOrCreateCustomer(provider: string, externalId: string) {
try {
return await database.transaction(async (tx) => {
const customer = await tx.customers.insert({ displayName: "Unknown" });
await tx.externalIds.insert({ customerId: customer.id, provider, externalId });
return customer;
});
} catch (error) {
if (!isUniqueViolation(error)) throw error;
return customerRepository.findByExternalId(provider, externalId);
}
}
DB의 UPSERT를 사용할 수도 있지만 충돌 시 어떤 컬럼을 update하는지 명확히 한다. 공격자가 보낸 새 이름으로 기존 고객 정보를 덮어쓰지 않도록, identity 결합과 속성 갱신의 권한을 분리한다.
선택 시나리오 비교
| 상황 | 기본 키 후보 | 별도 업무 제약 |
|---|---|---|
| 국가 기준 테이블 | country_code 자연 키 가능 |
이름 중복은 별도 정책 |
| 사용자 | 인조 user_id |
normalized email unique |
| 판매처 상품 | 인조 product_id |
(merchant_id, sku) unique |
| 순수 join table | 두 FK의 복합 자연 키 | 필요 시 관계 ID 추가 |
| 외부 결제 event | 내부 ID 또는 provider 복합 키 | (provider, event_id) unique |
| 변경 이력 row | 인조 version ID | entity와 effective time 제약 |
자연 키가 기본 키가 아니더라도 후보 키로서 unique constraint와 조회 API에 남는다. 인조 키를 선택하는 이유는 업무 규칙을 버리기 위해서가 아니라 identity의 안정성과 참조 비용을 분리하기 위해서다.
실전 점검 목록
- 자연 키 후보가 실제로 불변·필수·유일한가?
- 유일성 scope에 tenant와 활성 기간이 포함되어 있는가?
- 인조 키 외에 업무 unique constraint가 남아 있는가?
- 값 정규화 규칙이 DB index와 애플리케이션에서 같은가?
- 복합 키가 여러 참조 테이블에 전파되는 비용을 확인했는가?
- 외부 공급자 ID를 내부 identity와 분리했는가?
- 동시 생성 race를 unique constraint와 transaction으로 처리하는가?
변하지 않고 짧으며 확실히 유일한 자연 키라면 기본 키 후보가 된다. 의미가 바뀌거나 여러 컬럼이 필요한 경우 인조 키와 별도 unique constraint 조합이 실용적이다.
결론
자연 키는 도메인의 후보 키이고 인조 키는 시스템이 안정적인 행 identity를 위해 만든 값이다. 불변·필수·짧고 명확히 유일한 자연 키는 기본 키가 될 수 있지만, 변경 가능하거나 tenant·시간 범위가 복잡하면 인조 키와 업무 unique constraint를 함께 두는 편이 실용적이다. 인조 키를 추가해도 업무 중복과 동시 생성 race는 DB 제약으로 별도 보장해야 한다.