infra
Platform

모듈 맵

[Database] InnoDB 스토리지 엔진 구조와 한글 인코딩, Auto Increment

0 / 37 완료

펼치기
0 / 37 완료0%

Database · 26 / 37

[Database] InnoDB 스토리지 엔진 구조와 한글 인코딩, Auto Increment

MySQL의 InnoDB 엔진 특성을 이해하고 실무에서 자주 만나는 설정과 함정을 해결합니다

🚨INCIDENT ALERT
HIGH

MySQL은 익숙한 만큼 기본값의 함정도 자주 밟습니다. 스토리지 엔진, 문자셋, AUTO_INCREMENT, 실행 계획 차이를 모르면 같은 SQL도 예상과 다르게 동작합니다. MySQL 특성을 이해하면 운영 중 발생하는 미묘한 장애를 줄일 수 있습니다.

이번 챕터에서 배울 것

MySQL의 utf8은 진짜 UTF-8이 아닙니다. InnoDB 클러스터드 인덱스는 PK 설계에 영향을 줍니다. 이런 MySQL만의 특성을 이해하면 실무에서 발생하는 이상한 버그와 성능 문제를 빠르게 해결할 수 있습니다.

  • 1InnoDB vs MyISAM — 왜 항상 InnoDB를 사용해야 하는가
  • 2클러스터드 인덱스 — InnoDB의 물리적 데이터 구조
  • 3Buffer Pool — InnoDB의 메모리 캐시
  • 4utf8 vs utf8mb4 함정 — 이모지 저장이 안 되는 이유
  • 5Collation — 비교/정렬 규칙과 JOIN 오류
  • 6AUTO_INCREMENT 갭과 실무 주의사항
  • 7EXPLAIN 읽기 — 슬로우 쿼리 진단
  • 8ON DUPLICATE KEY UPDATE — MySQL의 Upsert

MySQL/MariaDB 실무 — InnoDB, 인코딩, Auto Increment

MySQL은 전 세계에서 가장 널리 사용되는 오픈소스 데이터베이스입니다. PostgreSQL과 비교하면 몇 가지 독특한 동작과 역사적인 함정들이 있습니다. 이 모듈에서는 MySQL을 실무에서 올바르게 사용하기 위해 반드시 알아야 할 InnoDB 특성, 인코딩 문제, 자주 만나는 함정들을 다룹니다.


💡개념

InnoDB 핵심 특성 — MySQL을 올바르게 사용하는 법

레거시 MySQL 서버를 인수받았는데 일부 테이블이 MyISAM 엔진을 사용하고 있습니다. 트랜잭션 중 서버가 재시작됐고 데이터 일부가 반영안된 상태로 남았습니다. MyISAM은 트랜잭션을 지원하지 않아서 중간 실패 시 롤백이 없습니다. 스토리지 엔진이 다르면 잠금 방식, 복구 가능성, 성능 특성이 모두 달라집니다. MySQL을 올바르게 사용하려면 InnoDB의 핵심 특성을 이해하는 것이 출발점입니다.

InnoDB 핵심 특성 — MySQL을 올바르게 사용하는 법

InnoDB vs MyISAM

InnoDB는 트랜잭션, 외래 키, 행 수준 잠금, WAL 기반 자동 복구를 모두 지원합니다. MyISAM은 이 기능들이 없어 데이터 일관성을 보장하지 못하고, 쓰기 충돌 시 테이블 전체를 잠급니다. 2010년대 이전 레거시 시스템에서만 MyISAM을 볼 수 있으며, 모든 새 테이블은 반드시 InnoDB로 만들어야 합니다.

특성InnoDBMyISAM (레거시)
트랜잭션지원 (ACID)미지원
FOREIGN KEY지원미지원
행 수준 잠금지원 (고성능 쓰기)테이블 수준 잠금
충돌 복구자동 (WAL 기반)수동 복구 필요
전체 텍스트 검색지원 (5.6+)지원
캐시Buffer PoolKey Cache만

테이블 엔진 확인과 변환 방법은 다음과 같습니다.

위험 명령어

운영 데이터에 적용하면 되돌리기 어려운 변경입니다. 실행 전 대상 테이블, WHERE 조건, 백업 또는 롤백 경로를 반드시 확인하세요.

SQL
SHOW TABLE STATUS WHERE Name = 'users';
SHOW CREATE TABLE users;

