정규화·반정규화 균형과 파티셔닝으로 대용량을 견디는 스키마를 설계한다.
실전 스키마 설계와 대용량 데이터 패턴
입문편에서 테이블 설계와 제약조건의 기초를 익혔다면, 이제는 "왜 이렇게 설계하는가"라는 질문에 답할 차례입니다. 수백만 건을 넘어 수억 건이 쌓이는 테이블은 교과서적 정규화만으로는 버티지 못하고, 잘못된 데이터 타입 하나가 디스크 사용량과 쿼리 속도를 수 배 차이 나게 만들기도 합니다.
이 레슨에서는 정규화와 반정규화의 트레이드오프를 실무 관점에서 판단하는 방법부터, 파티셔닝으로 대용량 테이블을 분할하는 전략, JSON 컬럼과 생성 컬럼의 활용, 무중단 스키마 변경, 아카이빙·소프트 삭제 패턴, 그리고 수평 샤딩 개요까지 다룹니다.
학습 목표
- 정규화와 반정규화의 트레이드오프를 근거 있게 판단할 수 있습니다.
- 데이터 타입 선택이 스토리지와 성능에 미치는 영향을 이해합니다.
- RANGE / LIST / HASH 파티셔닝으로 대용량 테이블을 분할할 수 있습니다.
- JSON 컬럼과 생성 컬럼을 올바르게 활용해 유연성과 성능을 동시에 확보합니다.
- 온라인 DDL, gh-ost, pt-osc 등 무중단 스키마 변경 도구의 원리를 설명할 수 있습니다.
정규화와 의도적 반정규화의 트레이드오프
정규화가 기본인 이유
3NF(제3정규형)까지 정규화하면 데이터 중복이 제거되어 UPDATE 이상, 삽입 이상, 삭제 이상이 사라집니다. 쓰기 비용이 낮고 일관성이 보장되는 구조입니다.
-- ✅ 정규화된 구조: 주문과 상품 정보 분리
CREATE TABLE products (
product_id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_name VARCHAR(200) NOT NULL,
category_id SMALLINT UNSIGNED NOT NULL
);
CREATE TABLE orders (
order_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
product_id INT UNSIGNED NOT NULL,
quantity SMALLINT UNSIGNED NOT NULL DEFAULT 1,
ordered_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
FOREIGN KEY (product_id) REFERENCES products(product_id)
);
반정규화가 필요한 순간
JOIN이 누적되어 실행 계획의 비용이 지나치게 높아지거나, 집계 쿼리가 매번 수억 건을 스캔해야 한다면 반정규화를 검토합니다. 핵심 원칙은 읽기 경로의 JOIN 비용 > 쓰기 경로의 중복 갱신 비용 일 때 반정규화가 유리하다는 것입니다.
-- ❌ 매번 집계: 주문 수 조회 시 orders 전체 스캔
SELECT user_id, COUNT(*) FROM orders GROUP BY user_id;
-- ✅ 반정규화: users 테이블에 집계 컬럼 캐시
ALTER TABLE users ADD COLUMN order_count INT UNSIGNED NOT NULL DEFAULT 0;
-- 주문 삽입 트리거 또는 애플리케이션에서 카운터 갱신
UPDATE users SET order_count = order_count + 1 WHERE user_id = ?;
반정규화를 선택할 때는 아래 체크리스트를 검토하세요.
| 체크 항목 | 반정규화 적합 | 정규화 유지 |
|---|---|---|
| 읽기 : 쓰기 비율 | 읽기가 압도적으로 많음 | 쓰기가 빈번함 |
| 집계 복잡도 | 매번 풀스캔 집계 필요 | 인덱스로 커버 가능 |
| 데이터 일관성 요구 | 약간의 지연 허용 가능 | 즉시 정확성 필수 |
| 운영 복잡도 | 갱신 로직 관리 가능 | 단순 유지가 중요 |
⚠️ 주의: 반정규화된 컬럼은 반드시 트리거 또는 애플리케이션 코드로 동기화 책임을 명확히 해야 합니다. 두 경로가 공존하면 데이터 불일치가 발생합니다.
적절한 데이터 타입 선택과 스토리지·성능 영향
데이터 타입은 단순한 형식 선언이 아닙니다. 컬럼당 바이트 수가 행 크기를 결정하고, 행 크기가 페이지당 행 수를 결정하며, 결국 전체 I/O 비용에 직결됩니다.
정수 타입 선택
-- ❌ 습관적으로 INT 사용
user_id INT, -- 4 bytes, 최대 약 21억
status INT, -- 0/1/2 세 값인데 4 bytes 낭비
-- ✅ 범위에 맞는 최소 타입
user_id INT UNSIGNED, -- 4 bytes, 최대 약 42억
status TINYINT UNSIGNED, -- 1 byte, 0~255
age TINYINT UNSIGNED, -- 1 byte
score SMALLINT UNSIGNED -- 2 bytes, 0~65535
문자열 타입: CHAR vs VARCHAR vs TEXT
-- CHAR(n): 고정 길이, 짧고 변동 없는 값에 유리 (빠른 오프셋 계산)
country_code CHAR(2) NOT NULL, -- 'KR', 'US'
uuid_col CHAR(36) NOT NULL, -- UUID 저장 시
-- VARCHAR(n): 가변 길이, 1~2 bytes 길이 prefix 추가
email VARCHAR(320) NOT NULL,
product_name VARCHAR(200) NOT NULL,
-- TEXT: 오프페이지(off-page) 저장 가능 — 인덱스 제약, 메모리 비용 주의
description TEXT, -- 인덱스 걸 때는 prefix 인덱스 필요
💡 TIP: UUID를 CHAR(36)이 아닌 BINARY(16)으로 저장하면 인덱스 크기가 절반 이하로 줄고 비교 연산도 빠릅니다.
UUID_TO_BIN(uuid, 1)/BIN_TO_UUID(col, 1)함수를 활용하세요.
-- UUID를 BINARY(16)으로 저장하는 패턴
CREATE TABLE sessions (
session_id BINARY(16) NOT NULL DEFAULT (UUID_TO_BIN(UUID(), 1)),
user_id INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (session_id)
);
-- 조회 시
SELECT BIN_TO_UUID(session_id, 1) AS session_uuid, user_id
FROM sessions
WHERE session_id = UUID_TO_BIN('550e8400-e29b-41d4-a716-446655440000', 1);
날짜/시간 타입
-- DATETIME vs TIMESTAMP 비교
event_time DATETIME(3), -- 타임존 무관, 8 bytes, '1000-01-01' ~ '9999-12-31'
updated_at TIMESTAMP(3), -- UTC 저장, 4 bytes, '1970-01-01' ~ '2038-01-19'
-- 상한은 부호 있는 32비트 한계인 2038-01-19로 고정(Year 2038 문제).
-- MySQL 8.0.28을 포함해 현재까지 이 상한은 확장되지 않음
-- ❌ 날짜를 VARCHAR로 저장 — 범위 쿼리, 인덱스 활용 불가
created_date VARCHAR(10), -- '2024-01-15' 형태로 저장하는 안티패턴
-- ✅ DATE 타입 사용
created_date DATE NOT NULL
파티셔닝으로 대용량 테이블 분할
파티셔닝은 논리적으로 하나인 테이블을 물리적으로 여러 파티션 파일로 나누는 기법입니다. 쿼리가 조건에 맞는 파티션만 스캔하는 파티션 프루닝(partition pruning) 덕분에 I/O를 획기적으로 줄일 수 있습니다.
RANGE 파티셔닝 — 시계열 데이터
가장 흔한 패턴은 날짜/시간 컬럼을 기준으로 월별·연별 파티션을 나누는 것입니다.
CREATE TABLE access_logs (
log_id BIGINT UNSIGNED AUTO_INCREMENT,
user_id INT UNSIGNED NOT NULL,
path VARCHAR(500) NOT NULL,
logged_at DATETIME NOT NULL,
PRIMARY KEY (log_id, logged_at) -- 파티션 키는 PK에 포함 필수
) PARTITION BY RANGE COLUMNS (logged_at) (
PARTITION p2024_01 VALUES LESS THAN ('2024-02-01'),
PARTITION p2024_02 VALUES LESS THAN ('2024-03-01'),
PARTITION p2024_03 VALUES LESS THAN ('2024-04-01'),
-- ... 월별 파티션 추가
PARTITION p_future VALUES LESS THAN (MAXVALUE)
);
-- 파티션 프루닝 확인 (EXPLAIN 출력의 partitions 컬럼)
EXPLAIN SELECT * FROM access_logs
WHERE logged_at BETWEEN '2024-02-01' AND '2024-02-28 23:59:59';
-- partitions: p2024_02 만 표시되면 프루닝 정상 동작
-- 오래된 파티션 순간 삭제 (DROP TABLE보다 수천 배 빠름)
ALTER TABLE access_logs DROP PARTITION p2024_01;
💡 TIP: RANGE 파티션의 가장 큰 실무 장점은
DROP PARTITION입니다. 수억 건 파티션을DELETE대신DROP PARTITION으로 밀리초 단위로 제거할 수 있습니다.
⚠️ 주의: 파티션 키로
YEAR(logged_at)*100 + MONTH(logged_at)같은 임의 산술 표현식을 쓰면, 원본 컬럼(logged_at)에 건 범위 조건만으로는 옵티마이저가 파티션 경계를 유도하지 못해 프루닝이 동작하지 않을 수 있습니다(모든 파티션 스캔). 위 예시처럼RANGE COLUMNS(logged_at)에 날짜 경계를 직접 지정하면 컬럼 범위 조건에서 프루닝이 정상 동작합니다.
LIST 파티셔닝 — 범주형 데이터
CREATE TABLE orders (
order_id BIGINT UNSIGNED AUTO_INCREMENT,
region_code TINYINT UNSIGNED NOT NULL, -- 1=서울, 2=경기, 3=부산, 4=기타
amount DECIMAL(12, 2) NOT NULL,
PRIMARY KEY (order_id, region_code)
) PARTITION BY LIST (region_code) (
PARTITION p_seoul VALUES IN (1),
PARTITION p_gyeong VALUES IN (2),
PARTITION p_busan VALUES IN (3),
PARTITION p_etc VALUES IN (4, 5, 6, 7)
);
HASH 파티셔닝 — 균등 분산
특정 컬럼 기준 없이 균등 분산이 목적일 때 사용합니다. 파티션 프루닝 효과는 없지만, 핫스팟(hot spot) 완화에 유리합니다.
CREATE TABLE user_events (
event_id BIGINT UNSIGNED AUTO_INCREMENT,
user_id INT UNSIGNED NOT NULL,
event_type VARCHAR(50) NOT NULL,
event_time DATETIME(3) NOT NULL,
PRIMARY KEY (event_id, user_id)
) PARTITION BY HASH (user_id) PARTITIONS 16;
⚠️ 주의: 파티션 수를 늘리면
ALTER TABLE ... REORGANIZE PARTITION이 필요하고 이는 테이블 잠금을 유발할 수 있습니다. 초기 설계 시 파티션 수를 충분히 잡으세요. HASH 파티션은 나중에 추가(COALESCE/ADD)가 가능하지만 전체 데이터 재배치가 발생합니다.
JSON 컬럼과 생성 컬럼(Generated Column) 활용
MySQL 5.7부터 네이티브 JSON 타입이 도입되었고, 8.0에서 대폭 개선되었습니다. 스키마 유연성이 필요할 때 유용하지만, 무분별하게 사용하면 인덱스 활용이 불가능해지는 함정이 있습니다.
JSON 컬럼 기본 활용
CREATE TABLE product_attributes (
product_id INT UNSIGNED NOT NULL PRIMARY KEY,
attrs JSON NOT NULL
);
INSERT INTO product_attributes VALUES
(1, '{"color": "red", "size": "L", "weight_kg": 0.5}'),
(2, '{"color": "blue", "size": "M", "tags": ["sale", "new"]}');
-- JSON 경로 추출
SELECT
product_id,
attrs->>'$.color' AS color, -- ->> 는 따옴표 없이 반환
attrs->'$.weight_kg' AS weight_raw, -- -> 는 JSON 값 반환
JSON_EXTRACT(attrs, '$.size') AS size
FROM product_attributes;
-- JSON 배열 검색
SELECT product_id
FROM product_attributes
WHERE JSON_CONTAINS(attrs->'$.tags', '"sale"');
생성 컬럼으로 JSON 경로 인덱싱
JSON 컬럼 자체에는 직접 인덱스를 걸 수 없습니다. 가상 생성 컬럼(virtual generated column) 을 이용해 JSON 경로 값을 추출하고 그 위에 인덱스를 만드는 패턴이 핵심입니다.
ALTER TABLE product_attributes
-- 가상 생성 컬럼: 디스크 저장 안 함, 읽을 때마다 계산
ADD COLUMN color_virtual VARCHAR(50)
GENERATED ALWAYS AS (attrs->>'$.color') VIRTUAL,
-- 스토어드 생성 컬럼: 디스크에 실제 저장, 쓸 때 계산
ADD COLUMN weight_stored DECIMAL(6, 3)
GENERATED ALWAYS AS (CAST(attrs->>'$.weight_kg' AS DECIMAL(6,3))) STORED,
ADD INDEX idx_color (color_virtual),
ADD INDEX idx_weight (weight_stored);
-- 이제 인덱스를 타는 조회 가능
SELECT product_id FROM product_attributes WHERE color_virtual = 'red';
💡 TIP: VIRTUAL 컬럼은 스토리지를 아끼지만 읽기 시 재계산 비용이 있습니다. 인덱스를 걸 컬럼이라면 STORED를 고려하세요. 반면 인덱스 없이 단순 편의용이라면 VIRTUAL이 적합합니다.
JSON Schema Validation (MySQL 8.0.17+)
-- JSON_SCHEMA_VALID로 삽입 시 형식 강제
ALTER TABLE product_attributes
ADD CONSTRAINT chk_attrs_schema
CHECK (JSON_SCHEMA_VALID(
'{"type": "object", "required": ["color", "size"],
"properties": {
"color": {"type": "string"},
"size": {"type": "string", "enum": ["S","M","L","XL"]}
}}',
attrs
));
온라인 DDL과 무중단 스키마 변경
프로덕션 테이블에 ALTER TABLE을 그대로 실행하면 수억 건 테이블의 경우 수십 분~수 시간 동안 테이블 잠금이 걸릴 수 있습니다.
MySQL 8.0 온라인 DDL
InnoDB는 많은 DDL 작업을 ALGORITHM=INPLACE, LOCK=NONE으로 처리합니다. 먼저 가능 여부를 확인하세요.
-- DDL 전 온라인 가능 여부 확인
ALTER TABLE orders
ADD COLUMN memo VARCHAR(500) NULL,
ALGORITHM=INPLACE,
LOCK=NONE; -- 가능하면 실행, 불가능하면 에러 반환
-- 인덱스 추가 (온라인 가능)
ALTER TABLE orders
ADD INDEX idx_user_status (user_id, status),
ALGORITHM=INPLACE,
LOCK=NONE;
-- ❌ 온라인 불가 예시: PRIMARY KEY 재정의, 컬럼 타입 변경 일부
-- ALGORITHM=COPY가 필요하면 실제로 테이블 전체 복사 발생
gh-ost: GitHub의 무중단 ALTER 도구
gh-ost는 binlog를 구독해 변경 사항을 섀도 테이블에 점진적으로 적용합니다. 테이블 잠금 없이 대형 테이블 스키마 변경이 가능합니다.
# gh-ost 실행 예시 (binlog_format=ROW 필요)
# gh-ost는 기본적으로 레플리카에 접속해 binlog를 읽으므로,
# 프라이머리(마스터)에 직접 연결하려면 --allow-on-master가 필요합니다.
gh-ost \
--host=db-primary.internal \
--allow-on-master \
--user=gh_ost \
--password=secret \
--database=myapp \
--table=orders \
--alter="ADD COLUMN priority TINYINT UNSIGNED NOT NULL DEFAULT 0, ADD INDEX idx_priority (priority)" \
--execute \
--chunk-size=2000 \
--max-lag-millis=1500 \
--throttle-control-replicas="db-replica.internal" \
--ok-to-drop-table
pt-online-schema-change (Percona Toolkit)
pt-osc는 트리거 기반으로 작동합니다. 원본 테이블에 INSERT/UPDATE/DELETE 트리거를 추가해 변경을 섀도 테이블로 전파합니다.
pt-online-schema-change \
--host=127.0.0.1 \
--user=root \
--password=secret \
--database=myapp \
--table=users \
--alter="ADD COLUMN last_login_at DATETIME NULL" \
--execute \
--chunk-size=5000 \
--max-load=Threads_running=50
| 구분 | gh-ost | pt-osc | MySQL Online DDL |
|---|---|---|---|
| 작동 방식 | binlog 기반 | 트리거 기반 | InnoDB 내부 |
| 잠금 없음 | 예 | 예 | DDL 종류에 따라 다름 |
| 일시정지/재개 | 가능 | 제한적 | 불가 |
| 전제 조건 | binlog ROW 형식 | 트리거 허용 | 없음 |
| 추천 상황 | 대형 OLTP 테이블 | 중형 테이블, 레플리카 환경 | 소형 테이블 또는 온라인 지원 DDL |
⚠️ 주의: pt-osc는 트리거를 사용하므로 이미 트리거가 있는 테이블에서는 충돌 가능성이 있습니다. gh-ost는 binlog_format=ROW와 binlog_row_image=FULL이 설정되어 있어야 합니다.
아카이빙·이력 테이블·소프트 삭제 설계 패턴
소프트 삭제(Soft Delete)
실제 레코드를 지우지 않고 deleted_at 컬럼으로 논리 삭제하는 패턴입니다. 감사 추적, 실수 복구, 참조 무결성 유지에 유리합니다.
CREATE TABLE posts (
post_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
title VARCHAR(500) NOT NULL,
body TEXT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
deleted_at DATETIME(3) NULL DEFAULT NULL, -- NULL이면 활성 레코드
INDEX idx_user_active (user_id, deleted_at)
);
-- 소프트 삭제 실행
UPDATE posts SET deleted_at = CURRENT_TIMESTAMP(3) WHERE post_id = 42;
-- 활성 레코드만 조회 (인덱스 활용)
SELECT * FROM posts WHERE user_id = 7 AND deleted_at IS NULL;
⚠️ 주의: 소프트 삭제를 채택하면 모든 쿼리에
WHERE deleted_at IS NULL을 붙여야 합니다. ORM 레벨의 글로벌 스코프나 뷰(View)로 실수를 방지하는 것이 좋습니다.
이력(Audit) 테이블 패턴
중요 데이터의 변경 이력을 별도 테이블에 기록합니다. 원본 테이블과 분리하여 조회 성능을 보호합니다.
CREATE TABLE product_prices (
product_id INT UNSIGNED NOT NULL PRIMARY KEY,
price DECIMAL(12, 2) NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3)
);
-- 이력 테이블: 변경될 때마다 스냅샷 누적
CREATE TABLE product_price_history (
history_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id INT UNSIGNED NOT NULL,
old_price DECIMAL(12, 2) NOT NULL,
new_price DECIMAL(12, 2) NOT NULL,
changed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
changed_by INT UNSIGNED NULL, -- 변경한 사용자 ID
INDEX idx_product_time (product_id, changed_at)
);
-- 트리거로 자동 기록 (또는 애플리케이션 레이어에서 처리)
DELIMITER //
CREATE TRIGGER trg_price_history
BEFORE UPDATE ON product_prices
FOR EACH ROW
BEGIN
IF OLD.price <> NEW.price THEN
INSERT INTO product_price_history
(product_id, old_price, new_price)
VALUES (OLD.product_id, OLD.price, NEW.price);
END IF;
END //
DELIMITER ;
아카이빙 패턴
오래된 데이터를 아카이브 테이블로 이동하여 운영 테이블의 크기를 일정하게 유지합니다.
-- 아카이브 테이블: 원본과 동일 구조 + 아카이빙 메타
CREATE TABLE orders_archive LIKE orders;
ALTER TABLE orders_archive
ADD COLUMN archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- 배치 아카이빙: 작은 청크로 나눠 잠금 최소화
INSERT INTO orders_archive (order_id, user_id, amount, created_at, archived_at)
SELECT order_id, user_id, amount, created_at, NOW()
FROM orders
WHERE created_at < DATE_SUB(NOW(), INTERVAL 2 YEAR)
LIMIT 1000; -- 한 번에 1000건씩
DELETE FROM orders
WHERE created_at < DATE_SUB(NOW(), INTERVAL 2 YEAR)
LIMIT 1000;
-- 위 두 쿼리를 반복 실행 (애플리케이션 루프 또는 이벤트 스케줄러)
샤딩 개념과 수평 분할 전략 개요
파티셔닝이 단일 서버 내 분할이라면, 샤딩(Sharding) 은 데이터를 여러 물리 서버에 분산하는 수평 분할 전략입니다.
샤딩 키 설계
샤딩의 핵심은 샤딩 키(Shard Key) 선택입니다. 잘못된 샤딩 키는 특정 샤드에 부하가 집중되는 핫스팟을 만듭니다.
예: user_id 기반 샤딩 (모듈로 방식)
shard_number = user_id % 4
user_id=1 → 샤드 1
user_id=5 → 샤드 1
user_id=2 → 샤드 2
...
문제점:
- 신규 샤드 추가 시 전체 데이터 재분배 필요
- 특정 사용자의 모든 주문이 한 샤드에 집중될 수 있음
일관된 해싱(Consistent Hashing):
- 샤드 추가 시 일부 데이터만 이동
- 가상 노드(virtual node)로 균등 분산 보장
- Vitess, ProxySQL 등 미들웨어에서 구현
수평 분할 전략 비교
| 전략 | 장점 | 단점 | 적합한 상황 |
|---|---|---|---|
| 범위 기반(Range) | 구현 단순, 범위 쿼리 효율 | 핫스팟 발생 가능 | 시계열, ID 단조 증가 |
| 해시 기반(Hash) | 균등 분산 | 범위 쿼리 비효율 | 읽기/쓰기 균등 분산 |
| 디렉토리 기반 | 유연한 라우팅 | 디렉토리 서버가 SPOF | 복잡한 분할 규칙 |
| 지리 기반(Geo) | 레이턴시 최소화 | 지역 간 쿼리 복잡 | 글로벌 서비스 |
💡 TIP: 자체 샤딩을 직접 구현하기 전에 Vitess(YouTube가 개발, CNCF 졸업 프로젝트)나 PlanetScale 같은 관리형 샤딩 솔루션을 먼저 검토하세요. 크로스-샤드 JOIN, 분산 트랜잭션, 재샤딩 자동화 등을 처리하는 비용이 상당합니다.
요약
- 정규화는 기본, 읽기 비율과 집계 복잡도가 높을 때만 근거 있는 반정규화를 선택합니다.
- 데이터 타입은 작을수록 좋습니다. TINYINT, BINARY(16) UUID, DATE/DATETIME 등 범위에 맞는 최소 타입을 선택하면 스토리지와 인덱스 효율이 함께 개선됩니다.
- RANGE 파티셔닝은 시계열 데이터의
DROP PARTITION으로 대량 삭제를 순간적으로 처리하는 것이 핵심 실무 패턴입니다. - JSON + 생성 컬럼 조합으로 스키마 유연성과 인덱스 성능을 동시에 확보할 수 있습니다.
- 대형 테이블 스키마 변경은 gh-ost 또는 pt-osc로 무중단 처리하며, 먼저 MySQL Online DDL 가능 여부를 확인합니다.
- 소프트 삭제·이력 테이블·아카이빙은 각각 논리 삭제, 감사 추적, 운영 테이블 사이즈 관리라는 서로 다른 문제를 해결하는 독립 패턴입니다.
연습문제
-
커머스 서비스에서
order_items테이블에product_name VARCHAR(200)컬럼이 있습니다. 이 컬럼이 정규화 위반임에도 유지해야 할 실무적 이유를 설명하고, 이 경우 발생할 수 있는 문제와 대응 방안을 작성하세요.힌트: 주문 시점의 상품명 스냅샷 vs 현재 상품명 변경 시나리오를 생각해보세요.
-
일별 방문 로그가 하루 평균 5백만 건씩 쌓이는
visit_logs테이블을 설계하세요. 조건: 3개월 이후 데이터는 즉시 삭제 가능해야 하고,visited_at컬럼으로 파티셔닝합니다. 월별 RANGE 파티션 DDL을 작성하고 오래된 파티션 삭제 명령도 포함하세요.힌트: 파티션 키는 반드시 PRIMARY KEY에 포함되어야 합니다.
-
상품 속성을 JSON 컬럼(
attrs)에 저장하는products테이블에서attrs->>'$.brand'값으로 빠른 검색이 필요합니다. 인덱스를 활용할 수 있도록 생성 컬럼과 인덱스를 추가하는 DDL을 작성하세요.힌트: VIRTUAL vs STORED 중 인덱스가 목적이라면 어느 쪽이 더 적합한지 생각해보세요.
-
수억 건의
payments테이블에retry_count TINYINT NOT NULL DEFAULT 0컬럼을 추가해야 합니다. 직접ALTER TABLE을 실행했을 때의 위험과, gh-ost를 사용할 때의 주요 옵션(최대 복제 지연 제한, 청크 크기)을 포함한 명령어를 작성하세요.힌트:
--max-lag-millis와--chunk-size옵션을 사용하세요.
💡 연습문제 풀이
불러오는 중…
댓글 0
“MySQL 심화” 강좌에 대한 댓글입니다.