1. 들어가며

Quizly는 사용자가 문제를 풀 때마다 solve_history에 한 줄씩 쌓입니다. 이 테이블을 읽는 기능은 두 가지입니다. 매일 새벽 전날 풀이를 집계하는 일일 통계 배치, 그리고 지금까지 푼 문제를 날짜·주제별로 다시 보여주는 문제 조회 API입니다.

서비스 초기에는 둘 다 문제가 없었습니다. 하지만 서비스를 운영할수록 문제 조회 페이지의 로딩이 점차 길어지는 문제가 발생했습니다.

운영 기간이 늘어 사용자별 데이터가 쌓이면서 생긴 문제라고 보고, 동일한 사양의 개발 서버에서 재현해 보았습니다.

[재현 화면 캡쳐]

데이터 규모 (풀이 이력) 10배 (2.2만) 50배 (11만) 100배 (22만) 200배 (44만)
배치 Job 실행 시간 0.7초 2.0초 3.7초 8.3초
그중 사용자 조회(Reader) 0.03초 0.29초 0.92초 3.33초

전날 풀이 양은 그대로인데, 전체 이력이 쌓일수록 배치가 느려졌습니다. 문제 조회 API도 비슷했습니다. 이력이 가장 많은 사용자는 한 번 요청에 4MB짜리 응답을 받고 있었습니다.

이 글에서는 두 기능을 어떻게 진단하고 고쳤는지, 그리고 그 효과를 부하 테스트로 어떻게 확인했는지 정리합니다. API 쪽은 인덱스만으로 해결되지 않아 처음 설계한 응답 구조부터 바꿔야 했습니다.

2. 테스트 환경

2.1 실서버 분포를 유지한 더미 데이터

더미 데이터를 만들기 전에 실서버 데이터의 모양부터 확인했습니다. 개인정보가 나오지 않도록 건수와 비율만 뽑았습니다.

항목 실서버
user (풀이 기록이 있는 사용자) 90 (47)
quiz / solve_history 1,622 / 1,396
사용자당 풀이 이력 (최대 / 평균) 357 / 30
오답 / 재풀이 / 미풀이 비율 73.9% / 39.8% / 50.7%

생성기는 별도 소스셋(src/devtools)에 Java로 만들었습니다. SQL 프로시저 대신 코드로 만든 이유는 서비스 로직과 같은 순서로 데이터를 쌓고 싶었기 때문입니다. 문제를 만들면 초안 행이 생기고, 채점하면 그 행이 갱신되고, 오답 노트에서 다시 풀면 새 행이 추가되는 흐름을 그대로 따랐습니다.

for (QuizRow quiz : memberQuizzes) {
    if (rng.nextDouble() < UNSOLVED_RATIO) {
        // 문제 생성 시 저장되는 초안(CreateMemberQuizzesService)
        rows.add(draft(quiz));
    } else {
        // 채점 시 초안 행을 갱신(GradeMemberQuizzesService)
        // ...
    }
    // 오답 노트에서 다시 풀면 새 행 추가(GradeMemberWrongQuizzesService), 맞힐 때까지 최대 3회
    // ...
}

나머지 규칙은 이렇게 정했습니다.

항목 200배 적재 결과
user (풀이 기록이 있는 사용자) 18,000 (9,400)
quiz / quiz_options / solve_history 324,400 / 722,420 / 445,808
오답 / 재풀이 / 미풀이 50.2% / 39.9% / 30.1%

2.2 부하 테스트 구성