퀴즈뽑기 성능 개선기(인데 된건없다)

말하는 감자·2025년 6월 25일

내일배움캠프

목록 보기
72/73
  1. quiz_category : 5개 소분류 생성
  2. quiz : 1,000,000개 생성 (category 랜덤 할당)
  3. subscription : 10,000개 생성
  4. user_quiz_answer : 각 subscription에 대해 1~100개 랜덤 생성 → 총 약 650,000개

퀴즈 100만개, 답변기록 20만개, 유저 1만개 넣는스크립트

 @SpringBootTest(classes = Cs25BatchApplication.class)
 @TestInstance(TestInstance.Lifecycle.PER_CLASS)
 class TodayQuizServiceInsertTest {

     @Autowired
     QuizCategoryRepository quizCategoryRepository;

     @Autowired
     private JdbcTemplate jdbcTemplate;

     QuizCategory parent;
     List<QuizCategory> categories = new ArrayList<>();

     private long startTime;

     @BeforeEach
     void beforeEach(TestInfo testInfo) {
         startTime = System.currentTimeMillis();
         System.out.println("!!!!!시작: " + testInfo.getDisplayName());
     }

     @AfterEach
     void afterEach(TestInfo testInfo) {
         long duration = System.currentTimeMillis() - startTime;
         System.out.print("@@@@ 종료: " + testInfo.getDisplayName());
         System.out.println("  (소요 시간: " + duration + " ms)");
     }

     @Test
     @Order(1)
     @DisplayName("테스트용 카테고리 5개 넣기")
         //   "테스트용 카테고리 5개 넣기")
     void insertQuizCategories() {

         parent = QuizCategory.builder()
             .categoryType("TEST")
             .parent(null)
             .build();

         quizCategoryRepository.save(parent);

         for (int i = 1; i <= 5; i++) {
             QuizCategory sub = QuizCategory.builder()
                 .categoryType("Sub" + i)
                 .parent(parent)
                 .build();
             quizCategoryRepository.save(sub);
             categories.add(sub);
         }
     }

     @Test
     @Order(4)
     @DisplayName("퀴즈 답변 100만개 넣기")
     void insertUserQuizAnswersTest() {
         List<Long> subscriptionIds = jdbcTemplate.queryForList(
             "SELECT id FROM subscription", Long.class);

         List<Long> quizIds = jdbcTemplate.queryForList(
             "SELECT id FROM quiz", Long.class);

         insertUserQuizAnswers(subscriptionIds, quizIds);
     }

     void insertUserQuizAnswers(List<Long> subscriptionIds, List<Long> quizIds) {
         Random random = new Random();
         Faker faker = new Faker();

         int batchSize = 5000;
         List<Object[]> batch = new ArrayList<>();

         for (Long subId : subscriptionIds) {
             int count = random.nextInt(100) + 1; // 1~100
             Set<Long> sample = getRandomSample(quizIds, count);

             for (Long quizId : sample) {
                 batch.add(new Object[]{
                     Timestamp.valueOf(LocalDateTime.now().minusDays(random.nextInt(30))),
                     faker.lorem().sentence(), //답변
                     random.nextBoolean(), // 정답 여부
                     quizId,
                     subId
                 });

                 // 배치 실행
                 if (batch.size() >= batchSize) {
                     jdbcTemplate.batchUpdate(
                         "INSERT INTO user_quiz_answers (created_at, user_answer, is_correct, quiz_id, subscription_id) "
                             +
                             "VALUES (?, ?, ?, ?, ?)",
                         batch
                     );
                     batch.clear();
                 }
             }
         }

         // 마지막 남은 데이터 처리
         if (!batch.isEmpty()) {
             jdbcTemplate.batchUpdate(
                 "INSERT INTO user_quiz_answers (created_at, user_answer, is_correct, quiz_id, subscription_id) "
                     +
                     "VALUES (?, ?, ?, ?, ?)",
                 batch
             );
         }
     }

     private Set<Long> getRandomSample(List<Long> list, int count) {
         Collections.shuffle(list);
         return new HashSet<>(list.subList(0, Math.min(count, list.size())));
     }

     @Test
     @Order(2)
     @DisplayName("테스트 퀴즈 100만개 넣기")
     void insertQuizzes() {

         if (categories.isEmpty()) {
             List<Long> categoryIds = jdbcTemplate.queryForList(
                 "SELECT id FROM quiz_category WHERE parent_id = 9", Long.class);

             for (Long id : categoryIds) {
                 QuizCategory category = QuizCategory.builder().build();
                 ReflectionTestUtils.setField(category, "id", id);
                 categories.add(category);
             }
         }

         Random random = new Random();
         Faker faker = new Faker();
         int batchSize = 5000;
         List<Object[]> batch = new ArrayList<>();
         List<QuizFormatType> types = List.of(QuizFormatType.MULTIPLE_CHOICE,
             QuizFormatType.SHORT_ANSWER, QuizFormatType.SUBJECTIVE);

         List<QuizLevel> levels = List.of(QuizLevel.EASY,
             QuizLevel.NORMAL, QuizLevel.HARD);

         for (int i = 0; i < 1_000_000; i++) {
             QuizCategory category = categories.get(i % categories.size());

             batch.add(new Object[]{
                 Timestamp.valueOf(LocalDateTime.now().minusDays(random.nextInt(90))),
                 types.get(i % 2).name(),
                 "Q" + faker.yoda().quote() + i,
                 "A" + faker.lorem().paragraph() + i,
                 "Commentary " + faker.chuckNorris().fact() + faker.yoda().quote() + i,
                 "1. A / 2. B / 3. C / 4. D" + faker.lorem().sentence() + faker.shakespeare()
                     .asYouLikeItQuote(),
                 category.getId(),
                 false,
                 levels.get(i % 2).name(),
             });

             if (batch.size() >= batchSize) {
                 jdbcTemplate.batchUpdate(
                     "INSERT INTO quiz (created_at,type, question, answer, commentary, choice, "
                         + "quiz_category_id, is_deleted, level) "
                         +
                         "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
                     batch
                 );
                 batch.clear();
             }
         }

         // 마지막 남은 데이터 처리
         if (!batch.isEmpty()) {
             jdbcTemplate.batchUpdate(
                 "INSERT INTO quiz (created_at,type, question, answer, commentary, choice, "
                     + "quiz_category_id, is_deleted, level) "
                     +
                     "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
                 batch
             );
         }
     }

     @Test
     @Order(3)
     @DisplayName("구독 만개 넣기")
     void insertSubscriptions() {
         Random random = new Random();
         List<Object[]> batch = new ArrayList<>();

         List<Integer> subscriptionTypes = List.of(
             2, 32, 10, 20, 42, 84, 62, 65, 43, 127
         );

         for (int i = 0; i < 10_000; i++) {
             batch.add(new Object[]{
                 Timestamp.valueOf(LocalDateTime.now().minusDays(random.nextInt(90))),
                 parent.getId(),
                 "user" + i + "@test.com",
                 true,
                 subscriptionTypes.get(i % subscriptionTypes.size())
             });
         }

         jdbcTemplate.batchUpdate(
             "INSERT INTO subscription (created_at, quiz_category_id, email, is_active, subscription_type) "
                 + "VALUES (?, ?, ?, ?,?)",
             batch
         );
     }

 }

