infra
Platform

모듈 맵

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

0 / 37 완료

펼치기
0 / 37 완료0%

Database · 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 문제 — ORM 사용 시 가장 흔한 성능 함정

사용자 목록을 불러오는 API가 로컬에서는 빠른데 운영에서 0.5초가 걸립니다. 로그를 보면 쿼리가 수백 개 찍혀 있습니다. ORM 코드는 단 두 줄인데 실제로는 사용자 수만큼 쿼리가 나가는 N+1 문제입니다. 이 패턴을 모르면 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 예시와 실제로 실행되는 쿼리입니다.

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 * 대신 필요한 컬럼만 조회하는지 확인합니다.
  • 형변환 여부조건 컬럼에 암묵적 형변환이 걸려 인덱스를 못 타지 않는지 봅니다.
  • 반복 조회루프 안에서 같은 쿼리가 반복 실행되지 않는지 점검합니다.

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 앞에 %를 쓰거나, 타입 불일치가 있으면 옵티마이저가 인덱스를 무시합니다. 이 패턴들을 알면 느린 쿼리의 절반 이상을 설명할 수 있습니다.

인덱스를 무력화하는 쿼리 패턴들 — 쓰면 안 되는 것들

패턴 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';
🔍실행 후 확인할 것
  • YEAR() 함수 쿼리 EXPLAIN에서 'Seq Scan'이 나오는지 확인합니다.
  • 범위 조건 쿼리 EXPLAIN에서 'Index Scan' 또는 'Index Range Scan'이 나오는지 확인합니다.
  • 실행 시간(actual time)을 비교해 개선 폭을 측정합니다.

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 리뷰 체크리스트에 이 패턴을 추가했습니다.

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

지식 확인

퀴즈 — 5문제

Q1

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

Q2

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

Q3

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

Q4

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

Q5

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

0 / 5 답변

🧪 실습으로 확인하기

PostgreSQL 설치 및 기본 설정

초급

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

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

이것도 배워보세요

database중급 · 40
[Database] IS NULL, COALESCE, NULLIF 함수의 함정과 안전한 처리 방법
Database 트랙 계속
linux입문 · 30
[Linux] 개발자가 왜 리눅스 서버와 커맨드라인을 반드시 배워야 하는가
Linux 트랙 시작점