infra
Platform

모듈 맵

[Database] N+1 문제, SELECT *, 인덱스 무력화 안티패턴 방지

0 / 37 완료

펼치기
0 / 37 완료0%

데이터베이스 설계 & 튜닝 · 14 / 37

[Database] N+1 문제, SELECT *, 인덱스 무력화 안티패턴 방지

실무에서 자주 발생하는 SQL 안티패턴을 인식하고 올바른 대안으로 개선합니다

🚨INCIDENT ALERT
HIGH

동작하는 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 의심 — '목록 1 + 항목 N개' 패턴. 로그의 쿼리 카운트가 결과 행 수에 비례하면 확정. 'N개 행에 N+1 쿼리'
로컬은 빠른데 운영만 느리다데이터량 차이 — 로컬 100건은 N+1도 빠르지만 운영 100만건은 폭발. '데이터 적을 때 안 보이는 함정'. EXPLAIN을 운영 규모 데이터로 봐야 보인다
인덱스가 있는데 안 탄다EXPLAIN에 type=ALL(풀스캔)·key=NULL이면 무력화. 함수로 컬럼 감쌈(WHERE YEAR(col)=)·앞 % LIKE·암묵적 형변환이 3대 원인. 'WHERE 좌변을 건드리면 인덱스가 죽는다'
rows 추정치가 크다EXPLAIN의 rows가 전체 행에 가까우면 필터가 효과 없음 — 인덱스 추가나 조건 재설계. '읽는 행 수 ÷ 반환 행 수'가 크면 비효율(예: 100만 읽어 10 반환)
SELECT * 를 써도 되나필요한 컬럼만 명시 — *는 커버링 인덱스를 못 쓰게 하고 불필요한 I/O·네트워크. 특히 TEXT/BLOB 포함 테이블에서 치명적. '* 는 편하지만 비싸다'
느린 쿼리를 어떻게 찾나slow query log(long_query_time 1초 등) + EXPLAIN ANALYZE로 실제 실행시간. '평균은 정상인데 가끔 느림'은 p95/p99와 함께. 추측 말고 측정
💡개념

N+1 문제 — ORM 사용 시 가장 흔한 성능 함정

사용자 목록을 불러오는 API가 로컬에서는 빠른데 운영에서 0.5초가 걸립니다. 로그를 보면 쿼리가 수백 개 찍혀 있습니다. ORM 코드는 단 두 줄인데 실제로는 사용자 수만큼 쿼리가 나가는 N+1 문제입니다. 이 패턴을 모르면 ORM을 쓸수록 성능이 나빠지는 이유를 찾지 못합니다.

N+1 문제 — ORM 사용 시 가장 흔한 성능 함정. 사용자 100명을 조회(1번 쿼리)한 뒤 각 사용자의 주문을 개별 조회하면 100번의 추가 쿼리가 나가 총 101번 DB 왕복이 발생한다(1000명이면 1001번). 네트워크 레이턴시 × N만큼 응답 시간이 폭증한다. 해결은 JOIN으로 1번에 조회(LEFT JOIN orders)하거나 ORM의 Eager Loading(Sequelize include, SQLAlchemy joinedload)을 쓰는 것. 감지는 Django debug toolbar·Hibernate statistics·로그의 반복 SELECT 패턴으로 한다(100명 기준 N+1=505ms vs JOIN=8ms)확대

N+1 문제란?

N+1 문제는 1번의 목록 조회 후 각 항목에 대해 N번의 추가 쿼리가 발생하는 패턴입니다. ORM 코드에서는 단순한 속성 접근처럼 보이지만 실제로는 루프마다 DB 쿼리가 실행됩니다.

게시글 100개를 조회하고 각 게시글에서 post.author.name을 읽으면 1번(게시글 목록) + 100번(작성자 정보) = 총 101번의 쿼리가 실행됩니다. 게시글이 1,000개라면 1,001번입니다. 개발 환경에서는 데이터가 적어 눈에 띄지 않다가, 운영 환경에서 데이터가 쌓이면 급격히 느려집니다.

아래는 N+1이 발생하는 Python/SQLAlchemy 예시와 실제로 실행되는 쿼리입니다.

1N+1 발생 쿼리 확인

N+1 패턴이 실제로 어떻게 실행되는지 SQL 레벨에서 확인합니다. 목록 1번 + 작성자 N번 패턴을 파악합니다.

Python
posts = session.query(Post).limit(100).all()

for post in posts:
    print(f"{post.title} by {post.author.name}")

위 코드에서 실제 실행되는 SQL은 다음과 같습니다.

SQL
SELECT * FROM posts LIMIT 100;

SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
OUTPUT
실행 완료 또는 조회 결과가 표시됩니다.
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번에 반환합니다.

2JOIN으로 N+1을 단일 쿼리로 해결

N+1이 발생하던 101번 쿼리를 JOIN 1번으로 교체합니다. EXPLAIN으로 쿼리 실행 횟수가 줄었는지 확인합니다.

SQL
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번을 실행합니다.

Python
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 관계에 적합합니다.

Python
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를 사용합니다.

Java
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에서 직접 쿼리 수를 확인하려면 아래 코드를 사용합니다.

Python
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 조회를 사용합니다.

Python
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 쿼리를 작성합니다.

💼
실무 맥락프로덕션 슬로우 쿼리 알림 → N+1 발견 → 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 앞에 %를 쓰거나, 타입 불일치가 있으면 옵티마이저가 인덱스를 무시합니다. 이 패턴들을 알면 느린 쿼리의 절반 이상을 설명할 수 있습니다.

