본문 바로가기

키의 종류와 역할

슈퍼 키와 후보 키, 기본 키, 대체 키, 외래 키가 각각 무엇을 맡는지 정리하고, 이메일을 회원의 기본 키로 삼은 모델에서 이메일을 바꾸려다 외래 키에 막히는 과정을 재현합니다. 후보 키의 유일성과 최소성, NULL을 허용하는 UNIQUE, 안정적인 내부 식별자와 업무 규칙을 나누는 기준을 다룹니다.

회원 게시판에서 사용자가 이메일을 바꾸려는 순간을 생각해 봅시다. 이메일을 회원의 기본 키로 삼았다면, 한 번의 변경이 게시글과 댓글, 태그, 감사 기록까지 번집니다. 반면 내부 식별자와 이메일의 규칙을 나누어 두었다면, 회원을 가리키는 값은 그대로 두고 로그인 주소만 바꿀 수 있습니다.

키를 설계하는 출발점은 "어떤 열이 유일한가"가 아니라, 한 행의 정체성을 무엇으로 고정하고 어떤 업무의 규칙을 별도의 제약으로 지킬지를 정하는 일입니다. 1장에서 쓴 기본 키와 외래 키는 각각 행을 대표하고 테이블 사이를 참조하는 일을 맡았습니다. 이 글에서는 한 걸음 더 들어가, 유일해 보이는 조합에서 필요 없는 열을 빼도 여전히 유일한지 확인합니다.

최소한으로 유일한 조합이 후보 키(candidate key)이고, 그 가운데 대표로 고른 것이 기본 키(primary key), 고르지 않았지만 UNIQUE로 지키는 것이 대체 키(alternate key)입니다. 이 관계를 먼저 잡으면, 아래의 용어들이 따로 외울 목록이 아니라 하나의 선택 과정으로 읽힙니다.

키의 종류

키는 하나 이상의 열로 행을 구별하는 값의 집합입니다. 모두 유일성과 관련되어 보이지만 맡는 일은 다릅니다.

종류묻는 것과 회원 게시판의 예
슈퍼 키이 조합으로 행을 구별할 수 있는가. 예: (email, name)
후보 키유일하면서 필요 없는 열이 없는가. 예: email, (provider, provider_subject)
기본 키후보 가운데 행의 대표 정체성으로 무엇을 고르는가. 예: members.id
대체 키기본 키로 고르지 않은 후보를 어떻게 지키는가. 예: UNIQUE (email)
외래 키다른 테이블의 어느 행과 이어지는가. 예: posts.author_id

후보 키의 핵심은 유일성(uniqueness)과 최소성(minimality)입니다. 지금 표에 우연히 중복이 없다는 뜻이 아니라, 앞으로 저장을 허용할 모든 행에서도 그 조합이 유일하도록 업무의 규칙과 제약이 받쳐 주어야 한다는 뜻입니다. 지금 회원이 한 명뿐이라고 name을 후보 키로 고르면, 이름이 같은 두 사람을 허용하는 순간 식별이 깨집니다.

이메일이 이미 모든 사용자를 구별한다면 (email, name)도 결과적으로 유일합니다. 하지만 name을 빼도 여전히 유일하므로 최소성이 없어, 이 조합은 슈퍼 키일 수는 있어도 후보 키는 아닙니다.

기본 키는 NULL일 수 없고, 테이블 안에서 겹칠 수 없으며, 다른 테이블이 오래 참조할 수 있도록 값이 안정적이어야 합니다. 세 번째는 문법이 강제하는 조건이 아니라 설계의 원칙입니다. MySQL은 참조 관계와 옵션이 허용하는 범위에서 기본 키의 값을 고칠 수 있게 하지만, 고칠 수 있다는 것과 고쳐도 좋은 식별자라는 것은 전혀 다른 말입니다.

UNIQUE만 붙이면 후보 키일까

