SQL 성능 최적화: 개발자가 알아야 할 핵심 패턴들

오픈소스·2025년 9월 19일

데이터가 기하급수적으로 증가하는 현대의 애플리케이션에서 SQL 성능 최적화는 선택이 아닌 필수입니다. 이 글에서는 실제 개발 현장에서 자주 마주치는 성능 문제들과 그 해결책을 구체적인 예제와 함께 살펴보겠습니다.

1. JOIN vs EXISTS: 언제 무엇을 사용할까?

문제 상황

"2024년 이후 주문한 적이 있는 모든 사용자"를 조회하는 쿼리를 작성한다고 가정해봅시다.

❌ 비효율적인 JOIN 방식

SELECT DISTINCT u.user_id, u.username
FROM users u
JOIN orders o ON u.user_id = o.user_id
WHERE o.created_at >= '2024-01-01';

문제점:

  • DISTINCT가 필요해 중복 제거 과정에서 추가 비용 발생
  • 대용량 orders 테이블과의 JOIN으로 인한 메모리 사용량 증가
  • 한 사용자가 여러 주문을 가진 경우 불필요한 행 생성

✅ 효율적인 EXISTS 방식

SELECT u.user_id, u.username
FROM users u
WHERE EXISTS (
    SELECT 1 
    FROM orders o 
    WHERE o.user_id = u.user_id 
    AND o.created_at >= '2024-01-01'
);

장점:

  • 조건을 만족하는 첫 번째 행을 찾으면 즉시 중단
  • DISTINCT 불필요
  • 메모리 사용량 최소화

성능 비교

  • JOIN + DISTINCT: O(n × m) + 정렬 비용
  • EXISTS: O(n) + 인덱스 탐색 (조건 만족 시 즉시 중단)

2. NULL의 함정: NOT IN 사용 시 주의사항

위험한 코드 패턴

-- 절대 하면 안 되는 쿼리!
SELECT * FROM users u
WHERE u.user_id NOT IN (
    SELECT customer_id FROM orders  -- NULL 값이 포함될 수 있음
);

위험한 이유:
NOT IN 서브쿼리 결과에 NULL이 하나라도 있으면 전체 결과가 빈 집합이 됩니다.

안전한 해결책들

방법 1: NULL 명시적 제외

SELECT * FROM users u
WHERE u.user_id NOT IN (
    SELECT customer_id 
    FROM orders 
    WHERE customer_id IS NOT NULL
);

방법 2: NOT EXISTS 사용 (권장)

SELECT * FROM users u
WHERE NOT EXISTS (
    SELECT 1 
    FROM orders o 
    WHERE o.customer_id = u.user_id
);

왜 NOT EXISTS가 더 안전할까?

  • NULL 값에 영향받지 않음
  • 성능상으로도 일반적으로 더 우수
  • 의도가 명확하게 드러남

3. OR 조건 최적화: 인덱스를 살리는 방법

문제가 되는 OR 조건

-- Full Table Scan 유발 가능
SELECT * FROM products 
WHERE category_id = 1 OR price < 100;

문제점:

  • 복합 조건으로 인해 인덱스 활용이 어려움
  • 옵티마이저가 Full Table Scan을 선택할 가능성

해결책: UNION 활용

SELECT * FROM products WHERE category_id = 1
UNION
SELECT * FROM products WHERE price < 100 AND category_id != 1;

장점:

  • 각 조건별로 최적의 인덱스 활용 가능
  • 중복 제거도 자동으로 처리

복합 인덱스로 근본적 해결

-- 전략적 인덱스 생성
CREATE INDEX idx_products_category_price ON products(category_id, price);

-- 이제 OR 조건도 효율적으로 처리 가능
SELECT * FROM products 
WHERE category_id IN (1, 2, 3) OR price BETWEEN 50 AND 150;

인덱스 설계 팁

  1. 선택도가 높은 컬럼을 앞에 배치
  2. WHERE, ORDER BY, GROUP BY에서 자주 사용되는 컬럼 우선 고려
  3. 카디널리티가 높은 컬럼부터 복합 인덱스 구성

4. 페이징 최적화: OFFSET의 함정과 해결책

문제가 되는 OFFSET 페이징

-- 100만 번째 페이지를 조회한다면?
SELECT * FROM transactions 
ORDER BY created_at DESC 
LIMIT 10 OFFSET 100000;

성능 문제:

  • OFFSET이 클수록 성능 급격히 저하
  • 100만 개 행을 정렬한 후 처음 100만 개를 버리는 비효율성
  • 메모리 사용량 급증

커서 기반 페이징 (Cursor Pagination)

-- 첫 페이지
SELECT * FROM transactions 
ORDER BY created_at DESC 
LIMIT 10;

-- 다음 페이지 (이전 페이지의 마지막 created_at 값 사용)
SELECT * FROM transactions 
WHERE created_at < '2024-01-15 10:30:00'
ORDER BY created_at DESC 
LIMIT 10;

성능 비교 실험 결과

페이지 위치OFFSET 방식커서 방식
1-100 페이지~50ms~5ms
1000-10000 페이지~500ms~5ms
100000+ 페이지~5000ms+~5ms

5. 실전 성능 모니터링 및 디버깅

쿼리 분석 필수 명령어

-- PostgreSQL
EXPLAIN ANALYZE SELECT ...;

-- MySQL
EXPLAIN FORMAT=JSON SELECT ...;

-- SQL Server
SET STATISTICS IO ON;
SELECT ...;

성능 지표 체크리스트

  • Seq Scan vs Index Scan: 인덱스 활용도 확인
  • Rows Examined vs Rows Returned: 효율성 지표
  • Execution Time: 절대적 성능 지표
  • Buffer Pool Hit Ratio: 메모리 활용도

인덱스 효과 측정 방법

-- 인덱스 사용 통계 확인 (PostgreSQL)
SELECT 
    schemaname, tablename, indexname,
    idx_tup_read, idx_tup_fetch,
    idx_tup_read/nullif(idx_tup_fetch,0) as selectivity
FROM pg_stat_user_indexes
ORDER BY idx_tup_read DESC;

6. 마무리: 성능 최적화 체크리스트

쿼리 작성 시 점검사항

  • SELECT * 대신 필요한 컬럼만 선택
  • WHERE 절에 함수 사용 최소화
  • JOIN 대신 EXISTS 고려 (특히 중복 제거가 필요한 경우)
  • NOT IN 대신 NOT EXISTS 사용
  • OR 조건이 많다면 UNION 고려
  • 대용량 페이징에서는 커서 방식 적용

인덱스 설계 원칙

  • WHERE 절 조건 컬럼에 인덱스
  • ORDER BY 절 컬럼 고려
  • 복합 인덱스에서 선택도 높은 컬럼 우선
  • 사용하지 않는 인덱스 정기적 정리

지속적 관리

  • 정기적인 쿼리 성능 모니터링
  • 느린 쿼리 로그 분석
  • 인덱스 사용 통계 확인
  • 테이블 통계 정보 업데이트

SQL 성능 최적화는 단순히 빠른 쿼리를 작성하는 것을 넘어, 확장 가능하고 유지보수하기 쉬운 시스템을 만드는 기초입니다. 작은 최적화 하나하나가 모여 전체 애플리케이션의 성능을 크게 좌우할 수 있으니, 평상시 이런 패턴들을 의식적으로 연습해보시기 바랍니다.

💡 Pro Tip: 성능 최적화는 측정 없이는 의미가 없습니다. 항상 최적화 전후의 성능을 수치로 비교하고, 실제 운영 환경에서의 효과를 검증하세요.


by claude

0개의 댓글