-
기본 데이터 처리 - GROUP BY기술 파보기/MySQL 2026. 5. 8. 10:50
GROUP BY, 다 같은 집계가 아닙니다.
SQL을 작성할 때, GROUP BY를 아무 생각 없이 붙이기 쉽습니다.
하지만 MySQL 내부에서는 같은 GROUP BY라도 처리하는 방식이 완전히 달라집니다.
GROUP BY의 처리 방식은 옵티마이저가 실행 계획을 수립하는 시점에 결정됩니다.
그리고 옵티마이저는 상황에 따라 전혀 다른 방식으로 집계를 처리합니다.
똑같이 GROUP BY를 작성했더라도, 인덱스를 활용해 집계를 효율적으로 처리하기도 하고, 별도의 임시 테이블을 생성해 처리하기도 합니다. 이 차이가 쿼리 성능에 큰 영향을 미칩니다.
이 글에서는 MySQL이 GROUP BY를 처리하는 방식에 대해 다뤄보겠습니다. 주요 내용은 아래와 같습니다.
- 인덱스를 차례대로 읽는 타이트 인덱스 스캔 (Tight Index Scan)
- 인덱스를 건너뛰며 읽는 루스 인덱스 스캔 (Loose Index Scan)
- 인덱스를 사용하지 못할 때의 임시 테이블 (Temporary Table) 방식
- 실제로 어떤 방식이 선택됐는지 실행 계획으로 확인하는 방법
예시 테이블 , 데이터 , 인덱스(수정 필요)
[ERD]

