블로그

DB 테이블 설계 체크리스트

테이블 설계에는 정답표가 없습니다. 같은 요구사항을 놓고도 회사마다, 팀마다 다르게 그립니다. 그런데 실무에서 문제가 되는 지점은 대체로 정해져 있습니다. 이름을 어떻게 지을지, 자료형을 무엇으로 할지, 기본키를 어떻게 잡을지, NULL을 허용할지, 인덱스를 어디에 걸지. 이 다섯 가지에서 갈린 판단이 몇 달 뒤에 수정 비용으로 돌아옵니다.

데이터베이스 설계에서 아래 내용은 완성된 스키마 예제가 아니라, 이름·키·자료형·NULL·인덱스를 결정할 때 확인할 기준입니다. 끝의 점검 목록은 설계 리뷰에 그대로 사용할 수 있습니다.

테이블 명명 규칙: 이름부터 정한다

이름은 가장 사소해 보이지만 가장 오래 남습니다. 자료형은 나중에 ALTER로 바꿀 수 있어도, 이름은 애플리케이션 코드와 쿼리, 문서에 전부 퍼져 있어서 바꾸기가 훨씬 어렵습니다.

테이블 이름

단수와 복수 중 하나로 통일합니다. 정답은 없고 통일이 답입니다. 복수형(members, posts)이 조금 더 흔한데, user 같은 단수형 이름이 DBMS 예약어와 충돌하는 경우가 있기 때문입니다. 어느 쪽을 고르든 한 스키마 안에서 섞이지만 않으면 됩니다.

소문자와 언더스코어를 기본으로 합니다. order_items처럼 쓰는 방식이 대소문자 구분 문제에서 자유롭습니다. 리눅스와 윈도우에서 대소문자 처리가 다른 DBMS가 있어서, 대문자를 섞으면 환경을 옮길 때 문제가 생길 수 있습니다.

접두사는 필요할 때만 붙입니다. tb_, t_ 같은 접두사는 모든 테이블에 붙으면 정보를 담지 못합니다. 반면 하나의 DB에 여러 서비스가 들어가는 구조라면 shop_orders, blog_posts처럼 도메인 접두사가 도움이 됩니다.

중간 테이블은 두 이름을 잇되, 의미가 생기면 이름을 바꿉니다. 게시글과 태그를 잇는 post_tags는 단순 연결이라 이 이름으로 충분합니다. 여기에 태그를 단 사람과 시각이 붙으면 더 이상 연결만 하는 테이블이 아니므로, post_taggings처럼 그 자체로 읽히는 이름이 낫습니다.

컬럼 이름

컬럼은 규칙을 몇 개만 정해 두면 이후 설계가 빨라집니다.

  • 기본키는 id. 테이블 안에서 굳이 member_id라고 쓸 이유가 없습니다. 참조하는 쪽에서 member_id가 되면 관계가 눈에 들어옵니다.
  • 외래 키는 대상테이블단수_id. member_id, category_id 형태입니다. 같은 테이블을 두 번 참조할 때는 역할을 앞에 붙여 writer_id, approver_id처럼 구분합니다.
  • 시각은 _at, 날짜는 _date. created_at DATETIME과 birth_date DATE를 구분해 두면 자료형을 보지 않고도 무엇이 들어 있는지 알 수 있습니다.
  • 참과 거짓은 is_나 has_. is_active, has_attachment처럼 표기합니다. active 하나만 두면 값이 1일 때 활성인지 비활성인지 헷갈립니다.
  • 금액과 수량은 단위를 이름에 넣습니다. price보다 price_krw, weight보다 weight_kg가 낫습니다. 단위를 컬럼 설명에만 적어 두면 쿼리를 쓰는 사람은 보지 않습니다.

약어 사전을 만들어 둡니다

qty, amt, cd, dt 같은 약어는 팀마다 다르게 사용합니다. 세 개 이상 쓸 생각이라면 명세서 어딘가에 약어와 원말을 표로 남겨 두는 편이 좋습니다. 나중에 합류한 사람이 reg_dt가 등록일인지 등록 시각인지 추측하지 않아도 됩니다. 명세서에 어떤 항목을 두면 되는지는 테이블 명세서 작성법에 정리해 두었습니다.

