본문 바로가기
TYLER SONGBlog
← Blog index
CS7 min read

Primary Key: 행을 유일하게 식별하는 기본 키 제약 조건

기본 키(Primary Key)는 테이블의 각 행을 유일하게 식별하는 하나 이상의 열이다. PostgreSQL은 기본 키 열에 널(NULL)을 허용하지 않고, 중복을 막는 고유 인덱스(Unique Index)를 자동으로 생성한다. 안정적인 기본 키는 외래 키(Foreign Key), 조인(Join), 수정, 삭제의 기준점이 된다.

By 송민성

1. 개념

기본 키(Primary Key)는 테이블에서 각 행을 유일하게 식별하는 열 또는 열의 조합이다.

text
users

id | email             | nickname
---|-------------------|---------
1  | min@example.com   | 민수
2  | jin@example.com   | 진

이 테이블에서는 id가 기본 키다. id = 1은 정확히 하나의 행만 가리킨다.

기본 키에는 두 가지 규칙이 적용된다.

  • 고유성(Uniqueness): 같은 기본 키 값을 가진 행을 둘 이상 저장할 수 없다.
  • 널 불허(Not Null): 기본 키 열에 널(NULL)을 저장할 수 없다.

테이블에는 기본 키 제약 조건을 하나만 정의할 수 있다. 기본 키가 여러 열로 구성된 복합 키(Composite Key)일 수는 있다.

sql
CREATE TABLE enrollments ( student_id bigint, course_id bigint, enrolled_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (student_id, course_id) );

여기서는 student_id와 course_id의 조합이 하나의 기본 키다.

2. 왜 사용하는가

기본 키는 행을 안정적으로 찾고 관계를 연결하기 위해 사용한다.

기본 키가 없으면 같은 값을 가진 행 중 어느 행을 수정하거나 삭제할지 명확하지 않을 수 있다.

sql
UPDATE users SET nickname = '새 이름' WHERE id = 1;

id가 기본 키라면 이 조건은 최대 하나의 행만 선택한다.

다른 테이블은 외래 키(Foreign Key)로 기본 키를 참조할 수 있다.

text
users                         orders

id | nickname                 id  | user_id | amount
---|---------                 ----|---------|-------
1  | 민수                     101 | 1       | 25000
2  | 진                       102 | 1       | 18000

orders.user_id가 users.id를 참조하면 존재하지 않는 사용자의 주문을 저장하지 못하게 할 수 있다. 이런 규칙을 참조 무결성(Referential Integrity)이라고 한다.

3. 동작 원리

기본 키 제약 조건 검사

PostgreSQL에서 다음 두 정의는 데이터 무결성 측면에서 비슷하다.

sql
CREATE TABLE users_a ( id bigint PRIMARY KEY );
sql
CREATE TABLE users_b ( id bigint NOT NULL UNIQUE );

두 정의 모두 널과 중복을 거부한다. 차이는 의미와 용도에 있다.

PRIMARY KEY는 해당 열이 테이블의 대표 식별자임을 명시한다. 테이블당 하나만 정의할 수 있으며, 외래 키가 행을 참조할 때 대표 대상으로 사용된다. UNIQUE NOT NULL 제약 조건은 한 테이블에 여러 개 정의할 수 있다.

고유 인덱스 생성

PostgreSQL은 기본 키를 선언하면 해당 열에 비트리(B-tree) 고유 인덱스를 자동으로 생성한다.

sql
CREATE TABLE users ( id bigint PRIMARY KEY, email text NOT NULL );

개념적으로 다음 검사를 인덱스로 처리한다.

text
새 id가 인덱스에 존재하는가?
├─ 존재함: 중복 키 오류
└─ 존재하지 않음: 행과 인덱스 항목 저장

이 인덱스는 기본 키 조회에도 사용될 수 있다.

sql
SELECT * FROM users WHERE id = 100;

기본 키는 행의 논리적 식별자를 정의하지만 행의 물리적 저장 순서를 보장하지 않는다. 조회 결과 순서가 필요하면 ORDER BY를 명시해야 한다.

대리 키와 자연 키

대리 키(Surrogate Key)는 업무 의미 없이 식별만 담당하는 값이다.

sql
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY

자연 키(Natural Key)는 업무 데이터 자체를 식별자로 사용한다.

sql
country_code char(2) PRIMARY KEY

