본문 바로가기

자연 키와 대리 키

안정적인 내부 식별자와 이메일·외부 계정의 업무상 유일성을 분리해 변화에 강한 회원 모델을 만듭니다.

이전 문서의 이메일 기본 키 모델은 참조 무결성 덕분에 잘못된 변경을 막았지만, 정상적인 이메일 변경까지 어렵게 만들었습니다.

이번에는 회원의 정체성은 members.id가 맡고, 이메일의 중복 금지는 UNIQUE 제약이 맡도록 책임을 분리합니다.

대리 키를 도입한다는 말은 업무 규칙을 버린다는 뜻이 아닙니다.

대표 식별자는 안정적인 값으로 고정하고, 자연스럽게 발생한 후보 키는 대체 키로 계속 보호한다는 뜻입니다.


자연 키와 대리 키

자연 키는 이메일, ISBN, 국가 코드처럼 업무 세계에 이미 존재하는 값입니다.

값을 보면 의미를 알 수 있고, 조건이 맞으면 추가 조인 없이 업무 대상을 바로 찾을 수 있습니다.

반면 정책 변경, 형식 변경, 재발급, 개인정보 취급 같은 업무 변화가 식별 체계까지 흔들 수 있습니다.

대리 키는 행을 식별하려고 데이터베이스 안에서 부여한 값입니다.

보통 숫자나 UUID이며 업무 의미를 넣지 않습니다.

값이 작고 안정적이어서 참조하기 쉽지만, 대리 키만 추가하면 이메일 중복 같은 업무 규칙을 놓치기 쉽습니다.

판단 축자연 키를 PK로 사용대리 키를 PK로 사용
값의 의미업무 의미가 있음의미가 없음
변경 영향업무 변경이 참조에 전파될 수 있음업무 속성을 독립적으로 변경 가능
중복 방지PK가 자연스럽게 막음별도 UNIQUE가 반드시 필요
참조 크기문자열·복합 값이면 넓어질 수 있음짧은 숫자로 단순화 가능
외부 노출업무 값이 곧 식별자라 결합이 큼공개 ID 정책을 따로 설계 가능

둘 중 하나가 언제나 정답은 아닙니다.

범위가 작고 변경 관리 주체가 분명한 코드 테이블은 자연 키가 간결할 수 있습니다.

반면 회원, 게시글, 댓글처럼 수명이 길고 여러 테이블과 시스템이 참조하는 엔티티는 대리 키를 대표 식별자로 두는 편이 변화에 강합니다.


안정적인 회원 식별자

이제 오류 재현용 테이블과 분리된 실제 누적 모델을 만듭니다.

아래 스크립트는 MySQL 8.4의 InnoDB를 기준으로 기존 누적 테이블을 삭제하고 다시 만듭니다. 여기서 posts의 CHECK는 상태 목록뿐이며, 앞 장의 게시 시각 조건이 자동으로 이어지지는 않습니다.

앞 장의 실습을 같은 board_lab에서 이어 했다면, 아래 원본 초기화 목록 밖에도 members를 참조하는 연습용 자식이 남을 수 있습니다. 외래 키의 참조 검사에 따라 자식을 남긴 채 부모 테이블을 삭제할 수 없습니다. 이번 재작성 전에 다음 실습용 테이블도 먼저 정리합니다. 이것은 데이터를 비우고 모델을 새로 만드는 실습 단계이며 운영 데이터 이전 절차가 아닙니다.

이전 실습의 자식 테이블 먼저 정리
USE board_lab;
DROP TABLE IF EXISTS post_integrity_demo;
DROP TABLE IF EXISTS member_daily_post_quota;
DROP TABLE IF EXISTS daily_post_stats;
DROP TABLE IF EXISTS member_point_accounts;
DROP TABLE IF EXISTS daily_post_quota_composite_demo;
DROP TABLE IF EXISTS attachment_reference_demo;

