← Posts

Database · Design · Key

키 설계 - 무엇으로 행을 가리킬 것인가

자연키가 배신하는 순간, 대리키를 쓰면서 흔히 빠뜨리는 것, 그리고 UUID가 인덱스를 망가뜨리는 이유.

목차
  1. 키가 하는 일
  2. 자연키 - 현실의 값을 쓴다
  3. 바뀌면 어떻게 되나
  4. 대리키 - 의미 없는 값을 붙인다
  5. 여기서 흔히 빠뜨리는 것
  6. 순번이냐 UUID냐
  7. 그럼 무조건 순번인가
  8. 그래서 요즘은
  9. 외래키를 걸 것인가
  10. 정리

이메일을 회원 테이블의 기본키로 삼았다. 유일하고, 안 바뀌고, 자연스러웠다.

2년 뒤 “이메일 변경” 기능을 만들라는 요청이 왔다.

앞 글이 “무엇을 어디에 둘까”였다면, 이번엔 그걸 무엇으로 가리킬까다.

키가 하는 일

키는 행 하나를 콕 집어내는 값이다. 그리고 그 값은 다른 테이블에도 퍼진다. 주문은 회원을 가리키고, 배송은 주문을 가리킨다.

여기서 기본키의 조건이 나온다. 유일해야 하고, 비어 있으면 안 되고, 그리고 안 바뀌어야 한다.

앞의 둘은 DB가 강제한다. 문제는 세 번째다. “안 바뀐다”는 DB가 검사해주지 않는다. 우리가 믿는 것뿐이다.

자연키 - 현실의 값을 쓴다

이메일, 전화번호, 사업자등록번호, ISBN. 이미 세상에 있는 식별자를 그대로 키로 쓰는 것이다.

매력적이다. 값 자체가 의미가 있어서 조인 없이도 뭔지 알 수 있고, 별도 컬럼을 만들 필요도 없다.

그런데 안 바뀐다는 가정이 거의 항상 배신한다.

  • 이메일은 바꾼다. 회사를 옮기면 바꾼다.
  • 전화번호도 바꾼다.
  • 사업자등록번호는 폐업 후 재발급되기도 한다.
  • ISBN조차 개정판에서 바뀐다.

공통점이 있다. 전부 우리가 정한 게 아니다. 남이 정한 규칙이고, 남은 언제든 규칙을 바꾼다. “이건 절대 안 바뀝니다”라는 말은 대개 아직 안 바뀌었다는 뜻이다.

바뀌면 어떻게 되나

기본키 하나만 고치면 끝이 아니다. 그 값을 가리키던 모든 곳이 딸려온다.

diagramdiagram

앞 글에서 한 사실은 한 곳에 두라고 했다. 그런데 키는 정반대다. 키는 사방에 퍼진다. 그게 키의 일이니까.

그래서 자연키를 바꾸는 건 정규화가 막아주지 못한다. 애초에 중복이 아니라 참조이기 때문이다. 값이 바뀌면 참조를 전부 따라가야 하고, FK로 안 걸린 곳(로그, 캐시, 외부 시스템)은 아무도 못 찾는다.

대리키 - 의미 없는 값을 붙인다

그래서 대부분 이렇게 한다. 아무 의미 없는 id를 만들어 기본키로 삼는다.

의미가 없으니 바뀔 이유가 없다. 이메일이 바뀌어도 id는 그대로고, 참조도 그대로다. 한 줄만 고치면 끝난다.

여기서 흔히 빠뜨리는 것

대리키를 쓴다고 자연키를 버리는 게 아니다.

sql
-- ❌ id만 PK로. 이러면 같은 이메일로 두 번 가입된다
CREATE TABLE members (
  id     BIGINT PRIMARY KEY,
  email  TEXT
);

-- ✓ id는 식별, email은 여전히 유일해야 한다
CREATE TABLE members (
  id     BIGINT PRIMARY KEY,
  email  TEXT NOT NULL UNIQUE
);

id가 PK라는 건 “행을 무엇으로 가리키느냐” 의 답일 뿐이다. “이메일이 겹치면 안 된다” 는 전혀 다른 규칙이고, 아무도 대신 지켜주지 않는다.

주의

ACID의 C에서 본 그대로다. DB는 규칙을 모른다. 내가 제약으로 표현한 만큼만 지켜진다. id를 붙였으니 유일성이 해결됐다고 생각하는 순간, 같은 사람이 두 번 가입한 데이터가 조용히 쌓인다.

대리키는 자연키를 대체하는 게 아니라 덧붙이는 것이다. 자연키는 UNIQUE 제약으로 남는다.

순번이냐 UUID냐

대리키를 만드는 방법은 크게 둘이다. 그리고 이 선택이 인덱스에 직접 영향을 준다.

key-insertkey-insert

순번은 항상 커진다. 그래서 새 행이 늘 맨 오른쪽 블록에 붙는다. 만지는 블록이 하나뿐이고, 그 블록은 계속 쓰이니 메모리에 상주한다. 디스크를 거의 안 건드린다.

