Real MySQL 시즌1 Part 1 — 에피소드 1~12
지도/치트시트: realmysql · 다음: realmysql-part2
Ep 1. CHAR vs VARCHAR — "고정 길이면 CHAR"는 틀린 기준
배경: 물리 모델링은 누가 하나
논리 모델링(업무 이해 필요) vs 물리 모델링(DBMS 기능·성능 이해 필요). 대규모 인터넷 서비스는 소규모 프로젝트가 너무 많아 DBA가 논리 모델링까지 하기 어렵다. → 개발자가 논리+물리를 한 번에 하고 DBA가 물리 수준을 검토·보완하는 형태가 현실적. 그 검토 항목에 컬럼 타입 선정이 들어간다.
저장 구조
CHAR(10),VARCHAR(10)의 10은 바이트가 아니라 문자 개수.- latin1: 최대 10바이트 / utf8mb4: 10~40바이트 (한글 1자 3byte, 일부 이모지 4byte)
[latin1] CHAR(10) 에 'abcd' 저장
→ 10바이트를 미리 예약, 4바이트 사용 + 6바이트 공백
[latin1] VARCHAR(10) 에 'abcd' 저장
→ [4] a b c d ← 앞에 길이 저장 바이트(1~2byte)
중요한 함정: CHAR도 utf8mb4 같은 가변 길이 문자셋이면 길이 정보를 함께 관리한다. 그리고 예약 공백은 "글자 수"가 아니라 "바이트 수" 기준으로 계산된다.
CHAR(10) utf8mb4 에 한글 2글자('한글') 저장 → 6바이트 사용 → 4바이트 공백 예약
CHAR(10) utf8mb4 에 한글 4글자('한글연습') 저장 → 12바이트 사용 → 공백 예약 없음(!)
즉 가변 길이 문자셋에서는 CHAR가 VARCHAR처럼 동작할 수도 있다.
진짜 선택 기준 — 레코드 이동(fragmentation)
VARCHAR(10)에 'abcd' → 'abcde' 로 UPDATE하면:
- 레코드 길이가 바뀜 → in-place 업데이트 불가
- 같은 데이터 페이지 안에서 새 공간을 찾아 레코드를 통째로 이동, 기존 레코드는 삭제 표시
- INSERT/UPDATE/DELETE가 반복되면 16KB 페이지에서 빈 공간 찾기가 점점 어려워지고, 결국 페이지 컴팩션(조각 모음) 발생
CHAR(10)이었다면? 이미 공간을 예약해 뒀으니 그 자리에 쓰면 끝.
결론
- 값의 길이 변동폭이 좁고 + 자주 변경되는 컬럼 → CHAR
- 특히 그 컬럼이 인덱스 컬럼이면 CHAR가 더 유리할 가능성이 높다
- 낭비되는 몇~몇십 바이트보다 단편화 감소 이득이 커서, 오히려 CHAR가 공간을 더 아끼는 경우도 있다
- 반대로 변동폭이 100글자쯤 되면 CHAR는 낭비가 크므로 VARCHAR
Ep 2. VARCHAR vs TEXT
차이점
| VARCHAR | TEXT | |
|---|---|---|
| 최대 크기 | 64KB | 64KB (동일) |
| 길이 지정 | 필수 (문자 수) | 없음 |
| 인덱스 | 그냥 가능 | 프리픽스 길이 필수 |
| DEFAULT | 리터럴 가능 | 표현식(괄호)으로만 가능 |
| row size 제한 | 영향 받음 | 거의 영향 없음 |
| 세션 버퍼 | 최대 레코드 크기로 미리 할당·재사용 | 그때그때 할당/해제 |
-- TEXT의 DEFAULT
ALTER TABLE t ADD c1 TEXT DEFAULT 'abc'; -- ❌ 에러
ALTER TABLE t ADD c1 TEXT DEFAULT ('abc'); -- ✅
선택 기준
- VARCHAR: 데이터 사이즈가 크지 않음 + 컬럼 사용이 빈번 + 서버 메모리 충분 → 버퍼 재사용으로 오버헤드 감소
- TEXT: 데이터 사이즈가 큼 + 자주 안 씀 + 테이블에 이미 문자열 컬럼이 많음(row size 제한 회피)
- VARCHAR는 실제 사용하는 최대 길이만큼만 지정 (관성적인
VARCHAR(255)금지 — 버퍼가 그만큼 할당된다)
오프페이지(External Off-Page) 저장
InnoDB는 레코드 하나가 데이터 페이지의 절반보다 크면 가변 길이 컬럼 중 일부를 외부 페이지에 저장하고, 원래 자리에는 20바이트 포인터만 남긴다(DYNAMIC row format 기준).
-- extra_info(TEXT)에 큰 값이 있을 때
SELECT user_id, other FROM user_log WHERE ...; -- 빠름
SELECT * FROM user_log WHERE ...; -- 4배 느림 (외부 페이지 추가 읽기)
SELECT 절에 필요한 컬럼만 명시하자. 커버링 인덱스로 처리될 가능성도 생긴다. ORM으로 전체 컬럼을 관성적으로 읽는 패턴이 가장 위험하다.
Ep 3. COUNT(*) / COUNT(DISTINCT) 튜닝
COUNT(*)는 SELECT *보다 가볍지 않다
같은 WHERE 절이면 데이터 페이지를 읽고 가공하는 양은 거의 동일. 그런데 실무에선:
SELECT *→ 화면에 다 못 뿌리므로 LIMIT 10~20이 붙음COUNT(*)→ LIMIT 없이 실행 (LIMIT을 써도 MySQL이 제거해버림)
100만 건 게시판이면 SELECT는 20건, COUNT는 100만 건 → 5만 배 차이.
튜닝 1. 커버링 인덱스
-- 인덱스: (ix_fd1, ix_fd2)
SELECT COUNT(*) FROM t WHERE ix_fd1 = 'a'; -- ✅ 커버링
SELECT COUNT(*) FROM t WHERE ix_fd1 = 'a' AND non_ix = 'b'; -- ❌ 데이터 파일 읽어야 함
SELECT COUNT(non_ix_fd1) FROM t WHERE ix_fd1 = 'a'; -- ❌ NULL 여부 확인 위해 데이터 파일 필요
단, WHERE 조건의 모든 컬럼을 인덱스에 넣는 건 단점이 더 크다. 꼭 필요한 경우에만.
튜닝 2. 쿼리 자체를 제거하는 게 최고
- 페이지 번호 내비게이션 → 이전/다음 방식으로 UI를 바꾸면 COUNT가 사라진다.
- 깊게 고민 없이 넣은 COUNT가 의외로 많다.
튜닝 3. 대략적인 건수 사용
-- (a) 필요한 만큼만 확인: 20건씩 10페이지면 200건만 있으면 됨
SELECT COUNT(*) FROM (SELECT 1 FROM t WHERE ... LIMIT 200) x;
-- (b) 페이지 번호를 낙관적으로 표시 → 실제 이동 시 SELECT 결과로 보정
-- (구글 검색이 "10페이지" 표시했다가 3페이지로 줄어드는 방식)
-- (c) 통계 정보 활용
SELECT table_rows FROM information_schema.TABLES WHERE table_name = 't';
EXPLAIN ... -- rows 컬럼의 예측치 사용 (조인/서브쿼리 있으면 정확도 낮음)
판단 기준
| 상황 | 방향 |
|---|---|
| WHERE 없는 COUNT / 대상 건수가 많은 COUNT | 제거하거나 통계·예측치 사용 |
| 정확한 건수 필요 + 대상 건수 적음 + 커버링 가능 | 인덱스 튜닝 |
| 일반 OLTP | 몇만 건 이내면 인덱스 튜닝으로 커버, 그 이상은 다른 방법 |
COUNT(DISTINCT)의 내부 동작
- 중복 제거용 임시 테이블 생성
- WHERE에 맞는 레코드마다 → 임시테이블에서 SELECT로 중복 확인 → 없으면 INSERT
- 임시테이블 건수 반환
- 건수가 많으면 임시테이블이 디스크로 내려감
→ 레코드 건당 SELECT + INSERT가 한 번씩 더. 최소 2~3배 느림.
ORM 주의 (실제 사례)
TypeORM이 1:N 조인 + 페이징 시 getCount()를 호출하면:
-- ORM이 만든 것
SELECT COUNT(DISTINCT alias.comment_id) FROM (서브쿼리로 임시테이블 생성...) ...
-- comment_id는 이미 PK라서 DISTINCT가 전혀 필요 없음!
-- 있어야 했던 것
SELECT COUNT(*) FROM shop s JOIN comment c ON ... WHERE ...;
불필요한 DISTINCT → 임시테이블 → 정렬이 인덱스를 못 씀. ORM이 생성하는 쿼리를 배포 전에 검토하자. (개발 서버 general log 활용)
Ep 4. 페이징 쿼리
LIMIT/OFFSET의 문제
DB는 OFFSET 위치까지 모든 레코드를 순차적으로 읽은 뒤 버린다. 500건씩 N번 조회하면 총 500×(1+2+...+N) 건을 읽는 셈 → 전체를 한 번에 읽는 것보다 훨씬 많다.
방식 1. 범위 기반 (배치·마이그레이션·분석용)
WHERE에 범위를 직접 지정, LIMIT 없음. 1회차와 N회차 쿼리 형태가 동일해서 단순하다.
SELECT * FROM users WHERE id BETWEEN 1 AND 5000;
SELECT * FROM payments WHERE finished_at >= '2024-01-01' AND finished_at < '2024-01-02';
날짜 컬럼엔 인덱스를 미리 만들어 둘 것.
방식 2. 데이터 개수 기반 (서비스용)
ORDER BY + LIMIT 사용. 1회차와 N회차의 쿼리 형태가 다르다.
(a) 동등 조건 — 인덱스 (user_id, id)
-- 1회차
SELECT * FROM payments WHERE user_id = 1 ORDER BY id LIMIT 5;
-- N회차
SELECT * FROM payments WHERE user_id = 1 AND id > :lastId ORDER BY id LIMIT 5;
(b) 범위 조건 — 인덱스 (finished_at, id)
1회차 ORDER BY에 범위 조건 컬럼을 선행으로 넣어야 정렬이 사라진다.
-- 1회차
SELECT * FROM payments
WHERE finished_at >= :start AND finished_at < :end
ORDER BY finished_at, id LIMIT 5;
ORDER BY id만 쓰면? 조건 만족 데이터를 전부 읽고 다시 정렬한다.
N회차는 범위 컬럼 값의 순서와 식별자 순서가 같은지에 따라 달라진다.
-- ❌ 순서가 다른 경우(finished_at) id 조건만 추가하면 데이터가 누락된다
WHERE finished_at >= :start AND finished_at < :end AND id > :lastId -- 누락!
-- ✅ 순서가 다른 경우
WHERE (finished_at = :lastAt AND id > :lastId)
OR (finished_at > :lastAt AND finished_at < :end)
ORDER BY finished_at, id LIMIT 5;
-- ✅ 순서가 같은 경우 (created_at, id 둘 다 저장 순서대로 증가)
WHERE created_at >= :lastAt AND created_at < :end AND id > :lastId
ORDER BY created_at, id LIMIT 5;
-- ↑ 시작 날짜를 "직전 마지막 데이터의 값"으로 갱신해야 불필요한 인덱스 스캔이 없다
Ep 5. Stored Function의 DETERMINISTIC
MySQL 함수는 3종: 빌트인 / UDF(C·C++ 플러그인) / Stored Function. MySQL의 스토어드 프로그램 언어는 별칭 없이 그냥 "Stored Programming Language"이며 ANSI SQL 2003 SQL/PSM 스펙을 따른다.
확정적(DETERMINISTIC) vs 비확정적(NOT DETERMINISTIC)
- DETERMINISTIC: 입력이 같으면 언제 실행해도 출력이 같음
- 여기서 "입력"에는 함수가 참조하는 테이블 데이터도 포함된다.
- 단, MySQL의 한 SQL statement는 시작 시점의 스냅샷을 보므로, 한 문장 안에서 함수가 여러 번 호출돼도 인자만 같으면 입력은 동일한 것으로 본다.
실험 결과
내용이 똑같은 두 함수(fn1: DETERMINISTIC, fn2: NOT DETERMINISTIC)를 WHERE 절에 쓰면:
| 호출 횟수 | 실행 계획 | |
|---|---|---|
| fn1 (DETERMINISTIC) | 3회 | type=const, PK 1건 읽고 끝. 함수 호출 자체가 최적화로 제거됨 |
| fn2 (NOT DETERMINISTIC) | 12회 | type=ALL 풀 테이블 스캔 |
원인: NOT DETERMINISTIC이면 옵티마이저가 함수 결과를 상수로 취급할 수 없다. 레코드를 한 건씩 읽을 때마다 함수를 재실행해 비교 → 인덱스 최적화 불가.
빌트인 함수도 마찬가지 — NOW() vs SYSDATE()
둘 다 NOT DETERMINISTIC이지만:
SELECT NOW(), SYSDATE(), SLEEP(1) FROM t LIMIT 10;
-- NOW() : 10건 모두 동일 (쿼리 시작 시점) → 문장 안에서는 DETERMINISTIC처럼 동작
-- SYSDATE() : 레코드마다 1초씩 증가 → 진짜 NOT DETERMINISTIC
WHERE created_at = NOW() -- ✅ 인덱스 사용
WHERE created_at = SYSDATE() -- ❌ 풀 스캔
혼란이 커서 MySQL은 sysdate-is-now 설정을 추가했다. SYSDATE()의 원래 동작이 필요한 경우는 거의 없으므로 회사 표준 옵션으로 켜두는 것도 좋다.
핵심: MySQL Stored Function의 기본값은 NOT DETERMINISTIC이다. 명시하지 않으면 전부 비확정적으로 인식된다.
DETERMINISTIC,DEFINER,SQL SECURITY를 항상 명시하자.
Ep 6. LATERAL 파생 테이블
전통적 파생 테이블(FROM절 서브쿼리)의 단점: 선행 테이블의 컬럼을 참조할 수 없다. LATERAL 키워드가 이걸 푼다.
SELECT e.emp_no, s.sales_count, s.total_sales
FROM employees e
LEFT JOIN LATERAL (
SELECT COUNT(*) sales_count, SUM(amount) total_sales
FROM sales WHERE emp_no = e.emp_no -- ← 선행 테이블 참조!
) s ON TRUE; -- LEFT JOIN은 문법상 ON 필수 → TRUE
- 실행 계획의
select_type= DEPENDENT DERIVED - INNER JOIN LATERAL은 MySQL에서 ON이 선택 사항이라 생략 가능
활용 1. 종속 서브쿼리에서 여러 값 반환
SELECT 절 서브쿼리는 컬럼 1개만 반환 가능 → 같은 서브쿼리를 두 번 쓰는 비효율 발생. LATERAL로 한 번에 처리.
활용 2. 중복 연산 제거 + 가독성
-- ❌ SELECT 절의 계산된 별칭은 같은 SELECT 절 안에서 참조 불가 → 중복
SELECT total_sales * margin_rate,
expected_sales * margin_rate,
(total_sales * margin_rate) / (expected_sales * margin_rate) ...
-- ✅ 앞선 LATERAL의 결과를 다음 LATERAL이 참조 가능
FROM daily_revenue d,
LATERAL (SELECT d.total_sales * d.margin_rate AS profit) a,
LATERAL (SELECT d.expected_sales * d.margin_rate AS exp_profit) b,
LATERAL (SELECT a.profit / b.exp_profit AS rate) c
활용 3. 퍼널 분석 (실측 0.46초 → 0.08초)
가입 유저 목록을 먼저 뽑고, 그 유저들에 대해서만 결제 이력을 확인. LATERAL 없이 쓰면 두 번째 파생 테이블이 전체 결제 데이터를 GROUP BY 한다.
활용 4. Top-N 쿼리 (실측 2.88초 → 0초대)
-- ❌ 윈도우 함수: articles 전체를 읽고 정렬 후 필터
SELECT * FROM (
SELECT c.name, a.title, a.views,
ROW_NUMBER() OVER (PARTITION BY a.category_id ORDER BY a.views DESC) rn
FROM categories c JOIN articles a ON a.category_id = c.id
) x WHERE rn <= 3;
-- ✅ LATERAL: 카테고리별로 인덱스(category_id, views) 타고 3건만 읽음
SELECT c.name, a.title, a.views
FROM categories c
JOIN LATERAL (
SELECT title, views FROM articles
WHERE category_id = c.id ORDER BY views DESC LIMIT 3
) a;
Ep 7. SELECT ... FOR UPDATE / FOR SHARE
전제 — Non-Locking Consistent Read
MySQL의 순수 SELECT는 잠금 없는 일관된 읽기를 한다. 데이터 변경 시 변경 전 버전을 언두 영역에 백업해 두고, 다른 세션이 읽으려 하면 언두의 백업본을 준다.
- READ COMMITTED: 가장 최근 커밋된 데이터
- REPEATABLE READ: 트랜잭션 시작 직전 커밋된 데이터 (그래서 이름이 "반복 읽기")
FOR UPDATE의 결정적 차이
FOR UPDATE/FOR SHARE는 격리 수준과 무관하게 "실행 시점의 최신 커밋 데이터"를 반환한다. 락을 걸어야 하므로 스냅샷을 볼 수 없다.
이건 꼭 기억해야 한다. 같은 트랜잭션 안에서 순수 SELECT와 FOR UPDATE의 결과가 다를 수 있다.
왜 필요한가 — Lost Update
잔고 150원. 두 트랜잭션이 동시에 "100원 이상이면 100원 차감"
T1: SELECT → 150 ┐
T2: SELECT → 150 ┘ 둘 다 "충분하다"고 판단
T1: UPDATE -100 → 50
T2: UPDATE -100 → -50 (!)
SELECT ... FOR UPDATE로 X락을 선점하면 뒤 트랜잭션은 대기한다. 반드시 트랜잭션 안에서 써야 한다(autocommit이면 즉시 풀림).
튜닝 1. FOR UPDATE 자체를 제거
읽은 데이터가 필요 없다면 조건을 UPDATE로 옮긴다.
UPDATE account SET balance = balance - 100
WHERE user_id = 'A' AND balance >= 100;
-- affectedRows == 1 → 차감 성공
-- affectedRows == 0 → 계좌 없음 or 잔액 부족
UPDATE 자체가 X락을 잡으므로 동시성 문제 없음. SQL 문장 하나를 줄이는 것 자체가 튜닝이다.
튜닝 2. WHERE에 필터를 최대한 넣기
"잔고 10억 이상이면 보너스" 같은 요건은 실제 대상이 극소수. 그런데 FOR UPDATE로 먼저 조회하면 대부분 헛되이 락만 잡는다.
SELECT * FROM account WHERE user_id = 'A' AND balance >= 1000000000 FOR UPDATE;
-- 조건 불일치 시 락도 안 잡고 빈 결과
FOR UPDATE가 위험해지는 진짜 이유
락을 잡은 채로 외부 시스템(원격 DB, Redis, MQ)을 호출하는 패턴. 외부가 느려지면 트랜잭션이 계속 ACTIVE로 남고 락이 안 풀려 전체가 막힌다.
FOR SHARE
부모-자식 관계에서 "부모 존재 확인 후 자식 INSERT" 같은 로직에 쓴다. 부모 레코드가 중간에 삭제되는 걸 막는다.
FOR SHARE 이후 UPDATE를 실행할 확률이 높다면 처음부터 FOR UPDATE를 써라. S락 → X락 획득은 락 업그레이드로 데드락을 매우 쉽게 유발한다. 두 문장 사이 간격이 넓을수록 위험.
JPA 낙관적/비관적 락과의 관계
"MySQL은 낙관적 락을 쓰나요?" — 질문 자체가 성립하지 않는다. UPDATE는 락 없이 데이터를 바꿀 수 없으므로 INSERT/UPDATE/DELETE는 전부 비관적이다. 낙관적/비관적은 문장의 특성이 아니라 트랜잭션의 특성이다.
낙관적 락 (version 컬럼 필요)
T1: SELECT (version=1)
T2: SELECT (version=1)
T2: UPDATE ... SET balance=..., version=2 WHERE id=1 AND version=1 → 성공
T1: UPDATE ... WHERE id=1 AND version=1 → affectedRows=0
MySQL은 에러 안 냄. JPA가 ObjectOptimisticLockingFailureException 발생
비관적 락: 내부적으로 SELECT ... FOR UPDATE.
| 낙관적 | 비관적 | |
|---|---|---|
| version 컬럼 | 필요 (스키마 변경) | 불필요 |
| 경합 낮을 때 | 빠름 | 락 경합 거의 없어 빠름 |
| 경합 높을 때 | 예외가 빈번 → 예외 처리가 핵심 | 대기 시간 길어짐, 대신 에러는 거의 없음 |
결론: 트랜잭션 내 쿼리가 잘 튜닝되어 있다면 비관적 락 쪽이 더 깔끔한 처리를 보여주는 경우가 많다.
Ep 8. Generated Column & 함수 기반 인덱스
Generated Column
표현식으로 정의되어 값이 자동 생성되는 컬럼. 사용자가 직접 값을 넣을 수 없다(DEFAULT 키워드만 허용).
ALTER TABLE t ADD COLUMN c3 INT
[GENERATED ALWAYS] AS (c1 + c2) VIRTUAL|STORED [NULL] [KEY] [COMMENT '...'];
| VIRTUAL (기본) | STORED | |
|---|---|---|
| 디스크 저장 | X (읽기 직전 계산) | O (INSERT/UPDATE 시 계산) |
| PK 지정 | 불가 | 가능 |
| 인덱스 | 가능 (인덱스 데이터는 디스크에 저장됨) | 가능 |
- 한 테이블에 두 타입 혼용 가능
- 일반 ↔ STORED 전환은 가능하지만, VIRTUAL은 다른 타입으로 전환 불가 → 추가/삭제로만 가능
DDL 팁 (일반론으로도 중요)
알고리즘을 자동 선택에 맡기지 말고 직접 명시하자. 지정한 알고리즘이 불가하면 에러가 나므로 예상치 못한 방식으로 도는 걸 막을 수 있다.
ALTER TABLE t ADD ..., ALGORITHM=INSTANT; -- 1순위
ALTER TABLE t ADD ..., ALGORITHM=INPLACE, LOCK=NONE; -- 2순위
-- COPY만 가능하면 → pt-online-schema-change 같은 서드파티 도구 검토
유효성 검사 옵션 (VIRTUAL 컬럼 추가 시)
| WITHOUT VALIDATION (기본) | WITH VALIDATION | |
|---|---|---|
| 기존 데이터 계산 | 안 함 | 함 |
| 알고리즘 | INSTANT/INPLACE 가능, 빠름 | 테이블 전체 복사, MDL 대기 발생 |
| 리스크 | 조회 시 예상 밖 값/경고/에러 | 유효하지 않은 값 발견 시 DDL 실패 |
운영 DB에서는 사전에 따로 검증하고 WITHOUT VALIDATION으로 추가하는 것을 권장.
인덱스 사용 조건 (여기가 핵심)
-- total_price VIRTUAL AS (quantity * price), 인덱스 존재
WHERE total_price BETWEEN 300000 AND 500000 -- ✅ 컬럼명
WHERE quantity * price BETWEEN 300000 ... -- ✅ 표현식이 정의와 완전 일치
WHERE price * quantity BETWEEN 300000 ... -- ❌ 순서만 달라도 안 됨
WHERE quantity * price = '300000' -- ❌ 타입 불일치 (문자열)
- 표현식이 완전히 일치해야 함
- 조건 값 타입도 컬럼 타입과 일치해야 함 (컬럼명으로 쓰면 일반 컬럼처럼 형변환 우선순위 적용되지만, 표현식으로 쓰면 적용 안 됨)
- 이 최적화는
=, 비교 연산자,BETWEEN,IN에만 적용
제약
- 표현식에 비결정적 함수 / 스토어드 프로그램 변수 / 서브쿼리 불가
- 트리거에서 참조 불가라고 매뉴얼에 적혀 있으나 실제로는 참조 가능 (매뉴얼 오류)
함수 기반 인덱스 (8.0.13+)
CREATE INDEX ix ON t ((SUBSTRING_INDEX(email, '@', -1))); -- 괄호 필수
내부적으로 숨겨진 VIRTUAL 컬럼(_hidden_<인덱스명>_<키파트순서>_<카운터>)을 만들고 거기에 인덱스를 건다. (debug 모드 + show_hidden_columns로만 확인 가능)
활용 예: 이메일 도메인 추출, 주소의 구/동, 날짜의 연·월·일, LOWER() 대소문자 무시 검색, 계산값 조회, 대용량 값 해싱, JSON 필드 인덱싱(→ Ep19)
주의사항
- 반드시 실행 계획 확인.
LOWER()인덱스 +LIKE조합은 인덱스를 안 탄다(버그 제보됨). - 조건 값 타입이 표현식 결과 타입과 같아야 함.
MONTH()는 숫자,DOUBLE + DOUBLE은 정수1000대신1000.0.- 타입이 애매하면
mysql --column-type-info옵션으로 메타 정보 확인
- 타입이 애매하면
- 값 계산 비용이 추가되므로 변경이 매우 빈번하거나 표현식이 복잡하면 오버헤드가 크다.
제약: 비결정적 함수 불가 / 컬럼명 단독·프리픽스 길이 지정 불가 / 공간·전문검색 인덱스 불가 / PK 불가. 기본적으로 VIRTUAL 컬럼의 제약을 모두 상속한다.
Ep 9. MySQL 에러 핸들링
에러 3분류
| 분류 | 번호 대역 | 발생 위치 |
|---|---|---|
| Global | 1 ~ 999 | 공용 |
| Server | 1000 ~ 1999 (+ 3500 |
MySQL 서버 |
| Client | 2000 ~ 2999 | 각 언어 드라이버/커넥터 |
번호만으로 조사 범위를 좁힐 수 있다. 예: 없는 서버 접속 → 2005(클라이언트), 없는 테이블 SELECT → 1146(서버, 클라이언트로 전달만 됨).
에러 = 에러번호 + SQLSTATE + 에러메시지
① 에러 메시지로 핸들링하지 말 것
MySQL 5.5.20 → 5.5.23 업그레이드 시 에러 1366 메시지가 Incorrect string value... → Data truncation...으로 바뀌었고, 릴리즈 노트에 명시되지 않았다. 마이너 버전 간에도 호환되지 않는다.
② 에러 번호도 한계가 있다 — 같은 "중복 에러"인데:
| 스토리지 엔진/상황 | 에러 번호 |
|---|---|
| NDB 중복 | 1022 |
| InnoDB / MyISAM 중복 | 1062 |
| NDB 유니크 제약 위배 | 1169 |
③ SQLSTATE가 정답 — 위 셋 모두 SQLSTATE는 23000.
ANSI SQL 표준이라 벤더 간에도 호환된다.
| 앞 2자리 | 의미 |
|---|---|
00 |
정상 |
01 |
경고 |
02 |
레코드 없음 |
HY |
ANSI SQL 표준으로 미분류 |
| 그 외 | 에러 |
중복 에러 SQLSTATE: Oracle 23000, SQL Server 23000, PostgreSQL 23505. (ANSI에서 23000 = Integrity Constraint Violation, 23505 = Duplicate Key Violation이라 PostgreSQL이 더 정밀하게 쓰는 셈)
예외: SQLSTATE가
HY로 시작하면 미분류 상태이므로 버전 업그레이드 시 값이 바뀔 수 있다(1366은HY000→22001로 변경됨). 이 경우에만 에러 번호로 핸들링하자.
DBA에게 문의할 때 / 로깅할 때
- "DB 연결이 안 돼요" 같은 현상만 전달하면 원인 특정이 매우 어렵다. SQL 문장 + 에러번호 + SQLSTATE + 에러메시지 원문을 그대로 전달.
- SQLException을 다른 예외로 변환하면서 버리지 말 것. 반드시 래핑해 유지.
- ORM이 직접 SQLException을 만들기도 한다. DB 에러 정보가 없으면 "MySQL이 낸 에러인지 ORM이 낸 에러인지" 구분이 안 되고, DBA는 ORM 코드를 모르고 개발자는 MySQL을 모르는 상태로 시간만 흐른다.
Ep 10. LEFT JOIN
ON절 vs WHERE절 — 결과가 완전히 달라진다
users 3만 건, user_coupons 3천 건(coupon_id 1/2/3 각 1천 건):
-- ON절: "연결"하는 역할 → 3만 건 반환 (전체 유저 + 3번 쿠폰 정보)
SELECT * FROM users u
LEFT JOIN user_coupons uc ON uc.user_id = u.id AND uc.coupon_id = 3;
-- WHERE절: "필터링" 역할 → 1천 건 반환 (= INNER JOIN과 동일)
SELECT * FROM users u
LEFT JOIN user_coupons uc ON uc.user_id = u.id
WHERE uc.coupon_id = 3;
MySQL 옵티마이저는 두 번째 형태를 자동으로 INNER JOIN으로 변환한다. 의도가 INNER JOIN이었다면 처음부터 그렇게 쓰자.
예외:
WHERE uc.user_id IS NULL(안티 조인)은 WHERE에 있는 게 맞다.
처리 방식의 차이
- LEFT JOIN: 왼쪽(아우터/드라이빙) 테이블을 항상 먼저 읽는다. 순서 고정.
- INNER JOIN: 옵티마이저가 순서를 바꿀 수 있다. 위 예에서는
user_coupons의 coupon_id 인덱스로 1천 건만 먼저 읽고 users와 조인 → 훨씬 효율적.
COUNT + LEFT JOIN
1:1 관계이고 조인 결과가 여러 건이 아니라면 COUNT(*) 결과는 드라이빙 테이블만 조회한 것과 같다. → 조인을 제거하면 더 빠르다.
-- 제거 가능
SELECT COUNT(*) FROM users u LEFT JOIN user_coupons uc ON uc.user_id = u.id;
→ SELECT COUNT(*) FROM users;
-- 제거 불가 (안티 조인)
SELECT COUNT(*) FROM users u LEFT JOIN user_coupons uc ON uc.user_id = u.id
WHERE uc.user_id IS NULL;
Ep 11. Prepared Statement의 진실
장점
- SQL 인젝션 방지를 빠르고 간단하게 구현
- 쿼리 파싱 비용 절약 (두 번째 실행부터)
단점
- 파스 트리 캐시용 메모리 추가 사용
- 첫 실행 시 서버 왕복 2회 (prepare + execute) → 일반 statement보다 비효율
- MySQL은 실행 계획까지 캐시하지 않는다. 파스 트리만 캐시.
- 캐시된 PS는 하나의 커넥션 안에서만 공유된다.
4번이 가장 큰 문제
커넥션 5,000개 × 유니크 쿼리 패턴 100개 = PS 객체 50만 개가 서버 메모리에 필요.
max_prepared_stmt_count기본값 약 16,382개- 초과하면 LRU로 캐시를 정리 → 클라이언트는 계속 다시 prepare 요청 → 파싱 횟수만 증가
클라이언트 사이드 vs 서버 사이드
- 클라이언트 사이드 PS: MySQL이 PS를 지원하지 않던 시절 JDBC 표준을 에뮬레이션한 기능. 진짜 PS가 아니다.
- 서버 사이드 PS:
useServerPrepStmts=true여야 활성화되는데 기본값은 false. 즉, 코드에서PreparedStatement객체를 써도 실제로는 서버 PS가 아닐 수 있다. (일부 ORM은 기본 활성화한다) - 단, 둘 다 SQL 인젝션 방지 효과는 있다.
흔한 실수
// ❌ 루프 안에서 매번 prepare → 서버 왕복 200회, 파스트리 100개 캐시, 재사용 0회
for (int i = 0; i < 100; i++) {
PreparedStatement ps = conn.prepareStatement(sql);
ps.setInt(1, i); ps.execute();
}
// ✅ prepare 1회, 바인딩+실행 100회
PreparedStatement ps = conn.prepareStatement(sql);
for (int i = 0; i < 100; i++) { ps.setInt(1, i); ps.execute(); }
커넥션 풀을 쓰면 커넥션이 자주 새로 생성될수록 파싱 비용이 계속 올라간다. → 커넥션 수는 최소화, 수명은 최대한 길게.
종합 판단
- OLTP 쿼리는 대부분 단순 → 파싱 비용이 낮아 PS의 장점이 작다
- 클라우드 소형 RDS는 메모리가 늘 부족 → 그 메모리를 InnoDB 버퍼 풀에 쓰는 게 낫다
- 결론: MySQL의 서버 사이드 PS는 다른 DBMS만큼의 성능 향상을 기대하기 어렵다. 클라이언트 사이드 PS는 단점 없이 인젝션 방지 효과만 가진다.
Ep 12. SQL 가독성 — "보기 좋게"가 아니라 "의도가 드러나게"
가독성 = 커뮤니케이션 비용 ↓, 디버깅 속도 ↑, 실수 ↓, 유지보수 비용 ↓
1) DISTINCT를 함수처럼 쓰지 말 것
SELECT DISTINCT(fd1), fd2 FROM t; -- ❌ 괄호가 의도를 흐린다 (결과는 동일)
SELECT DISTINCT fd1, fd2 FROM t; -- ✅
2) LEFT JOIN은 규칙대로, 필요한 경우에만
드리븐 테이블 조건은 ON절에. INNER JOIN이 의도면 키워드도 INNER JOIN으로. 1:1 관계에서 드라이빙 컬럼만 조회하거나 COUNT만 한다면 조인 제거.
3) ORDER BY 없는 LIMIT 금지
결과가 여러 건일 때 무엇이 반환될지 예측 불가 + 의도 파악 불가. 불필요하면 제거, 페이징이면 ORDER BY 명시.
4) FULL GROUP BY 형태로
GROUP BY 절 컬럼 또는 집계 함수를 쓴 컬럼만 SELECT에. 아무 값이나 상관없다면 ANY_VALUE()를 명시적으로 쓰는 게 낫다. (ONLY_FULL_GROUP_BY는 기본 SQL 모드지만, 꺼져 있는 환경도 있다)
5) AND / OR 에는 괄호를
표준 SQL에서 AND가 OR보다 우선순위가 높다.
SELECT 1 OR 0 AND 0; -- 1 (AND 먼저)
SELECT 1 OR (0 AND 0); -- 1
SELECT (1 OR 0) AND 0; -- 0
우선순위를 알아도, 조건이 늘어날수록 의도 파악이 어려워지고 실수 확률이 커진다. 괄호를 쓰자.
6) COUNT는 COUNT(*)로
COUNT(*), COUNT(1), SUM(1) 모두 같은 결과지만, COUNT(*)가 "행의 수를 센다"는 의도를 가장 명확히 드러낸다. (성능 관점은 → realmysql-part2 Ep16)