2026-08-14
MySQL

집계 쿼리와 인덱스 최적화

챕터별 퀴즈 조회 API 최적화 중에 74만 건 풀이 기록을 집계하던 병목을 실행 계획과 인덱스로 개선하기

최근 서비스에서 챕터에 포함된 퀴즈를 조회하는 기능을 구현했습니다.
사용자에게는 하나의 목록으로 보이지만 서버에서는 퀴즈 정보뿐만 아니라 퀴즈별 풀이 통계, 현재 사용자의 풀이 결과, 각 퀴즈의 선택지를 함께 조합해야 했습니다.

QuizService.kt
@Transactional(readOnly = true)
fun getQuizzesByChapterId(
    userId: UUID,
    chapterId: UUID
): List<GetQuizResult> {
    // 퀴즈 조회
    val quizDetails =
        quizRepository.findQuizDetails(
            userId = userId,
            chapterId = chapterId
        )
 
    // 선택지 조회
    val quizOptions =
        quizOptionRepository.findAllByQuizIdIn(quizDetails.map { it.id })
            .groupBy { it.quizId }
 
    return quizDetails.map { ... }
}

데이터가 적을 때는 별다른 문제가 없었지만, 풀이 기록이 쌓이면서 이 목록을 조회하는 데 긴 시간이 걸리기 시작했습니다.

문제 상황

문제를 재현하기 위해 퀴즈와 풀이 기록을 여러 챕터와 사용자에 걸쳐 분산해 저장했습니다.

데이터개수
챕터24개
퀴즈72,000개
풀이 기록744,000개
선택지360,000개

챕터마다 퀴즈 3,000개가 있고, 각 퀴즈에는 선택지 5개가 있습니다.
성능 테스트는 로컬 데이터베이스 환경에서 수행했습니다.
특정 챕터의 퀴즈 3,000개를 조회하는 API를 호출해 워밍업한 뒤, 동시 요청 없이 순차적으로 100회 호출했습니다.
결과는 다음과 같습니다.

측정 항목결과
P95 응답 시간785.7ms

단일 요청을 순차적으로 보냈음에도 P95 응답 시간이 약 786ms로 측정되었습니다.
동시 요청으로 인한 커넥션 풀 대기나 CPU 경합이 없는 조건에서도 지연이 컸기 때문에, 먼저 API 내부의 데이터베이스 조회부터 살펴보기로 했습니다.

이제 원인을 하나씩 분석해 보겠습니다.

쿼리 개선

우선 해당 기능에서 사용하는 쿼리들을 살펴보기로 했습니다.

관련 테이블 스키마는 다음과 같습니다.

CREATE TABLE quiz (
    id BINARY(16) PRIMARY KEY,
    chapter_id BINARY(16) NOT NULL,
    question TEXT NOT NULL,
    solution TEXT NOT NULL
);
 
CREATE TABLE quiz_option (
    id BINARY(16) PRIMARY KEY,
    quiz_id BINARY(16) NOT NULL,
    content TEXT NOT NULL,
    is_answer BOOLEAN NOT NULL
);
 
CREATE TABLE user_solved_quiz (
    id BINARY(16) PRIMARY KEY,
    user_id BINARY(16) NOT NULL,
    quiz_id BINARY(16) NOT NULL,
    selected_option_id BINARY(16),
    is_correct BOOLEAN NOT NULL,
    UNIQUE INDEX uk_user_solved_quiz_user_quiz (user_id, quiz_id)
);

퀴즈 조회

퀴즈 조회에는 퀴즈 정보뿐만 아니라 추가로 다음 두 종류의 풀이 정보가 필요한데요.

  1. 모든 사용자의 풀이 기록을 집계한 퀴즈별 정답 및 오답 횟수
  2. 현재 사용자가 선택한 선택지와 정답 여부

이를 위해 풀이 기록을 저장하는 user_solved_quiz를 함께 조회했습니다.

