유니크 제약
고친 사람 github-actions[bot]
유니크 제약은 테이블의 한 열에 같은 값이 두 번 들어가지 못하게 막습니다. 이미 있는 값을 또 넣으려는 쓰기는 데이터베이스가 거절합니다. 이 검사는 애플리케이션 코드가 아니라 데이터베이스가 직접 합니다. 같은 값이 있는지 보는 일과 저장하는 일을 한 번에 처리하므로 요청이 한꺼번에 몰려도 중복이 새지 않습니다.
쉽고 빠른 이해
유니크 제약은 「이 열의 값은 행마다 달라야 한다」는 규칙을 데이터베이스에 맡겨 둡니다. 회원 테이블의 이메일 열에 걸어 두면 같은 이메일로 두 번째 회원이 만들어지지 않습니다.
코드에서 먼저 조회해 보고 넣는 방식으로는 모자랍니다. 가입 요청 둘이 동시에 오면 둘 다 「아직 없다」를 보고 둘 다 넣습니다.
어떻게 도는가:
- 테이블을 만들 때 이 열은 겹치면 안 된다고 적어 둡니다
- 행을 넣거나 고칠 때마다 데이터베이스가 같은 값이 이미 있는지 봅니다
- 있으면 그 쓰기를 오류로 돌려보냅니다. 없으면 저장합니다
2번과 3번은 한 번에 처리됩니다. 그 사이에 다른 쓰기가 끼어들지 못하므로 동시에 온 요청도 하나만 저장됩니다.
사람 이름처럼 겹쳐도 되는 값에는 걸지 않습니다. 걸면 동명이인 같은 정상적인 데이터가 거절됩니다.
대가도 있습니다. 빨리 찾으려고 값을 정렬해 둔 목록을 하나 더 두므로 공간이 늘고 쓰기가 조금 느려집니다. 코드는 거절 오류를 받아 「이미 쓰는 이메일입니다」 같은 응답으로 바꿔 줘야 합니다.
상세
공연장 좌석 예매를 떠올려 봅시다. 좌석 하나는 한 사람에게만 팔립니다. 창구가 여럿 열려 있어도 이미 팔린 좌석 번호는 어느 창구에서도 다시 나가지 않습니다.
유니크 제약은 테이블의 한 열을 두고 「행마다 값이 달라야 한다」고 정해 두는 규칙입니다. 회원 테이블의 이메일 열에 걸면 같은 이메일을 가진 행은 하나만 있을 수 있습니다. 여러 열을 묶어서 걸 수도 있습니다. 그 경우는 아래 소절에서 따로 봅니다.
이런 규칙을 통틀어 제약 조건이라고 부릅니다. 데이터가 지켜야 할 조건을 테이블 정의에 적어 두면 데이터베이스가 모든 쓰기를 그 조건에 비춰 봅니다. 어기는 쓰기는 저장하지 않고 오류로 돌려보냅니다.
규칙을 데이터베이스에 두는 까닭은 쓰는 쪽이 여럿이기 때문입니다. 가입을 받는 서버 코드, 관리자 화면, 데이터를 옮기는 배치 작업이 모두 같은 테이블에 씁니다. 그중 한 곳이라도 중복 확인을 빠뜨리면 중복이 들어옵니다. 규칙이 테이블에 붙어 있으면 어느 길로 들어오든 같은 검사를 거칩니다.
먼저 조회하고 넣는 코드가 새는 경우
이 소절은 유니크 제약 없이 코드로만 중복을 막을 때 무엇이 새는지 봅니다. 가입 요청 둘이 거의 같은 순간에 들어오는 경우를 예로 듭니다.
흔한 방식은 넣기 전에 같은 이메일이 있는지 조회해 보는 것입니다. 없으면 넣습니다. 있으면 「이미 가입된 이메일입니다」를 돌려줍니다. 요청이 하나씩 차례로 오면 이 방식으로 충분합니다.
요청 둘이 겹치면 달라집니다. 두 요청이 모두 조회를 먼저 마치면 둘 다 「없다」를 봅니다. 그다음 둘 다 넣습니다. 이렇게 작업의 순서가 엇갈려 결과가 틀어지는 상황을 경쟁 상태라고 부릅니다.
유니크 제약이 있으면 마지막 단계에서 막힙니다. 먼저 넣은 쪽은 저장되고 늦게 넣은 쪽은 거절됩니다. 조회는 둘 다 통과했어도 저장은 하나만 됩니다.
sequenceDiagram
participant A as 요청 A
participant B as 요청 B
participant D as 데이터베이스
A->>D: 이 이메일 있나
D-->>A: 없다
B->>D: 이 이메일 있나
D-->>B: 없다
A->>D: 넣는다
D-->>A: 저장됨
B->>D: 넣는다
D-->>B: 거절 · 이미 있는 값
Note over D: 제약이 없으면 두 번째 행도 저장된다
데이터베이스의 검사가 새지 않는 까닭은 확인과 저장이 한 덩어리이기 때문입니다. 같은 값이 있는지 보는 일과 행을 저장하는 일을 한 번에 처리하므로 그 사이에 다른 쓰기가 끼어들지 못합니다. 코드의 조회와 넣기는 따로 보내는 두 명령이라 그 사이가 비어 있습니다.
한 번에 한 작업만 지나가게 거는 잠금을 락이라고 부릅니다. 코드에서 락을 잡아 두 요청을 한 줄로 세우는 방법도 있습니다. 이 방법은 같은 테이블에 쓰는 코드가 전부 같은 락을 잡아야 통합니다. 한 곳이 빠지면 다시 샙니다.
앞의 조회를 버릴 필요는 없습니다. 대부분의 요청에서는 조회가 먼저 걸러 친절한 안내를 줍니다. 드물게 겹친 요청은 유니크 제약이 받아 냅니다.
선언하는 법
유니크 제약은 테이블을 만들 때 SQL(Structured Query Language, 구조화 질의 언어)로 적습니다. SQL 은
관계형 데이터베이스에 테이블을 만들고 데이터를 넣고 읽는 언어입니다. 열 이름 옆에 UNIQUE 한 낱말을
붙이면 됩니다.
CREATE TABLE users (
id bigint PRIMARY KEY,
email text UNIQUE
);
id 열에 붙은 PRIMARY KEY 는 이 열을 기본 키로 삼는다는 뜻입니다. 기본 키는 아래 「기본 키와 갈리는
두 가지」에서 따로 봅니다.
이 테이블에 같은 이메일로 두 번 넣어 봅니다. 두 행은 id 가 다르고 email 만 같습니다.
INSERT INTO users (id, email)
VALUES (1, '[email protected]'); -- 저장됨
INSERT INTO users (id, email)
VALUES (2, '[email protected]'); -- 거절 · 중복
두 번째 쓰기는 오류로 끝나고 테이블에는 행이 하나만 남습니다. 제약은 email 열만 보므로 id 가
다른 것은 소용이 없습니다.
이미 만든 테이블에 나중에 제약을 걸 수도 있습니다. 그때 테이블에 겹친 값이 이미 들어 있으면 제약을 걸지 못합니다. 겹친 행부터 정리해야 합니다.
여러 열을 묶은 유니크 제약
값 하나로는 겹쳐도 되고 조합으로만 겹치면 안 되는 경우가 있습니다. 수강 신청이 그렇습니다. 한 학생은 여러 과목을 듣고 한 과목에는 여러 학생이 듭니다.
그래서 학생 열만 보아도 값이 겹치고 과목 열만 보아도 겹칩니다. 막아야 할 것은 같은 학생이 같은 과목을 두 번 신청하는 경우 하나입니다. 이럴 때 두 열을 묶어 제약을 겁니다.
CREATE TABLE enrollments (
student_id bigint,
course_id bigint,
UNIQUE (student_id, course_id)
);
이 테이블에 네 행을 차례로 넣으면 이렇게 됩니다.
student_id |
course_id |
결과 |
|---|---|---|
| 1 | 10 | 저장됨 |
| 1 | 20 | 저장됨 · 학생은 같아도 과목이 다르다 |
| 2 | 10 | 저장됨 · 과목은 같아도 학생이 다르다 |
| 1 | 10 | 거절 · 첫 행과 조합이 같다 |
묶은 제약은 두 열의 조합을 하나의 값처럼 봅니다. 열 하나하나가 겹치는 것은 막지 않습니다.
빈 값이 여럿일 때
이 소절은 값이 비어 있는 행이 둘 이상일 때 유니크 제약이 그것을 겹침으로 보는지 다룹니다.
NULL은 열에 값이 없다는 표시입니다. 숫자 0 이나 빈 문자열과 달리 「모른다」를 뜻합니다. 모르는 값 둘이 같은지는 판정할 수 없습니다. 그래서 두 NULL 은 서로 같다고 보지 않습니다.
그 결과 유니크 제약이 걸린 열에도 NULL 인 행은 여럿 들어갈 수 있습니다. 전화번호를 안 적은 회원이
백 명이어도 전화번호 열의 유니크 제약은 그들을 막지 않습니다. 반드시 값이 있어야 하는 열이면
NOT NULL 제약을 함께 걸어 빈 값부터 막습니다.
여러 열을 묶은 제약도 같습니다. 묶음 가운데 한 열이라도 NULL 인 행은 다른 행과 겹친다고 보지 않습니다.
(1, NULL) 인 행은 몇 번이고 들어갑니다.
NULL 을 한 행에만 받는 데이터베이스도 있습니다. 설정으로 이 동작을 바꾸게 해 주는 데이터베이스도 있습니다. 빈 값이 들어올 수 있는 열이면 쓰는 데이터베이스의 규칙을 확인해야 합니다.
기본 키와 갈리는 두 가지
기본 키는 테이블에서 행 하나를 콕 집어 가리키려고 고른 열입니다. 기본 키도 값이 겹치면 안 됩니다. 유니크 제약과 자주 헷갈립니다.
첫째는 빈 값입니다. 기본 키에는 NULL 이 들어갈 수 없습니다. 유니크 제약은 위에서 본 것처럼 NULL 을 여럿 받습니다.
둘째는 개수입니다. 기본 키는 테이블에 하나뿐입니다. 유니크 제약은 필요한 만큼 걸 수 있습니다. 회원 테이블이라면 기본 키는 회원 번호에 두고, 이메일과 닉네임에는 유니크 제약을 하나씩 겁니다.
행 하나를 유일하게 가리킬 수 있는 열을 후보 키라고 부릅니다. 위 회원 테이블에서는 회원 번호도 이메일도 후보 키입니다. 기본 키로 고르지 않은 후보 키를 테이블 정의에 적어 두는 수단이 유니크 제약입니다.
검사를 맡는 인덱스
쓰기마다 「같은 값이 이미 있나」를 보려면 값을 빨리 찾을 수단이 있어야 합니다. 행이 백만 개인 테이블을 쓰기마다 처음부터 훑으면 쓰기 하나가 백만 번 비교를 합니다.
그래서 데이터베이스는 유니크 제약을 걸면 대개 그 열에 인덱스를 하나 만들어 검사에 씁니다. 인덱스는 열의 값을 정렬해 두고 그 값이 어느 행에 있는지 적어 둔 별도의 구조입니다. 정렬돼 있으니 같은 값이 있는지 금방 찾습니다.
값이 겹치지 않음을 보장하는 인덱스를 유니크 인덱스라고 부릅니다. 제약은 「겹치면 안 된다」는 규칙입니다. 유니크 인덱스는 그 규칙을 지키는 수단입니다.
이 인덱스는 조회에도 쓰입니다. 이메일로 회원을 찾는 쿼리가 빨라집니다. 대가는 공간과 쓰기입니다. 행을 넣거나 그 열을 고칠 때마다 인덱스도 함께 고쳐야 합니다.
걸린 쓰기가 코드로 돌아오는 모습
거절된 쓰기는 코드 쪽에 오류로 돌아옵니다. 이 오류를 흔히 중복 키 오류라고 부릅니다. 오류에는 대개 어느 제약에 걸렸는지 그 이름이 실려 옵니다.
그래서 제약에 이름을 붙여 두면 쓸모가 있습니다. 코드가 그 이름을 보고 「이메일이 겹쳤다」와
「닉네임이 겹쳤다」를 가려 서로 다른 안내를 줄 수 있습니다. 이름은 제약 앞에 CONSTRAINT 로 붙입니다.
CONSTRAINT users_email_uq UNIQUE (email)
중복 거절을 이용하는 쓰기
거절을 오류로만 볼 필요는 없습니다. 이 소절은 유니크 제약의 거절을 일부러 이용하는 쓰기 둘을 봅니다.
업서트는 「있으면 고치고 없으면 넣는」 쓰기입니다. 이때 「있다」를 판정하는 기준이 유니크 제약입니다. 넣다가 제약에 걸리면 넣는 대신 기존 행을 고칩니다. 조회와 넣기가 한 번의 쓰기로 합쳐지므로 앞에서 본 두 단계 사이의 틈이 생기지 않습니다.
멱등성 키는 같은 요청이 두 번 와도 한 번만 처리하려고 요청마다 붙이는 고유한 값입니다. 결제 요청이 네트워크 문제로 재시도되는 경우가 그 쓰임입니다. 이 키를 담는 열에 유니크 제약을 걸어 두면 두 번째 쓰기가 거절됩니다. 거절되면 이미 처리한 요청으로 보고 앞의 결과를 돌려줍니다.
걸 때와 안 걸 때
업무 규칙상 겹치면 안 되는 값이면 겁니다. 겹쳐도 되는 값에 걸면 정상적인 데이터가 거절됩니다.
| 값 | 거나 | 까닭 |
|---|---|---|
| 이메일 · 로그인 아이디 | 건다 | 한 사람만 가리켜야 한다 |
| 외부 결제 번호 | 건다 | 같은 결제가 두 번 기록되면 돈이 두 번 잡힌다 |
| 멱등성 키 | 건다 | 재시도가 두 번 처리되면 안 된다 |
| 사람 이름 | 안 건다 | 동명이인이 있다 |
위 표만으로 끝나지 않는 경우가 셋 있습니다. 셋 다 제약을 걸었어도 기대와 다르게 동작합니다.
첫째는 대소문자입니다. 대소문자를 가려 비교하는 설정이면 [email protected] 와 [email protected] 는 다른 값입니다.
사람 눈에는 같은 이메일이어도 제약은 둘 다 받습니다. 그래서 저장하기 전에 소문자로 바꿔 넣거나,
소문자로 바꾼 값에 제약을 겁니다.
둘째는 소프트 삭제입니다. 행을 지우지 않고 「지움」 표시만 해 두는 방식입니다. 표시만 된 행도 이메일을 계속 붙잡고 있습니다. 그래서 탈퇴한 사람이 같은 이메일로 다시 가입하려 하면 거절됩니다.
이럴 때는 조건에 맞는 행만 담는 인덱스로 검사 범위를 좁힙니다. 지워지지 않은 행만 담는 부분 인덱스에 유일성을 걸면 탈퇴한 행은 검사에서 빠집니다. 이 인덱스를 지원하지 않는 데이터베이스도 있습니다.
셋째는 데이터를 여러 데이터베이스에 나눠 담는 샤딩입니다. 유니크 제약은 한 데이터베이스 안에서만 지켜집니다. 다른 데이터베이스에 같은 값이 있는지는 보지 못합니다.
관련 항목
유니크 제약과 나란히 테이블에 걸리는 제약 조건
제약조건 · NOT NULL · 외래 키 · CHECK 제약 · 참조 무결성 · 무결성
유니크 제약으로 지키는 키
기본 키 · 후보 키 · 자연 키 · 대리 키 · 복합 키 · 식별자
유니크 제약을 강제하는 인덱스
인덱스 · 유니크 인덱스 · B-tree · 해시 인덱스 · 부분 인덱스 · 다중칼럼 인덱스
코드로 중복을 막을 때 터지는 동시성 문제
경쟁 상태 · 동시성 · 트랜잭션 · 격리 수준 · 락 · 낙관적 잠금
유니크 제약에 기대는 쓰기 방식
업서트 · 멱등성 · 멱등성 키 · 중복 제거
유니크 제약이 비교하는 값의 규칙
NULL · 삼값 논리 · 콜레이션 · 대소문자 구분
유니크 제약과 부딪히는 데이터 설계
소프트 삭제 · 샤딩 · 파티셔닝 · 분산 데이터베이스
유니크 제약 위반이 드러나는 오류
중복 키 오류 · 무결성 제약 위반 · SQLSTATE
유니크 제약을 선언하는 언어와 스키마 구성 요소
다른 이름: unique constraint · UNIQUE 제약 · 유니크 제약조건 · 유일성 제약 · 고유 제약