하이러닝 성능 최적화 및 운영 안정화, 2024.01 ~ 2025.10 트러블슈팅
반복 조회와 요청마다 하던 집계를 줄여 API 응답 지연 해소
반복문 안에서 매퍼를 부르던 구조와 요청마다 원본을 집계하던 쿼리를 한 번에 읽는 구조로 바꿔, 교사용 설문 상세 응답을 4초 이상에서 1초대로 줄임
이슈 상황
- 수업 목록 쿼리와 같은 방식으로 APM에서 응답 4초 이상인 API를 추적했습니다.
- 요청별 추적 화면의 SQL 호출 횟수를 보면, 한 번의 요청에서 같은 쿼리를 반복하거나 요청마다 원본을 집계하는 API가 있었습니다. 교사용 설문 상세의 반복 조회는 APM 호출 기록과 VoC로 찾았습니다.
원인 분석
- 교사용 설문 상세는 문항 목록을 읽은 뒤 문항마다 보기와 응답 수를, 보기마다 응답 학생을 다시 조회했습니다. N+1 조회가 문항과 보기 두 단계로 겹쳐, 20문항에 보기가 4개씩이면 1 + 20 + 80 = 101회입니다.
- 그중 가장 많은 응답 학생 조회는 실행할 때마다 학생 목록 서브쿼리와 프로필 이미지 서브쿼리를 다시 계산해, 한 번의 비용도 학생 수에 비례했습니다.
개선
- 문항과 보기를 조인해 보기별 응답 수를
GROUP BY로 한 번에 집계했습니다. - 학생 목록은 CTE로 한 번만 만들고 제출 정보와 조인해 한 번에 읽은 뒤, Java에서
groupingBy로 문항별·보기별로 묶었습니다. 조회는 문항 수와 관계없이 2회가 됐습니다. - 문항 20개 이상, 학생 15명 이상인 데이터로 검증했고, 사용자와 QA가 정확성을 확인한 뒤 배포했습니다.
설문 상세의 변경 전후 코드 보기
메서드와 테이블 이름은 일반 이름으로 바꿨고, 조인 조건은 구조가 보이도록 단순화했습니다.
// 변경 전: 문항마다, 보기마다 매퍼 호출
List<Quest> quests = mapper.selectQuestList(activityId); // 1회
for (Quest q : quests) {
q.setAnswers(mapper.selectAnswerList(q.getId())); // 문항 수만큼
for (Answer a : q.getAnswers()) {
a.setStudents(mapper.selectAnsweredStudents(a.getId())); // 보기 수만큼
}
}
// 변경 후: 두 번 읽고 Java에서 묶음
List<QuestAnswerRow> answers = mapper.selectQuestAnswerList(activityId); // 1회
List<SubmitRow> submits = mapper.selectSubmitList(activityId); // 1회
Map<Long, List<SubmitRow>> studentsByAnswer = submits.stream()
.collect(Collectors.groupingBy(SubmitRow::getAnswerId));
-- 첫 번째 조회: 문항, 보기, 보기별 응답 수를 한 번에
SELECT q.quest_id, a.answer_id, COUNT(d.answer_id) AS submit_cnt
FROM quest q
JOIN answer a ON a.quest_id = q.quest_id
LEFT JOIN submit_detail d ON d.answer_id = a.answer_id
WHERE q.activity_id = :activityId
GROUP BY q.quest_id, a.answer_id
-- 두 번째 조회: 학생 목록은 CTE로 한 번만 만들고 제출 정보와 조인
WITH students AS ( /* 수업의 학생 목록 */ )
SELECT st.student_id, q.quest_id, d.answer_id, f.file_path
FROM students st
JOIN quest q ON q.activity_id = :activityId
LEFT JOIN submit s ON s.student_id = st.student_id AND s.activity_id = q.activity_id
LEFT JOIN submit_detail d ON d.submit_id = s.submit_id AND d.quest_id = q.quest_id
LEFT JOIN file f ON f.file_id = d.file_id
같은 방식으로 고친 다른 조회
| 대상 | 변경 전 | 변경 후 | 결과 |
|---|---|---|---|
| 학생 종합 리포트 | 학기 시작부터 한 달씩 집계 쿼리 세 개 반복 | 전체 기간을 월 단위로 묶어 한 번에 집계하고 달별로 나눔 | 조회 3M회 → 3회 (M: 개월 수) |
| 과목별 학생 조회 | 과목마다 조회, 매번 프로필 이미지 CTE 재계산 | 과목 ID 목록을 IN으로 한 번에 조회하고 과목별로 나눔 | N회 → 1회 (N: 과목 수) |
| 콘텐츠 목록 | 요청마다 미반영 조회 원본을 집계해 조인 | 배치가 반영한 조회수 컬럼만 읽음 | 요청마다 하던 집계 제거 |
- 과목 ID 목록이 비어 있으면 조회하지 않게 했습니다. 빈 목록이
IN조건에 들어가면 SQL 문법 오류가 납니다. - 콘텐츠 목록은 조회수가 배치 주기만큼 늦게 반영되는 대신, 요청마다 하던 집계가 없어졌습니다.
다른 조회의 변경 전후 SQL 보기
-- 콘텐츠 목록, 변경 전: 요청마다 미반영 조회 원본을 집계해 더함
SELECT c.content_id, c.title, c.hits + COALESCE(h.hits, 0) AS hits
FROM content c
LEFT JOIN (
SELECT content_id, COUNT(*) AS hits
FROM content_hit_raw
WHERE apply_yn = 'N'
GROUP BY content_id
) h ON c.content_id::text = h.content_id
-- 콘텐츠 목록, 변경 후: 집계 배치가 반영한 조회수만 읽음
SELECT c.content_id, c.title, c.hits
FROM content c
-- 학생 종합 리포트, 변경 후(세 쿼리 중 하나를 단순화): 기간 전체를 월 단위로 묶어 한 번에 집계
SELECT TO_CHAR(reg_dt, 'YYYYMM') AS ym, student_id, /* 집계 값 */
FROM study_log
WHERE reg_dt BETWEEN :semesterStart AND :today
AND student_id IN (:studentIds)
GROUP BY TO_CHAR(reg_dt, 'YYYYMM'), student_id
결과
- 교사용 설문 상세 API 응답 시간을 4초 이상에서 운영 기준 1초대로 줄였습니다.
- 리포트와 과목별 학생 조회는 기간이나 과목 수가 늘어도 조회 횟수가 늘지 않게 됐습니다.