MySQL 설계 결정은 왜 처음이 전부인가
데이터 타입 선택부터 정규화 수준, AUTO_INCREMENT 전략, 운영 중 스키마 변경까지 — 되돌리기 어려운 MySQL 설계 결정의 핵심 원칙을 추적한다.
- 01 MySQL 쿼리 최적화의 공통 원리 — 인덱스를 죽이는 패턴들
- 02 MySQL 파티셔닝은 언제 써야 하는가
- 03 MySQL Replication은 왜 '보낸 것'과 '도착한 것'이 다른가
- 04 MySQL 백업은 왜 --single-transaction 없이 믿을 수 없나
- 05 MySQL 설계 결정은 왜 처음이 전부인가
- 06 MySQL은 어디서 얼마나 걸리는가
- 07 MySQL 보안 설계의 세 기둥 — 권한, 연결, 환경 분리
MySQL 설계에서 가장 위험한 착각은 “나중에 고치면 된다”는 생각이다. INT PK를 BIGINT로 바꾸는 것, DOUBLE 금액 컬럼을 DECIMAL로 전환하는 것, 5억 건 테이블에 컬럼을 추가하는 것 — 모두 처음부터 옳은 선택을 했다면 수 시간의 다운타임이 필요 없었다. 처음 설계 결정이 왜 전부인가?
타입은 데이터가 아니라 시간을 담는다
데이터 타입 선택의 실수는 보통 “지금은 괜찮다”에서 시작한다. INT AUTO_INCREMENT PK는 일 1,000만 건 서비스에서 215일, 즉 7개월 만에 소진된다. 소진 순간 ERROR 1062 Duplicate entry for key 'PRIMARY'가 발생하고 서비스가 멈춘다. BIGINT로의 전환은 대형 테이블 ALTER이므로 수 시간 작업이다.
금액 컬럼의 DOUBLE 선택도 마찬가지다. 0.1 + 0.2 = 0.30000000000000004. 건당 오차는 미미하지만 100만 건 누적 시 수만 원 단위의 정산 오차가 생긴다. 금액에는 반드시 DECIMAL(M, D)를 써야 한다.
-- 위험한 설계
CREATE TABLE payments (
id INT AUTO_INCREMENT PRIMARY KEY, -- 소진 위험
amount DOUBLE NOT NULL -- 부동소수점 오차
);
-- 올바른 설계
CREATE TABLE payments (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
amount DECIMAL(15, 2) NOT NULL
);
TIMESTAMP vs DATETIME도 같은 구조다. TIMESTAMP는 4바이트로 작지만 2038년 1월 19일에 한계에 닿는다. 더 즉각적인 문제는 타임존 자동 변환이다 — 서버 타임존을 UTC에서 Asia/Seoul로 바꾸는 순간 모든 TIMESTAMP 컬럼 값이 9시간 이동한 것처럼 보인다. 저장된 UTC 값은 그대로지만 세션 타임존으로 변환해서 반환하기 때문이다. 새 서비스라면 DATETIME을 기본으로 선택하는 것이 안전하다.
JSON 컬럼은 인덱스 없이는 Full Scan이다
MySQL 8.0의 JSON 타입은 바이너리 포맷으로 저장되어 경로 접근이 빠르고 유효성 검사가 자동으로 된다. 문제는 JSON 컬럼 자체에 인덱스를 걸 수 없다는 점이다. WHERE info->>'$.city' = 'Seoul'은 인덱스 없이 Full Scan이다.
해결책은 두 가지다. Generated Column으로 JSON 경로를 가상 컬럼으로 추출하거나, MySQL 8.0.13+의 Functional Index를 쓴다.
-- Generated Column + Index (명시적)
ALTER TABLE orders
ADD COLUMN city VARCHAR(50)
GENERATED ALWAYS AS (info->>'$.city') VIRTUAL;
CREATE INDEX idx_city ON orders (city);
-- Functional Index (간편)
CREATE INDEX idx_city ON orders ((info->>'$.city'));
VIRTUAL은 디스크에 저장되지 않고 조회 시 계산된다. STORED는 디스크에 값을 저장해 읽기가 빠르지만 추가 공간이 필요하다. -> 연산자는 JSON 타입을 반환하고, ->>는 따옴표가 없는 문자열을 반환한다. Generated Column 타입과 쿼리 타입이 일치해야 인덱스가 사용된다.
JSON은 필드가 자주 추가되는 반정형 데이터에 적합하다. 자주 조회하는 경로는 Generated Column으로 인덱싱하고, 항상 조회하고 조인에 쓰이는 필드는 정규화된 컬럼으로 분리하는 것이 현실적인 패턴이다.
정규화의 아이러니 — 데이터는 깔끔한데 쿼리가 느리다
5개 테이블을 조인하는 주문 목록 쿼리가 0.5초라면, 초당 100회 호출 시 DB에 초당 50초의 부하가 걸린다. 단일 테이블 비정규화 쿼리로 바꾸면 0.01초로 줄어든다. 읽기:쓰기가 8:2 이상인 서비스에서 반복적인 JOIN 패턴은 비정규화 검토 신호다.
정규화는 쓰기 정합성을 보장하고, 비정규화는 읽기 성능을 확보한다. 둘은 교환관계다. “먼저 정규화, 측정 후 선택적 비정규화”가 원칙이다 — EXPLAIN ANALYZE로 JOIN 비용을 측정하지 않고 비정규화부터 시작하면 나중에 정합성 버그를 디버깅하게 된다.
비정규화의 핵심 위험은 정합성 관리다. orders.user_name을 비정규화로 복사해뒀는데 사용자가 이름을 바꾸면 어디를 어디까지 업데이트해야 하는가? 반면 orders.product_price는 주문 시점 가격을 보존하는 의도된 비정규화다 — 현재 상품 가격이 바뀌어도 바꾸면 안 된다. 비정규화 전에 “이 데이터는 변경 시 어디까지 전파해야 하는가”를 명확히 결정해야 한다.
실무 패턴은 집계값 캐싱이다. 게시글 댓글 수를 매번 COUNT JOIN하는 대신 articles.comment_count에 저장하고 트리거로 동기화하면, 읽기:쓰기 9:1 서비스에서 수백 배의 읽기 성능 이득을 볼 수 있다.
AUTO_INCREMENT Gap은 버그가 아니다
“서비스가 10만 건인데 ID가 50만이야?” — 이건 정상이다. 트랜잭션 롤백 시 할당된 ID는 재사용되지 않는다. 동시성 성능을 위해 AUTO_INCREMENT 락 범위를 최소화하는 설계상 선택이다. ID 연속성을 가정하는 코드는 잘못된 가정이다.
더 위험한 문제는 MySQL 5.7의 재시작 후 중복이다. 최대 id=100인 테이블에서 id 90~100을 삭제하고 서버를 재시작하면, MySQL 5.7은 SELECT MAX(id) + 1으로 AUTO_INCREMENT를 재계산해 90을 할당한다. 이전에 삭제된 id를 참조하는 FK가 있었다면 혼선이 생긴다. MySQL 8.0은 AUTO_INCREMENT를 Redo Log에 기록하므로 재시작 후에도 이전 값이 유지된다.
분산 환경에서의 채번 충돌은 더 즉각적이다. 샤드 A와 샤드 B 모두 AUTO_INCREMENT=1부터 시작하면 id가 중복된다. 해결 전략은 세 가지다.
방법 1: auto_increment_offset 분리
샤드 A: increment=2, offset=1 → 1, 3, 5, 7 ...
샤드 B: increment=2, offset=2 → 2, 4, 6, 8 ...
방법 2: Snowflake ID (64비트)
타임스탬프(41) + 워커ID(10) + 시퀀스(12)
전역 유일, 시간 순 정렬, BIGINT 크기
방법 3: UUID v7 (시간 기반, MySQL 8.0.35+)
시간 순 정렬 → B-Tree 삽입 효율
전역 유일, 16바이트
UUID v4는 완전 랜덤이라 B-Tree에 무작위로 삽입되어 페이지 분할이 잦다. 외부에 노출되는 ID에는 보안상 UUID v4가 적절하지만, 내부 PK는 BIGINT AUTO_INCREMENT나 Snowflake ID를 쓰고 외부 공개용 UUID를 별도 컬럼으로 두는 것이 현실적인 패턴이다.
운영 중 ALTER TABLE — 알고리즘이 결과를 결정한다
5억 건 테이블에 컬럼을 추가해야 한다. 방법에 따라 결과가 완전히 달라진다.
ALGORITHM=INSTANT는 메타데이터(데이터 딕셔너리)만 변경한다. 실제 데이터 파일 수정 없이 수 밀리초 내에 완료되고 락이 거의 없다. 지원 조건은 테이블 끝에 컬럼을 추가하는 것(ROW_FORMAT=DYNAMIC 필요)이다. 컬럼 삭제, 컬럼 이름 변경도 지원된다.
ALGORITHM=INPLACE는 원본 테이블에서 직접 처리한다. 인덱스 추가 시 데이터를 스캔해 인덱스를 빌드하고, 그 동안 발생한 DML은 Alter Log에 기록했다가 반영한다. DML은 허용되지만 디스크에 인덱스 크기만큼 임시 파일이 필요하다.
컬럼 타입 변경처럼 COPY가 불가피한 경우에는 운영 중 직접 실행을 피하고 pt-osc나 gh-ost를 써야 한다.
pt-osc는 원본 테이블에 트리거를 설치해 Shadow 테이블을 동기화한다. FK가 있는 테이블이나 이미 트리거가 있는 테이블에서는 실패한다. gh-ost는 트리거 없이 Binary Log를 읽어 동기화하므로 FK 테이블과 높은 TPS 환경에서 더 안전하다. Pause/Resume도 가능하다.
정리
- PK는 처음부터
BIGINT, 금액은 처음부터DECIMAL, 날짜는 글로벌 서비스라면DATETIME— 나중에 바꾸는 비용이 처음부터 옳게 쓰는 비용보다 수십 배 크다. - JSON 컬럼 조건 검색은 Generated Column 또는 Functional Index 없이는 항상 Full Scan이다.
- 정규화 먼저, 측정 후 선택적 비정규화. ID Gap은 정상이고 연속성 가정은 잘못된 가정이다.
- 운영 중 DDL은
INSTANT→INPLACE→pt-osc/gh-ost순으로 시도하라.COPY를 직접 운영 중 실행하는 것은 다운타임을 허용하겠다는 결정이다.
다음 글에서는 MySQL 인덱스 내부 구조와 커버링 인덱스, 그리고 EXPLAIN으로 실행 계획을 읽는 방법을 추적한다.