CustomQuizRepositoryImpl.kt
override fun findQuizDetails(
    userId: UUID,
    chapterId: UUID
): List<QuizDetailProjection> {
    val quizStatistics =
        dsl
            .select(
                USER_SOLVED_QUIZ.QUIZ_ID,
                count()
                    .filterWhere(USER_SOLVED_QUIZ.IS_CORRECT.equal(true))
                    .`as`(QuizDetailProjection::correctCount),
                count()
                    .filterWhere(USER_SOLVED_QUIZ.IS_CORRECT.equal(false))
                    .`as`(QuizDetailProjection::incorrectCount)
            )
            .from(USER_SOLVED_QUIZ)
            .groupBy(USER_SOLVED_QUIZ.QUIZ_ID)
 
    return dsl
        .select(
            QUIZ.ID,
            QUIZ.CHAPTER_ID,
            QUIZ.QUESTION,
            QUIZ.SOLUTION,
            coalesce(quizStatistics[QuizDetailProjection::correctCount], 0)
                .`as`(QuizDetailProjection::correctCount),
            coalesce(quizStatistics[QuizDetailProjection::incorrectCount], 0)
                .`as`(QuizDetailProjection::incorrectCount),
            USER_SOLVED_QUIZ.SELECTED_OPTION_ID,
            USER_SOLVED_QUIZ.IS_CORRECT
        )
        .from(QUIZ)
        .leftJoin(quizStatistics)
        .on(quizStatistics[USER_SOLVED_QUIZ.QUIZ_ID]?.equal(QUIZ.ID))
        .leftJoin(USER_SOLVED_QUIZ)
        .on(
            USER_SOLVED_QUIZ.QUIZ_ID.equal(QUIZ.ID)
                .and(USER_SOLVED_QUIZ.USER_ID.equal(userId))
        )
        .where(QUIZ.CHAPTER_ID.equal(chapterId))
        .fetchInto()
}
SELECT
    quiz.id,
    quiz.chapter_id,
    quiz.question,
    quiz.solution,
    COALESCE(quiz_statistics.correct_count, 0) AS correct_count,
    COALESCE(quiz_statistics.incorrect_count, 0) AS incorrect_count,
    user_solved_quiz.selected_option_id,
    user_solved_quiz.is_correct
FROM quiz
LEFT JOIN (
    SELECT
        quiz_id,
        COUNT(CASE WHEN is_correct = TRUE THEN 1 END) AS correct_count,
        COUNT(CASE WHEN is_correct = FALSE THEN 1 END) AS incorrect_count
    FROM user_solved_quiz
    GROUP BY quiz_id
) AS quiz_statistics
    ON quiz_statistics.quiz_id = quiz.id
LEFT JOIN user_solved_quiz
    ON user_solved_quiz.quiz_id = quiz.id
   AND user_solved_quiz.user_id = :userId
WHERE quiz.chapter_id = :chapterId;

쿼리에서는 user_solved_quiz를 통해 파생 테이블(quiz_statistics)을 만들고, 그렇게 만들어진 테이블을 quizJOIN하도록 했습니다.

해당 쿼리의 성능 테스트 결과는 다음과 같습니다.

측정 항목결과
P95 실행 시간415.9ms

API 지연 시간의 절반을 차지하는 것을 확인할 수 있었습니다.

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

idtabletypekeyrefrowsfilteredExtra
1quizALLNULLNULL73,99010.00Using where
1<derived2>ref<auto_key0>quiz.id10100.00
1user_solved_quizeq_refuk_user_solved_quiz_user_quizconst, quiz.id1100.00Using index condition
2user_solved_quizALLNULLNULL781,006100.00Using temporary
-> Nested loop left join
   (actual time=454..494 rows=3000 loops=1)
   -> Nested loop left join
      (actual time=454..486 rows=3000 loops=1)
      -> Filter: quiz.chapter_id = :chapterId
         (actual time=0.0504..27.4 rows=3000 loops=1)
         -> Table scan on quiz
            (actual time=0.0408..25
             rows=72000 loops=1)
      -> Index lookup on quiz_statistics using <auto_key0>
         (quiz_id=quiz.id)
         (actual time=0.152..0.153
          rows=1 loops=3000)
         -> Materialize
            (actual time=454..454
             rows=72000 loops=1)
            -> Table scan on <temporary>
               (actual time=411..416
                rows=72000 loops=1)
               -> Aggregate using temporary table
                  (actual time=411..411
                   rows=72000 loops=1)
                  -> Table scan on user_solved_quiz
                     (actual time=0.282..195
                      rows=744000 loops=1)
   -> Single-row index lookup on user_solved_quiz
      using uk_user_solved_quiz_user_quiz
      (user_id=:userId, quiz_id=quiz.id)
      (actual time=0.00265..0.00266
       rows=0.333 loops=3000)

