인덱싱을 통한 성능 개선

0. 테스트 환경

  • 서버 : Spring Boot + JPA + MySQL

  • 테스트 도구

    • MySQL CLI (쿼리 성능 확인)

  • 테스트 내용

    • ✔️쿼리 실행 시간 비교

    • ✔️더미 데이터로 인덱스 추가 전후 쿼리 성능 테스트

  • 테스트 한 API

    • 예약 가능한 좌석 조회 API

    • 동시 예약 요청 처리 API

  • 테스트 데이터 수 ⇒ (더 많은 데이를 넣을 경우 시간이 너무 오래걸림..)

    • user = 1,005

    • concert = 1,000

    • concert_schedule = 3,990

    • seat = 119,700

    • reservation = 1,000

1. 기본 기능 쿼리에 대한 인덱스

a. 예약 가능한 좌석 조회 (인덱스 생성)

  • 인덱스가 필요한 이유

    • 1️⃣잦은 조회와 데이터 양 증가

      • 예약 가능한 좌석 조회는 가장 자주 호쵤되는 쿼리 중 하나이며, 매번 전체 테이블을 스캔하면 성능 저하가 발생될 수 있다

      • 추가적으로 동시 예약 요청이 많은 콘서트 시스템의 경우, 데이터 양이 많아질수록 응답 시간이 길어지게 된다

    • 2️⃣ WHERE 조건의 다중 컬럼 사용

      • WHERE concert_id = ? AND schedule_date = ? AND status = ?

      • concert_id, schedule_date, status는 예약 가능한 좌석 조회 시 항상 사용하는 필터 조건이고,

        해당 조건들이 인덱스 없이 검색 될 경우 Full Table Scan이 발생한다

    • 3️⃣콘서트 상태 값 처리 시에도 사용

      • 콘서트 SoldOut 처리 시에도 그룹핑하여 사용하기 때문에 다른 쿼리에 비해 효율이 좋다

인덱스 생성

CREATE INDEX idx_seat_concert_schedule_status ON seat (concert_id, schedule_date, status);

  • 인덱스 적용 이유

    • ☑️Cardinality(선택도) 고려

      • concert_id와 schedule_date는 다양한 값을 가지며, 선택도가 높기 때문에 다른 컬럼에 비해인덱스 효율 좋다

        • concert_id ⇒ 많은 값이 존재 (선택도가 높다)

        • schedule_date ⇒ 날짜별로 정렬될 수 있음 (검색 속도가 빠름)

      • 반면 statusAVAILABLE, RESERVED등 값이 적어 선택도가 낮지만, concert_idschedule_date로 대부분의 필터링이 이러우진 이후에 status로 추가 필터링하므로 효율적일 수 있다

      • 따라서 Cardinality가 높은 concert_idschedule_date를 먼저 배치하고, 이후 status를 마지막에 배치

  • 💡잘못된 인덱스 설계 예시

    • CREATE INDEX idx_seat_status_schedule ON seat (status, schedule_date);

    • 비효율적인 이유

      • status는 값이 AVAILABLE, RESERVED 등 몇 개 안 됨 → 선택도가 낮음

      • schedule_date는 많이 사용되지만, 첫 번째 컬럼이 status일경우 인덱스를 제대로 활용 못 함

인덱스 적용 전

EXPLAIN SELECT * FROM seat WHERE concert_id = 351 AND schedule_date = '2025-04-08' AND status = 'AVAILABLE';

EXPLAIN ANALYZE SELECT * FROM seat WHERE concert_id = 351 AND schedule_date = '2025-04-08' AND status = 'AVAILABLE';

  • Table Scan 발생 ⇒ 전체 테이블 탐색하면서 데이터를 찾는다

  • 데이터가 많아질수록 O(n) 시간 소요

  • cost=12014 rows=119100 ⇒ 쿼리가 너무 많은 데이터를 훑어본다 (낮은 효율성)

인덱스 적용 후

EXPLAIN SELECT * FROM seat WHERE concert_id = 351 AND schedule_date = '2025-04-08' AND status = 'AVAILABLE';

EXPLAIN ANALYZE SELECT * FROM seat WHERE concert_id = 351 AND schedule_date = '2025-04-08' AND status = 'AVAILABLE';

인덱스 추가 전후 차이

구분
인덱스 추가 전
인덱스 추가 후

실행 계획

Full Table Scan

Index Scan

쿼리 실행 시간 (actual time)

32.4ms

0.02ms

행 스캔 (rows)

119,280

1