무작위 UUID는 어디에 꽂힐지 모른다. 새 행마다 다른 블록을 열어야 하고, 그 블록이 메모리에 없으면 디스크에서 읽어온다. 게다가 중간에 끼워 넣다 보니 블록이 꽉 차면 쪼개진다(페이지 분할). 쓰기 한 번이 여러 블록을 만진다.

차이는 데이터가 쌓일수록 벌어진다. 인덱스가 메모리에 다 들어가는 동안은 티가 안 나다가, 넘어가는 순간부터 급격히 느려진다.

그럼 무조건 순번인가

순번에도 대가가 있다.

밖으로 새어 나간다. 주문번호가 1001, 1002면 URL만 보고 남의 주문을 넘겨볼 수 있고(권한 검사를 안 했다면), 하루 간격으로 두 번 가입해보면 그 사이 몇 명이 가입했는지 알 수 있다. 경쟁사에게 규모를 알려주는 셈이다.

채번이 한 곳에 묶인다. 다음 번호를 정하려면 누군가 “지금 몇 번까지 나갔지”를 알아야 한다. 서버가 여러 대면 그걸 조율해야 하고, DB를 나누면 번호가 겹친다.

그래서 요즘은

시간 정렬되는 UUID가 있다. UUID v7이나 ULID 같은 것들이다.

앞부분에 시각을 넣고 뒷부분만 무작위로 채운다. 그러면 값이 대체로 커지는 순서로 생성된다. 위 그림의 순번처럼 오른쪽 끝에 붙으니 인덱스 문제가 사라진다. 순번처럼 “다음 값”을 바로 찍어낼 수도 없다.

다만 공짜로 다 얻은 건 아니다. 앞부분이 시각이라는 건 언제 만들어졌는지가 값에 그대로 드러난다는 뜻이고, 그만큼 무작위 부분도 줄어든다. 그래서 규격 자체가 “추측하기 어려워야 하는 용도에는 쓰지 말라”고 못 박는다. 두 값을 나란히 놓으면 어느 쪽이 먼저 만들어졌는지도 보인다.

순번UUID v4UUID v7 / ULID
인덱스 삽입오른쪽 끝 (쌈)여기저기 (비쌈)오른쪽 끝 (쌈)
다음 값 찍어내기쉬움어려움어려움
생성 시각 노출안 됨안 됨
규모 노출안 됨안 됨
여러 곳에서 채번조율 필요자유자유
크기8바이트16바이트16바이트
참고

순번을 쓰더라도 밖으로는 다른 값을 보여주면 된다. 내부 PK는 순번으로 두고, URL이나 API에는 별도의 공개용 식별자를 쓰는 식이다. 키 하나가 모든 역할을 다 할 필요는 없다.

그리고 이게 UUID를 쓸 때도 답이다. 남이 못 알아맞히는 게 중요한 자리(비밀번호 재설정 링크, 초대 코드)에는 저장용 키를 그대로 내보내지 말고 그 용도로 따로 만든 무작위 값을 쓴다. 저장에 좋은 키와 감춰야 하는 값은 요구사항이 다르다.

외래키를 걸 것인가

FK는 “이 값은 저 테이블에 반드시 있어야 한다”는 제약이다.

안 걸면 편하다. 순서 신경 안 쓰고 넣을 수 있고, 지울 때도 안 막힌다. 그래서 성능이나 편의를 이유로 FK를 빼는 경우가 있다.

대신 없는 회원을 가리키는 주문이 생긴다. 그리고 그건 조용히 생긴다. 나중에 조인했을 때 행이 안 나오고서야 안다.

이것도 결국 같은 이야기다. FK를 안 걸면 그 규칙은 사라지는 게 아니라 애플리케이션으로 넘어온다. 지금 짜는 코드뿐 아니라 6개월 뒤 다른 사람이 짤 코드, 그리고 급할 때 누가 직접 실행할 SQL까지 전부 그 규칙을 지켜야 한다.

정리

  • 기본키의 조건 셋 중 “안 바뀐다”만 DB가 검사해주지 않는다. 우리가 믿는 것뿐이고, 대개 그 믿음이 틀린다.
  • 자연키는 남이 정한 규칙이다. 남은 언제든 규칙을 바꾼다.
  • 대리키는 자연키를 대체하지 않는다. id를 PK로 삼되 자연키는 UNIQUE로 남긴다. 안 그러면 중복이 조용히 쌓인다.
  • 무작위 UUID는 인덱스를 흩뜨린다. 순번이나 시간 정렬 UUID(v7·ULID)는 오른쪽 끝에 붙어서 싸다.
  • 순번은 밖으로 새어 나간다. 내부 키와 공개용 식별자를 나누면 둘 다 얻는다.
  • FK를 빼면 규칙이 사라지는 게 아니라 사람에게 넘어온다.
참고

여기까지가 설계다. 다음은 규모다. 데이터가 한 대에 안 들어갈 때 어떻게 나누고(파티셔닝과 샤딩), 어떻게 복제하는가.

관련 글