아닙니다. 업무상 반드시 값이 있어야 하는 후보 키라면 NOT NULL도 함께 검토해야 합니다. MySQL의 UNIQUE 인덱스는 NULL끼리를 같은 값으로 보지 않으므로, 여러 행에 NULL이 들어갈 수 있습니다.

유일하지만 비어 있을 수 있는 컬럼
CREATE TEMPORARY TABLE candidate_key_demo (
  demo_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  external_code VARCHAR(40) NULL,
  PRIMARY KEY (demo_id),
  UNIQUE KEY uk_candidate_demo_external_code (external_code)
);

INSERT INTO candidate_key_demo (external_code)
VALUES (NULL), (NULL), ('EXT-100');

SELECT demo_id, external_code
FROM candidate_key_demo
ORDER BY demo_id;
demo_id | external_code
1       | NULL
2       | NULL
3       | EXT-100

NULL인 두 행이 모두 저장되었습니다. 외부 코드가 모든 행을 식별해야 한다면 열을 NOT NULL로 바꾸고, 외부 코드가 없는 상태를 허용해야 한다면 그 업무의 규칙을 분명히 적습니다.

제약의 이름도 설계의 일부입니다. uk_테이블_업무규칙, fk_자식_부모처럼 역할이 드러나는 이름을 붙이면, 운영의 오류에서 어느 규칙이 깨졌는지 바로 찾을 수 있습니다. 이름을 생략하면 MySQL이 자동으로 이름을 만들지만, 스키마를 비교하거나 이관할 때 의도를 읽기 어렵습니다. 키를 열 옆의 장식이 아니라 이름 있는 업무의 규칙으로 다루는 습관이, 나중에 모델을 바꾸는 비용을 낮춥니다.

이메일을 기본 키로 삼으면

먼저 이메일을 회원의 자연 키로 쓴 설계를 일부러 만들어 봅니다. 이 테이블은 오류를 살펴보기 위한 연습용이며, 다음 글에서 올바른 모델로 바꿉니다.

01_natural_key_failure.sql
CREATE DATABASE IF NOT EXISTS board_lab
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_0900_ai_ci;

USE board_lab;

DROP TABLE IF EXISTS posts_natural;
DROP TABLE IF EXISTS members_natural;

CREATE TABLE members_natural (
  email VARCHAR(255) NOT NULL,
  name VARCHAR(80) NOT NULL,
  joined_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY (email)
) ENGINE = InnoDB;

CREATE TABLE posts_natural (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  author_email VARCHAR(255) NOT NULL,
  title VARCHAR(160) NOT NULL,
  view_count SMALLINT UNSIGNED NOT NULL,
  created_at DATETIME(6) NOT NULL,
  PRIMARY KEY (id),
  KEY ix_posts_natural_author (author_email),
  CONSTRAINT fk_posts_natural_author
    FOREIGN KEY (author_email)
    REFERENCES members_natural (email)
) ENGINE = InnoDB;

INSERT INTO members_natural (email, name)
VALUES ('[email protected]', '안동민');

INSERT INTO posts_natural
  (author_email, title, view_count, created_at)
VALUES
  ('[email protected]', '후보 키와 최소성', 45, '2026-07-13 09:00:00.000000'),
  ('[email protected]', '외래 키 실습', 35, '2026-07-13 10:00:00.000000');

이 모델은 당장은 그럴듯합니다. 이메일은 겹치지 않고, 없는 회원을 가리키는 게시글도 외래 키가 막아 줍니다. 문제는 이메일이 로그인의 정책과 회사의 도메인, 사용자의 선택에 따라 바뀌는 업무의 속성이라는 점입니다.

이메일 변경 시도
UPDATE members_natural
SET email = '[email protected]'
WHERE email = '[email protected]';
ERROR 1451 (23000): Cannot delete or update a parent row: a foreign key constraint fails (`board_lab`.`posts_natural`, CONSTRAINT `fk_posts_natural_author` ...)

