[SQL] 실행 계획 분석해 성능 개선하기(MariaDB)

2025. 12. 23. 17:42·SQL

✅ 기존 쿼리 및 실행 계획 분석

기존 쿼리 ↓

더보기
더보기
analyze
select a1_0.accommodation_id                   as '숙소 ID',
       a1_0.title                              as '숙소 제목',
       max(ap1_0.price)                        as '가격',
       coalesce(avg(r2_0.rating), 0.0)         as '평균 평점',
       ai1_0.image_url                         as '썸네일',
       w1_0.wishlist_id is not null            as '위시리스트 등록 여부',
       w1_0.wishlist_id                        as '위시리스트 ID',
       w1_0.name                               as '위시리스트 이름',
       coalesce(count(r1_0.reservation_id), 0) as '예약 수',
       ac1_0.code_name                         as '지역명',
       ac1_0.area_code                         as '지역 코드'
from accommodations as a1_0
		 # 1
         join accommodation_prices as ap1_0
              on ap1_0.accommodation_id = a1_0.accommodation_id
                  and ap1_0.season = 'PEAK'
                  and ap1_0.day_type = 'WEEKEND'
         # 2
         join accommodation_images as ai1_0
              on ai1_0.accommodation_id = a1_0.accommodation_id
                  and ai1_0.thumbnail = true
         # 3
         join sigungu_codes as sc1_0 on sc1_0.sigungu_code = a1_0.sigungu_code
         join area_codes as ac1_0 on ac1_0.area_code = sc1_0.area_code
         # 4
         left join reservations as r1_0 on r1_0.accommodation_id = a1_0.accommodation_id
         left join reviews as r2_0 on r2_0.reservation_id = r1_0.reservation_id
         # 5
         left join wishlist_accommodations as wa1_0 on wa1_0.accommodation_id = a1_0.accommodation_id
         left join wishlists as w1_0
                   on w1_0.wishlist_id = wa1_0.wishlist_id
                       and w1_0.member_id = 114
group by a1_0.accommodation_id,
         w1_0.wishlist_id
# 6         
order by count(r1_0.reservation_id) desc,
         avg(r2_0.rating) desc;

성능 개선 대상은 에어비앤비 클론코딩 프로젝트로, 숙소와 관련된 테이블들을 모두 조인한 다음 필요한 컬럼들만 `select`하고 있습니다. 쿼리를 간단하게 설명하면 다음과 같습니다.

  1. 가격 테이블과 조인합니다. 숙소 하나당 주중/주말 & 비수기/성수기로 총 네 가지 조합의 가격이 각각 존재합니다.
  2. 이미지 테이블과 조인합니다. 숙소 하나당 N개의 이미지가 있고 반드시 하나의 썸네일이 존재합니다.
  3. 지역 테이블과 조인합니다.
  4. 예약, 후기 테이블과 조인합니다.
  5. 위시리스트 테이블과 조인합니다.
  6. 예약 수, 평점 기준 내림차순 정렬합니다. (=인기순)

위 쿼리의 `analyze`에 의해 실행된 실행 계획은 다음과 같습니다. (쿼리 실행에 앞서 숙소 10만 개와 이미지 10장씩 `insert`하였습니다. 즉 숙소 10만 개, 가격 40만 개, 이미지 100만 개가 저장되어 있습니다.)

