
데이터베이스의 성능이 느려졌다면? 그 원인은 대부분 비효율적인 SQL 쿼리 때문일 가능성이 높다. SQL 쿼리를 최적화하는 방법을 알면 서비스의 속도를 대폭 향상시킬 수 있다. 이번 글에서는 SQL 쿼리 튜닝의 개념부터 최적화 방법, 인덱스 활용까지 적어보려고 한다.
쿼리 튜닝(Query Tuning)은 SQL 쿼리를 최적화하여 DB 성능을 개선하는 과정이다. 느린 쿼리는 DB 부하를 증가시키고, 서비스 응답 속도를 저하시킨다.
📌 쿼리 튜닝이 필요한 이유
쿼리 최적화는 다양한 방법으로 이루어진다. 아래에서 주요 기법들을 살펴보자.
❌ 비효율적인 쿼리
SELECT * From users;
✅ 최적화된 쿼리
SELECT id, name, email FROM users;
가장 기본적이고 모두가 실현 중인 방법이다. 항상 필요한 컬럼만 조회하는 습관을 들이도록 하자.
필요한 데이터만 골라 가져오자!
WHERE 조건을 사용할 때 인덱스를 올바르게 활용하면 검색 성능을 극대화할 수 있다.
❌ 비효율적인 쿼리(인덱스 미사용)
SELECT * FROM users WHERE LOWER(name) = 'john';
✅ 최적화된 쿼리(인덱스 활용)
SELECT * FROM users WHERE name = 'John';
LOWER(name) 같이 가공된 함수는 인덱스를 무력화시킨다. 인덱스를 활용하려면 가공되지 않은 컬럼을 그대로 비교하는 것이 좋다.
함수 적용을 최소화해야 인덱스를 제대로 활용할 수 있다!
JOIN을 사용할 때 불필요한 데이터를 조회하지 않도록 주의해야 한다.
❌ 비효율적인 쿼리
SELECT u.name, o.order_id FROM users u LEFT JOIN orders o ON u.id = o.user_id;
✅ 최적화된 쿼리
SELECT u.name, o.order_id FROM users u INNER JOIN orders o ON u.id = o.user_id;
LEFT JOIN은 불필요한 NULL 데이터를 포함할 수 있다. 결측치가 나오지 않고 필요한 데이터만 조인할 수 있도록 상황에 따라 INNER JOIN을 사용하자.
대량의 데이터를 페이징할 때 LIMIT과 OFFSET을 사용할 수 있지만, 큰 OFFSET값은 성능 저하를 유발할 수있다.
❌ 비효율적인 쿼리
SELECT * FROM orders ORDER BY created_at DESC LIMIT 10 OFFSET 10000;
✅ 최적화된 쿼리
SELECT * FROM orders WHERE id > (SELECT id FROM orders ORDER BY created_at DESC LIMIT 1 OFFSET 10000) ORDER BY created_at DESC LIMIT 10;
OFFSET이 클수록 불필요한 데이터를 데이터를 많이 조회하게 되므로 WHERE 조건을 추가해 성능을 개선 할 수 있다.
대량 데이터 페이징에서는 커서(Cursor) 기반 페이징도 고려해보자.
SQL의 WHERE 절에서 좌변(컬럼)에 연산을 적용하면 인덱스를 사용할 수 없게 되어 검색 속도가 급격히 저하된다.
❌ 비효율적인 쿼리
SELECT * FROM users WHERE YEAR(created_at) = 2023;
✅ 최적화된 쿼리
SELECT * FROM users WHERE created_at >= '2023-01-01' AND created_at < '2024-01-01';
기존의 쿼리문은 사용자 입장에서 직관적일 수 있겠지만 DB 관점에서는 효율성에 문제가 생길 수 있다.
데이터 원본을 번형하여, 내가 찾고자 하는 범위와 비교하는 연산은 DB가 인덱스를 제대로 활용할 수 없게 만든다. 예를 들어, 책의 페이지 번호를 찾는데 하나하나 함수 연산하여 계산하며 찾는 것과 같다.
인덱스는 원본 데이터를 그대로 가지고 만들어진다. 따라서, 기존의 쿼리문과 같이 연산을 수행하면 한줄 한줄 일일이 YEAR(created_at)연산을 수행하고, 그 결과가 2023인지 확인하게 된다. 데이터가 많을수로 엄청난 작업량이 된다.
이렇게 되면 인덱스를 통해 빠른 검색을 할 수 없고 모든 데이터를 처음부터 끝까지 훑어보게 되므로 위와 같이 우변에서의 데이터 필터링을 해야한다.
date 컬럼을 그대로 사용하여 별도의 연산을 수행하지 않고, 날짜 값 자체를 직접 비교하고 있다.
인덱스에 저장된 날짜 값과 쿼리에서 지정한 기간을 비교하며, 필요한 데이터를 빠르게 찾을 수 있다.
원본 데이터를 직접 비교하는 조건을 사용하는 것이 인덱스를 최대한 활용하고 쿼리 성능을 높이는 방법!
SQL의 OR 연산자를 사용하면 인덱스가 제대로 활용되지 않아 성능이 저하될 수 있다. UNION을 사용하여 개별 쿼리를 최적화하면 성능이 향상 될 수 있다.
❌ 비효율적인 쿼리
SELECT * FROM users WHERE status = 'active' OR age > 30;
✅ 최적화된 쿼리
SELECT * FROM users WHERE status = 'active'
UNION
SELECT * FROM users WHERE age > 30;
기존의 쿼리는 우리가 보기에 간단하고 직관적이지만 성능 면에서는 최선의 선택이 아니다.
OR 연산자를 사용하면 DB는 한 번의 스캔으로 모든 조건을 확인해야 한다. 이 과정에서 불필요한 데이터까지 대량으로 검색하게 되고 인덱스를 제대로 활용하지 못하는 경우가 많이 생긴다.
인덱스는 단일 값에 대한 빠른 검색을 위해 최적화 되어 있는데, OR는 여러 값을 동시에 찾아야 해서 적절하지 않다. 결국 인덱스의 장점을 살리지 못하고, 전체 데이터를 모두 뒤져야 하는 상황이 생긴다.
이런 경우 위의 쿼리처럼 UNION을 활용할 수 있다. UNION은 각 조건에 대한 쿼리를 별도로 실행 한 뒤, 그 결과를 합쳐주는 연산자이다.
status = 'active'와age > 30는 각각의 인덱스를 통해 빠르게 처리될 수 있다. 그 후 UNION이 두 결과를 합치는 방식이다.
중복된 결과는 자동으로 제거되면서 불필요한 값은 찾지 않게 된다!
분석 함수(윈도우 함수, Analytic Functions)는 SQL 쿼리의 성능을 한 단계 높이는 도구이다. 단순히 데이터를 처리하는 것을 넘어, 데이터 분석과 쿼리 최적화에 있어 핵심적인 역할을 한다.Rank(),ROW_NUMER(),LEAD(),LAG()이 대표적인 분석 함수이다.
❌ 비효율적인 쿼리
SELECT id, name, salary,
(SELECT COUNT(*) FROM employees e2 WHERE e2.salary >= e1.salary AND e2.department = e1.department) AS rank
FROM employees e1;
✅ 최적화된 쿼리
SELECT id, name, salary, RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rank
FROM employees;
🔔
PARTITION BY:GROUP BY와 같이 집계된 값을 반활할 때 사용하지만, 원래 행에 있었던 값을 볼 수 없는GROUP BY와 달디 기존 행의 값들도 함께 볼 수 있다
기존의 쿼리처럼 서브쿼리를 사용하여 랭킹을 계산하면 각 행마다 서브쿼리를 실행해야 하므로 성능이 저하된다. 반면 RANK() 등의 분석 함수를 활용하면 그룹 내 순위를 매기면서도 서브쿼리를 줄여 성능을 높일 수 있다.
분석 함수을 활용하면 불필요한 서브쿼리를 줄이고 성능을 향상시킬 수 있다!
앞에 %가 있으면 전체 검색이 되어 인덱스를 사용할 수 없다. 반면, %가 뒤에 있으면 인덱스를 활용할 수 있다.
❌ 비효율적인 쿼리
SELECT * FROM users WHERE name LIKE '%준일';
✅ 최적화된 쿼리
SELECT * FROM users WHERE name LIKE '준일%';
기존의 쿼리는 '준일'로 끝나는 모든 이름을 찾으려 한다. 문제는 와일드카드가 앞에 있으면, DB가 '준일'로 끝나는 모든 가능한 문자열 조합을 일일이 검색해야 한다는 것이다.
결과적으로 DB는 엄청난 자원을 소모하고, 쿼리 속도도 느려질 수 밖에 없다.
위의 쿼리처럼 문자열 뒤에 와일트카드를 쓰는 경우는, '준일'로 시작하는 모든 이름을 찾고 있다. 이렇게 하면 DB가 인덱스를 활용해서 검색 범위를 좁힐 수 있다.
DB는 우선 인덱스에서 '준일'로 시작하는 첫 번째 항복을 찾아낸다. 그리고 '준일'로 시작하지 않는 첫 번째 항목이 나올 때까지만 검색하면 된다.
LIKE연산자와%를 사용할 때는 가급적 문자열 끝에 두자!
DB에서 복잡한 계산을 실시간으로 처리하는 것은 쿼리 성능에 큰 부담이 될 수 있다(대량의 데이터라면 더더욱)
이런 상황에서는 자주 사용되는 계산값을 미리 저장해두었다가, 필요할 때 바로 꺼내 쓰는 것이 효과적인 최적화 방법이 될 수 있다.
❌ 비효율적인 쿼리
SELECT order_id, price * quantity AS total_price FROM orders;
✅ 최적화된 쿼리
ALTER TABLE orders ADD COLUMN total_price DECIMAL(10,2);
UPDATE orders SET total_price = price * quantity;
SELECT order_id, total_price FROM orders;
기존의 쿼리는 실행될 때마다 계산을 반복해서 수행해야 되니 자원 낭비가 발생한다. 따라서 위의 최적화된 쿼리와 같이 미리 계산 결과를 별도의 테이블에 저장 후 필요할 때만 쓰는 방법이 좋다. 복잡한 실시간 계산 대신 미리 저장된 값을 사용하니 쿼리 속도가 훨씬 빨라진다.
위의 예시는 단순한 식이라 체감이 잘 안된다면 아래의 예시를 보자
SELECT
p.product_id,
AVG(od.quantity * od.unit_price) AS avg_order_amount,
SUM(od.quantity * od.unit_price) AS total_sales,
COUNT(DISTINCT o.customer_id) AS num_purchasers,
COUNT(DISTINCT CASE WHEN o.customer_id IN (
SELECT customer_id
FROM orders
WHERE product_id = p.product_id
GROUP BY customer_id
HAVING COUNT(*) > 1
) THEN o.customer_id END) * 1.0 / COUNT(DISTINCT o.customer_id) AS repurchase_rate
FROM
products p
JOIN order_details od ON p.product_id = od.product_id
JOIN orders o ON od.order_id = o.order_id
GROUP BY
이 쿼리는 products, order_details, orders 테이블을 조인하여 각 상품(product_id)별로 평균 구매 금액(avg_order_amount), 총 매출(total_sales), 구매자 수(num_purchasers), 재구매율(repurchase_rate)을 계산하고 있다. 이런 경우에는 이 쿼리가 수행될 때마다 방대한 양의 주문 및 고객 데이터를 모두 읽어서 복잡한 계산을 수행해야 한다. 특히 재구매율 계산을 위해 서브쿼리까지 사용되고 있어 쿼리 속도는 더 느려질 것이다.
이런 문제를 해결하기 위해 우리는 계산 결과를 별도의 테이블에 저장해둘 수 있다.
CREATE TABLE product_stats AS
SELECT
p.product_id,
AVG(od.quantity * od.unit_price) AS avg_order_amount,
SUM(od.quantity * od.unit_price) AS total_sales,
COUNT(DISTINCT o.customer_id) AS num_purchasers,
COUNT(DISTINCT CASE WHEN o.customer_id IN (
SELECT customer_id
FROM orders
WHERE product_id = p.product_id
GROUP BY customer_id
HAVING COUNT(*) > 1
) THEN o.customer_id END) * 1.0 / COUNT(DISTINCT o.customer_id) AS repurchase_rate
FROM
products p
JOIN order_details od ON p.product_id = od.product_id
JOIN orders o ON od.order_id = o.order_id
GROUP BY
p.product_id;
이 쿼리는 먼저 product_stats라는 새 테이블을 만들고, 앞서 본 복잡한 계산을 수행하여 각 상품의 통계치를 미리 저장하고 있다.
이렇게 해두면 나중에 이런 통계치가 필요할 때, 이 테이블에서 바로 값을 가져올 수 있어 속도가 향상된다.
물론 아래와 같이 주문 데이터가 추가될 때마다 통계치를 업데이트해줘야 한다. 일정 주기(ex: 하루 한번)마다 통계치를 업데이트하는 배치 작업을 수행하면 된다.
UPDATE product_stats ps
SET
avg_order_amount = (
SELECT AVG(od.quantity * od.unit_price)
FROM order_details od
WHERE od.product_id = ps.product_id
),
total_sales = (
SELECT SUM(od.quantity * od.unit_price)
FROM order_details od
WHERE od.product_id = ps.product_id
),
num_purchasers = (
SELECT COUNT(DISTINCT o.customer_id)
FROM order_details od
JOIN orders o ON od.order_id = o.order_id
WHERE od.product_id = ps.product_id
),
repurchase_rate = (
SELECT
COUNT(DISTINCT CASE WHEN o.customer_id IN (
SELECT customer_id
FROM orders
WHERE product_id = ps.product_id
GROUP BY customer_id
HAVING COUNT(*) > 1
) THEN o.customer_id END) * 1.0 / COUNT(DISTINCT o.customer_id)
FROM order_details od
JOIN orders o ON od.order_id = o.order_id
WHERE od.product_id = ps.product_id
);
OFFSET을 쓸 땐 WHERE 조건을 추가하자OR 대신 UNION을 사용할 것%는 뒤에 작성할 것