기본키: 대리키를 기본으로 두고 예외를 관리한다

기본키는 설계에서 되돌리기가 가장 어려운 결정입니다. 참조하는 테이블이 늘어날수록 바꾸는 비용이 급격히 커집니다.

기본은 대리키(surrogate key)입니다. SQLD 같은 자격시험에서는 인조식별자라고 부르는 그 키입니다. id BIGINT AUTO_INCREMENT 같은 값에는 업무 의미가 없어서, 업무 규칙이 바뀌어도 흔들리지 않습니다. 이메일을 기본키로 두면 회원이 이메일을 바꾸는 순간 그 값을 참조하던 모든 테이블을 함께 수정해야 합니다.

자연키(natural key)가 나은 경우도 있습니다. 업무에서 의미를 가진 값을 그대로 키로 쓰는 방식입니다. 국가 코드나 통화 코드처럼 국제 표준으로 고정된 값, 그리고 중간 테이블의 (post_id, tag_id) 복합키가 대표적입니다. 값이 바뀌지 않는다는 확신이 있고 참조하는 쪽에서 조인을 줄일 수 있다면 자연키가 단순합니다.

INT와 BIGINT의 경계를 미리 정합니다. INT의 최대값은 약 21억입니다. 회원 테이블이라면 INT로 충분하지만, 로그나 이력처럼 행이 빠르게 쌓이는 테이블은 BIGINT로 시작하는 편이 안전합니다. 운영 중에 INT를 BIGINT로 바꾸는 작업은 테이블 크기에 따라 긴 잠금을 동반합니다.

UUID를 기본키로 쓸 때 알아 둘 것

UUID는 목적이 분명할 때 사용합니다. 여러 서버에서 키를 미리 만들어야 하거나, 순번이 밖으로 드러나면 곤란한 경우입니다. 주문 번호가 1씩 늘어나는 값이면 경쟁사가 하루 주문량을 추정할 수 있는데, 이런 상황에서 UUID가 답이 됩니다.

대신 두 가지 비용이 따라옵니다. 저장 공간이 커지고, 무작위로 만들어진 UUID는 삽입 위치가 인덱스 전체에 흩어집니다. B-트리 인덱스는 값이 순서대로 들어올 때 가장 효율적인데, 무작위 값은 매번 다른 페이지를 건드리게 되어 삽입 성능과 캐시 적중률이 함께 떨어집니다. 흔히 쓰이는 UUID 버전 4가 여기에 해당합니다.

이 문제를 줄이기 위해 나온 것이 UUID 버전 7입니다. 2024년 RFC 9562에 정리된 형식으로, 앞쪽 48비트에 밀리초 단위 시각을 담고 나머지 비트는 구현에 따라 더 세밀한 시각·카운터·무작위 값으로 구성됩니다. 그래서 버전 4보다 시간 순으로 정렬되고 삽입 위치도 인덱스 뒤쪽에 모이는 편입니다. 다만 같은 밀리초 안의 생성 순서까지 항상 보장되는 것은 아니며, 그 보장은 생성기 구현에 달려 있습니다. PostgreSQL 18에는 uuidv7() 함수가 들어왔고, 다른 환경에서는 라이브러리로 생성해 넣는 방식이 일반적입니다.

또 하나 자주 쓰이는 구성은 두 가지를 나누는 것입니다. 내부 조인과 외래 키는 숫자 대리키로 처리하고, API나 URL에 노출할 식별자만 UUID로 따로 둡니다. 성능과 노출 문제를 각각 다른 컬럼으로 해결하는 방식이라, 규모가 커진 서비스에서 자주 보입니다.

자료형 선택 기준

자료형은 한 번 정하면 데이터가 쌓인 뒤에 바꾸기 번거롭습니다. 처음부터 완벽할 필요는 없지만, 아래 기준만 지켜도 나중에 손댈 일이 크게 줄어듭니다.

문자

