동작하는 SQL과 운영 가능한 SQL은 다릅니다. SELECT *, 묵시적 형변환, 과한 서브쿼리 같은 패턴은 작은 데이터에서는 티가 안 나다가 운영에서 터집니다. 안티패턴을 미리 알아두면 느린 쿼리를 만들 가능성을 크게 줄일 수 있습니다.
SQL 안티패턴은 쿼리 자체는 올바르지만 성능이나 정확성에 심각한 문제를 일으키는 패턴입니다. 가장 흔한 원인은 N+1(반복문 속 쿼리), 인덱스 컬럼에 함수 적용, 불필요한 SELECT *, OFFSET 페이지네이션입니다. 각 패턴의 원리를 이해하면 코드를 작성할 때 자연스럽게 피할 수 있습니다.
- 1N+1 문제의 발생 원리를 이해하고 ORM별로 해결할 수 있다
- 2인덱스를 무력화하는 쿼리 패턴을 진단하고 올바른 대안으로 바꿀 수 있다
- 3SELECT *의 숨은 비용을 이해하고 커버링 인덱스를 활용할 수 있다
- 4OFFSET 페이지네이션의 함정을 피해 Keyset Pagination을 적용할 수 있다
- 5암묵적 형변환과 OR 조건이 일으키는 인덱스 문제를 진단하고 수정할 수 있다
- 6LIKE 부분일치 패턴의 한계를 이해하고 전문 검색이 필요한 경우를 판단할 수 있다
SQL 안티패턴 — N+1, SELECT *, 인덱스 무력화
코드 리뷰에서 가장 많이 지적되는 SQL 문제들이 있습니다. N+1 쿼리, 불필요한 SELECT *, 인덱스를 무력화하는 함수 사용... 이런 패턴들은 개발 환경에서는 보이지 않다가 데이터가 쌓인 운영 환경에서 갑자기 장애로 이어집니다. 이 모듈에서는 실무에서 가장 자주 만나는 안티패턴을 패턴별로 정리하고 올바른 대안을 익힙니다.
N+1 문제 — ORM 사용 시 가장 흔한 성능 함정
사용자 목록을 불러오는 API가 로컬에서는 빠른데 운영에서 0.5초가 걸립니다. 로그를 보면 쿼리가 수백 개 찍혀 있습니다. ORM 코드는 단 두 줄인데 실제로는 사용자 수만큼 쿼리가 나가는 N+1 문제입니다. 이 패턴을 모르면 ORM을 쓸수록 성능이 나빠지는 이유를 찾지 못합니다.
확대
N+1 문제란?
N+1 문제는 1번의 목록 조회 후 각 항목에 대해 N번의 추가 쿼리가 발생하는 패턴입니다. ORM 코드에서는 단순한 속성 접근처럼 보이지만 실제로는 루프마다 DB 쿼리가 실행됩니다.
게시글 100개를 조회하고 각 게시글에서 post.author.name을 읽으면 1번(게시글 목록) + 100번(작성자 정보) = 총 101번의 쿼리가 실행됩니다. 게시글이 1,000개라면 1,001번입니다. 개발 환경에서는 데이터가 적어 눈에 띄지 않다가, 운영 환경에서 데이터가 쌓이면 급격히 느려집니다.
아래는 N+1이 발생하는 Python/SQLAlchemy 예시와 실제로 실행되는 쿼리입니다.
N+1 패턴이 실제로 어떻게 실행되는지 SQL 레벨에서 확인합니다. 목록 1번 + 작성자 N번 패턴을 파악합니다.
posts = session.query(Post).limit(100).all()
for post in posts:
print(f"{post.title} by {post.author.name}")
위 코드에서 실제 실행되는 SQL은 다음과 같습니다.
SELECT * FROM posts LIMIT 100;
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
실행 완료 또는 조회 결과가 표시됩니다.
EXPLAIN SELECT * FROM posts LIMIT 100;- 읽기 순서—애플리케이션 로그에서 같은 SELECT 패턴이 N번 반복되는지 먼저 확인합니다. 반복 횟수가 결과 행 수(N)와 비례하면 N+1입니다.
- N+1 판단 기준—단일 API 요청에서 동일 쿼리가 10번 이상 반복 → N+1 의심. 50번 이상 → 즉시 JOIN/Eager Loading으로 전환 필요.
- 읽은 컬럼—SELECT * 대신 필요한 컬럼만 조회하는지 확인합니다.
- 형변환 여부—조건 컬럼에 암묵적 형변환이 걸려 인덱스를 못 타지 않는지 봅니다.
JOIN으로 한 번에 해결
N+1의 근본 해결책은 처음부터 JOIN을 사용해 필요한 데이터를 한 번의 쿼리로 가져오는 것입니다. 아래 쿼리는 위 101번 쿼리와 동일한 결과를 단 1번에 반환합니다.
N+1이 발생하던 101번 쿼리를 JOIN 1번으로 교체합니다. EXPLAIN으로 쿼리 실행 횟수가 줄었는지 확인합니다.
SELECT
p.id,
p.title,
p.created_at,
u.name AS author_name,
u.avatar_url
FROM posts p
JOIN users u ON p.author_id = u.id
ORDER BY p.created_at DESC
LIMIT 100;
SELECT p.id, p.title, u.name AS author_name FROM posts p JOIN users u ON p.author_id = u.id ORDER BY p.created_at DESC LIMIT 100;- 쿼리 실행이 1번으로 줄었는지 확인합니다 (DB 로그 또는 쿼리 카운터).
- EXPLAIN 결과에서 JOIN이 Nested Loop가 아닌 Hash Join 또는 Index Scan으로 처리되는지 봅니다.
- author_id 컬럼에 인덱스가 있는지 확인합니다 — 없으면 Full Scan이 발생합니다.
ORM별 Eager Loading 해결법
각 ORM 프레임워크는 N+1을 방지하는 Eager Loading 기능을 제공합니다. Django의 select_related는 FK 관계에 SQL JOIN을 사용하고, prefetch_related는 M:N이나 역방향 FK에 별도 쿼리 1번을 실행합니다.
posts = Post.objects.select_related('author').all()[:100]
posts = Post.objects.prefetch_related('tags', 'comments').all()[:100]
posts = Post.objects.select_related('author').prefetch_related('tags')[:100]
SQLAlchemy에서는 joinedload가 JOIN 방식이고, selectinload는 별도 IN 쿼리 방식으로 M:N 관계에 적합합니다.
from sqlalchemy.orm import joinedload, selectinload
posts = session.query(Post).options(joinedload(Post.author)).limit(100).all()
posts = session.query(Post).options(selectinload(Post.tags)).limit(100).all()
Hibernate/JPA에서는 JPQL의 JOIN FETCH 또는 @EntityGraph를 사용합니다.
List<Post> posts = em.createQuery(
"SELECT p FROM Post p JOIN FETCH p.author WHERE p.deleted = false",
Post.class
).setMaxResults(100).getResultList();
N+1 탐지 방법
개발 환경에서 쿼리 로그를 활성화해두면 N+1을 조기에 발견할 수 있습니다. Django Debug Toolbar, Hibernate Statistics 같은 도구도 활용합니다. Django에서 직접 쿼리 수를 확인하려면 아래 코드를 사용합니다.
from django.db import connection
with connection.execute_wrapper(lambda execute, *a, **kw: execute(*a, **kw)):
posts = list(Post.objects.all()[:100])
print(f"쿼리 실행 횟수: {len(connection.queries)}")
배치 처리 스크립트에서도 N+1이 발생합니다. 루프 안에서 단건 조회 대신 bulk 조회를 사용합니다.
orders = Order.objects.filter(id__in=order_ids).select_related('customer')
for order in orders:
process(order)
ORM에서 연관 객체를 LazyLoad로 접근할 때 N+1이 발생합니다. 가장 위험한 점은 로컬 개발 환경에서는 데이터가 수십 건이라 빠르게 느껴지다가, 운영 환경에서 수만 건이 되면 갑자기 타임아웃이 발생한다는 것입니다.
확인 방법: 개발 환경에서 Django의 경우 django-silk 또는 Debug Toolbar를 설치해 요청당 쿼리 수를 확인합니다. 단일 API 요청에서 쿼리가 50번 이상 실행된다면 N+1을 의심하세요.
해결 방법: ORM 코드에서 연관 객체에 접근하는 모든 루프를 찾아 select_related / prefetch_related / joinedload로 전환합니다. 또는 뷰/API에서 직접 JOIN 쿼리를 작성합니다.
주문 목록 API의 응답 시간이 평균 200ms에서 8초로 급증하는 알림이 발생했습니다. pg_stat_statements를 확인하니 SELECT * FROM users WHERE id = ? 쿼리가 분당 15만 번 실행되고 있었습니다.
코드를 추적하니 주문 목록을 가져온 후 각 주문의 고객 정보를 루프에서 별도로 조회하는 ORM 코드가 원인이었습니다. 주문 건수가 늘어나면서 쿼리 수가 선형으로 증가한 것입니다.
ORM 쿼리에 select_related('customer')를 추가하자 API 응답 시간이 8초에서 120ms로 감소했습니다. DB 쿼리 수도 요청당 1,000번에서 2번으로 줄었습니다.
인덱스를 무력화하는 쿼리 패턴들 — 쓰면 안 되는 것들
인덱스를 걸었는데도 EXPLAIN이 Full Table Scan을 보여줍니다. 쿼리가 뭔가 잘못됐다는 건 알겠는데 왜 인덱스가 안 타는지 이해가 안 됩니다. 인덱스 컬럼을 함수로 감싸거나, LIKE 앞에 %를 쓰거나, 타입 불일치가 있으면 옵티마이저가 인덱스를 무시합니다. 이 패턴들을 알면 느린 쿼리의 절반 이상을 설명할 수 있습니다.
확대
패턴 1: 함수로 인덱스 컬럼 감싸기
B-Tree 인덱스는 컬럼의 원래 값으로 만들어집니다. WHERE 절에서 인덱스가 걸린 컬럼을 함수로 감싸면 인덱스 키와 비교 대상이 달라져 인덱스를 전혀 사용할 수 없고 Full Table Scan이 발생합니다.
해결 방법은 함수를 적용하지 않고 범위 조건으로 변환하는 것입니다. 날짜 연산의 경우 YEAR(created_at) = 2024 대신 created_at >= '2024-01-01' AND created_at < '2025-01-01'로 바꾸면 인덱스를 그대로 활용할 수 있습니다.
어쩔 수 없이 함수를 사용해야 한다면 PostgreSQL의 표현식 인덱스(Function-Based Index)를 생성하면 됩니다. 표현식 인덱스는 동일한 함수 표현식을 인덱스 키로 저장하므로 같은 함수를 적용한 WHERE 조건이 인덱스를 사용할 수 있습니다.
YEAR() 함수 사용 쿼리와 범위 조건 쿼리를 EXPLAIN으로 비교합니다. 인덱스 사용 여부 차이를 직접 확인합니다.
-- 인덱스 무력화 (Seq Scan 발생)
EXPLAIN SELECT * FROM orders WHERE YEAR(created_at) = 2024;
-- 인덱스 활용 (Index Range Scan)
EXPLAIN SELECT * FROM orders
WHERE created_at >= '2024-03-01'
AND created_at < '2024-04-01';
SELECT * FROM users WHERE phone_number = '01012345678';
-- 표현식 인덱스로 함수 그대로 사용
CREATE INDEX idx_name_upper ON users (UPPER(name));
SELECT * FROM users WHERE UPPER(name) = 'HONG';
EXPLAIN SELECT * FROM orders WHERE created_at >= '2024-03-01' AND created_at < '2024-04-01';- 읽기 순서—EXPLAIN 결과는 type 컬럼 → rows 컬럼 → Extra 컬럼 순으로 읽습니다. type이 ALL이면 이미 문제이므로 rows와 Extra는 참고용입니다.
- type 판단 기준—const/eq_ref: 최적(단건 조회) → ref: 양호 → range: 수용 가능 → index: 주의(전체 인덱스 스캔) → ALL: 위험(즉시 인덱스 추가 필요).
- rows 판단 기준—EXPLAIN의 rows 추정치가 실제 반환 rows의 10배 이상이면 비효율적 쿼리. 예: rows=1,000,000인데 실제 결과가 50건이면 인덱스 또는 조건 재검토.
- 조합 해석—type=ALL + rows=수백만 → 즉시 인덱스 추가 필요. 운영 중이라면 pt-query-digest로 먼저 영향도 분석 후 CREATE INDEX CONCURRENTLY 적용.
- 실행 시간 비교—함수 사용 쿼리와 범위 조건 쿼리의 actual time을 EXPLAIN ANALYZE로 비교해 개선 폭을 측정합니다.
created_at 컬럼에 인덱스가 있어도 YEAR(created_at) = 2024는 인덱스를 사용하지 못합니다. 인덱스는 원본 타임스탬프 값으로 정렬되어 있는데, YEAR() 함수를 적용하면 정수 값으로 변환되어 인덱스 키와 비교할 수 없게 됩니다.
EXPLAIN으로 확인하면 Seq Scan이 표시되고, 수백만 행 테이블에서는 수 초가 걸립니다.
범위 조건으로 변환하면 동일한 결과를 Index Range Scan으로 처리할 수 있습니다. WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'로 작성하세요.
패턴 2: LIKE 앞에 % 와일드카드
B-Tree 인덱스는 전화번호부처럼 앞에서부터 정렬됩니다. LIKE '삼성%'처럼 앞이 고정된 접두사 매칭은 인덱스 범위 검색을 할 수 있지만, LIKE '%노트북%'처럼 앞에 와일드카드가 오면 어디서 시작할지 알 수 없어 전체 테이블을 스캔해야 합니다.
중간 포함 검색이 필요하다면 Full-Text Search를 사용합니다. PostgreSQL은 tsvector와 to_tsquery로 전문 검색을 지원하며, 대용량 검색에는 Elasticsearch 도입을 검토합니다.
SELECT * FROM products WHERE name LIKE '삼성%';
SELECT * FROM products
WHERE to_tsvector('korean', name) @@ to_tsquery('korean', '노트북');
패턴 3: 암묵적 형변환
컬럼 타입과 비교 값의 타입이 다르면 DB가 자동으로 형변환을 시도합니다. 이 과정에서 인덱스가 걸린 컬럼에 변환 함수가 내부적으로 적용되어 인덱스가 무력화됩니다.
VARCHAR 컬럼을 숫자 리터럴과 비교하거나, INTEGER 컬럼을 문자열 리터럴과 비교할 때 발생합니다. 항상 컬럼 타입과 동일한 타입의 리터럴을 사용합니다.
SELECT * FROM users WHERE phone_number = '01012345678';
SELECT * FROM users WHERE user_id = 42;
패턴 4: OR 조건의 인덱스 문제
각 컬럼에 인덱스가 있더라도 OR 조건은 플래너가 두 인덱스를 결합하기 어려워 Seq Scan을 선택할 수 있습니다. UNION ALL로 분리하면 각 브랜치에서 독립적으로 인덱스를 활용합니다. UNION ALL은 UNION과 달리 중복 제거를 하지 않으므로 중복이 없는 경우 더 빠릅니다.
SELECT * FROM users WHERE status = 'active' AND region = 'seoul'
UNION ALL
SELECT * FROM users WHERE status = 'vip'
AND NOT (status = 'active' AND region = 'seoul');
패턴 5: SELECT * 의 숨은 비용
SELECT *는 테이블의 모든 컬럼을 읽어 네트워크로 전송합니다. TEXT, BLOB 같은 대용량 컬럼이 포함된 테이블에서는 불필요한 데이터가 대량으로 전송됩니다. 또한 필요한 컬럼만 인덱스에 포함하는 커버링 인덱스(Index Only Scan) 활용이 불가능해집니다.
스키마에 컬럼이 추가될 때 예상치 못한 동작이 발생하거나 ORM 매핑 오류가 생길 위험도 있습니다.
SELECT order_id, status, total_amount, created_at
FROM orders
WHERE customer_id = 42;
패턴 6: OFFSET 페이지네이션
OFFSET N은 DB가 처음 N개 행을 읽고 버린 뒤 다음 결과를 반환하는 방식입니다. OFFSET 10000이면 10,020개를 읽고 앞의 10,000개를 버립니다. 페이지가 뒤로 갈수록 읽어야 할 행이 선형적으로 늘어납니다.
Keyset Pagination(커서 기반)은 마지막으로 본 항목의 값을 기준으로 다음 페이지를 조회합니다. 복합 인덱스가 있으면 항상 빠른 Index Range Scan으로 처리됩니다. 단, 임의 페이지로 바로 이동(예: 페이지 번호 클릭)은 불가능합니다.
SELECT * FROM articles
WHERE created_at < '2024-03-15 12:00:00'
OR (created_at = '2024-03-15 12:00:00' AND id < 12345)
ORDER BY created_at DESC, id DESC
LIMIT 20;
| 방식 | 첫 페이지 | 100번째 페이지 | 임의 페이지 이동 | 실시간 데이터 |
|---|---|---|---|---|
| OFFSET | 빠름 | 느림 | 가능 | 중복/누락 가능 |
| Keyset | 빠름 | 빠름 | 불가 | 안전 |
안티패턴 요약 체크리스트
| 안티패턴 | 증상 | 해결책 |
|---|---|---|
| N+1 | ORM 코드에서 루프 + 단건 조회 | JOIN 또는 Eager Loading |
| 함수로 컬럼 감싸기 | WHERE YEAR(col) = ... | 범위 조건으로 변환 |
| LIKE '%검색어%' | 앞에 % 있는 LIKE | 전문 검색 또는 LIKE '검색어%' |
| 암묵적 형변환 | 컬럼 타입과 값 타입 불일치 | 타입 맞춰서 비교 |
| SELECT * | 모든 컬럼 조회 | 필요한 컬럼만 명시 |
| OFFSET 페이지네이션 | 뒷 페이지로 갈수록 느림 | Keyset Pagination |
월별 주문 통계 쿼리에 슬로우 쿼리 알림이 발생했습니다. EXPLAIN ANALYZE 결과 orders 테이블 전체를 Seq Scan하고 있었습니다. created_at 컬럼에는 인덱스가 있었지만 쿼리가 WHERE YEAR(created_at) = 2024로 작성되어 있어 인덱스가 무력화된 것이었습니다.
쿼리를 WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'로 수정하자 실행계획이 Seq Scan에서 Index Range Scan으로 바뀌었고, 실행 시간이 4,200ms에서 18ms로 감소했습니다.
코드베이스 전체를 검색해 YEAR(), MONTH(), DATE() 함수가 인덱스 컬럼에 사용된 패턴을 모두 범위 조건으로 교체하고, PR 리뷰 체크리스트에 이 패턴을 추가했습니다.
안티패턴이 비용을 심는 지점 — 쿼리 실행 파이프라인 단계 매핑
지금까지 안티패턴을 하나씩(N+1·SELECT *·함수 씌운 WHERE·암묵 형변환·OFFSET) 봤습니다. 새로운 안티패턴을 만났을 때 목록을 외워 대조하는 대신, 쿼리가 실행되는 파이프라인의 어느 단계에서 비용이 생기는지로 위치를 잡으면 처방이 바로 나옵니다. SQL 한 문장은 파싱 → 계획 수립(옵티마이저) → 실행을 지나고, 애플리케이션은 이 과정을 여러 번 반복합니다. 각 안티패턴은 이 중 특정 단계에 비용을 심습니다.
하나의 SQL이 실행되는 경로와, 안티패턴이 비용을 심는 지점
│
① 파싱·바인딩 — 구문 검사, 컬럼·타입 확인
│ 암묵적 형변환의 씨앗: 컬럼 타입 ≠ 리터럴 타입
│
② 계획 수립(옵티마이저) — 인덱스·조인·스캔 방식 결정
│ 함수로 감싼 컬럼·앞% LIKE·형변환 → "인덱스 못 씀"으로 판정
│ OR 조건 → 두 인덱스 결합을 포기하고 Seq Scan 선택
│
③ 실행·접근 — 계획대로 행을 읽고 가공
│ Seq Scan이면 전체 행 I/O · SELECT *는 넓은 행·큰 컬럼까지 전송
│ OFFSET 10000은 버릴 앞 1만 행까지 읽음
│
④ 반복 — 애플리케이션이 ①~③을 N번 되풀이
▼ N+1: 목록 1번 + 항목마다 쿼리 N번 = 왕복 폭증
응답 (작은 데이터에선 안 보이다가 운영 규모에서 터진다)
어떤 안티패턴이 어느 단계에서 비용을 만드나:
| 안티패턴 | 비용 발생 단계 | 만들어지는 비용 |
|---|---|---|
암묵적 형변환 (phone = 01012345678) | ① 파싱·바인딩 → ② 계획 | 컬럼에 CAST가 씌워진 꼴이 되어 인덱스 무력화(Seq Scan) |
함수로 컬럼 감싸기 (YEAR(created_at)=) | ② 계획 | 인덱스 키(원본값)와 불일치 → Seq Scan |
앞 % LIKE (LIKE '%노트북%') | ② 계획 | B-Tree 시작점을 못 정해 전체 스캔 |
| OR 조건 | ② 계획 | 두 인덱스 결합을 포기 → Seq Scan (UNION ALL로 분리) |
SELECT * | ③ 실행·전송 | 불필요 컬럼·TEXT/BLOB까지 I/O·네트워크, 커버링 인덱스 무효 |
| OFFSET 페이지네이션 | ③ 실행 | 버릴 앞 N행까지 읽어 뒤 페이지일수록 느림 |
| N+1 | ④ 반복 | 쿼리 1 + N번, 네트워크 왕복 × N |
즉 인덱스를 죽이는 안티패턴은 대부분 ② 계획에서(옵티마이저가 인덱스를 포기), SELECT *·OFFSET은 ③ 실행에서(과도한 I/O·전송), N+1은 ④ 반복에서(왕복 횟수) 비용을 만듭니다. 그래서 진단 도구도 단계별로 갈립니다 — ②는 EXPLAIN의 type=ALL·Seq Scan·key=NULL로, ③은 실제로 읽은 행 수·전송량(EXPLAIN (ANALYZE, BUFFERS))으로, ④는 요청당 쿼리 카운트(slow query log·ORM 로그)로 잡습니다. 비용이 어느 단계에서 생기는지를 먼저 짚으면, 처방이 '인덱스 추가/조건 재작성'(②)인지 '컬럼 명시/Keyset'(③)인지 'JOIN·Eager Loading'(④)인지 바로 정해집니다.
심화 — 안티패턴은 읽기에만 있지 않다: 거대한 단일 쓰기
심화: 한 문장으로 수백만 행을 바꾸는 안티패턴
지금까지의 안티패턴은 대부분 SELECT — 어떻게 읽느냐의 문제였습니다. 그런데 운영에서 가장 크게 터지는 안티패턴은 오히려 쓰기 쪽, 그것도 "한 문장으로 너무 많은 행을 건드리는" 경우입니다. DELETE FROM logs WHERE created_at < now() - interval '90 days'로 2천만 행을 지우거나, UPDATE users SET flag = true로 전체 테이블을 한 번에 바꾸는 것이 대표적입니다. 문법은 완벽하고 개발 DB에선 잘 돌지만, 운영에서는 다음이 동시에 일어납니다.
- 락과 긴 트랜잭션: 문장이 끝날 때까지 트랜잭션이 열려 있어 그 행들에 대한 동시 쓰기를 막고, 대기 큐와 데드락 위험을 키웁니다. 게다가 열려 있는 긴 트랜잭션은 VACUUM이 회수할 수 있는 경계(xmin horizon)를 붙잡아, 그동안 시스템 전체의 죽은 튜플 회수를 지연시킵니다.
- MVCC 팽창: PostgreSQL의 DELETE/UPDATE는 행을 즉시 없애지 않고 죽은 튜플로 표시만 합니다. 2천만 행을 지우면 2천만 개의 죽은 튜플이 남아 테이블과 인덱스가 부풀고, VACUUM 전까지 디스크가 줄지 않습니다(일반 VACUUM은 공간을 재사용만 하고 OS에 반환하지 않습니다 — 반환은 VACUUM FULL/pg_repack 필요).
- WAL과 복제: 한 트랜잭션이 대량 WAL을 몰아서 만들어 복제 지연이 치솟고, WAL 디스크가 가득 찰 수도 있습니다.
- 올-오어-낫싱: 몇 시간짜리 삭제가 막판에 실패하면 통째로 롤백돼 시간을 통으로 날립니다.
해결의 핵심은 "큰 트랜잭션을 작은 트랜잭션 여러 개로" 바꾸는 배치 처리입니다. 수천 행씩 잘라 지우고 배치마다 커밋하면, 락이 곧바로 풀려 동시 쓰기가 진행되고, WAL이 조금씩 빠져 복제가 따라잡으며, autovacuum이 죽은 튜플을 회수해 팽창이 억제됩니다. 그리고 반복되는 기간 기반 삭제라면 애초에 파티셔닝으로 설계해, 오래된 파티션을 DROP/DETACH하면 삭제가 즉시 끝나고 죽은 튜플도 남지 않습니다.
상황: 매일 새벽 오래된 로그를 지우는 배치가 단일 DELETE로 2천만 행을 삭제합니다. 그 시간대에 다른 쓰기 요청들이 느려지거나 대기하고, 읽기 복제본의 지연이 수십 초로 튀며, 삭제가 끝난 뒤에도 logs 테이블이 차지하는 디스크는 그대로입니다.
원인: 거대한 단일 쓰기 트랜잭션이 문제입니다. (1) 삭제가 끝날 때까지 트랜잭션이 열려 관련 락을 오래 쥐어 동시 쓰기를 막았고, (2) PostgreSQL MVCC상 DELETE는 행을 죽은 튜플로만 표시하므로 2천만 개의 죽은 튜플이 남아 테이블·인덱스가 팽창했으며(그래서 용량이 안 줄어듦), (3) 한 트랜잭션이 만든 대량 WAL이 복제본으로 몰려 지연이 치솟았습니다.
진단: 삭제가 도는 동안 상태를 관찰합니다.
-- 오래 도는 삭제와, 그로 인해 대기 중인 쿼리 확인
SELECT pid, state, wait_event_type, wait_event,
now() - xact_start AS tx_age, left(query, 60) AS q
FROM pg_stat_activity
WHERE state <> 'idle'
ORDER BY tx_age DESC;
-- 삭제 후 죽은 튜플과 테이블 크기 확인
SELECT relname, n_dead_tup, n_live_tup,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_stat_user_tables
WHERE relname = 'logs';
tx_age가 수십 분인 DELETE 하나와 그 뒤에 대기(wait_event가 Lock)하는 쓰기들이 보이고, 삭제 후 n_dead_tup이 수천만이면 확정입니다. 같은 시간대 ReplicaLag 그래프의 급등을 함께 확인합니다.
해결: 배치로 쪼개고, 반복 삭제는 파티셔닝으로 바꿉니다.
-- 배치 삭제: 한 번에 수천 행씩, 각 반복 사이에 COMMIT
DELETE FROM logs
WHERE id IN (
SELECT id FROM logs
WHERE created_at < now() - interval '90 days'
ORDER BY id
LIMIT 5000
);
-- 반환 행이 0이 될 때까지 위를 반복하고, 반복마다 커밋한다
배치마다 커밋하면 락이 즉시 풀려 동시 쓰기가 진행되고, WAL이 조금씩 빠져 복제가 따라잡으며, autovacuum이 죽은 튜플을 회수해 팽창이 억제됩니다. 근본적으로는 logs를 월/일 단위로 파티셔닝해 두고 오래된 파티션을 DROP(또는 DETACH)하면, 삭제가 순식간에 끝나고 죽은 튜플도 WAL 폭증도 없습니다. 이미 팽창한 테이블의 디스크를 되돌리려면 유지보수 창에서 VACUUM FULL이나 pg_repack을 씁니다.
명령어·구문 빠른 참조
이 모듈에서 다룬 안티패턴의 '올바른 대안' 구문을 모았습니다. 왼쪽이 권장형입니다.
| 구문/명령 | 용도 | 예 |
|---|---|---|
| 범위 조건(함수 래핑 대신) | 인덱스 타는 날짜 필터 | WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01' (YEAR(created_at)=2024 대체) |
| 표현식 인덱스 | 함수 조건을 인덱스로 | CREATE INDEX ON users (UPPER(name)) → WHERE UPPER(name) = 'HONG' |
접두사 LIKE / 전문검색 | 앞 % 회피 | name LIKE '삼성%', to_tsvector('korean', name) @@ to_tsquery('korean', '노트북') |
| 타입 일치 비교 | 암묵적 형변환 회피 | WHERE phone = '01012345678' (문자열 리터럴) |
UNION ALL (OR 대신) | 각 브랜치 인덱스 활용 | … WHERE status='active' UNION ALL … WHERE status='vip' |
| 컬럼 명시(SELECT * 대신) | 네트워크↓·커버링 인덱스 | SELECT order_id, status, total_amount FROM orders WHERE customer_id = 42 |
| Keyset 페이지네이션 | OFFSET 대신 커서 기반 | WHERE created_at < ? OR (created_at = ? AND id < ?) ORDER BY created_at DESC, id DESC LIMIT 20 |
JOIN (N+1 대신) | 목록+상세를 1쿼리로 | SELECT p.id, p.title, u.name FROM posts p JOIN users u ON p.author_id = u.id |
배치 DELETE | 대량 삭제를 소분·커밋 | DELETE FROM logs WHERE id IN (SELECT id FROM logs WHERE created_at < … LIMIT 5000) |
EXPLAIN (ANALYZE, BUFFERS) | 인덱스 무력화·풀스캔 진단 | EXPLAIN ANALYZE SELECT … (type=ALL / Seq Scan 확인) |
pg_stat_user_tables | 죽은 튜플·테이블 팽창 확인 | SELECT relname, n_dead_tup FROM pg_stat_user_tables WHERE relname = 'logs' |
관련 모듈로 더 깊이:
- DB 슬로우 쿼리 실전 진단: EXPLAIN ANALYZE로 병목 찾고 인덱스 설계하기 — 안티패턴으로 생긴 슬로우 쿼리를 실제로 튜닝하는 법
- B-Tree 인덱스의 작동 원리와 인덱스 설계의 핵심 조건 — 인덱스 무력화가 왜 일어나는지의 근본 원리
- 쿼리 실행 계획(Execution Plan) 읽는 법과 인덱스 최적화 — EXPLAIN으로 안티패턴이 만든 Seq Scan을 진단하는 법
다음 모듈에서는 GROUP BY와 집계함수를 인덱스와 함께 효율적으로 활용하는 방법을 다룹니다.