퀴즈 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
);
}
}
SELECT count(*) FROM cs25.user_quiz_answers;
657,965
SELECT count(*) FROM cs25.quiz;
997,743
SELECT count(*) FROM cs25.subscription;
10,005
발생위치
// 7. 필터링 조건으로 문제 조회(대분류, 난이도, 내가푼문제 제외, 제외할 카테고리 제외하고, 문제 타입 전부 조건으로)
List<Quiz> candidateQuizzes = quizRepository.findAvailableQuizzesUnderParentCategory(
parentCategoryId,
allowedDifficulties,
solvedQuizIds,
excludedCategoryIds,
targetTypes
); //한개만뽑기(find first)
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}`);
}

| 항목 | |
|---|---|
| avg | 613ms) |
| min/max | min=3.54ms |
| max=59.99s | |
| med | 중앙값 (50%) = 12.68ms |
| p(90)/p(95) | p(90)=15.52ms |
| p(95)=17.98ms | |
| 실패 비율 | 3.9% |
exception 비율이 너무 높아서 스레드수를 너무 많이 했나 싶어서 메세지 큐에서 사용되고 있는 스레드 수를 확인 한 수 줄여보았다.
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}`);
}
| 항목 | 전체 | 성공 케이스만 |
|---|---|---|
| avg | 291.69ms | 39.44ms |
| min/max | min=3.42ms | |
| max=59.99s | min=9.96ms | |
| max=5.3s | ||
| med | 중앙값 (50%) = 12.68ms | med = 12.68ms |
| p(90)/p(95) | p(90)=14.88ms | |
| p(95)=15.96ms | p(90)=14.79ms | |
| p(95)=15.7ms | ||
| 실패 비율 | 1.90% |
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}`);
}
| 항목 | 전체 | 성공 케이스만 |
|---|---|---|
| avg | 1.15s | 413.1ms |
| min/max | min=3.02ms | |
| max=1m0s | min=7.49ms | |
| max=59.99s | ||
| med | med=21.26ms | med=19.02ms |
| p(90)/p(95) | p(90)=1.08s | |
| p(95)=1.26s | p(90)=28.99ms | |
| p(95)=32.74ms | ||
| 실패 비율 | 20.59% |
성능이 굉장히 안좋아졌다.
문제 뽑기 대상이 되는 쿼리를 짤때 조건이 많았는데, 그 조건을 집어넣을 때 뭔가 잘못하고 있는 것 같다.
@Index(name = "idx_quiz_category_level_type", columnList = "quiz_category_id, level, type")
| 항목 | 전체 | 성공 케이스만 |
|---|---|---|
| avg | 1.28s | 522.1ms |
| min/max | min=8.1ms | |
| max=1m0s | min=8.1ms | |
| max=58.88s | ||
| med | med=14.85ms | med=14.78ms |
| p(90)/p(95) | p(90)=902.14ms | |
| p(95)=1.09s | p(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분안으로 답이안오면 실패처리를 해버린다고한다.
이걸 늘렸을 때 과연 모두 통과하는지 확인해보자
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
| 항목 | 전체 | 성공 케이스만 |
|---|---|---|
| avg | 1.77s | 522.1ms |
| min/max | min=8.21ms | |
| max=1m59s | min=8.21ms | |
| max=1m59s | ||
| med | med=15.76ms | med=15.68ms |
| p(90)/p(95) | p(90)=915.49ms | |
| p(95)=1.12s | p(90)=911.36ms | |
| p(95)=1.09s | ||
| 실패 비율 | 0.75% |
실패 비율은 줄었지만 아직까지 있는 모습이다.
오히려 처음보다 각 요청별 시간이 계속 늘어나고 있는 모습을 보인다.
그래서 4에서 따로 Hikari 설정해줬던 것을 모두 빼고 다시 돌려보았다.
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}`);
}
| 항목 | 전체 | 성공 케이스만 |
|---|---|---|
| avg | 292.07ms | 49.85ms |
| min/max | min=8.44ms | |
| max=59.99s | min=8.44ms | |
| max=5s | ||
| med | med=11.49ms | med=11.48ms |
| p(90)/p(95) | p(90)=14.28ms | |
| p(95)=16.35ms | p(90)=14.19ms | |
| p(95)=16.06ms | ||
| 실패 비율 | 0.40% |
여기서 멈추기로 했다. (이유는 결론에)
퀴즈뽑기라는 함수는 사용자가 직접 호출을 하는 api가 아니고, 스케쥴러에서 일정 시간마다 호출된다.
호출 되었을 때 이메일 발송 실패가 뜰 경우엔 실패되는 요청만 모아놨다가 따로 추가 발송을 시도한다.
이 때문에 1만건 중 40건의 오류는 따로 오류 큐에 담겨져 있다가 모든 처리가 끝난 후에 다시 발송하게 된다.
소량의 메일을 보낼 때(200건까지 시도해봄)는 오류없이 모두 동작되는 것을 확인했다.
이번 시도를 통해 쿼리 단순화 + 인덱싱 + 오버헤드 제거로 성능 안정성 확보 를 하는 과정을 담아봤다.
| 테스트 | avg | 실패율 | 비고 |
|---|---|---|---|
| 기본 (1) | 613ms | 3.9% | 조건 많음 |
| 일부 조건 제거 (3) | 1.15s | 20.5% | 오히려 악화 |
| 인덱스 추가 (4)