VARCHAR를 기본으로 하고, 길이는 근거를 가지고 정합니다. 이름 컬럼을 VARCHAR(255)로 두는 관행이 있는데, 255가 특별한 숫자여서가 아니라 예전 기본값이 그랬기 때문입니다. 이메일은 255, 이름은 50, 우편번호는 고정 길이 CHAR처럼 데이터의 성격에 맞춰 정하면 됩니다.

본문처럼 길이를 예측할 수 없는 값은 TEXT 계열을 사용합니다. MySQL의 TEXT는 65,535바이트가 상한이라, utf8mb4에서 한글은 대략 2만 자 남짓입니다. 긴 글을 다루는 서비스라면 MEDIUMTEXT가 필요합니다.

VARCHAR(255)에 UNIQUE를 걸어도 되는가

인터넷에서 자주 보이는 조언 중에 "utf8mb4에서는 191자를 넘기면 인덱스를 못 건다"는 말이 있습니다. 지금은 조건부로만 맞는 이야기라, 그대로 따르면 필요 없는 제약을 스스로 만들게 됩니다.

InnoDB의 인덱스 키 접두사 상한은 행 형식과 페이지 크기에 따라 다릅니다. 기본 16KB 페이지에서 DYNAMIC이나 COMPRESSED 행 형식이면 3,072바이트이고, REDUNDANT나 COMPACT 행 형식이면 767바이트입니다. 페이지 크기를 8KB나 4KB로 낮추면 3,072바이트 한도도 비례해 작아집니다. utf8mb4는 한 글자가 최대 4바이트이므로, 767바이트 기준에서는 191자가 한계가 되고 그래서 저 조언이 돌아다닙니다.

MySQL 5.7.9부터 기본 행 형식이 DYNAMIC이라, 기본 16KB 페이지로 최근 만든 테이블에서 VARCHAR(255)는 최대 1,020바이트로 3,072바이트 안에 들어갑니다. 이 조건에서는 이메일 컬럼에 UNIQUE를 걸 수 있다는 뜻입니다. 오래된 서버에서 넘어온 테이블, 행 형식이나 페이지 크기를 다르게 설정한 환경이라면 한도가 달라질 수 있으니 오류가 생기면 해당 설정부터 확인하면 됩니다.

자료형별 한계값

설계 중에 자주 찾게 되는 숫자를 모아 두면 편합니다. MySQL 기준입니다.

자료형 범위·상한 실무에서 걸리는 지점
INT 약 -21억 ~ 21억 로그·이력 테이블에서 도달
INT UNSIGNED 0 ~ 약 43억 음수가 필요 없을 때만
BIGINT 약 ±922경 사실상 걱정할 일 없음
VARCHAR 행 전체 65,535바이트 안에서 컬럼이 많으면 길이를 줄여야 함
TEXT 65,535바이트 긴 본문은 MEDIUMTEXT
DATETIME 1000년 ~ 9999년 범위 문제는 거의 없음
TIMESTAMP 1970년 ~ 2038년 2038년 상한이 실재하는 제약
DECIMAL 전체 65자리, 소수 30자리 정밀도는 충분

여기서 실제로 자주 부딪히는 건 두 가지입니다. 행 전체 크기가 65,535바이트로 제한되므로 VARCHAR(255)를 수십 개 늘어놓은 테이블은 만들다가 오류가 납니다. 그리고 InnoDB 테이블의 컬럼 수 상한은 1,017개입니다. 이 숫자에 가까워지고 있다면 자료형 문제가 아니라 테이블을 나눠야 한다는 신호로 읽는 편이 맞습니다.

문자셋과 콜레이션도 설계 항목입니다

자료형만 정하고 문자셋을 넘어가면 나중에 데이터가 깨집니다. MySQL에서 한글과 이모지를 제대로 저장하려면 utf8mb4를 써야 합니다. 이름이 비슷한 utf8은 실제로는 최대 3바이트만 저장하는 utf8mb3의 별칭이었고, 이모지 같은 4바이트 문자가 들어오면 저장에 실패합니다. MySQL 8.0에서 utf8mb3는 폐기 예정으로 표시되었으니 새로 만드는 스키마라면 고민할 필요가 없습니다.