[인덱스 구성]
customers └─ idx_region_age (region, age) ← 타이트 인덱스 스캔 실습용 loans └─ idx_type_status (loan_type, status) ← 타이트 인덱스 스캔 실습용 └─ idx_branch_type_status (branch_id, loan_type, status) ← 루스 인덱스 스캔 실습용 repayments └─ idx_loan_date (loan_id, paid_date) ← 타이트 인덱스 스캔 실습용[CREATE 쿼리]
-- 고객 테이블 CREATE TABLE customers ( customer_id INT NOT NULL AUTO_INCREMENT, name VARCHAR(100) NOT NULL, region VARCHAR(50) NOT NULL, age INT NOT NULL, credit_score INT NOT NULL, join_date DATE NOT NULL, PRIMARY KEY (customer_id), INDEX idx_region_age (region, age) ); -- 대출 테이블 CREATE TABLE loans ( loan_id INT NOT NULL AUTO_INCREMENT, customer_id INT NOT NULL, loan_type VARCHAR(30) NOT NULL, -- 'mortgage','car','personal','business' status VARCHAR(20) NOT NULL, -- 'active','closed','overdue' branch_id INT NOT NULL, loan_amount DECIMAL(15,2) NOT NULL, interest_rate DECIMAL(5,2) NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, PRIMARY KEY (loan_id), INDEX idx_type_status (loan_type, status), INDEX idx_branch_type_status (branch_id, loan_type, status), INDEX idx_customer (customer_id) ); -- 상환 내역 테이블 CREATE TABLE repayments ( repayment_id INT NOT NULL AUTO_INCREMENT, loan_id INT NOT NULL, paid_date DATE NOT NULL, amount DECIMAL(15,2) NOT NULL, payment_method VARCHAR(30) NOT NULL, -- 'transfer','card','cash' PRIMARY KEY (repayment_id), INDEX idx_loan_date (loan_id, paid_date) );[샘플 데이터 삽입]
INSERT INTO customers (name, region, age, credit_score, join_date) VALUES ('김민준', '서울', 35, 820, '2019-03-15'), ('이서연', '경기', 42, 750, '2018-07-22'), ('박도윤', '부산', 28, 690, '2021-01-10'), ('최지우', '서울', 55, 880, '2015-11-05'), ('정하은', '대구', 31, 710, '2020-06-18'), ('강지호', '경기', 47, 800, '2017-09-30'), ('윤서준', '인천', 39, 760, '2019-12-01'), ('임나윤', '서울', 26, 650, '2022-04-25'); INSERT INTO loans (customer_id, loan_type, status, branch_id, loan_amount, interest_rate, start_date, end_date) VALUES (1, 'mortgage', 'active', 1, 250000000, 3.50, '2021-03-01', '2041-03-01'), (1, 'car', 'closed', 1, 15000000, 5.20, '2019-05-01', '2024-05-01'), (2, 'personal', 'active', 2, 5000000, 7.80, '2023-01-15', '2025-01-15'), (2, 'mortgage', 'active', 2, 180000000, 3.20, '2020-08-01', '2040-08-01'), (3, 'car', 'overdue', 1, 20000000, 6.00, '2022-06-01', '2027-06-01'), (4, 'business', 'active', 3, 100000000, 4.50, '2021-11-01', '2031-11-01'), (4, 'personal', 'closed', 3, 8000000, 8.50, '2018-03-01', '2021-03-01'), (5, 'mortgage', 'active', 2, 210000000, 3.80, '2022-02-01', '2042-02-01'), (6, 'car', 'active', 1, 25000000, 5.50, '2023-07-01', '2028-07-01'), (7, 'personal', 'overdue', 3, 3000000, 9.00, '2023-03-01', '2025-03-01'), (8, 'business', 'active', 2, 50000000, 5.00, '2024-01-01', '2029-01-01'); INSERT INTO repayments (loan_id, paid_date, amount, payment_method) VALUES (1, '2024-01-05', 800000, 'transfer'), (1, '2024-02-05', 800000, 'transfer'), (1, '2024-03-05', 800000, 'transfer'), (2, '2024-01-10', 300000, 'card'), (3, '2024-02-20', 150000, 'transfer'), (4, '2024-01-08', 600000, 'transfer'), (4, '2024-02-08', 600000, 'transfer'), (5, '2024-01-15', 400000, 'cash'), (6, '2024-03-01', 1200000, 'transfer'), (8, '2024-01-20', 500000, 'card'), (9, '2024-02-25', 450000, 'transfer'), (11, '2024-03-10', 700000, 'transfer');타이트 인덱스 스캔
[개념]
타이트 인덱스 스캔은 GROUP BY 를 처리할 때 인덱스를 처음부터 끝까지 순서대로 읽는 방식입니다.
이름 그대로 인덱스를 빠짐없이(tight 하게) 읽습니다.
별도의 정렬 작업이나 임시 테이블 없이, 인덱스가 이미 정렬된 상태로 저장되어 있다는 점을 그대로 활용 합니다.
인덱스를 순서대로 읽으면서 값이 바뀌는 경계 지점마다 집계를 수행하면, GROUP BY 결과를 자연스럽게 만들어 낼 수 있기 때문입니다.
[동작방식]
idx_type_status 인덱스 (loan_type, status) 저장 구조 ┌──────────────┬──────────┬─────────┐ │ loan_type │ status │ loan_id │ ├──────────────┼──────────┼─────────┤ │ business │ active │ 6 │ │ business │ active │ 11 │ │ car │ active │ 9 │ ← (car, active) 그룹 시작 │ car │ closed │ 2 │ ← 값 바뀜 → 이전 그룹 집계 │ car │ overdue │ 5 │ ← 값 바뀜 → 이전 그룹 집계 │ mortgage │ active │ 1 │ │ mortgage │ active │ 4 │ │ mortgage │ active │ 8 │ │ personal │ active │ 3 │ │ personal │ closed │ 7 │ │ personal │ overdue │ 10 │ └──────────────┴──────────┴─────────┘ → 인덱스를 위에서 아래로 순서대로 읽으면서 (loan_type, status) 조합이 바뀌는 지점마다 집계 → 별도 정렬 없음, 임시 테이블 없음[예시쿼리]
Case 1. GROUP BY 컬럼이 인덱스와 완전히 일치하는 경우
-- idx_type_status (loan_type, status) 인덱스 활용 SELECT loan_type, status, COUNT(*) AS loan_count, AVG(loan_amount) AS avg_amount FROM loans GROUP BY loan_type, status;Case 2. WHERE 로 선행 컬럼을 고정하고 나머지로 GROUP BY 하는 경우
-- idx_branch_type_status (branch_id, loan_type, status) 인덱스 활용 SELECT loan_type, status, COUNT(*) AS loan_count, SUM(loan_amount) AS total_amount FROM loans WHERE branch_id = 1 GROUP BY loan_type, status;Case 3. 인덱스의 앞부분 컬럼만으로 GROUP BY 하는 경우
-- idx_type_status (loan_type, status) 인덱스 활용 SELECT loan_type, COUNT(*) AS loan_count, MAX(loan_amount) AS max_amount FROM loans GROUP BY loan_type;[실행 계획]
EXPLAIN SELECT loan_type, status, COUNT(*), AVG(loan_amount) FROM loans GROUP BY loan_type, status;
type → index 인덱스 전체를 순서대로 스캔한다는 의미입니다. 테이블을 직접 읽지 않습니다. key → idx_type_status 실제로 사용된 인덱스입니다. Extra → NULL Using temporary 없음 Using filesort 없음
루스 인덱스 스캔
[개념]
루스 인덱스 스캔은 인덱스를 처음부터 끝까지 전부 읽지 않고, 각 그룹에서 필요한 레코드만 골라 듬성듬성 읽는 방식 입니다.
타이트 인덱스 스캔과의 차이를 한 문장으로 표현하면 다음과 같습니다.
타이트 인덱스 스캔 → 인덱스를 처음부터 끝까지 전부 읽는다 루스 인덱스 스캔 → 각 그룹의 첫 번째 또는 마지막 레코드만 읽고 나머지는 건너뛴다루스 인덱스 스캔이 레코드를 건너뛸 수 있는 이유는, 인덱스가 이미 정렬된 상태로 저장되어 있기 때문입니다.
예를들면 , MIN() / MAX() 를 구할 때, 어차피 그룹 내 첫 번째 레코드가 MIN, 마지막 레코드가 MAX라는 것이 인덱스 정렬로 이미 보장됩니다. 중간 레코드를 전부 읽을 필요가 없습니다.
단, 이것이 가능하려면 MIN/MAX 대상 컬럼이 인덱스에 포함되어 있어야 합니다.
인덱스에 없는 컬럼의 MIN/MAX는 결국 테이블을 전부 읽어야 하므로 루스 인덱스 스캔의 이점을 살릴 수 없습니다.
[동작방식]
아래 쿼리를 기준으로 동작 방식을 설명하겠습니다.
EXPLAIN SELECT branch_id, loan_type, MIN(interest_rate) AS min_rate, MAX(interest_rate) AS max_rate FROM loans GROUP BY branch_id, loan_type;idx_branch_type_rate 인덱스에서 interest_rate 가 인덱스에 포함되어 있고, 이미 오름차순으로 정렬되어 있으므로 각 그룹에서 첫번째 레코드 = MIN , 마지막 레코드 = MAX 가 보장됩니다. 중간 레코드를 전부 읽을 필요가 없습니다.
STEP 1. 인덱스의 첫 번째 레코드로 점프 후 그룹별 순회 ┌───────────┬──────────────┬───────────────┬─────────┐ │ branch_id │ loan_type │ interest_rate │ loan_id │ ├───────────┼──────────────┼───────────────┼─────────┤ │ 1 │ car │ 5.20 │ 2 │ ← ✅ 읽음 (branch=1, car 그룹의 MIN) │ 1 │ car │ 5.30 │ 14 │ ← ⏭ SKIP │ 1 │ car │ 5.50 │ 9 │ ← ⏭ SKIP │ 1 │ car │ 5.80 │ 13 │ ← ⏭ SKIP │ 1 │ car │ 6.00 │ 5 │ ← ⏭ SKIP │ 1 │ car │ 6.10 │ 15 │ ← ✅ 읽음 (branch=1, car 그룹의 MAX) │ 1 │ mortgage │ 3.10 │ 16 │ ← ✅ 읽음 (branch=1, mortgage 그룹의 MIN) │ 1 │ mortgage │ 3.50 │ 1 │ ← ⏭ SKIP │ 1 │ mortgage │ 3.90 │ 17 │ ← ✅ 읽음 (branch=1, mortgage 그룹의 MAX) │ 2 │ business │ 5.00 │ 11 │ ← ✅ 읽음 (branch=2, business 그룹의 MIN) │ 2 │ business │ 5.00 │ 11 │ ← ✅ 읽음 (branch=2, business 그룹의 MAX) │ 2 │ mortgage │ 3.20 │ 4 │ ← ✅ 읽음 (branch=2, mortgage 그룹의 MIN) │ 2 │ mortgage │ 3.80 │ 8 │ ← ⏭ SKIP │ 2 │ mortgage │ 7.80 │ 3 │ ← ✅ 읽음 (branch=2, mortgage 그룹의 MAX) │ 2 │ personal │ 7.80 │ 3 │ ← ✅ 읽음 (branch=2, personal 그룹의 MIN/MAX) │ 3 │ business │ 4.50 │ 6 │ ← ✅ 읽음 (branch=3, business 그룹의 MIN/MAX) │ 3 │ personal │ 8.50 │ 7 │ ← ⏭ SKIP │ 3 │ personal │ 9.00 │ 10 │ ← ✅ 읽음 (branch=3, personal 그룹의 MAX) └───────────┴──────────────┴───────────────┴─────────┘ → 전체 16건 중 실제로 읽은 레코드는 일부에 불과 → (branch_id, loan_type) 그룹이 바뀔 때마다 첫/마지막 레코드로 점프 → 중간 레코드는 건너뜀 STEP 2. 집계 결과 반환 branch_id │ loan_type │ min_rate │ max_rate ──────────┼───────────┼──────────┼───────── 1 │ car │ 5.20 │ 6.10 1 │ mortgage │ 3.10 │ 3.90 2 │ business │ 5.00 │ 5.00 2 │ mortgage │ 3.20 │ 7.80 2 │ personal │ 7.80 │ 7.80 3 │ business │ 4.50 │ 4.50 3 │ personal │ 8.50 │ 9.007 개의 레코드를 건너 뛰었습니다.
interest_rate가 인덱스에 정렬된 상태로 저장되어 있기 때문에 중간 레코드를 볼 필요가 없습니다. 데이터가 많아질수록, 그룹 내 레코드가 많아질수록 건너뛰는 양이 극적으로 늘어납니다.
[예시쿼리]
Case 1. 인덱스 선두 컬럼만으로 GROUP BY + MIN/MAX
-- idx_branch_type_rate (branch_id, loan_type, interest_rate) 인덱스 활용 -- 지점별 최저/최고 금리 조회 (loan_type 무관) SELECT branch_id, MIN(interest_rate) AS min_rate, MAX(interest_rate) AS max_rate FROM loans GROUP BY branch_id;Case 2. MIN 만 사용
-- idx_branch_type_rate (branch_id, loan_type, interest_rate) 인덱스 활용 -- 지점별, 대출 종류별 최저 금리만 조회 SELECT branch_id, loan_type, MIN(interest_rate) AS min_rate FROM loans GROUP BY branch_id, loan_type;Case 3. COUNT(DISTINCT) 활용
-- idx_branch_type_status (branch_id, loan_type, status) 인덱스 활용 -- 지점별 대출 종류가 몇 가지인지 조회 SELECT branch_id, COUNT(DISTINCT loan_type) AS type_count FROM loans GROUP BY branch_id;[실행 계획]
루스 인덱스 스캔은 옵티마이저가 비용을 계산해 유리하다고 판단할 때만 선택됩니다. 데이터가 적으면 그냥 인덱스를 처음부터 끝까지 읽는 게 더 저렴하다고 판단해 타이트 인덱스 스캔을 선택합니다. 따라서 실행 계획에서 확인하려면 충분한 양의 데이터가 필요합니다.
Step 1. 대량의 데이터 삽입
-- branch_id 1~3, loan_type car/mortgage/personal/business 조합으로 -- 그룹 수는 적고 그룹 내 데이터는 많은 구조로 삽입 DELIMITER $$ CREATE PROCEDURE insert_loose_test() BEGIN DECLARE i INT DEFAULT 1; DECLARE rate DECIMAL(5,2); WHILE i <= 100000 DO SET rate = ROUND(3.0 + RAND() * 7.0, 2); INSERT INTO loans (customer_id, loan_type, status, branch_id, loan_amount, interest_rate, start_date, end_date) VALUES (1, 'car', 'active', 1, 100000000, rate, '2020-01-01', '2030-01-01'); SET rate = ROUND(3.0 + RAND() * 4.0, 2); INSERT INTO loans (customer_id, loan_type, status, branch_id, loan_amount, interest_rate, start_date, end_date) VALUES (1, 'mortgage', 'active', 1, 200000000, rate, '2020-01-01', '2030-01-01'); SET i = i + 1; END WHILE; END$$ DELIMITER ; CALL insert_loose_test(); ANALYZE TABLE loans;루스 인덱스 스캔이 유리한 상황을 만들기 위해 그룹 종류는 적고, 그룹 내 레코드는 많은 구조로 데이터를 삽입합니다.
그룹 내 레코드가 많을수록 첫/마지막 레코드만 읽고 건너뛰는 루스 인덱스 스캔의 이점이 극대화 됩니다.
Step 2. 실행 계획 확인
EXPLAIN SELECT branch_id, loan_type, MIN(interest_rate) AS min_rate, MAX(interest_rate) AS max_rate FROM loans GROUP BY branch_id, loan_type;
type → range 인덱스의 특정 범위만 접근한다는 의미입니다. key → idx_branch_type_rate 실제로 사용된 인덱스입니다. Extra → Using index for group-by ★ 루스 인덱스 스캔의 핵심 시그니처 이 문구가 보이면 루스 인덱스 스캔이 선택된 것입니다. 그룹별 첫/마지막 레코드만 읽고 나머지는 건너뛰고 있다는 의미입니다. Using temporary 없음 → 임시 테이블 미사용 ✅ Using filesort 없음 → 별도 정렬 미발생 ✅Step 3. 데이터 원복
-- 프로시저 삭제 및 데이터 원복 DROP PROCEDURE IF EXISTS insert_loose_test; TRUNCATE TABLE loans; INSERT INTO loans (customer_id, loan_type, status, branch_id, loan_amount, interest_rate, start_date, end_date) VALUES (1, 'mortgage', 'active', 1, 250000000, 3.50, '2021-03-01', '2041-03-01'), (1, 'car', 'closed', 1, 15000000, 5.20, '2019-05-01', '2024-05-01'), (2, 'personal', 'active', 2, 5000000, 7.80, '2023-01-15', '2025-01-15'), (2, 'mortgage', 'active', 2, 180000000, 3.20, '2020-08-01', '2040-08-01'), (3, 'car', 'overdue', 1, 20000000, 6.00, '2022-06-01', '2027-06-01'), (4, 'business', 'active', 3, 100000000, 4.50, '2021-11-01', '2031-11-01'), (4, 'personal', 'closed', 3, 8000000, 8.50, '2018-03-01', '2021-03-01'), (5, 'mortgage', 'active', 2, 210000000, 3.80, '2022-02-01', '2042-02-01'), (6, 'car', 'active', 1, 25000000, 5.50, '2023-07-01', '2028-07-01'), (7, 'personal', 'overdue', 3, 3000000, 9.00, '2023-03-01', '2025-03-01'), (8, 'business', 'active', 2, 50000000, 5.00, '2024-01-01', '2029-01-01'), (1, 'car', 'active', 1, 18000000, 5.80, '2023-01-01', '2028-01-01'), (2, 'car', 'active', 1, 22000000, 5.30, '2023-06-01', '2028-06-01'), (3, 'car', 'closed', 1, 12000000, 6.10, '2020-01-01', '2023-01-01'), (4, 'mortgage', 'active', 1, 300000000, 3.10, '2022-01-01', '2042-01-01'), (5, 'mortgage', 'closed', 1, 150000000, 3.90, '2015-01-01', '2035-01-01');
임시 테이블 사용
[개념]
앞서 살펴본 타이트 인덱스 스캔과 루스 인덱스 스캔은 모두 정렬된 인덱스를 활용해 GROUP BY 를 처리합니다.
하지만 인덱스를 사용할 수 없는 상황이라면 MySQL 은 임시 테이블을 생성해 GROUP BY 를 처리 합니다.
[동작방식]
임시 테이블 방식은 아래와 같이 동작 합니다.
1. 테이블 전체를 읽는다 (풀 테이블 스캔 or 인덱스 풀 스캔) 2. GROUP BY 컬럼 기준으로 임시 테이블에 데이터를 쌓는다 3. 임시 테이블을 기반으로 집계를 수행한다 4. 결과를 반환한다 (필요시 정렬까지 수행)디스크 또는 메모리에 임시 테이블을 만들고 데이터를 전부 집어 넣은 뒤 집계하므로 , 데이터가 많을 수록 성능에 큰 영향을 미칩니다.
[예시 쿼리]
Case 1. GROUP BY 컬럼 순서가 인덱스와 불일치
-- idx_type_status 는 (loan_type, status) 순서인데 -- GROUP BY 순서를 뒤집어서 인덱스 활용 불가 SELECT status, loan_type, COUNT(*) AS loan_count, SUM(loan_amount) AS total_amount FROM loans GROUP BY status, loan_type;Case 2. GROUP BY 컬럼이 인덱스에 없는 경우
-- credit_score 는 loans 테이블에 없고 -- customers 테이블에 있지만 인덱스 없음 SELECT c.credit_score, COUNT(*) AS loan_count, AVG(l.loan_amount) AS avg_amount FROM loans l JOIN customers c ON l.customer_id = c.customer_id GROUP BY c.credit_score;Case 3. JOIN 이 포함된 GROUP BY
-- 조인이 포함되면 단일 테이블 인덱스로 GROUP BY 처리 불가 -- 임시 테이블 생성 후 집계 SELECT c.region, l.loan_type, COUNT(*) AS loan_count, SUM(l.loan_amount) AS total_amount FROM loans l JOIN customers c ON l.customer_id = c.customer_id GROUP BY c.region, l.loan_type;[실행 계획]
EXPLAIN SELECT status, loan_type, COUNT(*), SUM(loan_amount) FROM loans GROUP BY status, loan_type;실행계획에서 확인해야 할 포인트는 아래와 같습니다.
Extra → Using temporary ★ 임시 테이블 사용의 핵심 시그니처 GROUP BY 처리를 위해 내부적으로 임시 테이블을 생성했다는 의미입니다. 데이터가 많을수록 성능에 직접적인 영향을 미칩니다.
결론
GROUP BY 를 작성할 때 MySQL 이 어떤 방식으로 처리할지는 옵티마이저가 결정합니다.
개발자가 같은 GROUP BY 를 작성하더라도 , 인덱스 구성과 쿼리 형태에 따라 내부 처리 방식은 완전히 달라집니다.
지금까지 살펴본 3가지 방식을 정리하면 아래와 같습니다.
구분 타이트 인덱스 스캔 루스 인덱스 스캔 임시 테이블 인덱스 사용 사용 사용 사용 or 미사용 읽는 방식 인덱스 전체를 순서대로 읽음 그룹 첫/마지막 레코드만 점프 인덱스 또는 테이블 전체를 읽고 임시 테이블에 저장 임시 테이블 미생성 미생성 생성 추가 정렬 없음 없음 발생 가능 Extra 표시 Using index Using index for group-by Using temporary 성능 좋음 매우 좋음 (데이터 많을수록 유리) 나쁨 (데이터 많을수록 불리) '기술 파보기 > MySQL' 카테고리의 다른 글
기본 데이터 처리 - DISTINCT (0) 2026.05.11 기본 데이터 처리 - ORDER BY (0) 2026.05.06 MySQL 실행계획 분석 (4) - ref , rows , filtered (0) 2026.04.29 MySQL 실행계획 분석 (3) - key , key_len , possible_keys (0) 2026.04.27 MySQL 실행계획 분석 (2) - type (0) 2026.04.23 MySQL 실행계획 분석 (1) - id , select_type , table (0) 2026.04.21 MySQL 내부구조 (0) 2026.04.01 격리수준 좀 더 탐구해보기 (0) 2026.03.24