이 외래 키에는 ON UPDATE를 적지 않았으므로 기본 동작인 NO ACTION이 적용되고, InnoDB에서 NO ACTION은 RESTRICT와 같이 곧바로 거부합니다. 괄호 안에 이어지는 외래 키의 정의는 줄였습니다. 이 오류는 외래 키가 잘못된 것이 아니라 외래 키가 제대로 작동한 결과입니다. 부모의 식별자를 먼저 바꾸면 자식 행은 더 이상 있는 회원을 가리킬 수 없으므로, 변경을 거부한 것입니다.

ON UPDATE CASCADE면 해결될까

연쇄 갱신을 지정하면 문법상으로는 이메일의 변경을 성공시킬 수 있습니다. 그러나 참조하는 테이블이 늘어날수록 한 번의 프로필 수정이 대량의 갱신이 되고, 외부 시스템에 이미 보낸 식별자는 저절로 바뀌지 않습니다. 지난 게시글에 그때의 이메일을 남겨야 한다는 요구와 지금의 이메일을 가리켜야 한다는 요구도 뒤섞입니다.

CASCADE는 유효한 참조 동작이지만, 바뀌는 업무의 값을 영구적인 식별자로 고른 판단 자체를 고쳐 주지는 않습니다. 참조 대상의 수명과 변경이 번지는 범위를 이해한 뒤에 고릅니다.

연쇄 갱신의 파급 범위 확인
SELECT
  rc.CONSTRAINT_NAME,
  rc.TABLE_NAME,
  rc.REFERENCED_TABLE_NAME,
  rc.UPDATE_RULE
FROM information_schema.REFERENTIAL_CONSTRAINTS AS rc
WHERE rc.CONSTRAINT_SCHEMA = 'board_lab'
  AND rc.REFERENCED_TABLE_NAME = 'members_natural';
CONSTRAINT_NAME         | TABLE_NAME    | REFERENCED_TABLE_NAME | UPDATE_RULE
fk_posts_natural_author | posts_natural | members_natural       | NO ACTION

members_natural을 참조하는 외래 키와 그 갱신 규칙을 한눈에 볼 수 있습니다. 참조하는 테이블이 많다면 CASCADE가 번질 범위도 이 조회로 셉니다. 기본 키와 UNIQUE, 외래 키라는 개념은 관계형 모델과 SQL 전반에 통하지만, information_schema의 열과 표현은 DBMS마다 다릅니다.

더 깊이: InnoDB가 연쇄 갱신을 처리하는 방식과 한계

InnoDB는 외래 키에 대응하는 인덱스의 레코드를 깊이 우선 탐색(depth-first search)으로 따라가며 연쇄 동작을 실행합니다. 부모의 이메일 하나를 바꾸면 그 값을 가리키는 자식 행을 바꾸고, 그 자식을 다시 가리키는 행이 있으면 이어서 내려가는 식입니다. 이렇게 이어지는 연쇄는 15단계보다 깊게 중첩될 수 없고, 매뉴얼은 두 테이블 사이에 같은 열에 작용하는 ON UPDATE CASCADE를 여러 개 정의하지 말라고 적습니다. 또 연쇄 동작으로 바뀐 행에서는 트리거가 실행되지 않으므로(8장 4편), 이메일의 변경 이력을 트리거로 남긴다면 자식 테이블에서 함께 바뀐 값은 기록되지 않습니다.

같은 테이블로 되돌아오는 연쇄는 막혀 있습니다. MySQL 8.4 매뉴얼에 따르면 ON UPDATE CASCADE나 ON UPDATE SET NULL이 같은 연쇄 안에서 이미 갱신한 테이블을 다시 갱신하게 되면 RESTRICT처럼 동작하므로, 자기 자신을 참조하는 외래 키에는 이 두 동작을 쓸 수 없습니다. 연쇄 갱신이 끝없이 이어지는 것을 막으려는 규칙이며, 자기 참조의 ON DELETE CASCADE와 ON DELETE SET NULL은 쓸 수 있습니다. 그래서 추천한 회원의 이메일을 같은 회원 테이블에 저장하는 식으로 자기 자신의 자연 키를 참조하는 모델이라면, 이메일을 연쇄 갱신으로 바꾸는 방법 자체가 막힙니다.