b. (단일) 좌석 조회 (인덱스 생성 안함)

  • 인덱스가 필요한 이유

    • 1️⃣id는 자주 사용하는 기본키 기반 조회 컬럼

      • 예약 가능한 좌석 조회는 가장 자주 호쵤되는 쿼리 중 하나이며, 매번 전체 테이블을 스캔하면 성능 저하가 발생될 수 있음

      • 추가적으로 인기있는 콘서트에서, 인기있는 좌석의 경우에는 seat 테이블에 데이터가 많을 경우 모든 좌석 데이터를 순차적으로 조회(Full Table Scan)해야하기 때문에 성능 저하가 발생

    • 2️⃣ 특정 좌석이 예약되었는지 확인 할 때도 사용(임시 예약, 결제 요청 시)

      • MySQL의 FOR UPDATE를 사용하여, 행 수준 잠금(Row Lock)을 사용 할 경우 인덱스가 없다면 전체 테이블을 스캔하여 조건에 맞는 행을 찾고 잠그기 때문에 Full Table Scan이 발생

❗MySql 에서 기본키는 자동으로 클러스터형 인덱스(Clustered Index)가 생성되므로, 별도의 인덱스를 생성 할 필요가 없다. (혹시나.. id가 기본키가 아니라면..ㅎㅎ.. 생성 필요)

2. 기본 기능 외 지연 발생할 수 있는 쿼리

a. 사용자가 최근 30일간 결제한 모든 내역 조회

  • 인덱스가 필요한 이유

    • 1️⃣잦은 조회와 데이터 양 증가

      • 사용자별 결제 내역은 자주 조회되는 쿼리 중 하나이며, 예약 테이블(reservation)이 커질수록 성능 저하 발생 가능

      • 최근 30일 데이터를 paid_at을 기준으로 필터링하는데, 인덱스가 없으면 Full Table Scan이 발생할 수 있다

    • 2️⃣ 사용자 기준 데이터 조회 최적화

      • WHERE user_id = ? AND paid_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)

      • user_id와 paid_at을 조합한 필터링이므로, 복합 인덱스가 없으면 Full Scan 발생 가능

    • 3️⃣정렬 및 필터링 최적화

      • 결제 내역을 paid_at 기준으로 정렬할 가능성이 높으므로, 해당 인덱스가 정렬 성능까지 개선 가능

인덱스 생성

CREATE INDEX idx_reservation_user_paid ON reservation (user_id, paid_at);

  • 인덱스 적용 이유

    • ☑️Cardinality(선택도)

      • user_id ⇒ 사용자의 예약 내역을 빠르게 찾기 위해 필요

      • paid_at ⇒ 날짜 범위를 기준으로 필터링을 최적화하기 위해 필요

인덱스 적용 전

EXPLAIN SELECT r.id, r.price, r.paid_at, s.seat_number, c.id AS concert_id FROM reservation r JOIN seat s ON r.seat_id = s.id JOIN concert c ON s.concert_id = c.id WHERE r.user_id = 1001 AND r.paid_at >= DATE_SUB(NOW(), INTERVAL 30 DAY);

EXPLAIN ANALYZE SELECT r.id, r.price, r.paid_at, s.seat_number, c.id AS concert_id FROM reservation r JOIN seat s ON r.seat_id = s.id JOIN concert c ON s.concert_id = c.id WHERE r.user_id = 1001 AND r.paid_at >= DATE_SUB(NOW(), INTERVAL 30 DAY);

  • Table Scan (r 테이블) 발생 ⇒ 전체 테이블 탐색하면서 데이터를 찾는다

  • 데이터가 많아질수록 O(n) 시간 소요

인덱스 적용 후

EXPLAIN SELECT r.id, r.price, r.paid_at, s.seat_number, c.id AS concert_id FROM reservation r JOIN seat s ON r.seat_id = s.id JOIN concert c ON s.concert_id = c.id WHERE r.user_id = 1001 AND r.paid_at >= DATE_SUB(NOW(), INTERVAL 30 DAY);

EXPLAIN ANALYZE SELECT r.id, r.price, r.paid_at, s.seat_number, c.id AS concert_id FROM reservation r JOIN seat s ON r.seat_id = s.id JOIN concert c ON s.concert_id = c.id WHERE r.user_id = 1001 AND r.paid_at >= DATE_SUB(NOW(), INTERVAL 30 DAY);

인덱스 추가 전후 차이

구분
인덱스 추가 전
인덱스 추가 후

실행 계획

Full Table Scan

