🤖 AI/ML

Query Optimization

쿼리 최적화

데이터베이스 쿼리의 실행 계획을 분석하고 최적화하여 성능을 향상시키는 기법입니다. 인덱스 활용, 조인 순서 조정, 실행 계획 분석을 통해 쿼리 응답 시간을 단축하고 시스템 리소스 사용을 최소화합니다.

📖 상세 설명

Query Optimization(쿼리 최적화)은 SQL 쿼리가 데이터베이스에서 실행될 때 최적의 실행 계획을 찾아 성능을 극대화하는 과정입니다. 데이터베이스 옵티마이저가 자동으로 실행 계획을 결정하지만, 개발자가 쿼리 구조, 인덱스 설계, 통계 정보를 적절히 관리해야 최적의 성능을 얻을 수 있습니다.

쿼리 최적화는 크게 두 가지 접근법으로 나뉩니다: (1) Rule-based Optimization(RBO) - 미리 정의된 규칙에 따라 실행 계획 결정, (2) Cost-based Optimization(CBO) - 테이블 통계와 인덱스 정보를 기반으로 각 실행 계획의 비용을 계산하여 최소 비용 계획 선택. 현대 RDBMS(PostgreSQL, MySQL, Oracle 등)는 대부분 CBO를 사용합니다.

최적화의 핵심 요소는 인덱스 설계, 조인 최적화, 서브쿼리 처리입니다. 적절한 인덱스는 Full Table Scan을 Index Seek으로 변환하여 성능을 극적으로 향상시킵니다. 조인 순서와 방식(Nested Loop, Hash Join, Merge Join)에 따라 성능이 수십 배 차이날 수 있으며, 서브쿼리는 종종 JOIN으로 재작성하여 최적화합니다.

AI/ML 맥락에서 쿼리 최적화는 특히 중요합니다. 대규모 데이터셋에서 학습 데이터를 추출하거나, Feature Store에서 실시간 피처를 조회하거나, 벡터 데이터베이스에서 유사도 검색을 수행할 때 쿼리 성능이 전체 파이프라인 지연에 직접적으로 영향을 미칩니다. 최근에는 AI 기반 쿼리 최적화(Learned Query Optimizer)도 연구되고 있습니다.

💻 코드 예제

PostgreSQL을 기준으로 쿼리 최적화의 핵심 기법들을 보여드립니다.

-- ============================================
-- Query Optimization 실전 예제 (PostgreSQL)
-- ============================================

-- 1. EXPLAIN ANALYZE로 실행 계획 분석
-- -----------------------------------------
-- 최적화 전: Full Table Scan
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'test@example.com';
-- 결과 예시: Seq Scan on users  (cost=0.00..25461.00 rows=1 width=123)
--           actual time=142.532..142.532 rows=1 loops=1

-- 인덱스 생성
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);

-- 최적화 후: Index Scan
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'test@example.com';
-- 결과 예시: Index Scan using idx_users_email on users  (cost=0.42..8.44 rows=1 width=123)
--           actual time=0.028..0.029 rows=1 loops=1


-- 2. 복합 인덱스 설계
-- -----------------------------------------
-- 자주 함께 사용되는 조건에 복합 인덱스 생성
-- 순서 중요: 선택도(Selectivity)가 높은 컬럼을 앞에

-- WHERE status = 'active' AND created_at > '2024-01-01'
CREATE INDEX idx_orders_status_created
ON orders(status, created_at DESC);

-- Covering Index: 인덱스만으로 쿼리 처리 (Index Only Scan)
CREATE INDEX idx_orders_covering
ON orders(status, created_at)
INCLUDE (total_amount, user_id);

-- 이제 인덱스만 읽고 테이블 접근 불필요
EXPLAIN ANALYZE
SELECT user_id, total_amount
FROM orders
WHERE status = 'completed' AND created_at > '2024-01-01';


-- 3. JOIN 최적화
-- -----------------------------------------
-- 작은 테이블을 먼저 필터링하여 조인

-- 비효율적인 쿼리
SELECT o.*, u.name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.created_at > '2024-01-01'
  AND u.country = 'KR';

-- 최적화: 필터링을 서브쿼리로 먼저 수행
SELECT o.*, u.name
FROM orders o
JOIN (
    SELECT id, name FROM users WHERE country = 'KR'
) u ON o.user_id = u.id
WHERE o.created_at > '2024-01-01';

-- 또는 CTE 활용 (가독성 향상)
WITH korean_users AS (
    SELECT id, name FROM users WHERE country = 'KR'
)
SELECT o.*, ku.name
FROM orders o
JOIN korean_users ku ON o.user_id = ku.id
WHERE o.created_at > '2024-01-01';