콜레이션은 비교와 정렬의 규칙입니다. 대소문자를 구분할지, 악센트를 구분할지가 여기서 정해집니다. 이름 끝에 _ci가 붙으면 대소문자를 구분하지 않고, _cs면 구분합니다. 이메일을 UNIQUE로 두면서 대소문자를 구분하지 않는 콜레이션을 쓰면 [email protected]과 [email protected]이 같은 값으로 취급되는데, 이건 대부분의 서비스에서 오히려 원하는 동작입니다. 반대로 코드값이나 해시처럼 대소문자가 의미를 갖는 컬럼이라면 콜레이션을 따로 지정해야 합니다.

문자셋과 콜레이션은 데이터베이스, 테이블, 컬럼 단위로 각각 지정할 수 있어서 섞이기 쉽습니다. 서로 다른 콜레이션의 컬럼을 조인하면 인덱스를 타지 못하고 느려지는 경우가 있으니, 스키마 전체에서 하나로 통일해 두는 편이 안전합니다.

숫자

정수는 INT와 BIGINT 중에서 예상 행 수를 기준으로 고릅니다. 음수가 없는 값이라면 UNSIGNED를 붙여 범위를 두 배로 쓸 수 있지만, 계산 과정에서 음수가 나오는 상황이 있는지 먼저 확인해야 합니다.

금액과 비율은 DECIMAL을 사용합니다. FLOAT이나 DOUBLE은 이진 부동소수점이라 0.1을 정확히 표현하지 못합니다. 한 건씩 보면 문제가 없어 보여도 수만 건을 더하면 1원이 어긋납니다. DECIMAL(15,2)처럼 전체 자릿수와 소수 자릿수를 명시하고, 세율이나 환율은 소수 자릿수를 넉넉히 잡습니다.

날짜와 시각

DATE는 날짜만, DATETIME과 TIMESTAMP는 시각까지 담습니다. 생일처럼 시각이 의미 없는 값에 DATETIME을 쓰면 자정 기준 비교에서 실수가 생깁니다.

시간대 정책을 먼저 정합니다. 서비스가 한 국가 안에서만 돌아가더라도, 서버가 여러 지역에 있거나 나중에 해외로 확장하면 저장된 시각의 기준이 문제가 됩니다. UTC로 저장하고 표시할 때 변환하는 방식이 가장 다루기 쉽습니다. MySQL의 TIMESTAMP는 세션 시간대에 따라 값이 변환되고 2038년 상한이 있으므로, 이 차이를 알고 고르면 됩니다.

참과 거짓, 그리고 상태

MySQL에는 별도의 불리언 타입이 없어서 TINYINT(1)을 사용합니다. 값이 두 가지로 끝나는지 먼저 확인하는 게 중요합니다. 승인 여부가 승인·거절·대기 세 가지가 되는 순간 불리언으로는 표현할 수 없습니다.

상태 컬럼에 ENUM을 쓰는 방식은 편하지만, 값을 추가할 때마다 테이블 정의를 변경해야 합니다. 상태가 늘어날 가능성이 있거나 상태마다 설명과 정렬 순서 같은 부가 정보가 붙는다면, 코드 테이블을 두고 참조하는 쪽이 확장하기 쉽습니다.

JSON 컬럼

JSON은 통째로 저장하고 통째로 읽는 데이터에 적합합니다. 사용자 설정값이나 외부 API 응답 원문처럼 구조가 자주 바뀌고 검색 대상이 아닌 값이 여기에 해당합니다.

반대로 그 안의 값으로 검색하거나 조인해야 한다면 컬럼으로 꺼내야 합니다. JSON 배열에 태그를 넣으면 콤마로 이어 붙인 문자열 컬럼과 같은 문제를 그대로 겪습니다. 이 판단의 근거는 데이터베이스 정규화의 제1정규형 부분에 예제와 함께 있습니다.

NULL 정책: 없음과 비어 있음을 구분한다

