realmysql

Real MySQL 시즌1 — 전체 지도 & 치트시트

원본: 이성욱·백은빈(당근마켓 DB팀) 강의 "Real MySQL 시즌1" 24개 에피소드 (Part1 12개 + Part2 12개). 에피소드 간 선후관계가 없는 옴니버스 구성이라, 지금 필요한 것부터 골라 읽어도 된다.

관련: realmysql-part1 · realmysql-part2 · isolation (격리 수준) · db

한 줄 결론(면접 오프닝):

MySQL 실무 사고의 90%는 "인덱스를 못 타는 쿼리", "필요 없는데 잡는 잠금", "한 번에 너무 많이 읽는 작업" 이 셋에서 나온다. 나머지는 이 셋의 변주다.


§0. 24개 에피소드 지도

# 주제 한 줄 요점 링크
1 CHAR vs VARCHAR 길이 변동폭 좁고 자주 바뀌면 CHAR가 유리 realmysql-part1
2 VARCHAR vs TEXT TEXT는 버퍼 재사용 X, row size 제한 X · 오프페이지 주의 realmysql-part1
3 COUNT(*) 튜닝 최고의 튜닝은 쿼리 제거, 차선은 커버링 인덱스 realmysql-part1
4 페이징 쿼리 LIMIT ... OFFSET은 지양, 범위/개수 기반으로 realmysql-part1
5 Stored Function DETERMINISTIC 안 붙이면 풀스캔 realmysql-part1
6 LATERAL 파생테이블 선행 테이블 컬럼 참조 가능 → Top-N·퍼널 분석 최적 realmysql-part1
7 SELECT FOR UPDATE 격리수준 무관 최신 커밋 데이터를 읽는다 realmysql-part1
8 Generated Column / 함수기반 인덱스 표현식이 완전히 일치해야 인덱스 탄다 realmysql-part1
9 에러 핸들링 에러 메시지 X, 에러번호 △, SQLSTATE realmysql-part1
10 LEFT JOIN 드리븐 테이블 조건은 ON절에 (WHERE면 INNER가 됨) realmysql-part1
11 Prepared Statement MySQL은 커넥션 단위 캐시 → 기대만큼 안 빠르다 realmysql-part1
12 SQL 가독성 의도가 드러나는 쿼리 = 유지보수 비용 realmysql-part1
13 Collation 기본 콜레이션에서 = ㄱㅏ 로 인식됨(!) realmysql-part2
14 UUID 랜덤 + 32byte → 인덱스 워킹셋 = 전체. 8byte 정수로 대체 realmysql-part2
15 인덱스 못 타는 경우 컬럼 가공 / OR / 선행컬럼 누락 / %LIKE / 정규식 realmysql-part2
16 COUNT(*) vs COUNT(col) COUNT(*)가 거의 항상 빠르다 (커버링 인덱스) realmysql-part2
17 NOWAIT / SKIP LOCKED 선착순 쿠폰·잡 큐의 정석. 조인 시 OF 필수 realmysql-part2
18 UNION 그냥 UNION = UNION DISTINCT = 임시테이블 realmysql-part2
19 JSON 타입 부분 업데이트 · 함수기반 인덱스 · binlog 설정까지 realmysql-part2
20 데드락 완전 정복은 불가능. 재처리 로직도 정당한 해법 realmysql-part2
21 JOIN UPDATE/DELETE VALUES 생성자로 다건 개별값 업데이트 가능 realmysql-part2
22 커넥션 관리 풀 max는 20~30부터, min≠max로 두고 관측 realmysql-part2
23 파티셔닝 파티션 프루닝이 전부. 기준 컬럼은 모든 고유키에 포함 realmysql-part2
24 배치/롱 트랜잭션 굵고 짧게(개발자) vs 가늘고 길게(DBA) realmysql-part2

§1. 5분 치트시트 — 쿼리 작성 전 체크리스트

인덱스를 못 타는 5가지 (Ep15)

-- ❌ 컬럼 가공 (산술연산 / 함수 / 형변환)
WHERE id * 1 = 100
WHERE DATE(joined_at) = '2024-01-01'
WHERE account_type = 7          -- account_type이 VARCHAR면 형변환 발생

