현재 문제 업로드를 할 때 임시저장 기능 구현을 아래와 같이 하고 있는데, 임시저장과 최종 업로드 하는 기능을 같은 API로 사용하고 있다.
사용자가 저장하기 버튼을 누르면 아래와 같은 작업이 수행된다.
- 현재 해당 교육에 업로드된 모든 퀴즈를 삭제한다.
- 요청 온 퀴즈를 다시 업로드한다.
문제 상황
현재 로직
@Transactional
public void createQuizzes(Long educationId, CreateQuizzesRequest request) {
Education findEducation = findEducationById(educationId);
checkQuizBefore(findEducation);
validateDuplicateNumber(request);
quizRepository.deleteAllByEducationId(educationId); // 삭제 쿼리
createShortQuizzes(findEducation, request.getShortQuizzes());
createMultipleQuizzes(findEducation, request.getMultiples());
}
이때, 교육 당 10개이긴 하지만, ‘임시 저장’으로 문제가 업로드 될 때 DB의 쿼리는 아래와 같이 복잡하게 나간다.
- 지울 모든 문제 조회
- 해당 문제에 양방향으로 매핑된 선지, 주관식 정답들 조회
- 하나씩 delete 쿼리 발생
가령, 주관식 문제 2개, 객관식 문제 1개를 삭제 후 재 업로드 한다고 하면
1. 문제 조회 + 선지 조회로 문제 개수 * 2개의 조회 쿼리가 우선 발생한다.
현재 3문제 → 6번의 조회쿼리가 발생한다.
2. 삭제 연산이 진행된다.
주관식 정답 2개 + 주관식 문제 1개 삭제 → 3개
주관식 정답 3개 + 주관식 문제 1개 삭제 → 4개
객관식 선지 4개 + 객관식 문제 1개 삭제 → 5개
총 12개의 삭제 쿼리가 발생하고 또 그에 준하는 삽입 연산이 일어난다.
(오.. 삭제가 제일 마지막에 나가네) JPA의 트랜잭션이 종료될때 나가는듯
즉, N개의 문제를 재업로드한다고 하면 2N번의 조회연산, N + a의 업로드 연산, N번의 삭제연산이 발생한다.
이후 재업로드를 통해 각각의 insert 연산을 해야하는 상황에서 하나라도 쿼리를 줄일 필요가 있었다.
원인
조졸두님의 블로그를 참고해 쿼리가 많이 발생하는 이유를 알 수 있었는데
이렇게 쿼리가 많이 나가는 이유는 Spring Data JPA는 deleteAllBy~ 와 같은 방식으로 삭제 연산을 처리할 때 JPA는 모든 데이터를 조회 후, 단건 삭제를 진행하기 때문에 N개의 데이터를 삭제한다면 N번 조회 + N번삭제한다고 한다.
따라서, 이를 해결하기 위해선 직접 In 쿼리를 작성해 한번의 쿼리로 모든 값을 삭제하는 것이 유리하다.
해결
우선, 교육에 해당하는 모든 문제 id를 List로 받는다.
List<Long> ids = quizRepository.findAllByEducationId(educationId).stream()
.map(Quiz::getId)
.toList();
이후, 삭제 연산을 수행한다.
이때, JPA 연관관계에 의해 삭제가 진행되는 것이 아니기 때문에 Quiz를 단방향 매핑하고 있는 ShortAnswer, Choice를 같이 삭제해줘야한다.
// 연관관계의 자식 클래스도 삭제 해줘야함
choiceRepository.deleteAllByQuizIdsInQuery(ids);
shortAnswerRepository.deleteAllByQuizIdsInQuery(ids);
//퀴즈 삭제
quizRepository.deleteAllByQuizIdsInQuery(ids);
관련 Repository 코드
public interface QuizRepository extends JpaRepository<Quiz, Long> {
@Transactional
@Modifying
@Query("delete from Quiz p where p.id in :ids")
void deleteAllByQuizIdsInQuery(@Param("ids") List<Long> ids);
}
public interface ChoiceRepository extends JpaRepository<Choice, Long> {
@Transactional
@Modifying
@Query("delete from Choice c where c.multipleQuiz.id in :quizIds")
void deleteAllByQuizIdsInQuery(@Param("quizIds") List<Long> quizIds);
}
public interface ShortAnswerRepository extends JpaRepository<ShortAnswer, Long> {
@Transactional
@Modifying
@Query("delete from ShortAnswer s where s.shortQuiz.id in :quizIds")
void deleteAllByQuizIdsInQuery(@Param("quizIds") List<Long> quizIds);
}
리팩토링 결과 조회 쿼리 1번, 삭제 쿼리 3번만(Choice, ShortAnswer, Quiz) 발생한다.
Hibernate:
select
q1_0.quiz_id,
q1_0.dtype,
q1_0.quiz_appear_second,
q1_0.created_at,
q1_0.education_id,
q1_0.generation_id,
q1_0.modified_at,
q1_0.quiz_number,
q1_0.quiz_photo_url,
q1_0.quiz_question,
q1_0.quiz_start,
q1_0.quiz_status
from
quiz q1_0
where
q1_0.education_id=?
Hibernate:
delete
from
choice
where
quiz_id in(?,?,?)
Hibernate:
delete
from
short_answer
where
quiz_id in(?,?,?)
Hibernate:
delete
from
quiz
where
quiz_id in(?,?,?)
<최종 코드>
@Transactional
public void createQuizzes(Long educationId, CreateQuizzesRequest request) {
Education findEducation = findEducationById(educationId);
checkQuizBefore(findEducation);
validateDuplicateNumber(request);
List<Long> ids = quizRepository.findAllByEducationId(educationId).stream()
.map(Quiz::getId)
.toList();
choiceRepository.deleteAllByQuizIdsInQuery(ids);
shortAnswerRepository.deleteAllByQuizIdsInQuery(ids);
quizRepository.deleteAllByQuizIdsInQuery(ids);
createShortQuizzes(findEducation, request.getShortQuizzes());
createMultipleQuizzes(findEducation, request.getMultiples());
}
PR 브랜치
'프로젝트 > COTATO.KR' 카테고리의 다른 글
멱등성을 통한 정답 제출 API 중복 요청 방지 (이론) (0) | 2024.06.01 |
---|---|
퀴즈 객체 양방향 매핑 없애기 (0) | 2024.04.24 |
MethodArgumentNotValidException 처리하기 (0) | 2024.04.17 |
Controller 리팩토링 (0) | 2024.04.15 |
ErrorResponse로 전달할 필드에 대한 고민 (0) | 2024.04.02 |