2. 데이터 타입 선택
2.1 가장 작은 데이터 타입을 사용하라
| 이점 | 설명 |
|---|---|
| 저장 공간 절약 | TINYINT(1B) vs INT(4B) — 1억 행 기준 100MB vs 400MB |
| 인덱스 크기 축소 | 작은 타입 → 인덱스가 메모리에 더 많이 적재 → 검색 속도 향상 |
| 메모리 사용량 감소 | 버퍼 풀·캐시 효율성 증가 |
| 네트워크 대역폭 절약 | 전송 데이터량 감소 → 응답 속도 향상 |
TIP — 정수 타입별 저장 범위 (선택 기준)
| 타입 | 크기 | 범위 (SIGNED) | 범위 (UNSIGNED) |
|---|---|---|---|
TINYINT |
1B | -128 ~ 127 | 0 ~ 255 |
SMALLINT |
2B | -32,768 ~ 32,767 | 0 ~ 65,535 |
MEDIUMINT |
3B | -8,388,608 ~ 8,388,607 | 0 ~ 16,777,215 |
INT |
4B | 약 -21억 ~ 21억 | 0 ~ 약 42억 |
BIGINT |
8B | 약 -922경 ~ 922경 | 0 ~ 약 1844경 |
- 음수를 저장할 일이 없는 컬럼(PK, 개수, 나이 등)은
UNSIGNED를 붙이면 같은 바이트로 표현 범위를 2배로 늘릴 수 있다 - 타입 범위는 실제 값과 예상 성장량을 기준으로 정하되, 단기간에 상한에 도달하지 않도록 합리적인 여유를 둔다.
INT → BIGINT같은 정수 타입 변경은 대용량 테이블에서 테이블 복사·재구축과 긴 작업 시간을 부를 수 있으므로, 운영 중 확장이 가볍다고 가정하면 안 된다. 대상 버전의 온라인 DDL 지원 범위를 확인하고, 같은 크기의 스테이징 테이블에서 실제 소요 시간을 측정한 뒤 적용한다 - 금액에
DECIMAL대신 정수(예: 원 단위)를 쓰라는 권고는 공식 문서의 규범이 아니라 실무에서 굳어진 기준이다. 통화 단위·환율·부분 정산처럼 소수점이 업무적으로 필요한 경우에는DECIMAL을 쓰고, 정밀도와 스케일을 스키마에 명시한다
2.2 문자열 타입 비교
| 특성 | CHAR |
VARCHAR |
TEXT 계열 |
|---|---|---|---|
| 길이 | 고정 (0~255자) | 가변 — VARCHAR(n)의 n은 문자 수 (선언 가능한 최대 n은 행 크기 65,535B에 종속) |
가변 (TINYTEXT 255B · TEXT ~64KB · MEDIUMTEXT ~16MB · LONGTEXT ~4GB) |
| 저장 방식 | 정의 길이에 맞춰 오른쪽을 공백으로 패딩 — 단 InnoDB의 compact 계열 행 포맷은 가변길이 문자셋의 CHAR 저장을 최적화한다 |
길이 접두(1~2B) + 실제 데이터 | InnoDB에서 값이 크면 off-page(별도 오버플로 페이지)에 저장하고 행에는 포인터만 |
| 장점 | 길이가 일정한 코드·해시·체크섬 같은 짧은 고정폭 값에 유리 | 공간 효율적, 다양한 길이 처리 | 매우 큰 문자열 저장 가능 |
| 단점 | 짧은 문자열에 공간 낭비, 후행 공백 비교 결과가 콜레이션에 좌우됨(3.3 참고) | — | 인덱스에 프리픽스 지정이 필수(col(100)), 정렬·GROUP BY 시 임시 테이블 비용 |
WARNING —
VARCHAR(n)의n은 문자 수이고, 그n의 상한이 바이트로 제약된다
- 정의 단위는 문자 수:
VARCHAR(n)은 캐릭터셋과 무관하게n자를 저장한다.VARCHAR(500)은utf8mb4에서도 그대로 500자 저장(125자로 줄지 않음).n을 얼마나 크게 잡을 수 있는가는 바이트로 제약: 컬럼(정확히는 행 전체) 최대 크기가 65,535바이트이고 이 한도를 모든 컬럼이 나눠 쓰므로, 멀티바이트 캐릭터셋에서는 정의 가능한 최대n이 줄어듦 —utf8mb4(문자당 최대 4B)면 단일VARCHAR컬럼의 실질 상한이 16,383자. 즉 “저장은 문자 수로, 정의 최댓값만 바이트 한도에 걸린다”고 이해하면 정확함.VARCHAR의 길이 접두 크기는 개별 값의 실제 길이가 아니라 컬럼이 저장할 수 있는 최대 바이트 길이로 결정됨. 최대 255B 이하면 1B, 255B를 초과할 수 있으면 2B를 사용함.CHAR(n)의n도 문자 수 기준이며, 내부 저장 바이트는 캐릭터셋에 따라 달라짐(utf8mb4면 최대n×4B).BLOB·TEXT컬럼은 이 65,535바이트 계산에 값 전체가 아니라 9~12바이트만 기여한다. 본문을TEXT로 빼면 행 크기 한도에 여유가 생기는 이유다.
WARNING —
CHAR가 항상 “고정폭 한 덩어리”는 아니다
- InnoDB의 compact 계열 행 포맷은 가변길이 문자셋에 대해
CHAR저장을 최적화한다. 즉CHAR를 썼다는 사실만으로 “행 안에서 늘 같은 바이트를 차지한다”고 단정할 수 없다.CHAR(255)에utf8mb4를 쓰면 최대 길이가 768바이트를 넘는다. InnoDB는 768바이트 이상이 될 수 있는 고정길이 필드를 가변길이로 인코딩해 off-page에 둘 수 있다. 이 조합은 “짧은 고정폭 값”이라는CHAR의 전제에서 이미 벗어나 있다.- 인덱스 프리픽스 상한도 행 포맷에 따라 다르다 — InnoDB
DYNAMIC·COMPRESSED는 3072바이트,REDUNDANT·COMPACT는 767바이트다.
INFO — 정렬·임시 테이블 비용의 실제 조건
“
TEXT를 정렬하면 디스크 임시 테이블이 생긴다”는 서술은 단정하기 어렵다. 8.4는 메모리 내부 임시 테이블에 TempTable 엔진을 기본으로 쓰고, 메모리 한도를 넘어 디스크로 넘어갈 때 InnoDB를 쓴다.tmp_table_size기본값은 16MiB이며,max_heap_table_size(16MiB)는MEMORY엔진을 쓸 때만 관여한다.공식 문서가 “메모리 임시 테이블을 쓸 수 없다”고 못 박는 조건은 좁다 —
UNION·UNION ALL을 쓰면서 select 리스트에 최대 길이 512를 넘는 문자열 컬럼이 있는 경우, 그리고SHOW COLUMNS·DESCRIBE다. 따라서 추측하지 말고 세션에서 실측한다 —Created_tmp_tables와Created_tmp_disk_tables를 쿼리 전후로 비교하면 된다. 단 메모리맵 파일로 만들어진 임시 테이블은Created_tmp_disk_tables에 집계되지 않는다.
2.3 날짜와 시간 — DATETIME vs TIMESTAMP
| 특성 | DATETIME | TIMESTAMP |
|---|---|---|
| 저장 형식 | packed binary (문자열이 아님) — 입력 값을 시간대 변환 없이 그대로 보관 | UTC epoch 기반 정수 — 저장 시 세션 시간대→UTC, 조회 시 UTC→세션 시간대로 자동 변환 |
| 범위 | 1000-01-01 ~ 9999-12-31 | 1970-01-01 00:00:01 UTC ~ 2038-01-19 03:14:07 UTC |
| 시간대 | 시간대 무관(저장한 그대로) | 세션(time_zone)에 따라 변환됨 |
| 저장 공간 | 5바이트 (MySQL 5.6.4+, 소수 초 정밀도에 따라 +0~3B) | 4바이트 (소수 초 정밀도에 따라 +0~3B) |
| 사용 시점 | 넓은 범위·시간대 변환이 필요 없는 값(예약 시각, 생년월일시) | 글로벌 서비스, created_at/updated_at처럼 UTC 절대시각 + 자동 갱신이 필요할 때 |
WARNING — 흔히 잘못 알려진 두 가지
- DATETIME은 “문자열로 저장”되지 않는다. MySQL 5.6.4부터 DATETIME은 비소수부에 5바이트를 쓰는 packed binary로 저장됨(그 이전 버전은 8바이트). 즉 “DATETIME=8B, 문자 저장”이라는 설명은 옛 버전 기준의 오해.
- 소수 초(fractional seconds) 저장 크기는 0~3바이트다. 정밀도를 지정하지 않으면(정밀도 0) 추가 바이트가 없고, 1~2자리에 1B, 3~4자리에 2B, 5~6자리에 3B가 붙는다.
DATETIME(6)은 5+3=8바이트,TIMESTAMP(3)은 4+2=6바이트다.- 소수 초를 쓰면 범위 상한도 같이 움직인다 — DATETIME은
9999-12-31 23:59:59.499999, TIMESTAMP는2038-01-19 03:14:07.499999까지다.
DANGER — 2038년 문제 (Y2038)
TIMESTAMP의 상한은 2038-01-19 03:14:07 UTC. 만료일·미래 예약 시각처럼 2038년을 넘길 수 있는 값에TIMESTAMP를 쓰면 오버플로가 발생하므로, 이런 컬럼은DATETIME(또는 애플리케이션에서 UTC를 명시적으로 다루는 설계)을 사용해야 함. 같은 상한이 Event Scheduler에도 걸려 있다 — 공식 문서는 이벤트가 Unix Epoch 이후(대략 2038년 이후)의 시각을 지원하지 않는다고 명시한다(7.3 참고).
TIP — 실무 권장 패턴
- “UTC 절대시각”을 앱에서 일관되게 다룰 수 있다면, 시간대 변환 부작용을 피하기 위해 DATETIME + 항상 UTC 저장 조합을 선호하는 팀도 많음. 반대로 서버의 자동 UTC 변환에 기대고 싶으면 TIMESTAMP. 어느 쪽이 낫다는 판단은 공식 문서의 규범이 아니라 팀의 운영 방식에 달려 있다.
- 자동 초기화·자동 갱신은 타입과 무관하게
DEFAULT CURRENT_TIMESTAMP/ON UPDATE CURRENT_TIMESTAMP로 DATETIME·TIMESTAMP 양쪽에 걸 수 있다.- 이미 저장된 TIMESTAMP를 UTC 기준 DATETIME으로 꺼내야 하면
CAST(col AT TIME ZONE INTERVAL '+00:00' AS DATETIME)을 쓴다.
DANGER — 명명 시간대는 시간대 테이블이 적재돼 있어야 동작한다
time_zone = 'Asia/Seoul'처럼 이름으로 시간대를 지정하려면mysql.time_zone*테이블이 채워져 있어야 한다(이름형'UTC'도 포함된다). MySQL 설치는 이 테이블을 만들기만 하고 값을 채우지 않으므로, 적재 없이 쓰면ERROR 1298 (HY000): Unknown or incorrect time zone이 난다. 오프셋 표기('+09:00')는 테이블 없이 동작한다.
time_zone = 'SYSTEM'은 시간대 계산 함수를 호출할 때마다 시스템 라이브러리를 거치며 전역 뮤텍스 경합을 만들 수 있다. 시간대 변환이 많은 워크로드라면 세션·서버time_zone을 명시적 값으로 고정한다.
2.4 함수를 이용한 데이터 저장
INET_ATON — IP 주소를 정수로 저장
CREATE TABLE ip_addresses (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
ip_address INT UNSIGNED NOT NULL,
PRIMARY KEY (id)
);
-- 삽입
INSERT INTO ip_addresses (ip_address) VALUES (INET_ATON('192.168.0.1'));
-- 조회
SELECT * FROM ip_addresses WHERE ip_address = INET_ATON('192.168.0.1');
-- 결과: 3232235521
- 문자열 대신
INT UNSIGNED로 저장 → 4바이트만 사용 UNSIGNED는 선택이 아니다. 공식 문서는 signed 컬럼을 쓰면 첫 옥텟이 127보다 큰 IP 주소가 올바르게 저장되지 않는다고 명시한다INET_ATON()은 잘못된 입력에NULL을 반환할 수 있으므로 함수만으로 무결성이 보장되지는 않음.NOT NULL, Strict SQL mode, 애플리케이션 입력 검증을 함께 적용
WARNING —
INET_ATON은 IPv4 전용, 그리고 단축 표기에 관대하다
INET_ATON/INET_NTOA는 IPv4만 처리함(IPv6 문자열을 넣으면NULL반환). IPv6까지 저장해야 한다면INET6_ATON/INET6_NTOA+VARBINARY(16)조합을 사용해야 함.INET6_ATON은 IPv4·IPv6를 모두 받아 각각 4B/16B 바이너리로 반환하므로, 듀얼스택 환경이면 처음부터VARBINARY(16)으로 통일해 두는 편이 안전함.- 공식 문서는
INET_ATON()에'127.1'같은 단축 표기를 쓰지 말라고 경고한다 — 시스템에 따라 non-NULL 값을 돌려줄 수 있다. 입력 검증에는INET_ATON()보다 엄격한IS_IPV4()를 쓰고,INET6_ATON()의 IPv4 판정은IS_IPV4()와 같은 수준으로 엄격하다.INET6_ATON()은 zone ID(fe80::3%eth0), 네트마스크(/64), 포트(:8080), 16진·8진 옥텟 표기를 거부한다. 저장 전에 입력을 정규화해야 하는 이유다.INET6_NTOA()의 반환값은 최대 39자 소문자이며 타입은VARCHAR(39) CHARACTER SET utf8mb3다. 이 값을 다시 컬럼에 담을 때 문자셋을 맞추지 않으면 3장의 콜레이션 충돌로 이어진다.
-- IPv4/IPv6 겸용 저장
INSERT INTO ip_log (ip) VALUES (INET6_ATON('2001:db8::1'));
SELECT INET6_NTOA(ip) FROM ip_log; -- 다시 문자열로 복원
UUID_TO_BIN / BIN_TO_UUID — UUID를 바이너리로 저장
- 저장 공간 절약: 문자열(36B) → 바이너리(16B).
UUID_TO_BIN()의 반환형은VARBINARY(16)이며, 보통BINARY(16)컬럼에 저장한다 - 성능 향상: 짧은 바이너리 비교가 36자 문자열 비교보다 유리하고, PK로 쓸 경우 모든 세컨더리 인덱스가 그 길이를 함께 짊어진다(6.2 참고)
- 단점: JOIN 키로 사용 시 직관적이지 않음, 함수 없이는 알아보기 어려움
TIP — Swap Flag의 실제 동작
UUID_TO_BIN(uuid, 1)은 UUID v1 값의 time-low와 time-high 부분을 swap해서 더 빠르게 변하는 부분을 오른쪽으로 옮긴다. 공식 문서의 표현대로, 결과를 인덱스 컬럼에 저장하면 indexing efficiency를 개선할 수 있다. 왜 이득인지는 6.2의 페이지 충전율 설명과 이어진다 — 값이 대체로 증가하는 순서로 들어오면 인덱스 페이지가 더 촘촘히 찬다.두 가지를 반드시 지킨다.
- 플래그를 짝 맞춘다.
UUID_TO_BIN(uuid, 1)로 저장했으면BIN_TO_UUID(val, 1)로 되돌려야 원래 UUID가 복원된다. 한쪽만 1이면 조용히 다른 값이 나온다.- v1이 아니면 의미가 없다. 공식 문서는 v1 형식을 따르지 않는 값에 대해서는 time-part swapping이 아무 이득을 주지 않는다고 명시한다.
-- 저장과 복원은 같은 swap flag 로 짝을 맞춘다
SELECT UUID_TO_BIN(UUID(), 1); -- time-ordered binary, VARBINARY(16)
SELECT BIN_TO_UUID(id_bin, 1) FROM t; -- 원본 UUID 복원
WARNING —
UUID()자체의 제약
UUID()는 UUID v1을 생성한다. 공식 문서는 이 값이 unique 하지만 unguessable·unpredictable 하지는 않다고 경고하므로, 추측 불가능성이 필요한 토큰에 쓰면 안 된다.UUID()는binlog_format=STATEMENT에서 복제에 안전하지 않다. 8.4의 기본값은ROW지만, 복제 설정을 바꿀 때 함께 확인해야 한다.
INFO — UUID v7 검토
MySQL의
UUID()함수는 v1만 생성하며 v7 생성 함수는 제공하지 않는다. 시간순 정렬 + 전역 유일성이 필요하면 애플리케이션 레벨에서 UUID v7을 생성해BINARY(16)으로 저장하는 방식이 실무에서 권장된다. 이 권고는 MySQL 공식 문서의 규범이 아니라 애플리케이션 쪽 관행이며, 근거는 6.2에서 다루는 삽입 순서와 페이지 충전율이다.