쿼리 실행 시간만 5.6초가 걸리고 있습니다. 실행 계획을 분석해 쿼리의 문제점을 정리해 보겠습니다.

  1. 풀스캔 및 정렬 처리
    • 숙소 테이블(a1_0)을 풀스캔(type=ALL)하면서 Using temporary; Using filesort가 발생했습니다. 인덱스를 전혀 사용하지 못해 결과를 바로 스트리밍 하지 못하고 조인 결과를 임시 테이블에 저장 후, 결과를 다시 정렬 처리했음을 의미합니다.
    • 이는 order by를 처리하는 방법 중 가장 속도가 떨어지는 방법으로 분명한 튜닝 대상으로 볼 수 있습니다.
    • + 임시 테이블이 필요한 쿼리
      더보기
      - ORDER BY와 GROUP BY에 명시된 컬럼이 다른 쿼리
      - ORDER BY나 GROUP BY에 명시된 컬럼이 조인의 순서상 첫 번째 테이블이 아닌 쿼리
      - DISTINCT와 ORDER BY가 동시에 쿼리에 존재하는 경우 or DISTINCT가 인덱스로 처리되지 못하는 쿼리
      - UNION이나 UNION DISTINCT가 사용된 쿼리 (select_type이 UNION RESULT인 경우)
      - 실행 계획에서 select_type이 DERIVED인 쿼리
  2. 비효율적인 필터링
    • r_filtered를 보면 가격 테이블(ap1_0)은 25.0, 이미지 테이블(ai1_0)은 9.84라고 나와 있습니다. 이는 가격은 4개 중 1개만 사용되어 3개는 버려지고, 이미지는 10개 중 1개만 사용되어 9개는 버려짐을 의미합니다.
    • 테이블 설계상 가격은 시기별 하나만 가져와도 되고, 이미지도 마찬가지로 10개 중 썸네일 하나만 가져오면 되기 때문에 모두 스캔하고 나머지를 버릴 필요 없이 하나만 가져오도록 튜닝이 필요해 보입니다.
  3. 불필요한 조인
    • 예약(r1_0)과 후기(r2_0) 테이블을 예약 수와 평균 평점, 즉 집계를 위해 조인하고 있습니다.
    • 지금은 예약과 후기에 대한 데이터가 많지 않아 성능에 영향은 없지만, 데이터가 쌓일수록 지금과 같은 실시간 집계는 분명 부담이 될 수 있으므로 개선이 필요해 보입니다.

✅ 성능 개선 시도

실행 계획을 분석해 발견한 문제점들을 개선해 보겠습니다.

1️⃣ 인덱스 추가

2번 문제를 해결하기 위해 인덱스를 추가하겠습니다. 인덱스 추가 대상 테이블은 가격과 이미지입니다.

# 가격
create index idx_prices_seasonal
on accommodation_prices (accommodation_id, season, day_type, price);

# 이미지
create index idx_images_thumbnail
on accommodation_images (accommodation_id, thumbnail, image_url);

인덱스 추가 후 실행 계획을 비교해 보겠습니다.

실행 계획 - 인덱스 추가

드디어 r_filtered가 각각 25.0, 9.84에서 모두 100으로 맞춰졌습니다. 즉 인덱스 추가로 인해 모두 스캔한 후 필요 없는 행을 버리는 대신 필요한 행 하나만 가져오게 되었습니다. 추가로 Using index, 커버링 인덱스로 동작하는 것을 확인할 수 있습니다.

결과적으로 인덱스 추가만으로 실행 시간이 기존 5.687초에서 2.067초로 개선이 되었습니다.

 

2️⃣ 반정규화

1번과 3번 문제를 해결하기 위해 반정규화를 해보겠습니다. 기존 쿼리의 문제점이 항상 예약과 후기 테이블을 조인해서 숙소의 예약 수, 평균 평점과 같은 집계를 수행하는 것이었습니다. 이를 반정규화로 조인 및 임시 테이블 정렬 비용을 줄이기 위해 기존 숙소 테이블에 다음 필드를 추가했습니다.

alter table accommodations
add column reservation_count int default 0,   # 예약 수
add column average_rating double default 0.0; # 평균 평점

이렇게 되면 쿼리는 다음과 같이 바뀝니다. 더 이상 예약과 후기 테이블까지 조인해서 `avg`, `count` 같은 집계함수를 사용할 필요 없이 바로 숙소 테이블에서 `select`할 수 있습니다.

select a1_0.accommodation_id        as '숙소 ID',
       a1_0.title                   as '숙소 제목',
       ap1_0.price                  as '가격',
       a1_0.average_rating          as '평균 평점',
       ai1_0.image_url              as '썸네일',
       w1_0.wishlist_id is not null as '위시리스트 등록 여부',
       w1_0.wishlist_id             as '위시리스트 ID',
       w1_0.name                    as '위시리스트 이름',
       a1_0.reservation_count       as '예약 수',
       ac1_0.code_name              as '지역명',
       ac1_0.area_code              as '지역 코드'
