처음에는 VARCHAR 하나로 모든 값을 저장해도 동작하는 것처럼 보입니다. 하지만 금액, 시간, 상태값의 타입을 잘못 고르면 정렬·계산·인덱스에서 바로 문제가 납니다. 데이터 타입 선택은 저장 공간보다 서비스의 정확성과 유지보수성을 좌우합니다.
데이터 타입 선택이 성능, 정확성, 저장 공간에 어떤 영향을 미치는지 이해하고, 실무에서 자주 마주치는 타입 선택 실수를 피하는 방법을 익힙니다.
- 1정수형 타입: SMALLINT ~ BIGINT 범위와 선택 기준
- 2실수형: DECIMAL vs FLOAT 함정
- 3문자형: CHAR vs VARCHAR vs TEXT
- 4날짜/시간형과 타임존 처리
- 5특수 타입: BOOLEAN, JSON, UUID, ENUM
데이터 타입 선택 기준 — 숫자, 문자, 날짜의 올바른 선택
입사 첫 달, 결제 금액 컬럼을 FLOAT으로 선언했다. 저장은 됐고, 조회도 됐고, 아무 문제 없어 보였다. 그런데 월말 정산 배치를 돌렸더니 합산 결과가 회계팀 숫자와 미묘하게 달랐다 — 1원, 2원씩. 원인을 찾는 데 이틀이 걸렸고, 결국 FLOAT의 부동소수점 오차가 수천 건에 걸쳐 누적된 것이었다. 컬럼 타입을 DECIMAL로 바꾸는 건 데이터 마이그레이션을 동반했고, 운영 시간 외에 작업해야 했다. 비슷한 일이 VARCHAR 쪽에서도 있었다 — 이름 컬럼을 TEXT로 선언해서 나중에 인덱스를 걸려니 길이 제한 오류가 났고, 글로벌 출시 후 TIMESTAMP 컬럼이 서울/뉴욕에서 9시간씩 틀어지는 사고도 겪었다. 타입 선택은 "저장만 되면 된다"의 문제가 아니라, 나중에 수정 비용이 얼마냐의 문제다. 이 모듈은 처음부터 올바른 타입을 고르는 판단 기준을 실수 사례와 함께 정리한다.
"어차피 다 저장되는 거 아닌가요?" 이런 생각으로 모든 컬럼을 VARCHAR(255)나 TEXT로 선언하는 경우가 있습니다. 하지만 잘못된 타입 선택은 저장 공간 낭비, 성능 저하, 심각한 경우 데이터 손실과 계산 오류로 이어집니다. 이 모듈에서는 각 타입의 내부 동작 원리와 선택 기준을 정확히 이해합니다.
숫자와 문자 타입 — 잘못 선택하면 생기는 문제들
INT로 선언한 ID 컬럼이 21억을 넘었습니다. 더 이상 INSERT가 안 됩니다. 컬럼 타입을 BIGINT로 바꾸려면 수천만 건 테이블에서 ALTER가 몇 시간씩 걸리고, 그 사이 서비스는 멈춥니다. 처음부터 타입을 올바르게 선택하지 않으면 이 문제를 나중에 서비스 중단 없이 해결하기 어렵습니다.