식별자와 업무의 규칙을 나눈다

이메일에는 두 가지 역할이 섞여 있습니다. 하나는 사용자를 찾아 로그인하게 하는 업무의 키이고, 다른 하나는 다른 테이블이 사용자를 가리키는 참조의 식별자입니다. 업무의 키는 정책에 따라 바뀔 수 있지만, 대표 식별자는 참조가 살아 있는 동안 안정적이어야 합니다.

그래서 다음 글에서는 뜻이 없는 members.id를 기본 키로 두고, 이메일에는 UNIQUE NOT NULL을 둡니다. 그러면 이메일의 중복은 계속 막으면서, 참조하는 식별자는 바뀌지 않습니다.

KEYS · IDENTITY / BUSINESS RULE

이메일 변경과 게시글의 회원 참조를 분리한다

본문의 이메일 자연 PK 모델은 참조가 남아 변경을 거절합니다. 다음 단원의 대리 PK 모델은 이메일 유일성을 별도로 지키며 내부 회원 ID를 그대로 참조합니다.

변하는 이메일 PK와 고정 ID PK의 두 자식 참조두 게시글의 FK는 각각 동일한 회원 PK를 참조합니다. 이메일 PK 모델은 부모 이메일 변경을1451로거절하고, 대리ID PK모델은 회원ID1과 두 author_id1이유지되어이메일만바뀝니다. 화살표는변경전파나실행순서가아니라자식FK에서부모PK로향하는참조입니다.회원 · email PK[email protected]UPDATE new → 1451게시글 1author_email[email protected]게시글 2author_email[email protected]회원 · id PK = 1email old → newid 값은 그대로게시글 1author_id = 1게시글 2author_id = 1참조가 남아 변경 거절UNIQUE(email)은 별도 중복 금지
변하는 이메일 PK와 고정 ID PK의 두 자식 참조두 게시글의 FK는 각각 동일한 회원 PK를 참조합니다. 이메일 PK 모델은 부모 이메일 변경을1451로거절하고, 대리ID PK모델은 회원ID1과 두 author_id1이유지되어이메일만바뀝니다. 화살표는변경전파나실행순서가아니라자식FK에서부모PK로향하는참조입니다.email PK · 현재 모델[email protected]new로 변경 → 1451게시글 1author_email[email protected]게시글 2author_email[email protected]화살표: 자식 FK → 부모 PKid PK = 1 · 다음 모델email old → newid 값은 그대로게시글 1author_id = 1게시글 2author_id = 1email 중복은 별도 UNIQUE

기본 NO ACTION / InnoDB RESTRICT는 고아 참조를 막습니다. CASCADE로 이메일을 연쇄 변경하는 선택도 가능하지만 파급을 검토해야 합니다. 참조 화살표는 조회나 UPDATE 실행 순서가 아닙니다.

키는 다음 순서로 고르는 것이 안전합니다. 행을 유일하게 만드는 후보를 모두 적고, 후보마다 값이 비는지, 바뀌는지, 다시 쓰이는지 확인합니다. 다른 시스템과 테이블이 얼마나 오래 참조할지 확인한 뒤, 대표 식별자와 업무상의 유일성 규칙을 나눌지 정하고, 고르지 않은 후보도 필요하다면 UNIQUE 제약으로 지킵니다.

자연 키가 언제나 나쁜 것은 아닙니다. ISO 국가 코드처럼 범위가 작고, 공식적인 관리 주체가 있으며, 바뀌거나 다시 쓰이는 규칙이 분명한 값은 자연 키로도 알맞을 수 있습니다. 다만 "지금 중복이 없다"는 것만으로는 부족하고, 값이 바뀔 가능성과 참조의 수명을 확인해야 합니다.