실행 계획에서 병목이라 판단할만한 부분은 다음과 같습니다.

  1. user_solved_quiz 테이블 풀 스캔
  2. quiz 테이블 풀 스캔

우선 첫 번째 부분부터 살펴보겠습니다.
user_solved_quiz를 테이블 풀 스캔하는 부분은 바로 파생 테이블을 만드는 서브 쿼리인데요.

  SELECT
        quiz_id,
        COUNT(CASE WHEN is_correct = TRUE THEN 1 END) AS correct_count,
        COUNT(CASE WHEN is_correct = FALSE THEN 1 END) AS incorrect_count
    FROM user_solved_quiz
    GROUP BY quiz_id
idtabletypekeyrowsfilteredExtra
1user_solved_quizALLNULL678,203100.00Using temporary

해당 쿼리에서는 JOINWHERE를 통해 스캔 대상을 특정 챕터에 속한 user_solved_quiz들로 좁혀보겠습니다.

SELECT
    user_solved_quiz.quiz_id,
    COUNT(CASE WHEN user_solved_quiz.is_correct = TRUE THEN 1 END) AS correct_count,
    COUNT(CASE WHEN user_solved_quiz.is_correct = FALSE THEN 1 END) AS incorrect_count
FROM user_solved_quiz
JOIN quiz
    ON quiz.id = user_solved_quiz.quiz_id
WHERE quiz.chapter_id = :chapterId
GROUP BY user_solved_quiz.quiz_id;
idtabletypekeyrefrowsfilteredExtra
1user_solved_quizALLNULLNULL720,936100.00Using temporary
1quizeq_refPRIMARYuser_solved_quiz.quiz_id110.00Using where

JOINWHERE 절을 추가했지만 인덱스가 없기 때문에 여전히 user_solved_quiz 테이블 풀 스캔이 수행되었습니다.
쿼리가 chapter_id로 대상 퀴즈를 찾은 뒤 quiz_id로 풀이 기록을 조회할 수 있도록 두 인덱스를 추가했습니다.

CREATE INDEX idx_quiz_chapter_id ON quiz (chapter_id);
CREATE INDEX idx_user_solved_quiz_quiz_id ON user_solved_quiz (quiz_id);
idtabletypekeyrefrowsfilteredExtra
1quizrefidx_quiz_chapter_idconst3,000100.00Using where; Using index; Using temporary
1user_solved_quizrefidx_user_solved_quiz_quiz_idquiz.id11100.00

기대하던 대로 테이블 풀 스캔이 사라지고, 데이터가 더 적은 quiz가 드라이빙 테이블(Driving Table)이 되었습니다.

이제 지금까지의 변경 사항을 반영해 다시 퀴즈 조회 쿼리를 실행해 보겠습니다.

SELECT
    quiz.id,
    quiz.chapter_id,
    quiz.question,
    quiz.solution,
    COALESCE(quiz_statistics.correct_count, 0) AS correct_count,
    COALESCE(quiz_statistics.incorrect_count, 0) AS incorrect_count,
    user_solved_quiz.selected_option_id,
    user_solved_quiz.is_correct
FROM quiz
LEFT JOIN (
    SELECT
        user_solved_quiz.quiz_id,
        COUNT(CASE WHEN user_solved_quiz.is_correct = TRUE THEN 1 END) AS correct_count,
        COUNT(CASE WHEN user_solved_quiz.is_correct = FALSE THEN 1 END) AS incorrect_count
    FROM user_solved_quiz
    JOIN quiz
        ON quiz.id = user_solved_quiz.quiz_id
    WHERE quiz.chapter_id = :chapterId
    GROUP BY user_solved_quiz.quiz_id
) AS quiz_statistics
    ON quiz_statistics.quiz_id = quiz.id
LEFT JOIN user_solved_quiz
    ON user_solved_quiz.quiz_id = quiz.id
   AND user_solved_quiz.user_id = :userId