이메일처럼 변경될 수 있는 값은 기본 키보다 고유 제약 조건으로 관리하는 편이 안정적이다.

sql
CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, email text NOT NULL UNIQUE );

id는 행의 관계를 유지하고, email의 고유성은 별도 제약 조건이 보장한다.

복합 기본 키

복합 기본 키는 여러 열의 조합에 고유성을 적용한다.

sql
PRIMARY KEY (student_id, course_id)

다음 데이터는 허용된다.

text
student_id | course_id
-----------|----------
1          | 10
1          | 20
2          | 10

student_id나 course_id 각각은 중복될 수 있다. 두 값의 조합만 중복될 수 없다.

4. 코드 예제

다음 SQL은 PostgreSQL에서 실행할 수 있다.

sql
DROP TABLE IF EXISTS orders; DROP TABLE IF EXISTS users; CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, email text NOT NULL UNIQUE, nickname text NOT NULL, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE orders ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint NOT NULL, amount numeric(12, 2) NOT NULL CHECK (amount >= 0), created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT orders_user_id_fkey FOREIGN KEY (user_id) REFERENCES users (id) ); INSERT INTO users (email, nickname) VALUES ('min@example.com', '민수'), ('jin@example.com', '진'); INSERT INTO orders (user_id, amount) VALUES (1, 25000), (1, 18000), (2, 32000); SELECT orders.id AS order_id, users.email, orders.amount FROM orders JOIN users ON users.id = orders.user_id ORDER BY orders.id;

기본 키 중복은 거부된다.

sql
INSERT INTO users (id, email, nickname) OVERRIDING SYSTEM VALUE VALUES (1, 'other@example.com', '다른 사용자');

실행 결과는 다음과 같은 중복 키 오류다.

text
ERROR: duplicate key value violates unique constraint "users_pkey"

존재하지 않는 사용자를 참조하는 주문도 거부된다.

sql
INSERT INTO orders (user_id, amount) VALUES (9999, 10000);
text
ERROR: insert or update on table "orders" violates foreign key constraint

복합 기본 키는 다대다 관계(Many-to-Many Relationship)를 표현하는 연결 테이블에 자주 사용한다.

sql
DROP TABLE IF EXISTS enrollments; DROP TABLE IF EXISTS courses; DROP TABLE IF EXISTS students; CREATE TABLE students ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL ); CREATE TABLE courses ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, title text NOT NULL ); CREATE TABLE enrollments ( student_id bigint NOT NULL REFERENCES students (id), course_id bigint NOT NULL REFERENCES courses (id), enrolled_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (student_id, course_id) ); INSERT INTO students (name) VALUES ('민수'); INSERT INTO courses (title) VALUES ('데이터베이스'); INSERT INTO enrollments (student_id, course_id) VALUES (1, 1); SELECT * FROM enrollments;

같은 학생을 같은 강의에 다시 등록하면 복합 기본 키가 중복을 막는다.

sql
INSERT INTO enrollments (student_id, course_id) VALUES (1, 1);

5. 시간 복잡도 또는 성능 특성

PostgreSQL의 기본 키는 기본적으로 비트리 인덱스로 지원된다. 테이블에 행이 n개 있을 때 일반적인 성능 특성은 다음과 같다.

연산일반적인 시간 복잡도
기본 키 동등 조회O(log n)
기본 키 범위 조회O(log n + k)
기본 키 삽입과 중복 검사O(log n)
기본 키 인덱스 삭제O(log n)

k는 범위 조회 결과의 행 수다. 실제 실행 시간은 캐시 적중률, 디스크 입출력, 데이터 분포, 테이블 통계, 동시 트랜잭션의 영향을 받는다.

기본 키 인덱스는 조회를 빠르게 하지만 쓰기 비용을 추가한다. 행을 삽입할 때 테이블 데이터뿐 아니라 인덱스 항목도 갱신해야 한다.

복합 기본 키의 열 순서도 조회 성능에 영향을 준다.

sql
PRIMARY KEY (student_id, course_id)

이 인덱스는 다음 조건에 효과적으로 사용될 수 있다.

sql
SELECT * FROM enrollments WHERE student_id = 1;

다음 조건만 사용하는 조회에는 같은 수준으로 유리하지 않을 수 있다.

sql
SELECT * FROM enrollments WHERE course_id = 10;