그 밖에 직접 만든 참조 테이블이 있다면 앞 절의 information_schema.REFERENTIAL_CONSTRAINTS 조회에서 부모를 members, posts, attachments로 바꾸어 먼저 확인하세요. 아래 목록에 있다고 추측해 참조 검사를 끄지 말고 자식부터 정리합니다. 여기부터는 새 모델을 만들고, 같은 연결에서 INSERT와 LAST_INSERT_ID()를 순서대로 실행합니다.

02_surrogate_key_schema.sql
USE board_lab;

DROP TABLE IF EXISTS post_attachments;
DROP TABLE IF EXISTS attachments;
DROP TABLE IF EXISTS post_tags;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS comments;
DROP TABLE IF EXISTS member_profiles;
DROP TABLE IF EXISTS daily_post_quota;
DROP TABLE IF EXISTS posts;
DROP TABLE IF EXISTS categories;
DROP TABLE IF EXISTS member_external_identities;
DROP TABLE IF EXISTS members;

CREATE TABLE members (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  email VARCHAR(255) NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  name VARCHAR(80) NOT NULL,
  status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY (id),
  UNIQUE KEY uq_members_email (email),
  CONSTRAINT ck_members_status
    CHECK (status IN ('ACTIVE', 'SUSPENDED', 'DELETED'))
) ENGINE = InnoDB;

CREATE TABLE posts (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  author_id BIGINT UNSIGNED NOT NULL,
  title VARCHAR(160) NOT NULL,
  content TEXT NOT NULL,
  status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
  view_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  updated_at DATETIME(6) NOT NULL
    DEFAULT CURRENT_TIMESTAMP(6)
    ON UPDATE CURRENT_TIMESTAMP(6),
  published_at DATETIME(6) NULL,
  version BIGINT UNSIGNED NOT NULL DEFAULT 0,
  request_key VARCHAR(64) NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uq_posts_author_request (author_id, request_key),
  KEY ix_posts_author_created (author_id, created_at DESC, id DESC),
  CONSTRAINT ck_posts_status
    CHECK (status IN ('DRAFT', 'HIDDEN', 'PUBLISHED', 'DELETED')),
  CONSTRAINT fk_posts_author
    FOREIGN KEY (author_id)
    REFERENCES members (id)
) ENGINE = InnoDB;

AUTO_INCREMENT는 MySQL 전용 생성 전략입니다.

대리 키라는 모델링 개념은 DBMS에 독립적이지만, PostgreSQL의 식별자, SQL 서버의 IDENTITY, 애플리케이션에서 만드는 UUID처럼 값을 발급하는 문법은 제품마다 다릅니다.

이 교재에서는 한 MySQL 인스턴스에서 시작하는 회원 게시판에 맞춰 BIGINT UNSIGNED AUTO_INCREMENT를 기준으로 사용합니다.

uq_members_email을 빼면 대리 키가 다른 두 행을 만들어 주기 때문에 같은 이메일 회원을 여러 번 저장할 수 있습니다.

대리 키는 행의 유일성을 보장할 뿐, 어떤 업무 속성이 중복되면 안 되는지는 알지 못합니다.

식별자는 고정하고 이메일만 바꾼다

회원과 게시글 저장
INSERT INTO members (email, password_hash, name)
VALUES ('[email protected]', '$2a$12$example.hash', '안동민');

SET @member_id := LAST_INSERT_ID();

INSERT INTO posts
  (author_id, title, content, status, view_count, created_at, published_at, request_key)
VALUES
  (@member_id, '대리 키의 책임', '대표 식별자와 업무 키를 분리합니다.', 'PUBLISHED', 50,
   '2026-07-13 13:00:00.000000', '2026-07-13 13:00:00.000000', 'post-key-001'),
  (@member_id, '업무 키의 유일성', '이메일 중복은 별도 제약으로 막습니다.', 'PUBLISHED', 40,
   '2026-07-13 14:10:00.000000', '2026-07-13 14:10:00.000000', 'post-key-002');

UPDATE members
SET email = '[email protected]'
WHERE id = @member_id;