WHERE quiz.chapter_id = :chapterId;
측정 항목결과
P95 실행 시간149.4ms
idtabletypekeyrefrowsExtra
1quizrefidx_quiz_chapter_idconst3,000Using index condition
1<derived2>ref<auto_key0>quiz.id10
1user_solved_quizeq_refuk_user_solved_quiz_user_quizconst, quiz.id1Using index condition
2quizrefidx_quiz_chapter_idconst3,000Using where; Using index; Using temporary
2user_solved_quizrefidx_user_solved_quiz_quiz_idquiz.id11
-> Nested loop left join
   (actual time=150..165 rows=3000 loops=1)
   -> Nested loop left join
      (actual time=150..159 rows=3000 loops=1)
      -> Index lookup on quiz
         using idx_quiz_chapter_id
         (chapter_id=:chapterId)
         (actual time=0.00637..7.29 rows=3000 loops=1)
      -> Index lookup on quiz_statistics
         using <auto_key0>
         (quiz_id=quiz.id)
         (actual time=0.0505..0.0506 rows=1 loops=3000)
         -> Materialize
            (actual time=150..150 rows=3000 loops=1)
            -> Table scan on <temporary>
               (actual time=149..149 rows=3000 loops=1)
               -> Aggregate using temporary table
                  (actual time=149..149 rows=3000 loops=1)
                  -> Nested loop inner join
                     (actual time=0.0659..143 rows=31000 loops=1)
                     -> Covering index lookup on quiz
                        using idx_quiz_chapter_id
                        (chapter_id=:chapterId)
                        (actual time=0.0164..0.458 rows=3000 loops=1)
                     -> Index lookup on user_solved_quiz
                        using idx_user_solved_quiz_quiz_id
                        (quiz_id=quiz.id)
                        (actual time=0.0464..0.0471
                         rows=10.3 loops=3000)
   -> Single-row index lookup on user_solved_quiz
      using uk_user_solved_quiz_user_quiz
      (user_id=:userId, quiz_id=quiz.id)
      (actual time=0.00178..0.00179
       rows=0.333 loops=3000)

인덱스 스캔으로 인해 성능이 확실하게 개선된 것을 확인할 수 있었습니다.
원래 존재하던 외부 WHERE 절의 테이블 풀 스캔은 quiz(chapter_id) 인덱스 덕분에 자연스럽게 사라졌습니다.

하지만 실행 계획을 보면 user_solved_quiz의 인덱스 조회가 3,000회 반복되며 대부분의 시간을 차지하고 있었습니다.
(quiz_id) 인덱스로 집계 대상 레코드의 위치는 찾을 수 있지만, 정답과 오답을 구분하려면 인덱스에 없는 is_correct가 필요합니다.
따라서 MySQL은 세컨더리 인덱스에서 얻은 PK를 이용해 클러스터드 인덱스의 원본 레코드를 다시 조회해야 했습니다.

이 추가 조회를 없애기 위해 집계에 필요한 두 컬럼을 모두 포함한 (quiz_id, is_correct) 커버링 인덱스를 생성했습니다.

CREATE INDEX idx_user_solved_quiz_quiz_correct ON user_solved_quiz (quiz_id, is_correct);
측정 항목결과
P95 실행 시간34.4ms
idtabletypekeyrefrowsfilteredExtra
1quizrefidx_quiz_chapter_idconst3,000100.00Using index condition
1<derived2>ref<auto_key0>quiz.id10100.00
1user_solved_quizeq_refuk_user_solved_quiz_user_quizconst, quiz.id1100.00Using index condition
2quizrefidx_quiz_chapter_idconst3,000100.00Using where; Using index; Using temporary
2user_solved_quizrefidx_user_solved_quiz_quiz_correctquiz.id10100.00Using index
-> Nested loop left join
   (actual time=16..24.6 rows=3000 loops=1)
   -> Nested loop left join
      (actual time=16..20.2 rows=3000 loops=1)
      -> Index lookup on quiz
         using idx_quiz_chapter_id
         (actual time=0.0181..2.51 rows=3000 loops=1)
      -> Index lookup on quiz_statistics
         using <auto_key0>
         (actual time=0.0057..0.0058 rows=1 loops=3000)
         -> Materialize
            (actual time=15.9..15.9 rows=3000 loops=1)
            -> Table scan on <temporary>
               (actual time=14.6..14.8 rows=3000 loops=1)
               -> Aggregate using temporary table
                  (actual time=14.6..14.6 rows=3000 loops=1)
                  -> Nested loop inner join
                     (actual time=0.0246..9.73 rows=31000 loops=1)
                     -> Covering index lookup on quiz
                        using idx_quiz_chapter_id
                        (actual time=0.0176..0.333
                         rows=3000 loops=1)
                     -> Covering index lookup on user_solved_quiz
                        using idx_user_solved_quiz_quiz_correct
                        (actual time=0.00197..0.00259
                         rows=10.3 loops=3000)
   -> Single-row index lookup on user_solved_quiz
      using uk_user_solved_quiz_user_quiz
      (actual time=0.00137..0.00138
       rows=0.333 loops=3000)