from accommodations as a1_0
         join accommodation_prices as ap1_0
              on ap1_0.accommodation_id = a1_0.accommodation_id
                  and ap1_0.season = 'PEAK'
                  and ap1_0.day_type = 'WEEKEND'
         join accommodation_images as ai1_0
              on ai1_0.accommodation_id = a1_0.accommodation_id
                  and ai1_0.thumbnail = true
         join sigungu_codes as sc1_0 on sc1_0.sigungu_code = a1_0.sigungu_code
         join area_codes as ac1_0 on ac1_0.area_code = sc1_0.area_code
         left join wishlist_accommodations as wa1_0 on wa1_0.accommodation_id = a1_0.accommodation_id
         left join wishlists as w1_0
                   on w1_0.wishlist_id = wa1_0.wishlist_id
                       and w1_0.member_id = 114
order by a1_0.reservation_count desc,
         a1_0.average_rating desc;

💡 반정규화 시 데이터 정합성을 지키는 것이 중요하므로 다음과 같은 추가 배치 작업이 필요합니다.

더보기
더보기
@Transactional
@Scheduled(cron = "0 */30 * * * *") // 30분마다 실행
public void refreshStats() {
    accommodationRepository.refreshStats();
}

----------------------------------------------------------------

@Modifying
@Query(value = """
        UPDATE accommodations a
        LEFT JOIN (
            SELECT accommodation_id, COUNT(*) AS cnt
            FROM reservations
            WHERE status != 'CANCELED'
            GROUP BY accommodation_id
        ) AS res ON res.accommodation_id = a.accommodation_id
        LEFT JOIN (
            SELECT r.accommodation_id, ROUND(AVG(rv.rating), 2) AS avg_rating
            FROM reservations r
            JOIN reviews rv ON r.reservation_id = rv.reservation_id
            GROUP BY r.accommodation_id
        ) AS rev ON rev.accommodation_id = a.accommodation_id
        SET a.reservation_count = coalesce(res.cnt, 0),
            a.average_rating = coalesce(rev.avg_rating, 0.0)
        """, nativeQuery = true)
void refreshStats();

 

추가로 `order by` 컬럼에 인덱스를 추가해 Using filesort를 없애고 인덱스를 이용해 정렬을 처리하도록 해보았습니다.

create index idx_reservation_count
    on accommodations (reservation_count DESC, average_rating DESC);

실행 계획은 다음과 같습니다.

실행 계획 - 반정규화

실행시간만 보면 1초 미만으로 개선이 되었습니다. 또한 예약 및 후기 테이블 조인이 사라졌고, `group by`를 사용하지 않아 임시 테이블(Using temporary) 사용을 하지 않습니다. 그러나 Filesort는 여전히 남아 있습니다. 원인을 파악하기 위해 위에서 만든 인덱스를 확인해 보았습니다.

우선 정렬 기준 컬럼으로 만든 인덱스는 카디널리티가 너무 낮습니다. 하지만 애초에 근본적인 원인은 `where` 조건 없이 풀스캔 후 정렬을 시도하는 것입니다.

 

3️⃣ 통계 테이블

현재 프로젝트 설계 상 DB에서 풀스캔해서 가져온 다음 서버에서 직접 지역별로 인기순 TOP N(8) 숙소를 추출하고 있습니다. 다음은 해당 코드입니다.

더보기
더보기
public List<MainAccResDto> getAccommodations(Long memberId) {
    LocalDate now = LocalDate.now();
    Season season = dateManager.getSeason(now);
    DayType dayType = dateManager.getDayType(now);

	// 풀스캔
    List<MainAccListQueryDto> accommodations = accommodationQueryRepository.getAreaAccommodations(season, dayType, memberId);

	// 지역별 그룹핑 후 반환
    return accommodations
            .stream()
            .collect(groupingBy(
                    MainAccListQueryDto::getAreaKey,
                    collectingAndThen(
                            toList(),
                            dtos -> dtos.stream()
                                        .map(MainAccListResDto::from)
                                        .limit(8).toList()
                    )
            ))
            .entrySet()
            .stream()
            .map(entry -> new MainAccResDto(
                    entry.getKey().areaName(),
                    entry.getKey().areaCode(),
                    entry.getValue())
            )
            .toList();
}

 