-- 4. 서브쿼리를 JOIN으로 변환
-- -----------------------------------------
-- 비효율적: 상관 서브쿼리 (각 행마다 서브쿼리 실행)
SELECT *
FROM products p
WHERE p.price > (
    SELECT AVG(price) FROM products WHERE category_id = p.category_id
);

-- 최적화: JOIN으로 변환
SELECT p.*
FROM products p
JOIN (
    SELECT category_id, AVG(price) as avg_price
    FROM products
    GROUP BY category_id
) cat_avg ON p.category_id = cat_avg.category_id
WHERE p.price > cat_avg.avg_price;


-- 5. 페이지네이션 최적화 (Keyset Pagination)
-- -----------------------------------------
-- 비효율적: OFFSET은 큰 값에서 느림
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 10000;

-- 최적화: Keyset 방식 (커서 기반)
-- 이전 페이지의 마지막 created_at 값을 사용
SELECT * FROM posts
WHERE created_at < '2024-06-15 10:30:00'  -- 마지막 항목의 timestamp
ORDER BY created_at DESC
LIMIT 20;


-- 6. 통계 업데이트
-- -----------------------------------------
-- 대량 데이터 변경 후 통계 갱신 (옵티마이저 정확도 향상)
ANALYZE orders;
ANALYZE users;

-- 특정 컬럼만 분석
ANALYZE orders(status, created_at);


-- 7. 파티셔닝 활용
-- -----------------------------------------
-- 대용량 테이블을 날짜별로 파티셔닝
CREATE TABLE events (
    id BIGSERIAL,
    event_type VARCHAR(50),
    payload JSONB,
    created_at TIMESTAMP NOT NULL
) PARTITION BY RANGE (created_at);

-- 월별 파티션 생성
CREATE TABLE events_2024_01 PARTITION OF events
    FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE events_2024_02 PARTITION OF events
    FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');

-- 특정 기간 조회 시 해당 파티션만 스캔 (Partition Pruning)
SELECT * FROM events
WHERE created_at >= '2024-01-15' AND created_at < '2024-01-20';


-- 8. 벡터 검색 최적화 (pgvector)
-- -----------------------------------------
-- AI/ML에서 자주 사용하는 벡터 유사도 검색
CREATE EXTENSION IF NOT EXISTS vector;

-- 벡터 컬럼에 인덱스 생성 (IVFFlat 방식)
CREATE INDEX idx_embeddings_ivfflat
ON documents USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);  -- 클러스터 수 (데이터 크기에 따라 조정)

-- HNSW 인덱스 (더 빠르지만 메모리 많이 사용)
CREATE INDEX idx_embeddings_hnsw
ON documents USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

-- 유사도 검색
SELECT id, content, embedding <=> '[0.1, 0.2, ...]'::vector AS distance
FROM documents
ORDER BY embedding <=> '[0.1, 0.2, ...]'::vector
LIMIT 10;

🗣️ 실무에서 이렇게 말하세요

미팅에서

"이 API 응답 시간이 느린 원인을 보니 Full Table Scan이 발생하고 있네요. 복합 인덱스 추가하고 EXPLAIN ANALYZE로 검증하면 해결될 것 같습니다."

기술 면접에서

"쿼리 최적화는 EXPLAIN 분석부터 시작합니다. Seq Scan 대신 Index Scan을 유도하고, 조인 순서와 방식을 최적화하며, 필요시 Covering Index로 테이블 접근 자체를 제거합니다."

기술 토론에서

"대량 데이터 처리에서는 Keyset Pagination이 OFFSET보다 효율적이에요. 파티셔닝으로 Partition Pruning을 활용하면 스캔 범위를 더 줄일 수 있고요."

⚠️ 흔한 실수 & 주의사항

❌ "인덱스를 많이 만들수록 좋다"

인덱스는 SELECT 성능을 향상시키지만 INSERT, UPDATE, DELETE 성능은 저하시킵니다. 또한 저장 공간도 차지합니다. 실제 쿼리 패턴을 분석하여 필요한 인덱스만 생성하고, 사용되지 않는 인덱스는 주기적으로 정리해야 합니다.

❌ "SELECT * 로 모든 컬럼을 가져와도 괜찮다"

필요한 컬럼만 명시적으로 SELECT 하면 I/O와 네트워크 전송량을 줄일 수 있습니다. 특히 Covering Index를 활용하면 테이블 접근 없이 인덱스만으로 쿼리를 처리하여 성능을 크게 향상시킬 수 있습니다.

✅ 올바른 접근

항상 EXPLAIN ANALYZE로 실행 계획을 먼저 확인하세요. 예상 비용(cost)과 실제 실행 시간(actual time)을 비교하고, Seq Scan, Nested Loop 등 비효율적인 패턴을 식별합니다. 대량 데이터 변경 후에는 ANALYZE로 통계를 갱신하여 옵티마이저의 판단 정확도를 유지합니다.

🔗 관련 용어

📚 더 배우기