실행 계획의 Index lookupCovering index lookup으로 바뀌었고, 퀴즈 조회 쿼리의 평균 시간은 141.0ms에서 33.3ms로 줄었습니다.
단순히 테이블 풀 스캔을 없애는 데서 멈추지 않고, 반복되는 PK 조회까지 실행 계획으로 확인한 것이 두 번째 개선으로 이어졌습니다.

선택지 조회

다음 살펴볼 쿼리는 선택지 조회 쿼리입니다.
해당 쿼리는 다음과 같이 Spring Data의 쿼리 메서드로 생성됩니다.

QuizOptionRepository.kt
interface QuizOptionRepository : JdbcRepository<QuizOption, UUID> {
    fun findAllByQuizIdIn(quizIds: Collection<UUID>): List<QuizOption>
    ...
}
SELECT
    quiz_option.id,
    quiz_option.quiz_id,
    quiz_option.content,
    quiz_option.is_answer
FROM quiz_option
WHERE quiz_option.quiz_id IN (...);

현재 테스트 데이터 기준으로는 챕터 하나당 퀴즈 3,000개가 존재하므로, IN 절에 3,000개의 퀴즈 식별자를 넣고 테스트를 수행했습니다.
결과 및 실행 계획은 다음과 같습니다.

측정 항목결과
P95 실행 시간175.5ms
idtabletypekeyrefrowsfilteredExtra
1quiz_optionALLNULLNULL368,70850.00Using where
-> Filter: (quiz_option.quiz_id in (...))
   (actual time=0.0257..129 rows=15000 loops=1)
   -> Table scan on quiz_option
      (actual time=0.024..73.9 rows=360000 loops=1)

퀴즈 조회 쿼리와 마찬가지로 인덱스가 없어 quiz_option 테이블 풀 스캔을 하고 있었습니다.
쿼리에 맞게 적절한 인덱스를 생성해주겠습니다.

CREATE INDEX idx_quiz_option_quiz_id ON quiz_option (quiz_id);
측정 항목결과
P95 실행 시간85.0ms
idtabletypekeyrefrowsfilteredExtra
1quiz_optionrangeidx_quiz_option_quiz_idNULL12,000100.00Using index condition
-> Index range scan on quiz_option
   using idx_quiz_option_quiz_id
   over (
       quiz_id = :quizId1 OR
       quiz_id = :quizId2 OR
       ...
       2998 more
   )
   (actual time=0.0425..34.7
    rows=15000 loops=1)

IN 절에 전달된 3,000개의 값은 하나의 연속된 범위가 아니므로 MySQL은 여러 개의 인덱스 범위를 탐색합니다.
그 결과 실행 계획의 접근 방식이 테이블 전체를 읽는 ALL에서 필요한 quiz_id 범위만 읽는 range로 바뀌었고, P95 지연 시간은 175.5ms에서 85.0ms로 감소했습니다.

API 재측정

두 쿼리의 변경과 인덱스를 실제 애플리케이션에 반영한 뒤, 처음과 동일한 데이터에서 API를 순차적으로 다시 100회 호출했습니다.

측정 항목최적화 전최적화 후
P95 응답 시간785.7ms358.0ms

P95 응답 시간은 785.7ms에서 358.0ms로 54.4% 감소했습니다.
응답 크기와 반환하는 데이터는 그대로 유지했으므로, 기능 명세를 변경하지 않고 조회 경로만 개선한 결과입니다.

측정한 애플리케이션 구간의 평균은 318.2ms였으며, HTTP API 평균과의 약 11.0ms 차이에는 인증 필터와 컨트롤러 호출, 응답 기록 등의 비용이 포함됩니다.

