MySQL ‐ Must‐Know SQL Anti‐Patterns - thought-corner/backend-roadmap GitHub Wiki
SQL Anti-Pattern 1: CASE WHEN 남용과 코드(차원) 테이블 패턴
- 변경 비용이 계속 증가한다 - 상태 코드가 하나 추가 혹은 변경되면 그 코드를 쓰는 모든 쿼리를 찾아서 수정해야 한다. 서비스가 커질수록 수정 범위를 파악하는 것 자체가 불가능하다.
- 코드가 계약이 아니라 추측이 된다 -
CASE WHEN status = 1 THEN '승인'이 코드가 팀마다 다르게 작성되는 순간 1이 무엇인지는 코드를 뜯어봐야만 알 수 있다. 즉, 명시적 계약이 없으니 해석이 팀마다 갈리게 된다.
- 데이터 불일치는 기술적 문제가 아니라 신뢰의 문제다 - CASE WHEN 로직의 차이로 결과가 달라진다면 어느 쪽이 맞는지 판단할 기준 자체가 없어진다.
-- ❌ 상태 코드의 의미가 쿼리마다 복제됨 (쿼리 100개면 매핑도 100벌)
SELECT id,
CASE WHEN status = 1 THEN '승인'
WHEN status = 2 THEN '반려'
ELSE '대기' END AS status_label
FROM orders;
-- ✅ 코드(차원) 테이블로 의미를 한 곳에서 관리
CREATE TABLE order_status (
status TINYINT PRIMARY KEY,
label VARCHAR(20) NOT NULL
);
INSERT INTO order_status VALUES (0, '대기'), (1, '승인'), (2, '반려');
SELECT o.id, s.label AS status_label
FROM orders o
JOIN order_status s ON s.status = o.status;
- 상태 코드가 추가·변경되면 코드 테이블의 행만 바꾸면 된다 - 이를 참조하는 쿼리들은 수정할 필요가 없다.
- 자주 쓰는 조합은 뷰로 감싸서 계약을 명시할 수 있다.(CREATE VIEW orders_with_status AS SELECT o.id, s.label ... JOIN ...) - 매핑 로직의 단일 출처(Single Source of Truth)가 DB 안에 생긴다.
SQL Anti-Pattern 2: 인덱스 컬럼에 함수 적용 문제와 해결책
- 인덱스는 원본 값을 기준으로 정렬되어 있다.
- 인덱스를 걸어둔 컬럼에 대해 가공 연산이 이루어지게 되면 옵티마이저는 인덱스에서 함수가 적용된 값을 찾을 수 없기 때문에 인덱스를 포기하고 전체 테이블을 스캔한다.
| 구분 |
인덱스 사용 |
함수 적용 |
| 탐색 방식 |
Index Seek |
Full Table Scan |
| I/O |
최소화 |
전체 행 읽기 |
| CPU |
낮음 |
모든 행에 대해 함수 실행 |
| 데이터 증가 시 |
영향 적음 |
선형으로 악화 |
-- ❌ 인덱스 컬럼을 가공 → 풀스캔
SELECT * FROM orders WHERE DATE(created_at) = '2024-01-01';
-- ✅ 컬럼은 그대로 두고 조건(리터럴) 쪽을 변형 → 인덱스 범위 탐색
SELECT * FROM orders
WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02';
- 애플리케이션 레이어에서 변환해서 넘기고 DB에 함수 부담을 넘기지 않으면 인덱스를 효율적으로 탈 수 있다.
- 데이터 저장 시점에 정규화(예: 소문자로 통일해서 저장)를 해서 함수 없이도 검색되게끔 하는 방식도 고려할 수 있다.
- 가공 조건이 꼭 필요하다면 MySQL이 제공하는 우회로가 있다.
MySQL 5.7+ : 생성 컬럼(Generated Column)을 만들고 그 컬럼에 인덱스 생성
MySQL 8.0.13+ : 함수 기반 인덱스(Functional Index) 직접 지원 - CREATE INDEX idx_created_date ON orders ((DATE(created_at)));
- 암묵적 형변환도 같은 안티패턴의 숨은 형태다 -
VARCHAR 컬럼 phone에 WHERE phone = 01012345678처럼 숫자 리터럴로 비교하면 MySQL이 컬럼 쪽을 숫자로 변환하면서 인덱스를 포기한다. 리터럴 타입을 컬럼 타입과 맞춰야 한다(WHERE phone = '01012345678').
SQL Anti-Pattern 3: SELECT *로 만든 VIEW의 문제와 해결책
SELECT *로 VIEW를 만드는 것은 실무에서 가장 피해야 할 안티 패턴인데 그 이유는 다음과 같다.
- 스키마 변경과 조용히 어긋난다 : MySQL은 뷰를 만드는 순간
*를 그 시점의 컬럼 목록으로 확장해서 고정(freeze)한다. 따라서 원본 테이블에 새 컬럼이 추가되어도 뷰에는 자동 반영되지 않는다. "뷰가 몰래 바뀌는" 문제가 아니라, *라서 당연히 반영될 것이라 기대하게 되는데 실제로는 반영되지 않아 원본과 뷰가 조용히 어긋나는 문제다.
- 원본에서 컬럼이 삭제되거나 이름이 바뀌면 뷰는 조회하는 시점에 에러가 난다.
*로 만들었기 때문에 뷰가 어떤 컬럼에 의존하는지 정의만 봐서는 알 수 없고, 장애가 나야 드러난다.
- 컬럼명 충돌 : 여러 테이블을 조인할 때 동일한 이름의 컬럼이 충돌할 위험이 있다.
- 성능 낭비 : 필요 없는 데이터까지 모두 읽어야 하기 때문에 비효율적이다.
- 위와 같은 문제들을 해결하기 위해 명시적으로 컬럼을 지정한 VIEW를 만드는 것이 좋다.
- 계약이 눈에 보인다 : 뷰 정의만 봐도 어떤 컬럼에 의존하고 어떤 데이터를 제공하는지 명확하다. 이 VIEW를 조회하는 다른 개발자나 시스템이 어떤 데이터를 받을 수 있는지 직관적으로 알 수 있다.
- 실패가 빨리 드러난다 : 의존하는 컬럼이 삭제·변경되면 어느 뷰가 영향을 받는지 정의에서 바로 추적할 수 있다.
SQL Anti-Pattern 4: DISTINCT 남용으로 인한 문제와 해결책
- SQL에서
DISTINCT는 중복된 결과값을 제거해주는 매우 편리하고 필수적인 기능이나 뚜렷한 목적 없이 사용하게 되면 심각한 성능 저하와 논리적 오류 은폐라는 큰 문제를 야기할 수 있다.
- 막대한 리소스 소모(성능 저하) : 데이터베이스가
DISTINCT를 처리하는 과정은 공짜가 아니다. 중복을 제거하기 위해 데이터베이스 엔진은 내부적으로 다음과 같은 작업을 수행하게 된다.
- 정렬(sort) 또는 해싱(hashing) : 중복을 찾아내기 위해 조회된 모든 데이터를 메모리나 디스크에 올려두고 정렬하거나 해시 테이블을 만든다.
- **데이터 건수가 적을 때 : 티가 나지 않지만 수십만 ~ 수천만 건의 데이터를 다룰 때
DISTINCT를 사용하면 CPU와 메모리 사용량이 급증하고 쿼리 속도가 눈에 띄게 느려진다.
- 잘못된 JOIN 조건 은폐 : 테이블 간
JOIN 조건을 잘못 설정하거나 1:N 관계를 잘못 이해하면 데이터가 의도치 않게 뻥튀기(팬아웃)된다. 이 때, "쿼리가 왜 중복을 만들었는가"를 분석해 JOIN 조건을 고치는 대신 단순히 DISTINCT를 붙여 눈앞의 중복만 지우는 임시방편을 쓰기 쉬운데, 이는 쿼리 자체의 근본적인 결함을 숨기기 때문에 나중에 데이터가 커지면 시스템 전체에 부하를 주는 시한폭탄으로 동작하게 된다.
-- ❌ 1:N 조인의 팬아웃을 DISTINCT로 덮음 (orders 수만큼 부풀린 뒤 다시 줄이는 이중 낭비)
SELECT DISTINCT u.id, u.name
FROM users u
JOIN orders o ON o.user_id = u.id;
-- ✅ "주문이 있는 유저"처럼 존재 여부만 필요하면 세미 조인(EXISTS)으로 팬아웃 자체를 만들지 않는다
SELECT u.id, u.name
FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
DISTINCT를 쓰기 전에 "왜 중복이 생겼는가"를 먼저 물어야 한다. 존재 확인이 목적이면 EXISTS, 집계가 목적이면 사전 GROUP BY(JOIN 문서의 사전 집계 패턴)가 정답인 경우가 대부분이다.
SQL Anti-Pattern 5: 과도한 뷰 계층화(View Stacking)의 문제와 해결책
- 계층형 뷰 (Nested Views / View-on-View)
- 장점 : 로직이 단계별로 분리되어 있어 코드를 읽기 쉽고, 특정 중간 단계의 뷰를 다른 쿼리에서 재사용하기 좋다.
- 단점(성능 및 유지보수) : 최상위 뷰를 조회할 때 옵티마이저는 그 아래 얽혀있는 모든 하위 뷰의 쿼리를 하나하나 풀어서(View Resolution) 거대한 단일 실행 계획을 만들어야 한다. 뷰의 깊이가 깊어질수록 최적의 실행 경로를 찾지 못해 심각한 성능 저하를 유발하며, 문제가 생겼을 때 어느 단계의 뷰에서 병목이 발생했는지 추적하기도 매우 어렵다.
- MySQL의 뷰 처리 방식 : 뷰는
MERGE(뷰 정의를 바깥 쿼리에 병합) 또는 TEMPTABLE(뷰 결과를 임시 테이블로 구체화) 알고리즘으로 처리된다. 집계 함수·GROUP BY·DISTINCT·UNION 등이 들어간 뷰는 MERGE가 불가능해 TEMPTABLE로 강제되는데, 계층형 뷰에서 이런 뷰가 중간에 끼어 있으면 단계마다 임시 테이블이 쌓이면서 성능이 급락한다. 이것이 뷰 계층화가 느려지는 실체다.
- 평탄화된 뷰 (Flattened View)
- 특징 : 원본 테이블에서 직접 조인하고, 집계 함수(COUNT, SUM, AVG)와 조건문(CASE)을 한 번의 SELECT 문 안에서 모두 처리
- 장점 : 뷰의 계층이 없으므로 옵티마이저가 쿼리의 의도를 정확히 파악하고 가장 효율적인 실행 계획을 세울 수 있다. 유지보수 시에도 여러 뷰를 옮겨 다닐 필요 없이 이 쿼리 하나만 수정하면 되므로 관리가 직관적이다.
- 단점 : 실시간으로 원본 테이블을 조인하고 연산하므로, 데이터가 수천만 건 단위로 커지면 이 뷰를 조회할 때마다 매번 무거운 연산을 수행해야 하는 부담이 여전히 존재한다.
- 구체화된 뷰 (Materialized View)
- 일반적인 뷰가 '쿼리(로직)'만 저장하는 것과 달리, 구체화된 뷰는 쿼리의 '실행 결과 데이터'를 실제 디스크(스토리지)에 저장
- 장점 : 데이터를 조회할 때마다 매번 조인과 집계를 다시 할 필요 없이, 이미 계산되어 저장된 결과 테이블을 읽기만 하면 되므로 압도적으로 빠른 조회 속도를 보장한다.
- 단점 : 원본 데이터가 변경되어도 구체화된 뷰의 데이터는 자동으로 바뀌지 않는다. 주기적으로 데이터를 갱신(동기화)해 주어야 하므로, '완벽한 실시간성'이 필요한 서비스에는 적합하지 않다.
- MySQL은 Materialized View를 네이티브로 지원하지 않는다 (Oracle·PostgreSQL은 지원). MySQL에서는 요약 테이블(Summary Table)을 직접 만들고 배치 스케줄러·이벤트 스케줄러(
CREATE EVENT)·트리거로 갱신하는 방식으로 동일한 효과를 구현한다. 대규모 집계라면 Kafka 등으로 변경 이벤트를 흘려 별도 집계 저장소를 두는 구성도 흔하다.