인덱스를 무력화하는 SQL 안티패턴 — 쓰면 안 되는 것들. 함수 래핑(WHERE YEAR(created_at)=2024)은 컬럼을 함수로 감싸 인덱스 스캔 불가 → BETWEEN 범위 조건으로. 전방 와일드카드(LIKE '%검색어%')는 인덱스를 앞에서부터 정렬 불가 → 후방 LIKE나 Full-Text Index. 컬럼 연산(WHERE age+1>30)은 인덱스가 원래 값으로만 저장돼 무력 → WHERE age>29. 부정 조건(!=)은 범위 스캔 불가 → IN으로 명시적 열거. 타입 불일치(id='100', id는 INT)는 암묵 형변환으로 인덱스 무력 → 타입 일치. SELECT *도 별도 주의(불필요 컬럼 전송·커버링 인덱스 무효)확대

패턴 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 조건이 인덱스를 사용할 수 있습니다.

3함수 제거 후 범위 조건으로 인덱스 복원

YEAR() 함수 사용 쿼리와 범위 조건 쿼리를 EXPLAIN으로 비교합니다. 인덱스 사용 여부 차이를 직접 확인합니다.

SQL
-- 인덱스 무력화 (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';
SQL
-- 표현식 인덱스로 함수 그대로 사용
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은 tsvectorto_tsquery로 전문 검색을 지원하며, 대용량 검색에는 Elasticsearch 도입을 검토합니다.

SQL
SELECT * FROM products WHERE name LIKE '삼성%';

SELECT * FROM products
WHERE to_tsvector('korean', name) @@ to_tsquery('korean', '노트북');

패턴 3: 암묵적 형변환

컬럼 타입과 비교 값의 타입이 다르면 DB가 자동으로 형변환을 시도합니다. 이 과정에서 인덱스가 걸린 컬럼에 변환 함수가 내부적으로 적용되어 인덱스가 무력화됩니다.

VARCHAR 컬럼을 숫자 리터럴과 비교하거나, INTEGER 컬럼을 문자열 리터럴과 비교할 때 발생합니다. 항상 컬럼 타입과 동일한 타입의 리터럴을 사용합니다.

SQL
SELECT * FROM users WHERE phone_number = '01012345678';

SELECT * FROM users WHERE user_id = 42;

패턴 4: OR 조건의 인덱스 문제

각 컬럼에 인덱스가 있더라도 OR 조건은 플래너가 두 인덱스를 결합하기 어려워 Seq Scan을 선택할 수 있습니다. UNION ALL로 분리하면 각 브랜치에서 독립적으로 인덱스를 활용합니다. UNION ALL은 UNION과 달리 중복 제거를 하지 않으므로 중복이 없는 경우 더 빠릅니다.

SQL
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 매핑 오류가 생길 위험도 있습니다.

SQL
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으로 처리됩니다. 단, 임의 페이지로 바로 이동(예: 페이지 번호 클릭)은 불가능합니다.

SQL
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+1ORM 코드에서 루프 + 단건 조회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 한 문장은 파싱 → 계획 수립(옵티마이저) → 실행을 지나고, 애플리케이션은 이 과정을 여러 번 반복합니다. 각 안티패턴은 이 중 특정 단계에 비용을 심습니다.

TEXT
하나의 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은 ④ 반복에서(왕복 횟수) 비용을 만듭니다. 그래서 진단 도구도 단계별로 갈립니다 — ②는 EXPLAINtype=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이 복제본으로 몰려 지연이 치솟았습니다.

진단: 삭제가 도는 동안 상태를 관찰합니다.

SQL
-- 오래 도는 삭제와, 그로 인해 대기 중인 쿼리 확인
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 그래프의 급등을 함께 확인합니다.

해결: 배치로 쪼개고, 반복 삭제는 파티셔닝으로 바꿉니다.

SQL
-- 배치 삭제: 한 번에 수천 행씩, 각 반복 사이에 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'

관련 모듈로 더 깊이:

다음 모듈에서는 GROUP BY와 집계함수를 인덱스와 함께 효율적으로 활용하는 방법을 다룹니다.

지식 확인

퀴즈 — 8문제

Q1

N+1 문제가 발생하는 전형적인 상황은?

Q2

WHERE UPPER(name) = 'HONG'이 느린 이유는?

Q3

SELECT * 대신 필요한 컬럼만 지정하면 얻을 수 있는 이점은?

Q4

LIKE '%검색어'처럼 앞에 %가 있을 때 인덱스 사용이 어려운 이유는?

Q5

OFFSET 기반 페이지네이션의 단점은?

Q6

phone 컬럼이 VARCHAR이고 인덱스가 있는데, WHERE phone = 01012345678 처럼 숫자로 비교했더니 인덱스를 안 타고 느려졌다. 원인은?

Q7

[심화] 오래된 로그 2천만 행을 DELETE FROM logs WHERE created_at < ... 한 문장으로 지우는 것이 안티패턴인 이유로 가장 정확한 것은?

Q8

[심화] 대량 로그 삭제가 동시 쓰기 지연·복제 지연·테이블 팽창을 일으킨다. 재발을 막는 가장 적절한 방법은?

0 / 8 답변

🧪 실습으로 확인하기

PostgreSQL 설치 및 기본 설정

초급

Linux 서버에 PostgreSQL을 설치하고, 데이터베이스와 사용자를 생성한 뒤 외부 접속이 가능하도록 설정한다.

50📋 5단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요