ALTER TABLE legacy_table ENGINE = InnoDB;
OUTPUT
실행 완료 또는 조회 결과가 표시됩니다.
🔍실행 후 확인할 것
  • 스토리지 엔진테이블이 InnoDB로 동작해 트랜잭션과 FK를 지원하는지 확인합니다.
  • 문자셋utf8mb4 설정이 DB·테이블·컬럼·연결 문자열에 일관되게 적용됐는지 봅니다.
  • 실행 계획MySQL 옵티마이저가 기대한 인덱스를 선택하는지 확인합니다.

my.cnf에 default_storage_engine = InnoDB를 설정하면 CREATE TABLE 시 ENGINE 절을 생략해도 InnoDB가 기본으로 적용됩니다.

클러스터드 인덱스 — InnoDB의 핵심

InnoDB에서 테이블 데이터는 PK 순서로 물리적으로 정렬된 B-Tree에 직접 저장됩니다. 별도의 힙(heap) 파일이 없고 PK B-Tree 리프 노드 자체가 데이터 행입니다. 이 구조를 클러스터드 인덱스라고 합니다.

PK 기반 범위 조회는 물리적으로 인접한 페이지를 순차 읽기하므로 매우 빠릅니다. 반면 UUID처럼 무작위 순서의 PK를 사용하면 새 행을 삽입할 때마다 기존 B-Tree 중간에 끼워 넣어야 해 페이지 단편화가 빈번히 발생합니다. 세컨더리 인덱스는 내부적으로 PK를 포함하므로, 세컨더리 인덱스로 조회하면 PK를 통해 실제 데이터에 한 번 더 접근하는 이중 조회가 발생합니다.

SQL
SELECT * FROM orders WHERE id BETWEEN 1000 AND 2000;

CREATE TABLE events_uuid (
    id    CHAR(36) PRIMARY KEY DEFAULT (UUID()),
    title VARCHAR(200)
);

CREATE INDEX idx_users_email ON users (email);

Buffer Pool — InnoDB 메모리 캐시

Buffer Pool은 InnoDB가 디스크에서 읽은 페이지를 메모리에 캐시하는 공간입니다. 히트율이 높을수록 디스크 I/O 없이 메모리에서 처리되므로 성능이 좋습니다. 일반적으로 서버 전체 RAM의 60~70%를 Buffer Pool에 할당합니다. 히트율이 95% 미만이면 Buffer Pool 크기 증가를 검토하세요.

SQL
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';

SELECT
    (1 - (
        (SELECT variable_value FROM information_schema.global_status
         WHERE variable_name = 'Innodb_buffer_pool_reads') /
        (SELECT variable_value FROM information_schema.global_status
         WHERE variable_name = 'Innodb_buffer_pool_read_requests')
    )) * 100 AS buffer_pool_hit_rate;

my.cnf 권장 설정 예시입니다. innodb_buffer_pool_instances는 Buffer Pool을 여러 인스턴스로 분할해 동시 접근 시 잠금 경쟁을 줄입니다.

SQL
SHOW VARIABLES LIKE 'innodb_buffer_pool_instances';

utf8 vs utf8mb4 함정

MySQL의 utf8 캐릭터셋은 실제 UTF-8 표준과 다릅니다. 최대 3바이트만 지원하므로 4바이트가 필요한 이모지(U+1F600 이상)를 저장하면 오류가 발생합니다. 진짜 UTF-8을 사용하려면 반드시 utf8mb4를 써야 합니다.

Collation은 utf8mb4_unicode_ci를 기본으로 사용하세요. 유니코드 표준을 올바르게 따르며 독일어 ß 같은 특수 문자도 정확히 처리합니다. utf8mb4_general_ci는 성능이 약간 빠르지만 표준 준수도가 낮아 레거시 시스템에서만 사용합니다.