정수형 타입 범위
| 타입 | 크기 | 최솟값 | 최댓값 | 사용 예시 |
|---|---|---|---|---|
| SMALLINT | 2바이트 | -32,768 | 32,767 | 연령, 월, 소규모 카운터 |
| INT / INTEGER | 4바이트 | -2,147,483,648 | 2,147,483,647 | 일반 ID, 카운터 |
| BIGINT | 8바이트 | -9.2 × 10¹⁸ | 9.2 × 10¹⁸ | 대용량 ID, 타임스탬프(ms) |
| SERIAL | 4바이트 | 1 | 2,147,483,647 | 자동 증가 PK (INT + 시퀀스) |
| BIGSERIAL | 8바이트 | 1 | 9.2 × 10¹⁸ | 대용량 자동 증가 PK |
INT Overflow 실제 사례
2012년 Pinterest는 INT PK overflow 문제를 경험했습니다. 21억 개 이상의 레코드가 쌓이면 INT가 오버플로우하기 때문입니다. 아래는 잘못된 선택과 올바른 선택을 비교한 예시입니다. 미래가 불확실하다면 처음부터 BIGINT를 사용하는 것이 안전합니다.
CREATE TABLE posts (
id INT PRIMARY KEY
);
실행 완료 또는 조회 결과가 표시됩니다.
- 저장 타입—숫자·날짜·문자 값이 의도한 타입으로 저장되는지 확인합니다.
- 정렬 결과—문자 숫자 정렬처럼 타입 선택으로 결과가 틀어지지 않는지 봅니다.
- NULL 허용—필수 값과 선택 값의 제약이 스키마에 반영됐는지 점검합니다.
CREATE TABLE posts (
id BIGSERIAL PRIMARY KEY
);
실수형: DECIMAL vs FLOAT
FLOAT과 DOUBLE은 IEEE 754 부동소수점 방식으로 소수를 이진 근사값으로 표현합니다. 이 때문에 SELECT 1.1 + 2.2를 실행하면 3.3000000000000003처럼 정밀도 오류가 발생합니다. 금액 컬럼에는 절대 FLOAT을 사용하면 안 됩니다. DECIMAL(NUMERIC)은 정확한 십진수 연산을 보장합니다.
DECIMAL(precision, scale)에서 precision은 전체 유효 숫자 개수이고, scale은 소수점 이하 자릿수입니다. 예를 들어 DECIMAL(12, 2)는 최대 9999999999.99까지 저장할 수 있습니다.
FLOAT/DOUBLE은 과학적 계산이나 통계 분석처럼 근사값이 허용되는 경우에만 사용합니다.
CREATE TABLE payments (
amount DECIMAL(15, 2) NOT NULL,
tax_rate DECIMAL(5, 4),
exchange_rate DECIMAL(10, 6)
);
CREATE TABLE sensor_readings (
temperature DOUBLE PRECISION,
humidity FLOAT
);
FLOAT 또는 DOUBLE 타입으로 금액 컬럼을 선언하면 IEEE 754 부동소수점 연산의 특성상 미세한 정밀도 오류가 누적됩니다. 수천~수만 건의 합산에서 오차가 커져 회계 불일치로 이어집니다.
해결 방법: 금액 컬럼은 항상 DECIMAL 또는 NUMERIC 타입을 사용하세요. 소수점 이하 2자리가 필요하면 DECIMAL(15, 2)가 표준적인 선택입니다.
문자형 타입 선택
세 가지 문자형 타입의 저장 방식은 다음과 같습니다.
CHAR(n): 고정 길이. 항상 n바이트를 사용합니다. 데이터가 n보다 짧으면 나머지를 공백으로 채웁니다.VARCHAR(n): 가변 길이. 최대 n글자까지 저장하며, 실제 문자열 길이 + 1~2바이트(길이 정보)만 사용합니다.TEXT: 가변 길이. 길이 제한이 없습니다.
길이가 항상 고정된 데이터(국가 코드, 통화 코드 등)에는 CHAR를, 최대 길이가 정해진 가변 데이터에는 VARCHAR를, 길이 제한 없는 긴 텍스트에는 TEXT를 사용합니다.
TEXT 컬럼에는 인덱스 크기 제한이 있으므로 전체 텍스트 검색이 필요하다면 전문 검색 기능을 사용해야 합니다.
CREATE TABLE products (
country_code CHAR(2) NOT NULL,
currency CHAR(3) NOT NULL,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
title VARCHAR(500) NOT NULL,
description TEXT,
content TEXT
);
VARCHAR 길이 선택 가이드
아래는 실무에서 각 필드 유형에 권장되는 VARCHAR 크기입니다. RFC 표준이나 알고리즘 출력 길이가 고정된 경우는 그 값을 그대로 따릅니다.
| 필드 | 타입 | 근거 |
|---|---|---|
| VARCHAR(255) | RFC 5321 표준 최대 길이 | |
| username | VARCHAR(50) | 일반적인 서비스 제한 |
| password_hash | CHAR(60) | bcrypt 해시는 항상 60자 |
| phone | VARCHAR(20) | 국제 전화번호 포함 |
| url | VARCHAR(2048) | URL 최대 길이 고려 |
| ip_address | VARCHAR(45) | IPv6 포함 최대 45자 |
| country_code | CHAR(2) | ISO 3166-1 |
| currency_code | CHAR(3) | ISO 4217 |
| zip_code | VARCHAR(10) | 국가별 우편번호 최대 길이 |
날짜/시간 타입과 타임존 함정 — TIMESTAMP vs DATETIME
한국 서버에서 정상적으로 저장된 시간이 미국 서버로 마이그레이션하자 9시간이 틀어졌습니다. DATETIME을 쓴 것이 원인이었습니다. TIMESTAMP와 DATETIME 중 어느 것을 쓰느냐에 따라 타임존 변환 동작이 완전히 다릅니다. 잘못 선택하면 글로벌 서비스에서 반드시 시간 버그가 발생합니다.