현재 문제

  • 퀴즈뽑는데 전체조회 및 조인 걸쳐진 QueryDSL문이 총 4개가 돈다
  • 성능조회를 더 당길 수 있는 방법은 없을까?

개선해볼 점

  • 큰 쿼리문 하나 만들어서 돌리는게 I/O 횟수가 적으니 성능 향상되지않을까?
  • 퀴즈 후보 리스트를 모두 뽑아오는게 아니라 제일 첫 조건 만족한 퀴즈 하나 뽑아오기로 쿼리를 줄이면 속도가 빠르지 않을까?

환경

💡

SELECT count(*) FROM cs25.user_quiz_answers;

657,965


SELECT count(*) FROM cs25.quiz;

997,743


SELECT count(*) FROM cs25.subscription;

10,005





DB 데이터 대용량으로 넣고 실험하니 java.lang.OutOfMemoryError: Java heap space 발생

발생위치

        // 7. 필터링 조건으로 문제 조회(대분류, 난이도, 내가푼문제 제외, 제외할 카테고리 제외하고, 문제 타입 전부 조건으로)
        List<Quiz> candidateQuizzes = quizRepository.findAvailableQuizzesUnderParentCategory(
            parentCategoryId,
            allowedDifficulties,
            solvedQuizIds,
            excludedCategoryIds,
            targetTypes
        ); //한개만뽑기(find first)
  • 이용자 수가 적더라도 Quiz 테이블의 양은 얼마든지 클 수 있음
  • 테스트 코드지만 이런식으로 후보군이 될 수 있는 퀴즈의 양이 많으면 OOE 문제 다시 일어날 것이라 생각됨.