기준utf8mb4_unicode_ciutf8mb4_general_ci
정확도유니코드 표준 기반, 더 정확단순화된 알고리즘
성능약간 느림약간 빠름
독일어 ß = ss 처리올바르게 처리처리 안 됨
권장권장레거시 시스템에서만
SQL
CREATE TABLE comments (
    id      INT AUTO_INCREMENT PRIMARY KEY,
    content TEXT
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

위험 명령어

운영 데이터에 적용하면 되돌리기 어려운 변경입니다. 실행 전 대상 테이블, WHERE 조건, 백업 또는 롤백 경로를 반드시 확인하세요.

SQL
ALTER TABLE comments_bad
    CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

CHARACTER SET utf8로 생성된 테이블에 '안녕하세요 😀'를 INSERT하면 ERROR 1366: Incorrect string value: '\xF0\x9F\x98\x80' for column 'content' 오류가 발생합니다. 이모지 U+1F600은 UTF-8로 4바이트(F0 9F 98 80)이지만 MySQL의 utf8은 3바이트까지만 허용하기 때문입니다.

해결책은 컬럼, 테이블, 데이터베이스, 서버 설정 모두를 utf8mb4로 변경하는 것입니다. 기존 테이블은 ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4로 일괄 변환할 수 있습니다. 서버 레벨 기본값은 my.cnf에 character-set-server = utf8mb4collation-server = utf8mb4_unicode_ci를 추가하고 재시작하면 됩니다. 애플리케이션 JDBC/ORM 연결 문자열에도 characterEncoding=utf8mb4를 명시하는 것을 잊지 마세요.

Collation이 다른 두 테이블을 JOIN하면 오류가 발생합니다. 테이블 A가 utf8mb4_unicode_ci이고 테이블 B가 utf8mb4_general_ci이면 ON 조건에서 ERROR 1267: Illegal mix of collations가 납니다. 임시 해결책은 아래처럼 명시적 COLLATE를 지정하는 것이지만, 근본적으로는 양쪽 테이블의 Collation을 통일해야 합니다.

SQL
SELECT a.*, b.*
FROM table_a a
JOIN table_b b ON a.name = b.name COLLATE utf8mb4_unicode_ci;
💼
실무 맥락레거시 MySQL 서비스에서 utf8 → utf8mb4 마이그레이션 절차
현업 패턴

2015년 이전에 구축된 MySQL 서비스는 대부분 utf8 캐릭터셋으로 되어 있습니다. 글로벌 진출이나 채팅 기능 추가 시 이모지 저장 요건이 생기면 마이그레이션이 필요합니다.

마이그레이션 순서는 다음과 같습니다. 첫째, my.cnf 서버 기본값 변경 후 재시작합니다. 둘째, 기존 데이터베이스와 테이블을 ALTER ... CONVERT TO CHARACTER SET utf8mb4로 변환합니다. 셋째, 애플리케이션 연결 설정을 업데이트합니다. 주의할 점은 VARCHAR(255) 컬럼이 utf8mb4로 변환되면 인덱스 키 크기(최대 767바이트 또는 3072바이트)를 초과할 수 있다는 것입니다. innodb_large_prefix를 활성화하거나 인덱스 컬럼 길이를 줄여야 합니다. 프로덕션 적용 전에 반드시 스테이징 환경에서 검증하세요.

💡개념

MySQL 실무 함정들 — AUTO_INCREMENT, GROUP BY, 슬로우 쿼리

주문 ID가 연속되지 않습니다. 결제 실패한 주문이 있었을 뿐인데 ID에 구멍이 생겼고 고객 문의가 들어옵니다. 다른 서비스에서 MySQL로 마이그레이션했는데 GROUP BY 쿼리가 MySQL에서만 다른 결과를 반환합니다. 운영 서버에서 슬로우 쿼리가 주기적으로 발생하는데 어디서 확인해야 할지 모릅니다. MySQL 고유의 동작 방식을 모르면 코드는 맞는데 결과가 예상과 다른 상황을 만납니다.

MySQL 실무 함정들 — AUTO_INCREMENT, GROUP BY, 슬로우 쿼리

AUTO_INCREMENT 갭(Gap) 문제

MySQL의 AUTO_INCREMENT는 INSERT 시 번호를 먼저 할당하고, 트랜잭션이 롤백되어도 그 번호를 재사용하지 않습니다. 따라서 id 값에는 건너뜀이 생깁니다. 또한 서버를 재시작하면 이전에 삭제된 최대값 이후부터 다시 시작하는 것이 아니라, 테이블 MAX(id) + 1에서 시작하므로 삭제된 레코드의 ID가 재사용될 수 있습니다(MySQL 5.7 이하).

innodb_autoinc_lock_mode 설정에 따라 갭 발생 방식이 다릅니다. 기본값 1(연속 모드)은 단건 INSERT에서는 갭이 없지만 대량 INSERT에서는 발생할 수 있습니다. 값 2(인터리브 모드)는 성능이 높지만 갭이 더 자주 발생합니다.

SQL
BEGIN;
INSERT INTO orders (user_id, amount) VALUES (1, 50000);
ROLLBACK;

BEGIN;
INSERT INTO orders (user_id, amount) VALUES (2, 30000);
COMMIT;

SHOW VARIABLES LIKE 'innodb_autoinc_lock_mode';

레코드를 삭제하고 다시 삽입해도 삭제된 ID를 재사용하지 않습니다. 예를 들어 id 1100을 삽입하고 50100을 모두 삭제한 뒤 새 행을 삽입하면 id는 101부터 시작합니다. 이 동작을 모르는 개발자가 "id가 연속적이어야 한다"는 비즈니스 로직을 작성하면 버그가 발생합니다.

AUTO_INCREMENT 값을 순서 번호나 비즈니스 식별자로 사용하면 안 됩니다. 단순히 고유 식별자로만 사용하고, 비즈니스에서 연속 번호가 필요하다면 별도 시퀀스 컬럼을 관리하거나 애플리케이션 레벨에서 채번 로직을 구현해야 합니다. MariaDB에서는 CREATE SEQUENCE로 이를 해결할 수 있습니다.

MySQL 5.7 이하의 느슨한 GROUP BY

MySQL 5.7 이하는 ONLY_FULL_GROUP_BY sql_mode가 기본으로 비활성화되어 있습니다. 이 때문에 GROUP BY 절에 없는 컬럼을 SELECT할 수 있어 비결정적 결과가 반환됩니다. PostgreSQL이라면 오류로 처리했을 쿼리가 MySQL에서는 조용히 잘못된 값을 반환합니다.

MySQL 5.7 이상에서 안전한 설정과 올바른 쿼리 작성법입니다.

SQL
SET sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION';

SELECT user_id, MAX(name) AS name, SUM(amount) AS total
FROM orders
GROUP BY user_id;

ON DUPLICATE KEY UPDATE — Upsert 패턴

ON DUPLICATE KEY UPDATE는 PRIMARY KEY 또는 UNIQUE KEY 충돌 시 INSERT 대신 UPDATE를 실행합니다. VALUES() 함수는 INSERT하려 했던 값을 참조합니다. PostgreSQL의 EXCLUDED와 동일한 역할입니다.

SQL
INSERT INTO user_stats (user_id, login_count, last_login)
VALUES (42, 1, NOW())
ON DUPLICATE KEY UPDATE
    login_count = login_count + 1,
    last_login  = NOW();

INSERT INTO inventory (product_id, stock)
VALUES (100, 50)
ON DUPLICATE KEY UPDATE
    stock = stock + VALUES(stock);

INSERT INTO product_prices (product_id, price, updated_at)
VALUES
    (1, 10000, NOW()),
    (2, 20000, NOW()),
    (3, 30000, NOW())
ON DUPLICATE KEY UPDATE
    price      = VALUES(price),
    updated_at = VALUES(updated_at);

SHOW PROCESSLIST와 슬로우 쿼리 찾기

운영 중 응답이 느릴 때 가장 먼저 실행 중인 쿼리 목록을 확인합니다. SHOW FULL PROCESSLIST는 전체 쿼리 텍스트를 보여줍니다. 슬로우 쿼리 로그는 my.cnf에서 활성화하고 long_query_time을 1초로 설정하면 1초 이상 걸리는 쿼리를 모두 기록합니다.

SQL
SHOW FULL PROCESSLIST;

SELECT *
FROM information_schema.PROCESSLIST
WHERE TIME > 10
  AND COMMAND != 'Sleep'
ORDER BY TIME DESC;

KILL QUERY 12345;
KILL 12345;

EXPLAIN 출력 해석

EXPLAIN은 MySQL이 쿼리를 어떻게 실행할지 보여줍니다. type 컬럼이 핵심입니다. consteq_ref는 최적 상태이고, ALL(Full Table Scan)은 인덱스가 없거나 적절하지 않다는 신호입니다. Extra 컬럼에 Using filesort가 보이면 인덱스를 이용하지 않는 정렬이 발생 중입니다.

SQL
EXPLAIN SELECT u.name, COUNT(o.id) AS orders
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at > '2024-01-01'
GROUP BY u.id, u.name
ORDER BY orders DESC
LIMIT 10;
type의미성능
constPK/UNIQUE로 단 1건 조회최고
eq_refJOIN에서 PK/UNIQUE 사용매우 좋음
ref인덱스 사용, 여러 행 반환 가능좋음
range인덱스 범위 스캔 (BETWEEN, >, <)보통
index인덱스 Full Scan나쁨
ALLFull Table Scan매우 나쁨

key 컬럼이 NULL이면 인덱스 미사용, ExtraUsing index가 있으면 커버링 인덱스(Covering Index)로 데이터 페이지 접근 없이 인덱스만으로 처리되는 최적 상태입니다.

💼
실무 맥락EXPLAIN으로 슬로우 쿼리를 진단하고 인덱스를 추가해 성능 개선
현업 패턴

사용자 주문 목록 API가 데이터가 늘어나면서 응답 시간이 수 초로 증가하는 경우, EXPLAIN으로 진단하면 대부분 type: ALL이 보입니다. WHERE user_id = ? 조건인데도 Full Table Scan이 발생한다면 user_id 컬럼에 인덱스가 없거나, 함수 변환(DATE(created_at) = ? 처럼)으로 인덱스가 무효화된 것이 원인입니다.

CREATE INDEX idx_orders_user_id ON orders (user_id, created_at DESC)로 복합 인덱스를 추가하면 type: ref로 개선되고 응답 시간이 대부분 수십 밀리초로 줄어듭니다. 인덱스 추가 후 다시 EXPLAIN을 실행해 key 컬럼에 인덱스 이름이 표시되는지 확인하세요.

MySQL vs MariaDB 주요 차이점

MariaDB는 MySQL 5.5 포크에서 시작한 독립 프로젝트입니다. 대부분의 SQL 문법이 호환되지만 고급 기능에서 차이가 있습니다.

기능MySQL 8.0MariaDB 10.6+
Window Functions지원지원
CTE (WITH 절)지원지원
DEFAULT에 함수8.0.13+부터 일부 지원오래전부터 지원
SEQUENCE미지원지원
INVISIBLE COLUMNS지원지원 (10.3+)
Temporal Tables미지원지원 (System Versioned)
JSON 함수풍부함일부 차이 있음

MariaDB의 SEQUENCE는 Oracle 스타일 시퀀스 객체로, AUTO_INCREMENT의 갭 문제를 우회하는 데 활용할 수 있습니다.

SQL
CREATE SEQUENCE order_seq START WITH 1000;
SELECT NEXTVAL(order_seq);

다음 모듈에서는 RDBMS, NoSQL, NewSQL의 특성과 적합한 사용 사례를 비교 분석합니다.

지식 확인

퀴즈 — 5문제

Q1

MySQL의 utf8 캐릭터셋으로 이모지(😀)를 저장하려 하면?

Q2

InnoDB 테이블에서 PK를 INT AUTO_INCREMENT 대신 UUID(랜덤값)로 변경했더니 INSERT 성능이 크게 떨어졌다. InnoDB 구조상 이유는 무엇인가?

Q3

AUTO_INCREMENT 값에 갭(Gap)이 생기는 주된 이유는?

Q4

SELECT * FROM orders WHERE status = 'PENDING'이 수천 건 테이블에서 느리다. EXPLAIN을 실행했더니 type: ALL이 나왔다. 무엇이 문제이고 어떻게 해결하는가?

Q5

user_visits(user_id PK, visits INT) 테이블에서, 처음 방문이면 visits=1로 INSERT, 이미 기록이 있으면 visits를 +1 해야 한다. 가장 간결한 MySQL 쿼리는?

0 / 5 답변

🧪 실습으로 확인하기

PostgreSQL 설치 및 기본 설정

초급

Ubuntu 서버에 PostgreSQL을 설치하고, 데이터베이스와 사용자를 생성한 뒤 외부 접속이 가능하도록 설정한다.

40📋 5단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요

database중급 · 55
[Database] Document DB (MongoDB) 임베딩 vs 참조 설계 기준
Database 트랙 계속
linux입문 · 30
[Linux] 개발자가 왜 리눅스 서버와 커맨드라인을 반드시 배워야 하는가
Linux 트랙 시작점