realmysql

Real MySQL 시즌1 Part 1 — 에피소드 1~12

지도/치트시트: realmysql · 다음: realmysql-part2


Ep 1. CHAR vs VARCHAR — "고정 길이면 CHAR"는 틀린 기준

배경: 물리 모델링은 누가 하나

논리 모델링(업무 이해 필요) vs 물리 모델링(DBMS 기능·성능 이해 필요). 대규모 인터넷 서비스는 소규모 프로젝트가 너무 많아 DBA가 논리 모델링까지 하기 어렵다. → 개발자가 논리+물리를 한 번에 하고 DBA가 물리 수준을 검토·보완하는 형태가 현실적. 그 검토 항목에 컬럼 타입 선정이 들어간다.

저장 구조

[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하면:

  1. 레코드 길이가 바뀜 → in-place 업데이트 불가
  2. 같은 데이터 페이지 안에서 새 공간을 찾아 레코드를 통째로 이동, 기존 레코드는 삭제 표시
  3. INSERT/UPDATE/DELETE가 반복되면 16KB 페이지에서 빈 공간 찾기가 점점 어려워지고, 결국 페이지 컴팩션(조각 모음) 발생

CHAR(10)이었다면? 이미 공간을 예약해 뒀으니 그 자리에 쓰면 끝.

결론


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');  -- ✅

선택 기준

오프페이지(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 절이면 데이터 페이지를 읽고 가공하는 양은 거의 동일. 그런데 실무에선:

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. 쿼리 자체를 제거하는 게 최고

튜닝 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)의 내부 동작

  1. 중복 제거용 임시 테이블 생성
  2. WHERE에 맞는 레코드마다 → 임시테이블에서 SELECT로 중복 확인 → 없으면 INSERT
  3. 임시테이블 건수 반환
  4. 건수가 많으면 임시테이블이 디스크로 내려감

→ 레코드 건당 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)

실험 결과

내용이 똑같은 두 함수(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

활용 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는 잠금 없는 일관된 읽기를 한다. 데이터 변경 시 변경 전 버전을 언두 영역에 백업해 두고, 다른 세션이 읽으려 하면 언두의 백업본을 준다.

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 지정 불가 가능
인덱스 가능 (인덱스 데이터는 디스크에 저장됨) 가능

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'               -- ❌ 타입 불일치 (문자열)

제약

함수 기반 인덱스 (8.0.13+)

CREATE INDEX ix ON t ((SUBSTRING_INDEX(email, '@', -1)));  -- 괄호 필수

내부적으로 숨겨진 VIRTUAL 컬럼(_hidden_<인덱스명>_<키파트순서>_<카운터>)을 만들고 거기에 인덱스를 건다. (debug 모드 + show_hidden_columns로만 확인 가능)

활용 예: 이메일 도메인 추출, 주소의 구/동, 날짜의 연·월·일, LOWER() 대소문자 무시 검색, 계산값 조회, 대용량 값 해싱, JSON 필드 인덱싱(→ Ep19)

주의사항

  1. 반드시 실행 계획 확인. LOWER() 인덱스 + LIKE 조합은 인덱스를 안 탄다(버그 제보됨).
  2. 조건 값 타입이 표현식 결과 타입과 같아야 함. MONTH()는 숫자, DOUBLE + DOUBLE은 정수 1000 대신 1000.0.
    • 타입이 애매하면 mysql --column-type-info 옵션으로 메타 정보 확인
  3. 값 계산 비용이 추가되므로 변경이 매우 빈번하거나 표현식이 복잡하면 오버헤드가 크다.

제약: 비결정적 함수 불가 / 컬럼명 단독·프리픽스 길이 지정 불가 / 공간·전문검색 인덱스 불가 / PK 불가. 기본적으로 VIRTUAL 컬럼의 제약을 모두 상속한다.


Ep 9. MySQL 에러 핸들링

에러 3분류

분류 번호 대역 발생 위치
Global 1 ~ 999 공용
Server 1000 ~ 1999 (+ 3500, 10000 대역은 8.0 이후 추가) 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은 HY00022001로 변경됨). 이 경우에만 에러 번호로 핸들링하자.

DBA에게 문의할 때 / 로깅할 때


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에 있는 게 맞다.

처리 방식의 차이

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의 진실

장점

단점

  1. 파스 트리 캐시용 메모리 추가 사용
  2. 첫 실행 시 서버 왕복 2회 (prepare + execute) → 일반 statement보다 비효율
  3. MySQL은 실행 계획까지 캐시하지 않는다. 파스 트리만 캐시.
  4. 캐시된 PS는 하나의 커넥션 안에서만 공유된다.

4번이 가장 큰 문제

커넥션 5,000개 × 유니크 쿼리 패턴 100개 = PS 객체 50만 개가 서버 메모리에 필요.

클라이언트 사이드 vs 서버 사이드

흔한 실수

// ❌ 루프 안에서 매번 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(); }

커넥션 풀을 쓰면 커넥션이 자주 새로 생성될수록 파싱 비용이 계속 올라간다. → 커넥션 수는 최소화, 수명은 최대한 길게.

종합 판단


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)