NULL은 값이 아직 없다는 사실을 기록하는 정상적인 상태입니다. 문제는 NULL과 빈 문자열, 0을 섞어 쓸 때 생깁니다. 전화번호 컬럼에 어떤 행은 NULL, 어떤 행은 빈 문자열이 들어 있으면 조회 조건을 두 번 써야 합니다.

기본 방침은 NOT NULL입니다. 값이 반드시 있어야 하는 컬럼은 NOT NULL로 막고, 필요하면 기본값을 함께 지정합니다. 그러면 애플리케이션이 값을 넣지 않아도 데이터가 비지 않습니다.

NULL을 허용할 때는 이유를 남깁니다. 아직 입력하지 않은 상태와 비워 두기로 한 상태를 구분해야 한다면 NULL이 맞습니다. 그 의도를 컬럼 설명에 한 줄 적어 두면 다음 사람이 임의로 NOT NULL을 걸지 않습니다.

세 가지 값 논리를 기억합니다. SQL에서 NULL과의 비교는 참도 거짓도 아닙니다. WHERE status != 'DONE' 조건은 status가 NULL인 행을 걸러 냅니다. 집계에서도 COUNT는 NULL을 세지 않고 AVG는 NULL을 제외하고 평균을 냅니다. 통계가 이상하게 나올 때 가장 먼저 확인할 지점입니다.

UNIQUE 제약과 NULL의 관계도 DBMS마다 다릅니다. 대부분의 DBMS는 NULL을 서로 다른 값으로 취급해서 NULL이 여러 행에 들어갈 수 있습니다. 유일성이 꼭 필요한 컬럼이라면 NOT NULL을 함께 거는 편이 확실합니다.

날짜와 금액 컬럼에서 반복되는 함정

이 두 종류의 컬럼은 실무에서 사고가 잦아서 따로 정리해 둘 만합니다.

생성 시각과 수정 시각은 모든 테이블에 둡니다. created_at과 updated_at이 없으면 데이터가 언제 들어왔는지 추적할 방법이 없습니다. 장애를 분석할 때 이 두 컬럼의 유무가 조사 시간을 좌우합니다. 기본값과 자동 갱신을 DB에서 처리할지 애플리케이션에서 넣을지는 팀에서 하나로 정하면 됩니다.

삭제를 어떻게 다룰지 미리 정합니다. 행을 실제로 지우는 대신 deleted_at에 시각을 남기는 소프트 삭제는 복구와 이력 보존에 유리하지만, 모든 조회 쿼리에 조건이 하나 더 붙습니다. 절반만 적용하면 삭제된 데이터가 어딘가에서 다시 보이는 문제가 생기니, 테이블 단위로 방침을 정하고 명세서에 남깁니다.

소프트 삭제가 UNIQUE 제약과 부딪히는 문제

소프트 삭제를 도입하면 거의 반드시 겪는 상황이 하나 있습니다. 회원 이메일에 UNIQUE를 걸어 둔 상태에서 탈퇴를 소프트 삭제로 처리하면, 탈퇴한 사람과 같은 이메일로는 다시 가입할 수 없습니다. 행이 남아 있으니 UNIQUE에 걸리는 것입니다.

해결 방법은 DBMS에 따라 갈립니다.

  • PostgreSQL은 부분 인덱스로 깔끔하게 해결됩니다. CREATE UNIQUE INDEX ... ON members (email) WHERE deleted_at IS NULL 처럼 조건을 붙이면, 살아 있는 행끼리만 유일성을 검사합니다.
  • MySQL에는 부분 인덱스가 없습니다. 대신 deleted_at을 UNIQUE 조합에 포함시키는 방법을 사용합니다. 다만 NULL은 서로 다른 값으로 취급되므로 삭제되지 않은 행에서는 유일성이 보장되지 않아, 삭제되지 않은 상태를 0이나 고정값으로 두는 별도 컬럼을 만들어 조합에 넣는 방식이 자주 사용됩니다.
  • 삭제 시 값을 바꾸는 방법도 있습니다. 탈퇴 처리할 때 이메일 뒤에 삭제 시각을 붙여 [email protected] 형태로 바꾸면 UNIQUE 충돌이 사라집니다. 개인정보 보관 정책과도 맞물리는 선택이라 기획과 함께 정해야 합니다.