Index Scan

쿼리 실행 시간 (actual time)

0.46ms

0.0194ms

행 스캔 (rows)

1,000

1


b. 특정 (인기) 콘서트 좌석 현황을 한 페이지에서 모두 조회

  • 인덱스가 필요한 이유

    • 1️⃣조회 데이터 양 증가

      • 인기 콘서트의 좌석 정보를 한 번에 불러와야 하며, 좌석(seat) 테이블이 커질수록 성능 저하 발생 가능

      • 좌석 상태(status)에 따라 필터링하는 경우도 고려 대상이 된다

    • 2️⃣ WHERE 조건의 다중 컬럼 사용

      • WHERE concert_id = ? AND schedule_date BETWEEN ? AND ?

      • concert_id와 schedule_date를 동시에 조회하므로, 복합 인덱스를 활용해야 성능 최적화 가능

인덱스 생성

CREATE INDEX idx_seat_concert_schedule_status ON seat (concert_id, schedule_date, status);

  • 인덱스 적용 이유

    • ☑️Cardinality(선택도) 고려

      • concertId → 콘서트별 좌석을 필터링하는데 필수적

      • scheduleDate → 콘서트 일정별 좌석을 조회하는 데 필요

      • status → AVAILABLE 또는 RESERVED 등 상태별 필터링 시 속도를 최적화

  • ❗ 해당 인덱스는 이미 1-a에서 적용되어있다

인덱스 적용 전

EXPLAIN SELECT s.id, s.seat_number, s.status, s.price FROM seat s WHERE s.concertId = 351 AND s.scheduleDate BETWEEN '2025-03-01' AND '2025-03-07' ORDER BY s.scheduleDate;

EXPLAIN ANALYZE SELECT s.id, s.seat_number, s.status, s.price FROM seat s WHERE s.concertId = 351 AND s.scheduleDate BETWEEN '2025-03-01' AND '2025-03-07' ORDER BY s.scheduleDate;

인덱스 적용 후

EXPLAIN SELECT s.id, s.seat_number, s.status, s.price FROM seat s WHERE s.concertId = 351 AND s.scheduleDate BETWEEN '2025-03-01' AND '2025-03-07' ORDER BY s.scheduleDate;

EXPLAIN ANALYZE SELECT s.id, s.seat_number, s.status, s.price FROM seat s WHERE s.concertId = 351 AND s.scheduleDate BETWEEN '2025-03-01' AND '2025-03-07' ORDER BY s.scheduleDate;

인덱스 추가 전후 차이

구분
인덱스 추가 전
인덱스 추가 후

실행 계획

Full Table Scan

Index Range Scan

쿼리 실행 시간 (actual time)

40ms

0.161ms

행 스캔 (rows)

119,700

30


c. 특정 사용자의 예약 및 결제 내역 통계 조회

  • 인덱스가 필요한 이유

    • 1️⃣자주 실행되는 통계 쿼리

      • 특정 사용자의 예약 내역을 통계(COUNT(*), SUM(price))로 조회하는 경우 성능 저하 발생 가능

      • 예약 테이블이 커질수록 필터링과 집계 연산이 느려질 수 있다

    • 2️⃣ WHERE 조건의 다중 컬럼 사용

      • WHERE user_id = ? AND paid_at IS NOT NULL

      • 특정 사용자 + 결제 완료된 예약을 필터링해야 하므로, 복합 인덱스가 필요하

    • 3️⃣집계 연산 성능 최적화

      • GROUP BY user_id를 수행할 경우, 인덱스를 활용하면 불필요한 테이블 스캔을 줄일 수 있다

인덱스 생성

CREATE INDEX idx_reservation_user_paid_status ON reservation (user_id, paid_at, status);

  • 인덱스 적용 이유

    • ☑️Cardinality(선택도)

      • user_id → 특정 사용자의 예약 데이터를 빠르게 찾기 위해 필요

      • paid_at → 결제된 예약 내역을 필터링하는 데 최적화

      • status → 결제 완료(PAID), 예약 취소(CANCELLED) 등 상태별 필터링 가능

인덱스 적용 전

EXPLAIN SELECT r.user_id, COUNT(r.id) AS total_reservations, SUM(r.price) AS total_spent FROM reservation r WHERE r.user_id = 1001 AND r.paid_at IS NOT NULL GROUP BY r.user_id;

EXPLAIN ANALYZE SELECT r.user_id, COUNT(r.id) AS total_reservations, SUM(r.price) AS total_spent FROM reservation r WHERE r.user_id = 1001 AND r.paid_at IS NOT NULL GROUP BY r.user_id;

