| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | |||||
| 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 10 | 11 | 12 | 13 | 14 | 15 | 16 |
| 17 | 18 | 19 | 20 | 21 | 22 | 23 |
| 24 | 25 | 26 | 27 | 28 | 29 | 30 |
| 31 |
- 함수형프로그래밍
- html
- Let's Encrypt
- Linux
- Certbot
- AI
- javascript
- node.js
- 비주얼 스튜디오 코드
- nodeJS
- docker
- GPT
- Functional Programming
- Schema Registry
- python
- ChatGPT
- typescript
- https
- vscode
- GIT
- 파이썬
- Generics
- nestjs
- MSA
- MSK
- stream
- 자료구조
- ES6
- Express
- 알고리즘
- Today
- Total
JangBaGeum.gif
나만의? DB 모델링 원칙 - MySQL 편 본문
개발자로 일하면 가장 오래 고민했던 주제 중 하나가 DB 모델링 컨벤션이다. 코드에는 ESLint, Prettier 같은 도구가 있어서 팀원 간 스타일을 자동으로 맞출 수 있지만, DB 설계에는 그런 자동화 도구가 없는 것으로 보인다. 결국 사람이 규칙을 정하고, 사람이 지켜야 한다.
이 글은 여러 프로젝트를 거치면서 정리한 나만의 MySQL 모델링 컨벤션을 정리하려고 한다. 요즘 AI를 통한 개발뿐만 아니라 설계도 함께하면서 사소한 컨벤션, 구두로 전해 내려오던 도메인적 규칙 등 모두 콘텍스트에 녹일 수 있도록 정리를 하고 있다. 이도 그 과정 중에 하나이다. 절대적인 정답은 아니고, 실무에서 반복적으로 부딪히며 다듬어 온 기준이다. 팀마다, 도메인마다 맞지 않는 부분이 있을 수 있으니 참고용으로 해도 좋을 것 같다.
1. 명명 규칙
명명 규칙은 컨벤션의 가장 기본이면서, 동시에 가장 효과가 큰 영역이다. 이름만 보고도 이 테이블이 뭔지, 이 컬럼이 어떤 역할인지 알 수 있다면 별도의 문서 없이도 스키마를 읽을 수 있다. 내가 생각하는 최고의 네이밍은 부가적인 설명 없이 역할을 바로 알 수 있는 것이다.
1.1 공통 규칙
a. 모든 네이밍은 snake_case를 사용한다.
memberList (X)
member_list (O)
이유는 단순하다. MySQL은 OS에 따라 대소문자 처리 방식이 다르다. Linux에서는 대소문자를 구분하지만 macOS나 Windows에서는 구분하지 않는 경우가 있다. 로컬 개발 환경(macOS)에서는 문제없이 동작하다가 운영 서버(Linux)에서 테이블을 못 찾는 상황이 실제로 발생한다. snake_case + 소문자 통일이면 이런 문제를 원천 차단할 수 있다.
b. 직관적인 기술형으로 작성한다.
이름만 보고도 어떤 데이터인지 명확히 알 수 있어야 한다.
가능한 쉬운 단어를 선택하고, 모호한 이름은 피한다.
log (X) — 어떤 로그인지 불명확
play_log (O) — 재생 로그임이 명확
access_log (O) — 접속 로그임이 명확
c. 동사는 능동태를 사용한다. (동명사는 허용)
created_date (X)
create_date (O)
과거분사 대신 능동태를 쓰면 네이밍이 일관되고 짧아진다. `created_at`이 더 자연스러운 영어라는 의견도 있지만, 컬럼명의 핵심은 팀 내 일관성이다. 어떤 쪽을 선택하든 섞어 쓰지만 않으면 된다. 나는 능동태 쪽을 택했다.
d. 데이터베이스 예약어는 사용하지 않는다.
`order`, `group`, `status` 같은 단어는 MySQL 예약어이거나 예약어에 가깝다. 백틱(`` ` ``)으로 감싸면 쓸 수 있지만, ORM이나 쿼리 빌더에서 문제를 일으킬 수 있다. 처음부터 피하는 게 정신건강에 좋다.
e. 약어는 최소한으로 사용한다.
약어는 작성자에게는 당연하지만, 새로 합류한 팀원에게는 퍼즐이다. 꼭 필요한 경우에만, 팀 내에서 합의된 약어만 사용한다.
| 대상 | 약어 | 예시 |
| number | no | episode_no, sort_no |
| count | cnt | play_cnt, like_cnt |
| address | addr | billing_addr, ip_addr |
| image | img | thumbnail_img, profile_img |
| authentication | auth | member_auth |
| maximum | max | duration_max, retry_max |
| minimum | min | price_min, age_min |
| quantity | qty | payment_ref, external_ref |
| transaction | tx | tx_id, tx_status |
1.2 테이블 명명 규칙
a. 접두사/접미사는 사용하지 않는다.
tb_content (X)
content_tbl (X)
content (O)
`tb_`, `tbl_` 같은 접두사는 정보량이 제로다. DB 안에 있으면 테이블이라는 건 자명하다. 불필요한 접두사는 이름만 길게 만들 뿐이다.
단, 마스터 테이블의 하위 속성 테이블은 마스터 테이블명을 접두사로 사용한다. 이건 관계를 명확히 표현하기 위함이다.
member — 회원 마스터
member_auth — 회원 인증 정보
member_profile — 회원 프로필
content — 콘텐츠 마스터
content_like — 콘텐츠 좋아요
b. 테이블 이름은 단수형으로 사용한다.
contents (X)
content (O)
복수형을 쓰면 불규칙 복수형 문제가 따라온다. `category` → `categories`, `person` → `people`. 단수형 통일이 훨씬 깔끔하다
1.3 컬럼 명명 규칙
컬럼은 타입에 따른 접미사를 통일하는 것이 핵심이다. 접미사만 보고도 이 컬럼이 어떤 성격의 데이터인지 파악할 수 있다.
| 용도 | 패턴 | 예시 |
| PK | <테이블명>_id | member_id, content_id |
| FK | <부모 테이블명>_id | member_id, genre_id |
| 날짜 (시간 없음) | <목적>_date | release_date, expire_date |
| 날짜 + 시간 | <목적>_at | create_at, update_at, delete_at |
| 코드/유형 | <목적>_code | quality_code, region_code |
| 순번 | <목적>_no | episode_no, sort_no |
| Boolean | is_<목적> | is_active, is_adult |
이 규칙의 장점은 IDE의 자동완성과 궁합이 좋다는 것이다. `is_`를 타이핑하면 Boolean 컬럼 목록이 쭉 나오고, `_at`으로 검색하면 시간 관련 컬럼만 필터링된다.
1.4 인덱스 명명 규칙
인덱스도 이름만 보고 어떤 테이블의 어떤 컬럼으로 구성되었는지 알 수 있어야 한다.
형식: <table_name>_<column1>_<column2>_..._<접미사>
| 인덱스 타입 | 접미사 | 예시 |
| 일반 인덱스 | _IDX | content_genre_id_create_at_IDX |
| 유니크 인덱스 | _UIDX | member_email_UIDX |
| Fulltext 인덱스 | _FTX | content_title_FTX |
복합 인덱스라면 실제 쿼리에서 사용되는 조건 순서대로 컬럼을 나열한다. 이렇게 하면 인덱스명 자체가 일종의 문서 역할을 한다.
-- content 테이블에서 genre_id와 create_at으로 자주 조회한다면
CREATE INDEX content_genre_id_create_at_IDX ON content (genre_id, create_at);
2. 테이블 타입 전략
데이터 타입 선택은 단순히 "돌아가면 되지"의 영역이 아니다. 타입 하나의 차이가 수억 건의 데이터에서는 GB 단위의 저장 공간 차이로 이어진다.
2.1 원칙: 가장 작은 데이터 타입을 사용하기
이 원칙이 영향을 미치는 범위는 생각보다 넓다.
- 저장 공간: 1억 행 기준, `TINYINT`(1byte) vs `INT`(4byte) = 100MB vs 400MB
- 인덱스 크기: 작은 타입 → 작은 인덱스 → 버퍼 풀에 더 많이 적재 → 검색 속도 향상
- 메모리: MySQL이 데이터를 처리할 때 필요한 메모리가 줄어든다
- 네트워크: 데이터 전송 시 필요한 대역폭이 줄어든다
"나중에 범위가 늘어나면 어떡하지?"라는 걱정에 무조건 `BIGINT`를 쓰는 경우를 종종 본다. 하지만 현재 요구사항에 맞는 타입을 선택하고, 필요할 때 마이그레이션하는 것이 올바른 접근이다. 미래의 불확실성을 위해 현재의 비용을 지불할 필요는 없다.
2.2 숫자 타입 선택 기준
| 데이터 범위 | 권장 타입 | 저장 공간 |
| -128 ~ 127 | TYNYINT | 1byte |
| -32,768 ~ 32,767 | SMALLINT | 2bytes |
| -8,388,608 ~ 8,388,607 | MEDIUMINT | 3bytes |
| -2^31 ~ 2^31-1 | INT | 4bytes |
| -2^63 ~ -2^63-1 | BIGINT | 6bytes |
- 음수를 사용하지 않는 컬럼에는 UNSIGNED 옵션을 사용한다. 양수 범위가 두 배가 확장된다.
- 금액 데이터는 반드시 DECIMAL을 사용한다. FLOAT나 DOUBLE은 부동소수점 오차가 있어서 정산 데이터에 쓰면 돈이 증발할 수 있다.
-- 구독료 컬럼
subscription_fee DECIMAL(10,2) UNSIGNED NOT NULL
2.3 문자열 타입 선택 기준
| 타입 | 특성 | 사용 시점 |
| CHAR(n) | 고정 길이, 빠른 검색 | 길이가 확실한 짧은 데이터 (status code, 'Y'/'N') |
| VARCHAR(n) | 가변 길이, 공간 효율적 | 길이가 다양한 일반 문자열 |
| TEXT | 가변 길이, 포인터 참조 | 인덱스 불필요한 대용량 텍스트 |
TEXT 계열 타입은 용량에 따라 세분화 가능하다.
| MySQL text type | 최대 용량 |
| TINYTEXT | 256 bytes |
| TEXT | 약 64KB |
| MEDIUMTEXT | 약 16MB |
| LONGTEXT | 약 4GB |
실무에서 자주 하는 실수 하나. VARCHAR(65535)로 때우는 경우가 있다. 이런 경우 차라리 TEXT를 쓰는 것이 낫다. MySQL은 VARCHAR가 일정 크기를 초과하면 내부적으로 임시 테이블을 디스크에 생성하는데, TEXT는 이 비용이 상대적으로 작다. 단 검색이나 정렬이 필요한 필드는 VARCHAR가 맞다.
2.4 날짜/시간 타입
| 특성 | DATETIME | TIMESTAMP |
| 저장 형식 | 'YYYY-MM-DD HH:MM:SS' | 유닉스 타임스탬프 |
| 범위 | 1000-01-01 ~ 9999-12-31 | 1970-01-01 ~ 2038-01-19 |
| 시간대 | 시간대와 무관 | 서버 시간대에 따라 변환 |
| 저장 공간 | 8 bytes | 4 bytes |
나의 선택은 DATETIME이다.
TIMESTAMP는 저장 공간이 절반이라 장점이 있지만, 2038년 문제가 있다. 2038년은 아직 멀어 보이지만, 서비스의 수명은 예측할 수 없다. 또한 TIMESTAMP는 서버 시간대에 의존하기 때문에, DB 서버의 시간대 설정이 바뀌면 기존 데이터의 의미가 달라질 수 있다.
글로벌 서비스에서 시간대 변환이 필요한 경우라면 TIMESTAMP가 우리할 수 있지만, 그런 경우에도 애플리케이션 레벨에서 UTC로 통일하고 DATETIME에 저장하는 방식이 더 명시적이고 안전하다고 생각한다.
2.5 Boolean 처리
MySQL의 BOOLEAN은 내부적으로 TINYINT(1)이다. 0과 1로 처리되는데, 이게 언어에 따라 미묘한 문제를 일으킬 수 있다. 예를 들어 일부 언어에서 0을 null이나 false로 해석하는 방식이 다르다.
나는TINYINT(1)을 기본으로 사용하되, ORM을 통해 boolean으로 매핑하는 방식을 선호한다. CHAR(1)에 'Y'/'N'을 넣는 방식도 실무에서 자주 보이지만, 이건 취향의 영역 같다. 아주 중요한 것은 프로젝트 내에서 일관성을 갖는 것이다.
-- TINYINT 방식
is_adult TINYINT(1) NOT NULL DEFAULT 0
-- CHAR(1) 방식 (대안)
is_adult CHAR(1) NOT NULL DEFAULT 'N'
2.6 기타 데이터 저장 Tip
IP 주소는 문자열 대신 정수로 저장하라.
-- 저장: 문자열 → 정수 변환
INSERT INTO access_log (ip_address) VALUES (INET_ATON('192.168.0.1'));
-- 조회: 정수 → 문자열 변환
SELECT INET_NTOA(ip_address) FROM access_log;
VARCHAR(15)로 저장하면 최대 15바이트지만, INT UNSIGNED로 변환하면 4바이트로 충분하다. 1억 건이면 약 1GB 차이다. 게다가 정수 비교가 문자열 비교보다 빠르고 IP형식의 무결성도 보장된다.
UUID는 바이너리로 저장하라.
-- 저장
INSERT INTO member (uuid) VALUES (UUID_TO_BIN(UUID(), 1));
-- 조회
SELECT BIN_TO_UUID(uuid, 1) FROM member;
UUID를 VARCHAR(36)으로 저장하면 36바이트지만, BINARY(16)으로 변환하면 16바이트다. UUID_TO_BIN의 두 번째 인자 1은 시간 기반 부분을 앞으로 재배치하여 인덱스 설능을 향상한다.
UUID보다 ULID를 고려해보라.시간순 정렬이 필요하다면 ULID도 고려해보라.UUID v4는 완전 랜덤이라 PK나 인덱스로 쓰면 페이지 분할이 자주 발생한다. 이게 신결 쓰인다면 ULID가 좋은 대안이 될 수 있다. 128비트 크기는 UUID와 같지만, 앞 48비트가 타임스탬프라 생성된 순서대로 자연스럽게 정렬된다. 즉 "정렬 가능한 UUID"인 셈이다.
저장 방식은 UUID와 동일하게 BINARY(16)을 쓰면 되고 생성은 애플리케이션 레이어에서 ULID 라이브러리로 처리한 뒤 바이너리로 변환해 넣는다. 분산 환경에서 PK를 만들거나, 외부 노출용 식별자가 필요한데 Auto Increment의 순번 노출이 부담스러울 때 잘 어울린다. 비슷한 목적의 UUID v7도 최근 표준화되었으니 함께 살펴보면 좋을 것 같다.
3. PK 설계 원칙
3.1 BIGINT UNSIGNED + Auto Increment가 기본이다
MySQL의 PK는 클러스터드 인덱스(Clustered Index) 다. 이게 무슨 뜻이냐면, PK 순서대로 데이터가 물리적으로 정렬된다는 뜻이다.
만약 PK가 UUID처럼 순서가 없는 랜덤 값이라면, 새 데이터가 삽입될 때마다 물리적 재배열(페이지 분할) 이 발생한다. 이는 쓰기 성능을 심각하게 저하시킨다.
-- 권장하는 PK 구조
member_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY
`BIGINT UNSIGNED`의 최댓값은 약 1844 경이다. 1초에 1만 건을 삽입해도 약 5,800만 년 동안 쓸 수 있다. 충분하다.
3.2 복합키(Composite Key)를 PK로 쓰지 않는다
복합키 PK의 문제는 여러 겹이다.
- 인덱스 크기 증가: 모든 세컨더리 인덱스가 PK를 포함하므로, PK가 크면 모든 인덱스가 커진다
- JOIN 복잡성: 다른 테이블에서 참조할 때 여러 컬럼을 항상 함께 써야 한다
- ORM 호환성: 대부분의 ORM은 단일 컬럼 PK를 전제로 설계되어 있다
복합키가 필요한 관계는 유니크 인덱스로 대체하고, PK는 서로게이트 키(surrogate key)를 사용한다.
-- 복합키 PK 대신
CREATE TABLE content_like (
content_like_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
member_id BIGINT UNSIGNED NOT NULL,
content_id BIGINT UNSIGNED NOT NULL,
create_at DATETIME NOT NULL,
-- 비즈니스 유니크 제약은 유니크 인덱스로
CONSTRAINT content_like_member_id_content_id_UIDX UNIQUE (member_id, content_id)
);
4. 컬럼 순서 정하기
테이블 내 컬럼 순서는 기능에 직접적인 영향을 미치지 않지만, 스키마의 가독성에는 큰 영향을 미친다. 팀원이 테이블 구조를 볼 때, 일관된 순서는 인지 부하를 줄여준다.
내가 사용하는 순서는 다음과 같다.
1. PK — 테이블의 핵심 식별자
2. FK — 이 테이블이 어떤 테이블과 관계를 맺는지 바로 보인다
3. 비즈니스 핵심 컬럼 — 카디널리티가 높고 조건절에 자주 걸리는 컬럼
4. 일반 컬럼 — 조건절에 잘 안 걸리는 데이터
5. 대용량 텍스트 — TEXT, LONGTEXT 등
6. 메타 컬럼 — `create_at`, `update_at`, `delete_at`
CREATE TABLE episode (
-- 1. PK
episode_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
-- 2. FK
series_id BIGINT UNSIGNED NOT NULL,
-- 3. 비즈니스 핵심
title VARCHAR(200) NOT NULL,
episode_no INT UNSIGNED NOT NULL,
duration_sec INT UNSIGNED NOT NULL DEFAULT 0,
is_display TINYINT(1) NOT NULL DEFAULT 1,
-- 4. 일반 컬럼
release_date DATE NULL,
-- 5. 대용량 텍스트
synopsis TEXT NULL,
-- 6. 메타 컬럼
create_at DATETIME NOT NULL,
update_at DATETIME NOT NULL
);
5. 인덱스 설계 원칙
인덱스는 읽기 성능과 쓰기 성능에서 매우 중요하다. 인덱스를 추가할수록 SELECT는 빨라지지만,
INSERT/UPDATE/DELETE는 느려진다. 인덱스가 하나 추가될 때마다 B-Tree 구조를 갱신해야 하기 때문이다.
5.1 기본 원칙
a. 필요한 인덱스만 생성한다.
"혹시 필요할지 모르니 미리 걸어두자"는 접근은 위험하다. 쓰기가 많은 테이블에서 불필요한 인덱스는 INSERT 지연의 주범이다.
b. 쓰기 중심 테이블 vs 읽기 중심 테이블을 구분한다.
- 쓰기 중심 (LOG성 테이블): 카디널리티가 높은 단일 컬럼 인덱스를 최소한으로
- 읽기 중심 (조회용 테이블): 복합 인덱스를 적극 활용하여 응답 속도 보장
c. 조건절에 함수를 사용하면 인덱스를 타지 않는다.
-- 인덱스 무효화 (X)
SELECT * FROM member WHERE DATE(create_at) = '2024-01-01';
-- 인덱스 활용 (O)
SELECT * FROM member WHERE create_at >= '2024-01-01' AND create_at < '2024-01-02';
인덱스가 걸려있는데 왜 느리지?라고 의아해하다가 조건절의 함수를 발견하는 경우가 많다.
e. `LIKE '%keyword'` 패턴은 Full Table Scan을 유발한다.
-- Full Table Scan (X)
SELECT * FROM content WHERE title LIKE '%드라마';
-- 인덱스 활용 가능 (O)
SELECT * FROM content WHERE title LIKE '마블%';
앞에 와일드카드가 오면 인덱스가 있어도 무조건 전체 스캔이다. 문자열 검색이 빈번하다면 Fulltext 인덱스를 고려하고, 데이터가 정말 많아지면 Elasticsearch 같은 전문 검색 엔진으로 이관하는 것이 맞다.
[MySQL] 자연어 검색 (FULLTEXT)
기본적으로 검색 쿼리를 작성할 때, 기본적을 와일드카드를 사용하여 LIKE 검색을 이용하는 것을 떠올릴 것이다. 이 방법은 가장 기본적이며 사용하기 쉬워 어렵지 않게 적용을 할 수 있으나, 데
jangbageum.tistory.com
5.2 복합 인덱스 설계 순서
복합 인덱스의 컬럼 순서는 성능에 직접적인 영향을 준다. 카디널리티가 높은 컬럼 → 낮은 컬럼 순서로, 등호(=) 조건 → 범위 조건 순서로 배치한다.
-- member_id(=)로 필터 후 create_at(범위)으로 정렬하는 쿼리가 많다면
CREATE INDEX play_history_member_id_create_at_IDX ON play_history (member_id, create_at);
6. 하지 말아야 할 것들
모델링에서 "무엇을 해야 하는가"만큼 중요한 것이 "무엇을 하지 말아야 하는가" 다.
6.1 Stored Procedure / Trigger / Event Scheduler
쓰지 않는다.
이유는 많지만 핵심은 두 가지다.
1. 비즈니스 로직이 분산된다. 애플리케이션 코드와 DB에 로직이 나뉘어 있으면, 버그를 추적할 때 두 곳을 모두 봐야 한다. 코드 리뷰, 테스트, 배포 파이프라인에도 포함되지 않는다.
2. MySQL SP는 성능상 이점이 없다. Oracle과 달리 MySQL은 SP(저장 프로시저)를 캐시 하지 않는다.(Oracle은 저장 프로시저 실행 시 실행 계획이나 관련 객체를 메모리에 유지(캐싱)하는 방식이 강한 반면, MySQL은 상대적으로 그런 캐싱 방식이 다르다) 호출할 때마다 매번 컴파일되고, SQL 파싱 오버헤드도 누적된다.
비즈니스 로직은 애플리케이션 레이어에서 관리하는 것이 디버깅, 테스트, 배포 모든 면에서 유리하다.
6.2 물리적 FK 제약조건
논리적 모델에서 FK를 그리는 것은 중요하다. 하지만 물리적 모델(실제 DDL)에 FK 제약조건을 거는 것은 권장하지 않는다.
- 쓰기 성능 저하: FK 제약조건은 INSERT/UPDATE/DELETE마다 참조 무결성 검사를 수행한다
- 잠금 경합: 참조 테이블에 추가적인 잠금이 발생하여 동시성이 떨어진다
- 마이그레이션 복잡도 증가: 스키마 변경 시 FK 순서를 고려해야 하며, 대량 데이터 마이그레이션이 번거로워진다
- 분산 환경 비호환: 샤딩이나 분산 DB 환경에서는 노드 간 FK 제약을 유지할 수 없다 참조 무결성은 애플리케이션 레벨에서 보장하고, DB에는 인덱스만 걸어두는 것이 현실적인 선택이다.
-- FK 제약 대신 인덱스만 생성
CREATE TABLE playlist_content (
playlist_content_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
playlist_id BIGINT UNSIGNED NOT NULL,
content_id BIGINT UNSIGNED NOT NULL,
sort_no INT UNSIGNED NOT NULL DEFAULT 0,
create_at DATETIME NOT NULL,
-- FK 제약 없이 인덱스만
INDEX playlist_content_playlist_id_IDX (playlist_id),
INDEX playlist_content_content_id_IDX (content_id)
);
6.3 JSON 컬럼 남용
MySQL 5.7부터 JSON 타입을 지원하지만, 남용하면 관계형 DB의 장점을 스스로 포기하는 꼴이라고 한다.
JSON 컬럼의 주요 문제점:
- 인덱싱 제한: JSON 내부 데이터를 인덱싱 하려면 가상 컬럼(generated column)을 별도로 만들어야 한다
- 스키마 검증 없음: 어떤 구조의 JSON이든 삽입 가능하므로 데이터 일관성이 깨진다
- JOIN 불가: JSON 내부 데이터로 다른 테이블과 JOIN 하는 것이 사실상 불가능하다
- 파싱 오버헤드: 조회할 때마다 JSON 문자열을 파싱해야 한다 JSON이 적합한 경우는 스키마가 자주 변경되는 메타데이터나 구조가 유동적인 설정값 정도다. 그 외의 정형 데이터는 테이블을 쪼개서 정규화하는 것이 관계형 DB를 제대로 쓰는 방법이다.
6.4 COUNT(*)를 검증 로직에 태우는 것
Oracle과 달리 MySQL(InnoDB)은 `row_num`을 별도로 저장하지 않는다. `COUNT(*)`는 매번 전체 행을 스캔한다. 데이터가 수백만 건이 넘는 테이블에서 `COUNT(*)`를 검증 로직에 태우면 심각한 성능 저하가 발생한다.
"이 회원의 시청 기록이 존재하는가?"를 확인하고 싶다면:
-- 느리다 (X)
SELECT COUNT(*) FROM play_history WHERE member_id = 456;
-- 빠르다 (O)
SELECT EXISTS(SELECT 1 FROM play_history WHERE member_id = 456);
-- 또는 LIMIT 활용
SELECT 1 FROM play_history WHERE member_id = 456 LIMIT 1;
7. 모범 사례 예시
지금까지의 규칙을 종합하여, 동영상 스트리밍 플랫폼 도메인의 핵심 테이블을 설계해 보겠다.
-- 회원 기본 정보
CREATE TABLE member (
member_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(100) NOT NULL,
password VARCHAR(255) NOT NULL,
nickname VARCHAR(50) NOT NULL,
phone VARCHAR(20) NOT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
create_at DATETIME NOT NULL,
update_at DATETIME NOT NULL,
CONSTRAINT member_email_UIDX UNIQUE (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 장르
CREATE TABLE genre (
genre_id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
parent_id INT UNSIGNED NULL,
name VARCHAR(50) NOT NULL,
depth TINYINT UNSIGNED NOT NULL DEFAULT 1,
sort_no INT UNSIGNED NOT NULL DEFAULT 0,
is_display TINYINT(1) NOT NULL DEFAULT 1,
create_at DATETIME NOT NULL,
update_at DATETIME NOT NULL,
INDEX genre_parent_id_IDX (parent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 콘텐츠 기본 정보
CREATE TABLE content (
content_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
genre_id INT UNSIGNED NOT NULL,
title VARCHAR(200) NOT NULL,
release_date DATE NOT NULL,
is_adult TINYINT(1) NOT NULL DEFAULT 0,
is_display TINYINT(1) NOT NULL DEFAULT 1,
synopsis TEXT NULL,
create_at DATETIME NOT NULL,
update_at DATETIME NOT NULL,
INDEX content_genre_id_IDX (genre_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 구독 기본 정보
CREATE TABLE subscription (
subscription_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
member_id BIGINT UNSIGNED NOT NULL,
subscription_no VARCHAR(20) NOT NULL COMMENT '구독번호',
subscription_fee DECIMAL(10,2) UNSIGNED NOT NULL,
payment_method VARCHAR(20) NOT NULL,
subscription_status VARCHAR(20) NOT NULL DEFAULT 'active',
start_date DATE NOT NULL,
expire_date DATE NOT NULL,
create_at DATETIME NOT NULL,
update_at DATETIME NOT NULL,
INDEX subscription_member_id_IDX (member_id),
CONSTRAINT subscription_subscription_no_UIDX UNIQUE (subscription_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
몇 가지 포인트를 짚어보면:
- `user` 대신 `member`를 선택한 이유는 `user`가 MySQL 예약어에 가까워 쿼리 작성 시 백틱이 필요할 수 있기 때문이다
- FK 제약조건 대신 일반 인덱스를 사용했다
- `genre_id`는 장르 수가 제한적이므로 `INT`로 충분하다. `content_id`는 대량 데이터가 예상되므로 `BIGINT`를 사용했다
- 모든 테이블에 `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`를 명시했다
DB 모델링 컨벤션은 결국 "미래의 나와 팀원을 위한 약속"이다.
지금 당장은 아무렇게나 만들어도 동작한다. 하지만 서비스가 커지고, 데이터가 쌓이고, 팀원이 바뀌었을 때, 그때 가서 후회하는 것은 언제나 설계 단계에서의 타협이다.
이 글에서 다룬 내용을 한 줄씩 요약하면 이렇다.
- 명명 규칙: snake_case, 단수형, 타입별 접미사 통일, 예약어 금지
- 데이터 타입: 가장 작은 타입, DECIMAL로 금액, DATETIME으로 시간
- PK: BIGINT UNSIGNED + Auto Increment, 복합키 PK 금지
- 인덱스: 필요한 만큼만, 복합 인덱스는 순서가 핵심
- 안티패턴: SP/Trigger 금지, 물리 FK 제거, JSON 남용 금지, COUNT(*) 검증 금지
컨벤션이 "귀찮은 제약"이 아니라 "편안한 기본값"이 되려면, 규칙의 이유를 팀원 모두가 이해해야 한다. 왜 이렇게 하는지 납득이 되어야 자발적으로 지키게 된다.
오늘 정한 규칙이 내일의 누군가를 편하게 만들 수 있기를 바란다.
참고 자료
- [MySQL 8.0 Reference Manual — Data Types](https://dev.mysql.com/doc/refman/8.0/en/data-types.html)
- [MySQL 8.0 Reference Manual — CREATE INDEX Statement](https://dev.mysql.com/doc/refman/8.0/en/create-index.html)
- [Use The Index, Luke — A Guide to Database Performance](https://use-the-index-luke.com/)
- [Percona — InnoDB Primary Key versus Secondary Index](https://www.percona.com/blog/)
- [MySQL Reserved Words](https://dev.mysql.com/doc/refman/8.0/en/keywords.html)
'Backend > DataBase' 카테고리의 다른 글
| [MySQL] 자연어 검색 (FULLTEXT) (2) | 2024.12.15 |
|---|---|
| [TypeORM] TypeORM 0.3.x (0) | 2022.09.14 |
| [DB] RDB와 NoSQL의 수평확장 (Scale Out) (1) | 2022.08.07 |
| [MongoDB] MongoDB Atlas Trigger (Scheduled Trigger) (1) | 2022.07.10 |