날짜/시간 타입 비교
| 타입 | 크기 | 범위 | 타임존 |
|---|---|---|---|
| DATE | 4바이트 | 4713 BC ~ 5874897 AD | 없음 |
| TIME | 8바이트 | 00:00:00 ~ 24:00:00 | 없음 |
| TIMESTAMP | 8바이트 | 4713 BC ~ 294276 AD | 없음 |
| TIMESTAMPTZ | 8바이트 | 4713 BC ~ 294276 AD | UTC 변환 저장 |
| INTERVAL | 16바이트 | ±178,000,000년 | 해당없음 |
TIMESTAMP vs TIMESTAMPTZ 차이
TIMESTAMP는 타임존 정보가 없어 입력값을 그대로 저장합니다. 서울(UTC+9) 세션에서 15:00:00을 저장하면 그냥 15:00:00이 저장되고, 이 값이 어느 타임존의 15시인지 DB는 알 수 없습니다.
TIMESTAMPTZ(TIMESTAMP WITH TIME ZONE)는 입력값을 UTC로 변환해 저장하고, 조회 시 세션 타임존에 맞게 자동으로 변환해 돌려줍니다. 같은 15:00:00 KST 값이 내부적으로 06:00:00 UTC로 저장되고, 뉴욕 타임존으로 조회하면 02:00:00 EDT로 변환됩니다.
INSERT INTO events (created_at_tz)
VALUES ('2026-04-07 15:00:00');
SELECT created_at_tz FROM events;
TIMESTAMP 타입을 사용하면 저장된 값에 타임존 정보가 없습니다. 서버 타임존을 변경하거나 글로벌 사용자가 늘어나면 동일한 데이터가 지역마다 다르게 해석됩니다.
해결 방법: 레코드 생성/수정 시간은 항상 TIMESTAMPTZ를 사용하고 UTC로 저장하세요. 생년월일처럼 순수한 날짜 데이터는 DATE를 사용합니다. 글로벌 서비스라면 TIMESTAMP는 사용하지 않는 것이 원칙입니다.
날짜 타입 실무 패턴
생년월일이나 이벤트 날짜처럼 특정 날짜만 필요한 경우에는 DATE를 사용합니다. 레코드의 생성/수정 시각에는 TIMESTAMPTZ가 맞습니다. updated_at은 UPDATE가 발생할 때마다 자동으로 갱신되도록 트리거를 걸어 두는 것이 일반적입니다.
CREATE TABLE users (
birthdate DATE,
joined_on DATE NOT NULL DEFAULT CURRENT_DATE
);
CREATE TABLE products (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
날짜 연산에는 EXTRACT와 INTERVAL을 활용합니다. 기간별 집계에는 DATE_TRUNC로 날짜를 월/주 단위로 잘라 GROUP BY에 사용합니다.
SELECT
EXTRACT(YEAR FROM age(birthdate)) AS age,
birthdate + INTERVAL '18 years' AS adult_date
FROM users;
SELECT
DATE_TRUNC('month', created_at) AS month,
COUNT(*) AS orders_count
FROM orders
GROUP BY 1
ORDER BY 1;
특수 타입 정리
BOOLEAN은 TRUE, FALSE, NULL 세 가지 값을 가집니다. 기본값을 반드시 명시하는 것이 좋습니다.
UUID는 분산 환경에서 충돌 없이 전역 고유 ID가 필요할 때 사용합니다. 16바이트(INT의 4배)이므로 내부 PK는 BIGINT, 외부 노출용으로 UUID를 별도 컬럼에 두는 패턴을 권장합니다.
JSONB는 JSON을 바이너리로 저장해 인덱스를 지원하고 조회 속도가 빠릅니다. JSON은 텍스트로 저장하며 입력 순서를 보존하지만 인덱스를 사용할 수 없어 일반적으로 JSONB를 권장합니다.
ENUM은 컬럼 값을 특정 문자열 집합으로 제한합니다. 단, 값 추가/변경이 번거롭기 때문에 CHECK 제약이 더 유연한 대안이 될 수 있습니다.
CREATE TABLE users (
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
metadata JSONB DEFAULT '{}'
);
CREATE TYPE order_status AS ENUM ('pending', 'paid', 'shipped', 'delivered', 'cancelled');
CREATE TABLE orders (
status order_status NOT NULL DEFAULT 'pending'
);
결제 서비스를 개발할 때 amount 컬럼을 FLOAT으로 선언하면 수천 건의 정산 합산에서 1~2원의 오차가 발생해 회계 불일치 문제가 생깁니다. 실무에서는 DECIMAL(15, 2)를 표준으로 사용합니다.
마찬가지로 created_at을 TIMESTAMP로 선언한 서비스가 글로벌로 확장될 때 기존 데이터 마이그레이션 비용이 발생합니다. 처음부터 TIMESTAMPTZ를 사용하면 DB가 타임존 변환을 자동으로 처리하므로 애플리케이션 레이어에서 별도 변환 로직이 필요 없습니다.
이 두 가지 타입 선택만 올바르게 해도 나중에 발생하는 대규모 데이터 마이그레이션을 예방할 수 있습니다.
다음 모듈에서는 PK, FK 제약조건과 CASCADE 삭제 설정이 데이터 정합성에 미치는 영향을 다룹니다.