SELECT
  l.id AS member_id,
  l.email,
  s.id AS post_id,
  s.title
FROM members AS l
JOIN posts AS s
  ON s.author_id = l.id
WHERE l.id = @member_id
ORDER BY s.id;

예상 결과에서 회원 이메일은 [email protected]로 바뀌지만 두 게시글의 author_id는 그대로입니다.

변경은 업무 속성 한 행에서 끝나고, 게시글의 소유 관계는 흔들리지 않습니다.

member_id | email            | post_id | title
1          | [email protected]  | 1          | 대리 키의 책임
1          | [email protected]  | 2          | 업무 키의 유일성

반대로 같은 이메일을 다시 넣으면 다음 제약이 작동합니다.

대체 키가 중복을 막는다
INSERT INTO members (email, password_hash, name)
VALUES ('[email protected]', '$2a$12$duplicate.hash', '중복 사용자');
ERROR 1062 (23000): Duplicate entry '[email protected]'
for key 'members.uq_members_email'

이 오류는 설계 의도입니다.

members.id는 참조 안정성을, uq_members_email은 이메일 유일성을 각각 지킵니다.


외부 로그인 식별자

한 사용자가 GitHub와 Google 계정을 모두 연결할 수 있다고 해 봅시다.

공급자가 준 식별자를 members의 기본 키로 쓰면 공급자 정책과 내부 회원 수명이 결합됩니다.

외부 계정을 독립된 자식 테이블로 두면 내부 회원 하나에 여러 계정을 연결할 수 있습니다.

외부 로그인 계정 모델
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,
  linked_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY (id),
  UNIQUE KEY uk_member_external_identities_provider_subject
    (provider, provider_subject),
  UNIQUE KEY uk_member_external_identities_member_provider
    (member_id, provider),
  KEY ix_member_external_identities_member (member_id),
  CONSTRAINT ck_member_external_identities_provider
    CHECK (provider IN ('github', 'google')),
  CONSTRAINT fk_member_external_identities_member
    FOREIGN KEY (member_id)
    REFERENCES members (id)
) ENGINE = InnoDB;

INSERT INTO member_external_identities
  (member_id, provider, provider_subject)
VALUES
  (@member_id, 'github', 'github-user-9182'),
  (@member_id, 'google', 'google-user-4410');

업무 키의 유일성은 문자열을 어떤 규칙으로 비교하는지도 포함합니다. 앞에서 만든 board_lab의 utf8mb4_0900_ai_ci를 상속하면 대소문자 차이를 구별하지 않습니다. 사람의 이메일 정책과 외부에서 발급한 식별자의 규칙을 같다고 가정하면 안 됩니다. 예를 들어 OpenID Connect의 sub 정의는 발급자 안에서 유일한 대소문자 구별 문자열이며 최대 255 ASCII 문자입니다.

아래 변경은 그 계약에 맞춰 연습 모델의 외부 식별자를 대소문자·끝 공백이 구별되는 비교 규칙으로 저장합니다. MySQL의 utf8mb4_0900_bin은 NO PAD 비교를 사용합니다. 이 모델에서 provider는 미리 설정한 발급자 하나를 가리킨다고 가정합니다. 공급자별 실제 식별자 길이와 발급자 매핑은 연동할 때 확인하며, 지금의 문자열 예시는 인증 자체를 구현한 코드가 아닙니다.

외부 식별자의 비교 규칙 보완
ALTER TABLE member_external_identities
  MODIFY provider_subject VARCHAR(255)
    CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL;

다음 비교는 참조할 두 회원이 존재하고, 한 회원의 GitHub 계정 하나만 연결된 상태를 가정합니다. ‘외부 계정 조합’은 (provider, provider_subject), ‘회원·공급자 조합’은 (member_id, provider)입니다.

KEYS · TWO UNIQUE CONSTRAINTS

같은 외부 계정과 같은 회원·공급자는 서로 다른 중복 규칙