course_id 단독 조회가 빈번하면 별도 인덱스를 검토한다.

sql
CREATE INDEX enrollments_course_id_idx ON enrollments (course_id);

6. 실무 사용 사례

단일 엔터티 식별

사용자, 주문, 게시글처럼 독립적으로 조회되는 엔터티에는 대리 키를 자주 사용한다.

sql
CREATE TABLE posts ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, title text NOT NULL, body text NOT NULL );

외부에 순차 번호 노출을 피해야 한다면 범용 고유 식별자(UUID)를 사용할 수 있다.

sql
CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE TABLE api_keys ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL );

다대다 관계의 중복 방지

사용자와 역할처럼 두 엔터티의 연결 자체가 하나의 행인 경우 복합 기본 키가 자연스럽다.

sql
CREATE TABLE user_roles ( user_id bigint NOT NULL REFERENCES users (id), role_id bigint NOT NULL, PRIMARY KEY (user_id, role_id) );

이 구조는 같은 역할을 한 사용자에게 중복 부여하는 것을 막는다.

외부 시스템 식별자 보존

외부 서비스의 식별자는 내부 기본 키와 분리할 수 있다.

sql
CREATE TABLE payments ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, provider text NOT NULL, provider_payment_id text NOT NULL, amount numeric(12, 2) NOT NULL, UNIQUE (provider, provider_payment_id) );

내부 관계는 id를 사용하고, 외부 결제의 중복 수집은 복합 고유 제약 조건으로 막는다.

7. 주의할 점

변경 가능한 값을 기본 키로 사용하지 않기

이메일, 전화번호, 사용자 이름은 변경될 수 있다. 이런 값이 기본 키면 이를 참조하는 외래 키까지 변경해야 할 수 있다.

sql
CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, email text NOT NULL UNIQUE );

변경 가능한 업무 값에는 UNIQUE를 적용하고, 관계에는 안정적인 대리 키를 사용하는 방식이 단순하다.

기본 키가 물리적 정렬을 보장한다고 오해하지 않기

다음 조회 결과가 id 순서로 나올 것이라고 가정하면 안 된다.

sql
SELECT * FROM users;

순서가 필요하면 명시한다.

sql
SELECT * FROM users ORDER BY id;

불필요하게 큰 복합 키 피하기

복합 기본 키를 참조하는 외래 키는 구성 열을 모두 가져야 한다. 키가 크면 관련 테이블의 인덱스와 조인 조건도 커진다.

관계 자체가 여러 열로 자연스럽게 식별되지 않는다면 단일 대리 키와 별도 고유 제약 조건을 사용할 수 있다.

sql
CREATE TABLE subscriptions ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint NOT NULL, plan_id bigint NOT NULL, UNIQUE (user_id, plan_id) );

식별자 생성과 기본 키를 구분하기

IDENTITY는 값을 생성하고, PRIMARY KEY는 널과 중복을 막는다. 둘은 서로 다른 기능이다.

sql
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY

IDENTITY만 선언하면 고유성이 보장되지 않는다. 기본 키만 선언하면 값을 자동으로 생성하지 않는다.

외래 키 열의 인덱스는 자동 생성되지 않음

PostgreSQL은 참조 대상인 기본 키에 인덱스를 생성하지만, 외래 키 열에는 자동으로 인덱스를 만들지 않는다.

sql
CREATE INDEX orders_user_id_idx ON orders (user_id);

사용자별 주문 조회, 부모 행 삭제 검사, 조인이 빈번하다면 외래 키 열의 인덱스를 검토한다.

8. 핵심 정리

  • 기본 키(Primary Key)는 각 행을 유일하게 식별한다.
  • 기본 키에는 고유성(Unique)과 널 불허(Not Null)가 적용된다.
  • PostgreSQL은 기본 키를 지원하는 비트리(B-tree) 고유 인덱스를 자동으로 생성한다.
  • 변경 가능한 업무 값은 기본 키보다 고유 제약 조건으로 관리하는 편이 안정적이다.
  • 복합 기본 키는 열 조합의 중복을 막으며, 열 순서는 인덱스 활용 범위에 영향을 준다.
  • 기본 키는 물리적 행 순서나 조회 결과 순서를 보장하지 않는다.
  • 외래 키 열의 인덱스는 자동 생성되지 않으므로 실제 조회와 삭제 패턴에 따라 추가한다.