정리

  • 후보 키는 유일성과 최소성을 모두 만족하는 열의 조합입니다.
  • 기본 키는 대표 정체성을, 대체 키는 고르지 않은 업무상의 유일성을 맡습니다.
  • 외래 키는 다른 테이블의 행을 가리키며 참조 무결성을 지킵니다.
  • UNIQUE는 NULL을 여러 개 허용하므로, 꼭 있어야 하는 후보 키에는 NOT NULL을 함께 겁니다.
  • 바뀌는 자연 키를 기본 키로 쓰면 변경의 파급과 기록의 해석, 외부 연동의 문제가 커지므로, 안정적인 내부 식별자와 UNIQUE의 업무 규칙을 나눕니다.

연습 문제

1. 소셜 로그인의 후보 키

회원 게시판이 소셜 로그인을 지원한다고 해 봅시다. 로그인 공급자는 google과 github 가운데 하나이고, 공급자마다 provider_subject라는 사용자 식별자를 줍니다. 두 값 모두 NULL이 아니며, provider_subject는 공급자 안에서 유일하지만 공급자가 다르면 같은 문자열이 다시 쓰일 수 있습니다. 예를 들어 아래 세 연결은 모두 허용됩니다.

내부 회원providerprovider_subject
1googleaccount-7
1githubaccount-7
2googleaccount-8

provider, provider_subject, (provider, provider_subject), (provider, provider_subject, name) 가운데 후보 키를 고르고, 기본 키로 쓸지 대체 키로 둘지 판단해 보세요. 한 사용자가 여러 로그인 공급자를 이을 수 있다는 요구까지 고려해, 테이블의 최소한의 열도 적어 봅니다.

해설 보기

provider만 보면 첫째와 셋째 행이 google로 같고, provider_subject만 보면 첫째와 둘째 행이 account-7로 같으므로, 어느 열도 혼자서는 연결 하나를 고르지 못합니다. (provider, provider_subject)는 두 값을 함께 써야 외부 계정 하나를 유일하게 찾을 수 있으므로 복합 후보 키입니다. 여기에 name을 더한 조합은 유일하더라도 최소성이 없어 후보 키가 아닙니다.

외부 공급자의 정책에 식별을 맡기기보다, 내부의 대리 키를 기본 키로 두고 복합 후보 키를 대체 키로 지킬 수 있습니다.

DROP TABLE IF EXISTS member_external_identities;
CREATE TABLE member_external_identities (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  member_id BIGINT UNSIGNED NOT NULL,
  provider VARCHAR(30) NOT NULL,
  provider_subject VARCHAR(191) NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uk_member_external_identities_provider_subject
    (provider, provider_subject)
);

회원을 가리키는 외래 키는 다음 글에서 최종 members를 만든 뒤에 더합니다. 여기서 핵심은 대표 기본 키와 외부 공급자의 유일성 규칙을 같은 열에 맡기지 않았다는 점입니다.

2. 국가 코드를 기본 키로 쓴다면

국가 테이블의 기본 키로 ISO 3166-1의 두 글자 코드(KR, US 같은)를 쓰는 것은 이메일과 무엇이 다른가요? 그래도 확인해 둘 점은 무엇인가요?

해설 보기

국가 코드는 공식적인 관리 주체가 있고, 값의 범위가 작으며, 바뀌거나 다시 쓰이는 규칙이 공개되어 있어 사용자가 마음대로 바꾸는 이메일과 다릅니다. 그래서 국가 테이블처럼 참조가 많지 않고 값이 안정적인 곳에서는 자연 키로 쓸 만합니다. 다만 국가가 바뀌거나 사라져 코드가 폐지되거나 다른 나라에 다시 배정된 사례가 있으므로, 폐지된 코드를 지난 데이터가 어떻게 가리킬지와, 코드가 바뀌었을 때 참조하는 행을 어떻게 옮길지는 미리 정해 둡니다.

다음 글 자연 키와 대리 키에서는 오류를 재현하던 자연 키 모델을 떠나, 회원 게시판의 누적 모델을 대리 키를 중심으로 다시 만듭니다.