인덱스 적용 후

EXPLAIN SELECT r.user_id, COUNT(r.id) AS total_reservations, SUM(r.price) AS total_spent FROM reservation r WHERE r.user_id = 1001 AND r.paid_at IS NOT NULL GROUP BY r.user_id;

EXPLAIN ANALYZE SELECT r.user_id, COUNT(r.id) AS total_reservations, SUM(r.price) AS total_spent FROM reservation r WHERE r.user_id = 1001 AND r.paid_at IS NOT NULL GROUP BY r.user_id;

인덱스 추가 전후 차이

구분
인덱스 추가 전
인덱스 추가 후

실행 계획

Full Table Scan

Index Range Scan

쿼리 실행 시간 (actual time)

0.403ms

0.0183ms

행 스캔 (rows)

1,000

1


d. 가장 많이 예약된 콘서트 조회

  • 인덱스가 필요한 이유

    • 1️⃣콘서트 별 예약 건수 집계 속도 개선

      • 예약 가능한 좌석 조회는 가장 자주 호쵤되는 쿼리 중 하나이며, 매번 전체 테이블을 스캔하면 성능 저하가 발생될 수 있다

      • 추가적으로 동시 예약 요청이 많은 콘서트 시스템의 경우, 데이터 양이 많아질수록 응답 시간이 길어지게 된다

    • 2️⃣ 대량 데이터 그룹화에 대한 죄적화

      • WHERE concert_id = ? AND schedule_date = ? AND status = ?

      • concert_id, schedule_date, status는 예약 가능한 좌석 조회 시 항상 사용하는 필터 조건이고,

        해당 조건들이 인덱스 없이 검색 될 경우 Full Table Scan이 발생한다

    • 3️⃣콘서트 상태 값 처리 시에도 사용

      • 콘서트 SoldOut 처리 시에도 그룹핑하여 사용하기 때문에 다른 쿼리에 비해 효율이 좋음

인덱스 생성

CREATE INDEX idx_reservation_concert ON reservation (concert_id);

  • 인덱스 적용 이유

    • ☑️예약 테이블이 커질수록 성능 저하 가능

      • 콘서트별 예약 건수를 집계(COUNT(*))하여 가장 인기 있는 콘서트를 찾는 쿼리이다.

      • 예약 데이터가 많아질수록 GROUP BY concertId 연산이 부담될 수 있음

    • ☑️WHERE 조건이 없지만 GROUP BY 최적화 필요

      • concertId를 기준으로 집계해야 하므로, 이 컬럼에 대한 인덱스가 있으면 성능 개선 가능

인덱스 적용 전

EXPLAIN SELECT r.concertId, COUNT(r.id) AS total_reservations FROM reservation r GROUP BY r.concertId ORDER BY total_reservations DESC LIMIT 10;

EXPLAIN ANALYZE SELECT r.concertId, COUNT(r.id) AS total_reservations FROM reservation r GROUP BY r.concertId ORDER BY total_reservations DESC LIMIT 10;

인덱스 적용 후

EXPLAIN SELECT r.concertId, COUNT(r.id) AS total_reservations FROM reservation r GROUP BY r.concertId ORDER BY total_reservations DESC LIMIT 10;

EXPLAIN ANALYZE SELECT r.concertId, COUNT(r.id) AS total_reservations FROM reservation r GROUP BY r.concertId ORDER BY total_reservations DESC LIMIT 10;

인덱스 추가 전후 차이

구분
인덱스 추가 전
인덱스 추가 후

실행 계획

Temporary Table 사용 + Full Table Scan

Covering Index Scan

쿼리 실행 시간 (actual time)

0.472ms

0.296ms

행 스캔 (rows)

1,000

1000 (Covering Index)


3. 기대 효과 및 최종 결론

1. 검색 성능 향상

  • 인덱싱을 통해 데이터가 미리 구조화되므로, 사용자가 검색할 때 옵티마이저가 최적으로 데이터셋에서 빠르게 결과 도출

  • 전체 테이블 스캔(O(n)) 방식 → B-트리 기반 검색O(log n)으로 전환

2. 시스템의 전반적인 개선

  • 주요 쿼리에서 높은 Cardinality를 가진 컬럼에 인덱스를 집중적으로 적용

  • 사용자 잔액 조회, 콘서트 스케줄 조회, 좌석 조회 등 빈번하게 호출되는 쿼리의 처리 속도가 향상

Last updated