rastalion.dev
Docs / Database / MySQL for Developers

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 시 임시 테이블 비용

WARNINGVARCHAR(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로 빼면 행 크기 한도에 여유가 생기는 이유다.

WARNINGCHAR가 항상 “고정폭 한 덩어리”는 아니다

  • InnoDB의 compact 계열 행 포맷은 가변길이 문자셋에 대해 CHAR 저장을 최적화한다. 즉 CHAR를 썼다는 사실만으로 “행 안에서 늘 같은 바이트를 차지한다”고 단정할 수 없다.
  • CHAR(255)utf8mb4를 쓰면 최대 길이가 768바이트를 넘는다. InnoDB는 768바이트 이상이 될 수 있는 고정길이 필드를 가변길이로 인코딩해 off-page에 둘 수 있다. 이 조합은 “짧은 고정폭 값”이라는 CHAR의 전제에서 이미 벗어나 있다.
  • 인덱스 프리픽스 상한도 행 포맷에 따라 다르다 — InnoDB DYNAMIC·COMPRESSED3072바이트, REDUNDANT·COMPACT767바이트다.

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_tablesCreated_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, 애플리케이션 입력 검증을 함께 적용

WARNINGINET_ATON은 IPv4 전용, 그리고 단축 표기에 관대하다

  • INET_ATON/INET_NTOAIPv4만 처리함(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 복원

WARNINGUUID() 자체의 제약

  • 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에서 다루는 삽입 순서와 페이지 충전율이다.


Advertisement