어느 방법을 고르든, 소프트 삭제를 도입하기로 했다면 UNIQUE가 걸린 컬럼 목록을 먼저 확인하는 순서가 맞습니다. 가입이 막히는 문제는 대부분 운영에 들어간 뒤에 발견됩니다.

금액에는 통화와 시점이 함께 필요합니다. 여러 통화를 다룬다면 금액 컬럼 옆에 통화 코드가 있어야 하고, 환율이 개입한다면 환율과 적용 시점까지 남겨야 나중에 계산을 재현할 수 있습니다. 주문 시점의 단가를 주문 상세에 복사해 두는 것도 같은 이유입니다. 이건 중복이 아니라 그 시점의 사실을 기록하는 일입니다.

관계와 제약: DB에 맡길 것과 코드에 맡길 것

외래 키 제약을 걸지 말지부터 정합니다. FK를 걸면 잘못된 참조가 DB 수준에서 막히고 관계가 스키마에 남아 다이어그램으로도 보입니다. 대량 적재나 샤딩 환경에서 제약이 부담이 되는 경우도 있지만, 일반적인 업무 시스템이라면 거는 쪽의 이점이 큽니다. 걸지 않기로 했다면 그 사실과 이유를 문서에 남겨야 다음 사람이 관계가 없는 줄로 오해하지 않습니다.

삭제 규칙은 관계마다 정합니다. 기본은 RESTRICT로 막아 두고, 부모가 사라지면 자식도 의미가 없어지는 관계에만 CASCADE를 지정합니다. 회원 탈퇴 시 게시글을 어떻게 처리할지 같은 판단은 업무 규칙이지 기술 결정이 아니므로, 기획과 함께 정하는 게 맞습니다.

UNIQUE는 단일 컬럼만이 아니라 조합에도 겁니다. 같은 회원이 같은 상품을 장바구니에 두 번 담지 못하게 하려면 (member_id, product_id) 조합에 UNIQUE가 필요합니다. 애플리케이션에서 확인하고 넣는 방식은 동시 요청에서 뚫립니다.

CHECK 제약은 값의 범위를 지킬 때 유용합니다. 별점이 1에서 5 사이여야 한다면 CHECK (rating BETWEEN 1 AND 5)로 막아 둘 수 있습니다.

여기에는 버전 함정이 하나 있습니다. MySQL은 8.0.16부터 CHECK 제약을 실제로 강제합니다. 그 이전 버전은 문법을 받아들이기만 하고 조용히 무시했습니다. 오래된 스키마를 그대로 옮겨 왔다면 CHECK가 적혀 있어도 실제로는 검증되지 않았을 수 있으니, 버전을 올린 뒤에는 기존 데이터가 제약을 통과하는지 먼저 확인해야 합니다. 통과하지 못하는 데이터가 남아 있으면 제약을 켜는 시점에 오류가 납니다.

제약을 정의만 해 두고 당장 강제하고 싶지 않다면 NOT ENFORCED를 붙일 수 있습니다. 마이그레이션 중간 단계에서 쓸 만한 선택지입니다.

인덱스: 설계 시점에 정할 것만 정한다

인덱스는 조회를 빠르게 하는 대신 입력과 수정을 느리게 하고 저장 공간을 차지합니다. 그래서 설계 단계에서는 확실한 것만 잡고, 나머지는 실제 쿼리를 보고 추가하는 편이 낫습니다.

외래 키 컬럼에는 인덱스를 둡니다. 부모를 기준으로 자식을 찾는 조회는 반드시 생깁니다. MySQL의 InnoDB는 외래 키 컬럼에 인덱스를 자동으로 만들지만, 모든 DBMS가 그렇지는 않으므로 확인이 필요합니다.

복합 인덱스는 컬럼 순서에 따라 쓰임이 달라집니다. (member_id, created_at) 인덱스는 회원으로 조회하는 쿼리와 회원 안에서 최신순으로 정렬하는 쿼리에 쓰이지만, 작성일만으로 조회하는 쿼리에는 쓰이지 않습니다. 등호 조건에 쓰이는 컬럼을 앞에, 범위나 정렬에 쓰이는 컬럼을 뒤에 두는 것이 기본 원칙입니다.