위와 같이 DB에서 풀스캔해서 가져온 다음 서버에서 직접 지역별 인기순 TOP N을 추출하는 설계는 인덱스 문제보다 구조 자체의 문제에 가깝기 때문에 풀스캔 문제를 해결하는 데 한계가 있습니다. 이를 해결하기 위해 다음 방법들을 생각해 보았습니다.

  1. 모든 지역 개별 조회
    • 장점 : 쿼리마다 인덱스 활용 및 풀스캔 회피, 최소 행 조회 가능
    • 단점 : 지역 수만큼 쿼리 필요, 개별 조회 후 병합 로직 필요
  2. 윈도우 함수 사용
    • 장점 : 쿼리 한 번으로 목표(지역별 TOP N) 달성 가능
    • 단점 : 풀스캔 필요, 네이티브 쿼리 필요(유연성 및 유지보수성 감소)
  3. 통계 테이블
    • 장점 : 풀스캔 없이 단순 select 조회 가능, 조인 및 정렬 비용 최적화
    • 단점 : 추가 배치 작업 필요, 실시간성 감소

지역별 인기 숙소는 실시간성이 중요한 정보는 아니기에 `쿼리 한번 + 풀스캔 X`을 모두 만족할 수 있는 통계 테이블을 추가하기로 하였습니다. 

 

통계용 테이블은 다음과 같이 정의했습니다.

create table accommodation_stats
(
    stat_id           bigint auto_increment comment '고유 ID' primary key,
    accommodation_id  bigint           not null comment '숙소',
    area_code         varchar(255)     not null comment '지역 코드',
    area_name         varchar(255)     not null comment '지역명',
    title             varchar(255)     not null comment '숙소 제목',
    average_rating    double default 0 null comment '평균 평점',
    reservation_count int    default 0 null comment '예약 수',
    thumbnail_url     varchar(700)     not null comment '숙소 썸네일'
);

이렇게 되면 API 시 실행되는 쿼리 및 실행 계획은 다음과 같습니다.

select as1_0.accommodation_id       as '숙소 ID',
       as1_0.title                  as '숙소 제목',
       ap1_0.price                  as '가격',
       as1_0.average_rating         as '평균 평점',
       as1_0.thumbnail_url          as '썸네일',
       w1_0.wishlist_id is not null as '위시리스트 등록 여부',
       w1_0.wishlist_id             as '위시리스트 ID',
       w1_0.name                    as '위시리스트 이름',
       as1_0.reservation_count      as '예약 수',
       as1_0.area_name              as '지역명',
       as1_0.area_code              as '지역 코드'
from accommodation_stats as1_0
         join accommodation_prices ap1_0
              on ap1_0.accommodation_id = as1_0.accommodation_id
                  and ap1_0.season = 'PEAK'
                  and ap1_0.day_type = 'WEEKDAY'
         left join wishlist_accommodations wa1_0
                   on wa1_0.accommodation_id = as1_0.accommodation_id
         left join wishlists w1_0
                   on w1_0.wishlist_id = wa1_0.wishlist_id
                       and w1_0.member_id = 114;

가격과 위시리스트는 각각 시기별, 사용자별로 동적인 정보가 포함되어 있으므로 조인을 해줍니다.

실행 계획 - 통계 테이블

시간이 0.001초로 매우 단축되었습니다. `type=ALL`로 풀스캔이 발생했지만 10만 개 전체가 아닌 인기 있는 128개 행만을 풀스캔 한 것이므로 성능에는 전혀 영향이 없습니다.

 

위와 같이 반정규화 및 통계 테이블 분리로 성능은 개선되었지만 데이터 정합성을 지켜주는 것이 중요합니다. 따라서 다음과 같이 통계 갱신 배치 코드를 추가해 주었습니다.