애플리케이션 구간 측정

앞서 직접 실행한 퀴즈 상세 쿼리와 선택지 쿼리의 P95 합계는 119.4ms였습니다.
하지만 실제 API의 P95 응답 시간은 358.0ms였기 때문에 SQL 실행 이후의 비용도 확인할 필요가 있었습니다.

동일한 데이터로 5회 워밍업한 뒤, 실제 서비스와 같은 트랜잭션 경계에서 각 처리 단계를 30회 측정했습니다.

구간P95 실행 시간
quizRepository.findQuizDetails()69.0ms
quizOptionRepository.findAllByQuizIdIn()249.0ms
groupBy()1.7ms
JSON 직렬화13.4ms

저장소 호출 시간에는 쿼리 실행뿐 아니라 JDBC를 통한 객체 매핑도 포함됩니다.
특히 선택지 저장소는 15,000개의 행을 조회해 QuizOption 엔티티로 변환하므로 P95 249.0ms가 걸린 것을 확인할 수 있었습니다.
따라서 남은 주된 병목은 groupBy() 연산이나 JSON 직렬화가 아니라 선택지 조회 결과를 객체로 매핑하는 구간이었습니다.

Trade-off

이번 최적화를 위해 세 개의 인덱스를 추가했습니다.

CREATE INDEX idx_quiz_chapter_id ON quiz (chapter_id);
CREATE INDEX idx_user_solved_quiz_quiz_correct ON user_solved_quiz (quiz_id, is_correct);
CREATE INDEX idx_quiz_option_quiz_id ON quiz_option (quiz_id);

인덱스는 조회 성능을 높이는 대신 레코드 저장 시 인덱스 갱신 비용과 저장 공간을 추가로 사용합니다.
특히 user_solved_quiz는 퀴즈 채점 기능에서 계속 생성하는 테이블이므로, 인덱스가 쓰기 성능에 악영향을 주는지도 함께 확인해야 했습니다.

이 중 퀴즈 채점 과정에서 새 풀이 기록과 함께 갱신되는 인덱스는 user_solved_quiz(quiz_id, is_correct)입니다.
해당 인덱스의 쓰기 비용을 확인하기 위해 기존 풀이 기록 744,000건이 쌓여 있는 동일한 데이터에서 채점 API를 다시 측정했습니다.

요청마다 새로운 사용자를 사용해 모든 요청에서 풀이 기록 INSERT가 발생하도록 했으며, 인덱스 적용 전후에 각각 100회씩 순차적으로 호출했습니다.

측정 항목인덱스 미적용인덱스 적용
P95 응답 시간11.7ms9.3ms

풀이 기록을 저장할 때는 테이블 레코드뿐만 아니라 세컨더리 인덱스 엔트리도 함께 기록하므로 쓰기 작업과 저장 공간은 늘어납니다.
그러나 인덱스 적용 후 P95 응답 시간도 2.4ms 낮게 측정되어 명확한 지연 증가는 관찰되지 않았습니다.

인덱스 적용 결과가 조금 더 빠르게 나온 것은 인덱스 갱신 비용보다 API 성능 측정의 변동 폭이 더 컸기 때문으로 판단했습니다.
동시 쓰기 요청이 많거나 데이터가 더 증가하면 결과가 달라질 수 있지만, 적어도 현재 데이터와 요청 패턴에서는 채점 성능에 미치는 영향이 크지 않았습니다.

세 인덱스 모두 실제 API의 조인 조건이나 검색 조건에 직접 사용되고, 풀이 기록과 선택지는 조회 빈도가 쓰기 빈도보다 높은 데이터입니다.
조회 API의 P95 응답 시간이 785.7ms에서 358.0ms로 감소한 반면 채점 API에서는 측정 가능한 성능 저하가 나타나지 않았기 때문에, 현재 사용 패턴에서는 인덱스를 유지하는 편이 더 적합하다고 판단했습니다.

한편 한 챕터의 퀴즈 3,000개와 선택지 15,000개를 한 번에 반환하는 구조 자체는 여전히 큰 병목을 만듭니다.
페이지네이션을 적용하면 응답 크기와 객체 생성 비용까지 더 줄일 수 있지만, 이는 API 명세를 변경하는 별도의 문제이므로 이번 최적화 범위에서는 제외했습니다.