인덱스를 만든 이유를 남깁니다. 이름과 컬럼만 나열된 인덱스는 다음 담당자가 지워도 되는지 판단할 수 없습니다. "회원별 최신 글 목록 조회용" 같은 한 줄이 명세서에 있으면 함부로 건드리지 않습니다.

확장을 염두에 둔 선택

코드값은 테이블로 관리합니다. 주문 상태나 회원 등급처럼 값이 늘어날 수 있는 항목은 코드 테이블에 두고 참조합니다. 코드마다 이름, 정렬 순서, 사용 여부를 함께 담을 수 있어서 화면에 노출할 때도 편합니다.

이력이 필요한지 미리 판단합니다. 값이 바뀌었을 때 이전 값을 알아야 하는 항목이라면 별도의 이력 테이블이나 스냅샷 컬럼이 필요합니다. 나중에 붙이려면 그 사이 기간의 데이터는 복구할 수 없습니다.

다국어는 컬럼이 아니라 행으로 늘립니다. name_ko, name_en처럼 컬럼을 늘리면 언어가 추가될 때마다 테이블 구조를 바꿔야 합니다. 번역 테이블을 두고 언어 코드로 구분하면 언어 추가가 데이터 입력으로 끝납니다.

PostgreSQL을 쓴다면 달라지는 것

여기까지의 기준은 대부분 그대로 적용되지만, MySQL과 다르게 판단해야 하는 지점이 몇 가지 있습니다.

문자 자료형에 길이를 고민할 이유가 적습니다. PostgreSQL에서는 varchar(n)과 text 사이에 성능 차이가 없습니다. 길이 제한은 저장 방식이 아니라 업무 규칙을 표현하는 수단이므로, 정말 제한이 필요한 값에만 길이를 두고 나머지는 text로 두는 스타일이 흔합니다.

순번 컬럼은 IDENTITY를 권합니다. serial은 오래된 방식이고, 표준 문법인 GENERATED ALWAYS AS IDENTITY가 권장됩니다. 시퀀스 소유권과 권한 처리가 더 깔끔합니다.

시각은 timestamptz를 기본으로 둡니다. 이름과 달리 시간대를 저장하는 게 아니라 UTC로 정규화해 저장하고 조회 시 세션 시간대로 변환해 주는 타입입니다. 시간대 정책을 코드에서 일일이 다루지 않아도 되어서, 여러 지역을 다루는 서비스라면 기본값으로 삼을 만합니다.

부분 인덱스가 있습니다. 위에서 본 소프트 삭제와 UNIQUE 충돌이 WHERE deleted_at IS NULL 조건 하나로 해결됩니다. 조건에 맞는 행만 인덱스에 담기므로 크기도 작아집니다. MySQL에는 없는 기능이라, 두 DBMS를 오가는 설계라면 이 차이를 알고 있어야 합니다.

대소문자 처리가 반대에 가깝습니다. PostgreSQL은 기본적으로 대소문자를 구분하고, 따옴표 없이 쓴 식별자는 소문자로 변환합니다. MySQL에서 옮겨 온 스키마에서 이름이 예상과 다르게 보인다면 대부분 이 규칙 때문입니다.

같은 설계를 두 DBMS로 각각 내보내야 하는 상황이라면, 자료형 대응을 표로 만들어 두고 시작하는 편이 안전합니다. DBMS 사이의 문법 차이는 SQL을 ERD로 변환하기에서 파서 관점으로 정리해 두었습니다.

테이블 설계 리뷰 체크리스트

