dev.syw

정규화·반정규화 균형과 파티셔닝으로 대용량을 견디는 스키마를 설계한다.

실전 스키마 설계와 대용량 데이터 패턴

입문편에서 테이블 설계와 제약조건의 기초를 익혔다면, 이제는 "왜 이렇게 설계하는가"라는 질문에 답할 차례입니다. 수백만 건을 넘어 수억 건이 쌓이는 테이블은 교과서적 정규화만으로는 버티지 못하고, 잘못된 데이터 타입 하나가 디스크 사용량과 쿼리 속도를 수 배 차이 나게 만들기도 합니다.

이 레슨에서는 정규화와 반정규화의 트레이드오프를 실무 관점에서 판단하는 방법부터, 파티셔닝으로 대용량 테이블을 분할하는 전략, 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-ostpt-oscMySQL 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 가능 여부를 확인합니다.
  • 소프트 삭제·이력 테이블·아카이빙은 각각 논리 삭제, 감사 추적, 운영 테이블 사이즈 관리라는 서로 다른 문제를 해결하는 독립 패턴입니다.

연습문제

  1. 커머스 서비스에서 order_items 테이블에 product_name VARCHAR(200) 컬럼이 있습니다. 이 컬럼이 정규화 위반임에도 유지해야 할 실무적 이유를 설명하고, 이 경우 발생할 수 있는 문제와 대응 방안을 작성하세요.

    힌트: 주문 시점의 상품명 스냅샷 vs 현재 상품명 변경 시나리오를 생각해보세요.

  2. 일별 방문 로그가 하루 평균 5백만 건씩 쌓이는 visit_logs 테이블을 설계하세요. 조건: 3개월 이후 데이터는 즉시 삭제 가능해야 하고, visited_at 컬럼으로 파티셔닝합니다. 월별 RANGE 파티션 DDL을 작성하고 오래된 파티션 삭제 명령도 포함하세요.

    힌트: 파티션 키는 반드시 PRIMARY KEY에 포함되어야 합니다.

  3. 상품 속성을 JSON 컬럼(attrs)에 저장하는 products 테이블에서 attrs->>'$.brand' 값으로 빠른 검색이 필요합니다. 인덱스를 활용할 수 있도록 생성 컬럼과 인덱스를 추가하는 DDL을 작성하세요.

    힌트: VIRTUAL vs STORED 중 인덱스가 목적이라면 어느 쪽이 더 적합한지 생각해보세요.

  4. 수억 건의 payments 테이블에 retry_count TINYINT NOT NULL DEFAULT 0 컬럼을 추가해야 합니다. 직접 ALTER TABLE을 실행했을 때의 위험과, gh-ost를 사용할 때의 주요 옵션(최대 복제 지연 제한, 청크 크기)을 포함한 명령어를 작성하세요.

    힌트: --max-lag-millis--chunk-size 옵션을 사용하세요.


💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

MySQL 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.