해결방안

  • 전부 불러오지 말고 일부만 불러오자 (100개)







개선 전

테스트 케이스 1

export const options = {
  vus: 200,              // 동시 실행자 수 (스레드 개념)
  iterations: 10000,     // 총 요청 수
};

export default function () {
  const subscriptionId = __ITER; // 1부터 10000까지
  http.get(
      `http://host.docker.internal:8080/accuracyTest/getTodayQuiz/${subscriptionId}`);
}




결과

항목
avg613ms)
min/maxmin=3.54ms
max=59.99s
med중앙값 (50%) = 12.68ms
p(90)/p(95)p(90)=15.52ms
p(95)=17.98ms
실패 비율3.9%

exception 비율이 너무 높아서 스레드수를 너무 많이 했나 싶어서 메세지 큐에서 사용되고 있는 스레드 수를 확인 한 수 줄여보았다.




테스트 케이스 2

export const options = {
  vus: 50,              // 동시 실행자 수 (스레드 개념)
  iterations: 10000,     // 총 요청 수
};

export default function () {
  const subscriptionId = __ITER; // 1부터 10000까지
  http.get(
      `http://host.docker.internal:8080/accuracyTest/getTodayQuiz/${subscriptionId}`);
}

결과

항목전체성공 케이스만
avg291.69ms39.44ms
min/maxmin=3.42ms
max=59.99smin=9.96ms
max=5.3s
med중앙값 (50%) = 12.68msmed = 12.68ms
p(90)/p(95)p(90)=14.88ms
p(95)=15.96msp(90)=14.79ms
p(95)=15.7ms
실패 비율1.90%




테스트 케이스 3

  • 퀴즈뽑을 때 조건이 너무많아서 일부 조건을 제거
    • 가장 최근에 풀었던 소분류 제거
    • 퀴즈 리스트를 뽑아오는 것이 아닌 조건을 만족하는 첫 row만 가져오도록 하기
  • for문 하나 제거 (로직 수정)
export const options = {
  vus: 50,              // 동시 실행자 수 (스레드 개념)
  iterations: 10000,     // 총 요청 수
};

export default function () {
  const subscriptionId = __ITER; // 1부터 10000까지
  http.get(
      `http://host.docker.internal:8080/accuracyTest/getTodayQuiz/${subscriptionId}`);
}

결과

항목전체성공 케이스만
avg1.15s413.1ms
min/maxmin=3.02ms
max=1m0smin=7.49ms
max=59.99s
medmed=21.26msmed=19.02ms
p(90)/p(95)p(90)=1.08s
p(95)=1.26sp(90)=28.99ms
p(95)=32.74ms
실패 비율20.59%

성능이 굉장히 안좋아졌다.

문제 뽑기 대상이 되는 쿼리를 짤때 조건이 많았는데, 그 조건을 집어넣을 때 뭔가 잘못하고 있는 것 같다.




테스트 케이스 4

  • 오류 찾아서 제거( QueryDSL에 들어가는 id값이 subscriptionId여야하는데 userId가 들어가고 있었다…(이때까지 어떻게 돌아간거지))
  • 카테고리 목록을 받아오는 로직 제거 (혹시나 생길 N+1 문제 제거)
    → 서브 쿼리 없이 한 번에 조회 (대신 Category 조인이 들어감)
  • 퀴즈 필터링 조회시 필요한 컬럼에 대한 인덱스 생성

@Index(name = "idx_quiz_category_level_type", columnList = "quiz_category_id, level, type")

결과