설계를 마쳤다면 다이어그램을 놓고 아래 항목을 순서대로 확인해 보시면 됩니다.

  • 테이블 이름의 단수·복수 규칙이 하나로 통일되어 있는가
  • 예약어와 겹치는 이름이 없는가
  • 컬럼 이름 규칙(외래 키, 시각, 불리언)이 일관된가
  • 모든 테이블에 기본키가 있는가
  • 대리키와 자연키의 선택에 근거가 있는가
  • 행이 빠르게 쌓이는 테이블의 정수형이 충분한가
  • 금액 컬럼이 DECIMAL인가
  • 시각 컬럼의 시간대 기준이 정해져 있는가
  • 생성 시각과 수정 시각이 있는가
  • 삭제 방침(물리·소프트)이 테이블마다 정해져 있는가
  • NULL을 허용한 컬럼에 그 이유가 적혀 있는가
  • 같은 개념의 컬럼이 테이블마다 같은 자료형인가
  • 외래 키가 관계의 N쪽에 있는가
  • 삭제 규칙이 관계마다 정해져 있는가
  • 중복을 막아야 하는 조합에 UNIQUE가 있는가
  • 외래 키 컬럼에 인덱스가 있는가
  • 계산으로 얻을 수 있는 값을 저장하고 있지는 않은가
  • 한 칸에 여러 값을 넣은 컬럼이 없는가
  • 코드값이 여러 테이블에 흩어져 있지 않은가
  • 각 컬럼에 설명이 한 줄씩 있는가

관계와 기본키를 정하는 순서 자체가 아직 익숙하지 않다면 ERD 그리는 법에 개체 선정부터 표기까지 게시판 예제로 정리해 두었고, 테이블을 어디까지 쪼갤지 판단하는 기준은 데이터베이스 정규화에 있습니다.

자주 묻는 질문

기본키는 무조건 대리키가 정답인가요?

대부분의 업무 테이블에서는 대리키가 무난합니다. 이메일이나 사업자번호처럼 업무에서 의미를 가진 값은 언젠가 바뀌거나 재사용될 수 있고, 그때 그 값을 참조하던 모든 테이블을 함께 고쳐야 하기 때문입니다. 다만 코드 테이블처럼 값이 고정된 마스터 데이터나, 중간 테이블의 두 키를 묶는 복합키는 자연키가 더 단순합니다.

금액은 어떤 자료형으로 저장해야 하나요?

DECIMAL을 사용합니다. FLOAT이나 DOUBLE은 이진 부동소수점이라 0.1 같은 값을 정확히 표현하지 못해서, 합계를 낼 때 소수점 아래에서 오차가 쌓입니다. 정산이나 세금 계산에서 1원이 맞지 않는 문제가 대부분 여기서 시작합니다. 통화가 여러 개라면 금액 컬럼 옆에 통화 코드 컬럼을 함께 두는 편이 안전합니다.

NULL을 허용하지 않는 게 항상 좋은가요?

아닙니다. NULL은 값이 없다는 사실을 기록하는 정상적인 상태입니다. 문제는 NULL과 빈 문자열, 0을 섞어 쓰는 경우입니다. 아직 입력하지 않은 것과 비워 두기로 한 것을 구분해야 한다면 NULL이 맞고, 그 구분이 필요 없다면 NOT NULL에 기본값을 두는 쪽이 조회가 단순해집니다.

설계 단계에서 인덱스를 어디까지 정해야 하나요?

외래 키 컬럼과 조회 조건이 확실한 컬럼까지만 잡아 두면 충분합니다. 나머지는 실제 쿼리와 데이터가 쌓인 뒤에 실행 계획을 보고 추가하는 편이 정확합니다. 인덱스는 조회를 빠르게 하는 대신 입력과 수정을 느리게 하고 저장 공간도 차지하므로, 추측으로 미리 만들어 두면 비용만 남는 경우가 많습니다.

DB 설계를 시작할 때 이름 규칙과 기본키 원칙을 먼저 합의하고, 자료형은 실제 데이터에 맞춰 고릅니다. NULL과 삭제 방침까지 문서에 남겨 두면 이후 리뷰에서 같은 결정을 반복하지 않아도 됩니다.

설계를 마치고 DDL이 준비되었다면 WorksCove ERD에 붙여넣어 다이어그램과 테이블 명세서를 만드는 방법도 있습니다. 붙여넣기부터 결과까지는 SQL을 ERD로 변환하기에 화면과 함께 정리되어 있습니다.