더보기
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class AccommodationStatisticsService {

    private final EntityManager em;

	// 매일 새벽 2시 실행
    @Scheduled(cron = "* * 2 * * *")
    public void refreshStats() {
        log.info("지역별 인기 숙소 TOP N 통계 갱신");
        String sql = """
                TRUNCATE TABLE accommodation_stats;
                
                INSERT INTO accommodation_stats (accommodation_id, area_code, area_name, title, average_rating, reservation_count, thumbnail_url)
                SELECT
                    ranked.accommodation_id,
                    ac.area_code,
                    ac.code_name,
                    ranked.title,
                    ranked.average_rating,
                    ranked.reservation_count,
                    ai.image_url
                FROM (
                    SELECT
                        a.accommodation_id,
                        a.title,
                        a.average_rating,
                        a.reservation_count,
                        sc.area_code,
                        ROW_NUMBER() OVER (
                            PARTITION BY sc.area_code
                            ORDER BY a.reservation_count DESC, a.average_rating DESC
                        ) AS rn
                    FROM accommodations a
                    JOIN sigungu_codes sc ON sc.sigungu_code = a.sigungu_code
                ) ranked
                JOIN area_codes ac ON ac.area_code = ranked.area_code
                JOIN accommodation_images ai
                ON ai.accommodation_id = ranked.accommodation_id
                AND ai.thumbnail = true
                WHERE ranked.rn <= 8
                """;

        em.createNativeQuery(sql)
          .executeUpdate();
    }

	// 매 30분 단위(정각, 30분) 실행
    @Scheduled(cron = "0 */30 * * * *")
    public void refreshRecentStats() {
        log.info("숙소 반정규화 통계 필드 갱신 - 최근 변경");
        String sql = """
                UPDATE accommodations a
                SET
                    a.reservation_count = (
                        SELECT COUNT(*)
                        FROM reservations r
                        WHERE r.accommodation_id = a.accommodation_id
                          AND r.status != 'CANCELED'
                    ),
                    a.average_rating = COALESCE((
                        SELECT ROUND(AVG(rv.rating), 2)
                        FROM reviews rv
                        JOIN reservations rs ON rv.reservation_id = rs.reservation_id
                        WHERE rs.accommodation_id = a.accommodation_id
                    ), 0.0)
                WHERE a.accommodation_id IN (
                    SELECT DISTINCT accommodation_id
                    FROM reservations
                    WHERE updated_at >= DATE_SUB(NOW(), INTERVAL 30 MINUTE)
                    UNION
                    SELECT DISTINCT rs.accommodation_id
                    FROM reviews rv
                    JOIN reservations rs ON rv.reservation_id = rs.reservation_id
                    WHERE rv.updated_at >= DATE_SUB(NOW(), INTERVAL 30 MINUTE)
                )
                """;
        em.createNativeQuery(sql)
          .executeUpdate();
    }

	// 매일 새벽 3시 실행
    @Scheduled(cron = "0 0 3 * * *")
    public void refreshAllStats() {
        log.info("숙소 반정규화 통계 필드 갱신 - 전체");
        String sql = """
                UPDATE accommodations a
                SET
                    a.reservation_count = COALESCE((
                        SELECT COUNT(*)
                        FROM reservations r
                        WHERE r.accommodation_id = a.accommodation_id
                          AND r.status != 'CANCELED'
                    ), 0),
                    a.average_rating = COALESCE((
                        SELECT ROUND(AVG(rv.rating), 2)
                        FROM reviews rv
                        JOIN reservations rs ON rv.reservation_id = rs.reservation_id
                        WHERE rs.accommodation_id = a.accommodation_id
                    ), 0.0)
                """;
        em.createNativeQuery(sql)
          .executeUpdate();
    }
}

 

✅ 성능 개선 정리

Version 개선점 특징 쿼리 실행 시간
0 x 인덱스 사용 x
풀스캔 및 정렬 비용 발생
5.687s
1 인덱스 추가 커버링 인덱스 및 버리는 행 최소화
풀스캔 및 정렬 비용 발생
2.067s
2 반정규화 group by 제거(임시 테이블 및 집계함수 사용 x)
풀스캔 및 정렬 비용 발생
정합성 관리 추가 로직 필요
0.730s
3 통계 테이블 풀스캔(발생하지만 성능 영향 x) 및 정렬 비용 제거
정합성 관리 추가 로직 필요
0.001s

 

'SQL' 카테고리의 다른 글

[SQL] MySQL 격리 수준  (0) 2025.12.26
'SQL' 카테고리의 다른 글
  • [SQL] MySQL 격리 수준
이런개발
이런개발
geun-00의 흔적 보관소
  • 이런개발
    내일이 기대되는 오늘
    이런개발
  • 전체
    오늘
    어제
    • 분류 전체보기 N
      • 백엔드 면접
      • SQL N
        • SUM, MAX, MIN
        • SELECT
        • GROUP BY
        • JOIN
      • Spring
      • JPA
      • 트러블슈팅
      • Infra
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    오블완
    티스토리챌린지
    자바
    스프링
    JPA
    토스 페이먼츠
    데브코스
    백엔드 면접
    raid
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
이런개발
[SQL] 실행 계획 분석해 성능 개선하기(MariaDB)
상단으로

티스토리툴바