항목전체성공 케이스만
avg1.28s522.1ms
min/maxmin=8.1ms
max=1m0smin=8.1ms
max=58.88s
medmed=14.85msmed=14.78ms
p(90)/p(95)p(90)=902.14ms
p(95)=1.09sp(90)=884.51ms
p(95)=1.04s
실패 비율1.27%

2025-06-25 14:03:28 time="2025-06-25T05:03:28Z" level=warning msg="Request Failed" error="Get \"http://host.docker.internal:8080/accuracyTest/getTodayQuiz/11\": request timeout"
2025-06-25 14:03:28 time="2025-06-25T05:03:28Z" level=error msg="[❌ FAIL] iter=1, status=0, url=http://host.docker.internal:8080/accuracyTest/getTodayQuiz/11\nresponse: null" source=console

타임아웃문제가 일어나는데 k6는 기본적으로 대기시간이 1분이기 때문에 1분안으로 답이안오면 실패처리를 해버린다고한다.

이걸 늘렸을 때 과연 모두 통과하는지 확인해보자




테스트 케이스 5

  • 타임아웃 조건 1m → 2m으로 조정
  • Quiz를 뽑는 QueryDSL 내부에서 필요없는 연산 제거
  • 추가로 DB의 병목이 심해보여서 hIkari 설정을 추가해주었다.
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=0
spring.datasource.hikari.idle-timeout=5000  
spring.datasource.hikari.max-lifetime=10000    
spring.datasource.hikari.leak-detection-threshold=3000 

결과

항목전체성공 케이스만
avg1.77s522.1ms
min/maxmin=8.21ms
max=1m59smin=8.21ms
max=1m59s
medmed=15.76msmed=15.68ms
p(90)/p(95)p(90)=915.49ms
p(95)=1.12sp(90)=911.36ms
p(95)=1.09s
실패 비율0.75%

실패 비율은 줄었지만 아직까지 있는 모습이다.

오히려 처음보다 각 요청별 시간이 계속 늘어나고 있는 모습을 보인다.

그래서 4에서 따로 Hikari 설정해줬던 것을 모두 빼고 다시 돌려보았다.




테스트 케이스 6

export const options = {
  vus: 50,              // 동시 실행자 수 (스레드 개념)
  iterations: 10000,     // 총 요청 수
};

export default function () {
  const subscriptionId = __ITER; // 1부터 10000까지
  http.get(
      `http://host.docker.internal:8080/accuracyTest/getTodayQuiz/${subscriptionId}`);
}

결과

항목전체성공 케이스만
avg292.07ms49.85ms
min/maxmin=8.44ms
max=59.99smin=8.44ms
max=5s
medmed=11.49msmed=11.48ms
p(90)/p(95)p(90)=14.28ms
p(95)=16.35msp(90)=14.19ms
p(95)=16.06ms
실패 비율0.40%

여기서 멈추기로 했다. (이유는 결론에)







결론

퀴즈뽑기라는 함수는 사용자가 직접 호출을 하는 api가 아니고, 스케쥴러에서 일정 시간마다 호출된다.

호출 되었을 때 이메일 발송 실패가 뜰 경우엔 실패되는 요청만 모아놨다가 따로 추가 발송을 시도한다.

이 때문에 1만건 중 40건의 오류는 따로 오류 큐에 담겨져 있다가 모든 처리가 끝난 후에 다시 발송하게 된다.

소량의 메일을 보낼 때(200건까지 시도해봄)는 오류없이 모두 동작되는 것을 확인했다.

이번 시도를 통해 쿼리 단순화 + 인덱싱 + 오버헤드 제거로 성능 안정성 확보 를 하는 과정을 담아봤다.




성능 변화 요약

테스트avg실패율비고
기본 (1)613ms3.9%조건 많음
일부 조건 제거 (3)1.15s20.5%오히려 악화

| 인덱스 추가 (4)

  • 직접 조인 | 375ms | 19.9% | 속도 ↑ 실패율 ↓ |
    | 타임아웃 연장
  • 오류 제거
  • QueryDSL 내부 함수 조정
  • Hikari 설정 (5) | 1.77s | 0.75% | 대부분 통과 |
    | Hikari 설정 제거 (6) | 292ms | 0.40% | 최종 선택 |
profile
대충 데굴데굴 굴러가는 개발?자

0개의 댓글