기존 연결이 회원 1 · github · github-A인 비교 예제입니다. 회원 2명은 존재하고 회원 2에게 아직 연결이 없는 상태에서 각 추가를 따로 시험합니다.

두 외부 계정 UNIQUE가 거절하는 변경은 다르다
추가하려는 연결외부 계정 조합회원·공급자 조합
같은 외부 계정 → 다른 회원중복 · 거절새 조합일 수 있음
같은 회원 → 같은 공급자의 다른 계정새 조합중복 · 거절
같은 회원 → 다른 공급자의 새 계정새 조합새 조합 · 허용
같은 외부 계정 → 다른 회원
외부 계정 조합: 중복 · 거절
회원·공급자 조합: 새 조합일 수 있음
같은 회원 → 같은 공급자의 다른 계정
외부 계정 조합: 새 조합
회원·공급자 조합: 중복 · 거절
같은 회원 → 다른 공급자의 새 계정
외부 계정 조합: 새 조합
회원·공급자 조합: 새 조합 · 허용

두 UNIQUE의 실제 컬럼

외부 계정 조합: (provider, provider_subject)

회원·공급자 조합: (member_id, provider)

회원 2 · github · github-A → 첫 조합 중복. 회원 1 · github · github-B → 둘째 조합 중복. 회원 1 · google · google-C → 둘 다 새 조합입니다. 이 A/B/C는 비교용 값이며 본문의 실행 fixture는 github-user-9182와 google-user-4410입니다.

나중에 “GitHub 계정을 여러 개 연결할 수 있다”로 요구가 바뀌면 두 번째 제약만 재검토하면 됩니다.

대표 식별자는 그대로입니다.

외부 계정의 중복 연결 실패
INSERT INTO member_external_identities
  (member_id, provider, provider_subject)
VALUES
  (@member_id, 'github', 'github-user-9182');

이 문장은 uk_member_external_identities_provider_subject 또는 uk_member_external_identities_member_provider 중 먼저 검사된 제약에서 오류 1062를 냅니다.

어느 제약에서 오류가 발생하든 “동일 계정을 중복 연결하지 않는다”는 결과는 같지만, 두 제약이 표현하는 업무 문장은 다르므로 이름을 분리해 두었습니다.


대리 키 선택 기준

AUTO_INCREMENT는 짧고 정렬 친화적이며 사람이 로그를 읽기 쉽습니다.

ID를 발급하는 쓰기 주체가 하나인 서비스에는 좋은 기본값입니다.

다만 여러 지역에서 오프라인으로 ID를 발급해야 하거나, 데이터를 합칠 때 충돌 없는 값이 필요하다면 UUID나 별도의 분산 ID 생성 전략을 검토할 수 있습니다.

선택할 때는 다음을 구분해야 합니다.

  • 저장용 기본 키: 조인과 외래 키에 적합한 안정적인 값
  • 공개 식별자: URL이나 API에 노출할 값, 추측 가능성 정책 포함
  • 업무 키: 이메일, 외부 계정처럼 중복되면 안 되는 값

AUTO_INCREMENT 값을 감춘다고 보안이 생기지는 않으며, 노출한다고 권한 검사를 생략할 수도 없습니다.

공개 ID가 순차적이라는 사실과 “이 사용자가 해당 행을 읽을 권한이 있는가”는 별개의 문제입니다.

InnoDB에서는 각 보조 인덱스 레코드에 기본 키 값이 함께 저장됩니다.

따라서 매우 긴 문자열이나 넓은 복합 키를 기본 키로 고르면 보조 인덱스도 커질 수 있습니다.

작은 숫자 대리 키가 유리한 이유 중 하나입니다.

그렇다고 자연 키 조회가 무조건 느린 것은 아닙니다.

UNIQUE (email) 인덱스가 있으면 이메일 검색은 여전히 인덱스를 사용할 수 있고, 실제 선택은 EXPLAIN ANALYZE와 데이터 분포로 확인해야 합니다.


연습 문제