-- ✅
WHERE id = 100
WHERE joined_at >= '2024-01-01' AND joined_at < '2024-01-02'
WHERE account_type = '7'

-- ❌ 인덱스 없는 컬럼과 OR         → OR는 "모든" 조건 컬럼에 인덱스 필요
-- ❌ 복합인덱스 선행 컬럼 누락
-- ❌ LIKE '%esther%'              (프리픽스 '%esther'는 OK)
-- ❌ REGEXP                       (항상 풀스캔)

예외: !=IS NULL항상 인덱스를 못 탄다는 건 틀린 말이다. 데이터 분포도에 따라 옵티마이저가 판단한다.

잠금 관련

-- 격리수준 무관, 항상 "최신 커밋 데이터"를 읽는다
SELECT ... FOR UPDATE;   -- X락
SELECT ... FOR SHARE;    -- S락 → 이후 UPDATE 하면 락 업그레이드 = 데드락 유발

-- 동시성 옵션
SELECT ... FOR UPDATE NOWAIT;       -- 잠겨 있으면 즉시 에러
SELECT ... FOR UPDATE SKIP LOCKED;  -- 잠긴 행은 건너뜀 (선착순 쿠폰/잡 큐)
SELECT ... FOR UPDATE OF coupon;    -- 조인 시 특정 테이블만 잠금

SELECT FOR UPDATE를 아예 없애는 튜닝: 조건을 UPDATE의 WHERE로 옮기고 affectedRows로 판단.

COUNT

COUNT(*)          -- 커버링 인덱스 가능, 가장 빠름. 기본으로 이것만 써라
COUNT(col)        -- col IS NOT NULL 인 건수 → 데이터 파일을 읽어야 할 수 있음 (최대 수십 배 느림)
COUNT(DISTINCT x) -- 임시테이블 + 건별 SELECT/INSERT → 최소 2~3배 느림

UNION

UNION            -- = UNION DISTINCT (임시테이블 생성, 전 컬럼 비교) ← 함정
UNION ALL        -- 임시테이블 없음, 첫 결과부터 스트리밍

페이징

-- ❌ OFFSET이 커질수록 앞부분을 계속 다시 읽음
SELECT * FROM t WHERE user_id = 1 ORDER BY id LIMIT 20 OFFSET 10000;

-- ✅ 데이터 개수 기반 (동등 조건)
SELECT * FROM t WHERE user_id = 1 AND id > :lastId ORDER BY id LIMIT 20;

-- ✅ 범위 조건 + 식별자 순서가 다른 경우
WHERE (finished_at = :lastAt AND id > :lastId)
   OR (finished_at > :lastAt AND finished_at < :endAt)
ORDER BY finished_at, id LIMIT 20;

§2. 자주 하는 오해 정리

흔한 믿음 실제
"고정 길이면 CHAR, 가변이면 VARCHAR" 무의미한 기준. 변동폭이 좁고 자주 UPDATE되면 CHAR (페이지 단편화 감소)
"COUNT(*)는 SELECT *보다 가볍다" 대부분 비슷하거나 더 무겁다 (SELECT는 LIMIT이 붙지만 COUNT는 전부 읽음)
"MySQL은 낙관적 락/비관적 락 중 뭘 쓰나요?" 질문 자체가 성립 안 됨. 트랜잭션 작성 방식의 문제지 서버 기능이 아니다
"Prepared Statement 쓰면 무조건 빠르다" MySQL은 파스트리만 캐시 + 커넥션 단위. 실행계획 재사용 안 함
"UUID를 PK로 쓰면 편하다" 랜덤 + 32byte → 인덱스 전체가 워킹셋. 비용이 월 수천 달러 차이로 번짐
"!=, IS NULL은 인덱스를 못 탄다" 분포도에 따라 탄다
"데드락이 나면 내 코드가 잘못된 것" MySQL 데드락은 완전 회피 불가능한 경우가 더 많다. 재시도 로직도 정당한 해법
"유니크 인덱스는 성능에 좋다" MySQL에선 성능 이점 전혀 없고 체인지 버퍼 못 씀 + 데드락 빈도만 올림

§3. 실무에 바로 적용할 것 (강사 권장사항 모음)


§4. 이어서 볼 것