회원 게시판에 첨부파일의 출처를 저장하는 attachments를 추가하려 합니다.

첨부파일에는 URL이 있지만, 운영자의 URL 정규화나 도메인 이전으로 URL이 바뀔 수 있습니다.

같은 정규화 URL은 중복 저장하지 않아야 합니다.

  1. URL을 기본 키로 쓰지 않는 이유를 설명하세요.
  2. 대리 키와 업무 키를 분리한 DDL을 작성하세요.
  3. URL이 바뀌어도 기존 참조가 유지되는지 확인하는 SQL을 작성하세요.
해설과 예시 답안

URL은 사람이 이해할 수 있는 자연 키 후보지만 리다이렉트, 쿼리 정규화, 도메인 이전으로 바뀔 수 있습니다.

내부 참조는 대리 키로 고정하고 정규화 URL의 중복은 대체 키로 막습니다.

CREATE TABLE attachments (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  canonical_url VARCHAR(768) NOT NULL,
  title VARCHAR(200) NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uk_attachments_canonical_url (canonical_url)
) ENGINE = InnoDB;

INSERT INTO attachments (canonical_url, title)
VALUES ('https://dev.mysql.com/doc/refman/8.4/en/', 'MySQL 8.4 Reference Manual');

SET @attachment_id := LAST_INSERT_ID();

UPDATE attachments
SET canonical_url = 'https://dev.mysql.com/doc/refman/8.4/en/index.html'
WHERE id = @attachment_id;

SELECT id AS attachment_id, canonical_url
FROM attachments
WHERE id = @attachment_id;

위 SQL은 같은 첨부파일의 id가 유지되는지를 보입니다. 문제의 “기존 참조”까지 확인하려면 그 ID를 저장한 자식 행도 있어야 합니다. 같은 연결에서 위 실험 뒤 다음 보충 실험을 진행합니다.

URL 변경 뒤에도 자식 참조는 같은 ID
CREATE TABLE attachment_reference_demo (
  reference_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  attachment_id BIGINT UNSIGNED NOT NULL,
  PRIMARY KEY (reference_id),
  CONSTRAINT fk_attachment_reference_demo_file
    FOREIGN KEY (attachment_id) REFERENCES attachments (id)
) ENGINE = InnoDB;

INSERT INTO attachment_reference_demo (attachment_id)
VALUES (@attachment_id);

UPDATE attachments
SET canonical_url = 'https://dev.mysql.com/doc/refman/8.4/en/'
WHERE id = @attachment_id;

SELECT r.reference_id, r.attachment_id, a.canonical_url
FROM attachment_reference_demo AS r
JOIN attachments AS a ON a.id = r.attachment_id;

새 실습 상태라면 참조 한 행이 남고, attachment_id는 그대로이며 조회한 URL만 디렉터리 주소로 바뀝니다. 자식의 참조 값을 UPDATE하지 않아도 된다는 점이 대리 키의 효과입니다.

실무에서는 긴 URL 전체의 인덱스 크기, 정규화 규칙, 해시 충돌 처리도 함께 결정해야 합니다.

여기서는 키 책임의 분리에 집중한 최소 모델입니다.


핵심 정리

  • 자연 키는 업무 의미와 유일성을 가지지만 변경 정책까지 함께 따라옵니다.
  • 대리 키는 안정적인 참조점을 만들지만 업무 중복을 자동으로 막지 않습니다.
  • 회원 게시판은 members.id를 PK로, 이메일을 UNIQUE NOT NULL 대체 키로 사용합니다.
  • 외부 로그인 계정은 별도 테이블과 복합 유일성 제약으로 내부 회원 수명에서 분리합니다.
  • MySQL의 AUTO_INCREMENT는 구현 전략이며, 대리 키 자체는 제품에 독립적인 모델링 선택입니다.

다음 문서에서는 한 컬럼으로 끝나지 않는 복합 키를 다루고, members와 카테고리·게시글 사이의 일대다 관계를 완성합니다.