JPARepository에서 기본적으로 제공하는 메서드들이 있다.
findById, findAll, delete(), deleteAll() 등.
이들 메서드는 CRUD 작업을 손쉽게 처리할 수 있도록 돕는다.
그런데 최근에 궁금해진 것이 있다. 이 작업들을 한 번에 최적화하여 처리할 수는 없을까?
데이터베이스 쿼리에서 select 연산을 조인으로 최적화하여 N번의 쿼리를 단일 쿼리로 해결하는 경우가 많다.
이를 통해 성능을 크게 향상시킬 수 있다.
그렇다면 insert, update, delete와 같은 벌크성 쿼리도 비슷한 방식으로 최적화할 수 있을까?
벌크성 쿼리의 최적화
일반적으로 insert, update, delete와 같은 벌크성 쿼리는 select처럼 조인을 통해 N번의 쿼리를 단일 쿼리로 해결하는 것이 불가능하다.
그러나 JPA는 이를 최적화할 수 있는 기능을 제공한다.
Batch 처리와 최적화
예를 들어, 모든 회원의 나이를 1살 올려야 하는 상황을 생각해보자.
기본적으로는 각 회원에 대해 update 쿼리가 개별적으로 실행되어 N번의 쿼리가 발생할 것이다.
그러나 batch_size를 설정해두면, 이 쿼리들을 하나의 배치로 묶어 한 번에 처리할 수 있다.
이는 성능 최적화에 있어 큰 장점을 제공한다.
Batch 설정의 효과
application.yml에서 batch_size를 1000으로 설정한다고 가정해보자.
만약 900개의 벌크성 쿼리가 실행되어야 하는 상황이라면, 배치 처리가 있는 경우와 없는 경우의 성능 차이는 어떻게 될까?
배치 처리 없음: 900개의 쿼리가 개별적으로 실행되어 데이터베이스에 과부하가 발생할 수 있다.
배치 처리 있음: 900개의 쿼리가 1000개의 배치 단위로 묶여 실행된다.
이로 인해 데이터베이스와 애플리케이션 간의 통신 횟수가 줄어들어 성능이 크게 향상된다.
결론적으로, batch_size를 적절히 설정하면 벌크성 쿼리의 성능을 대폭 개선할 수 있다.
현재 Mash-Up에서 동아리 구성원들간 칭찬을 하는 투표 서비스를 개발 중이다.
우리의 칭찬 픽 서비스는 기본적으로 아래와 같다.
1. 매일 2번 오전 9시, 오후 6시에 모든 회원에 대해 질문지가 생성된다.
2. 질문지에 해당하는 질문 12개의 순서와 내용은 모든 회원이 같다.
3. 질문 12개 중 9개는 친구 질문, 3개는 비친구 질문이다.
4. 모든 회원에 있어서 오전 9시, 오후 6시가 되기 5분 전, 미리 질문지가 만들어져야한다.
5. 단, 모든 회원은 모두 친구정보가 다르기 때문에, 친구 비친구 여부에 따라 나뉜 질문에 대해 후보자를 랜덤으로 갖고와야한다.
6. 사용하다가 나갔다가 다시 들어왔을 경우 어디까지 픽을 해야했는지 알아야하기 때문에, 질문을 내려줄 때, 남은 질문지를 내려준다.
이렇게 서비스가 존재하는데, 내가 맡은 역할은 질문지를 스케줄러를 사용하여 모든 회원에 대해 생성하는 것, 각 회원별 받은 픽 페이징, 픽 중 특정 질문에 대해 받은 픽에 대한 종류, 친구 프로필 조회 등이 있다.
여기서, 나는 스케줄러를 사용하여 질문지를 생성하다 위와같은 고민을 하게 됐다.
https://github.com/mash-up-kr/Dojo-Spring/pull/86
feat: Create QuestionSheet by toychip · Pull Request #86 · mash-up-kr/Dojo-Spring
🔀 PR 제목의 형식을 잘 작성했나요? e.g. [add] pr template 🧹 불필요한 코드는 제거했나요? 작업 내용 QuestionSheet 생성 로직을 구현했습니다. 친구 관계의 질문은 친구 중에서, 비친구 관계의 질문은
github.com
완성작은 위와 같다.
@Transactional
override fun createQuestionSheet(): List<QuestionSheet> {
val currentQuestionSet =
questionService.getNextOperatingQuestionSet()
?: throw DojoException.of(DojoExceptionType.QUESTION_SET_NOT_READY)
val allMemberRecords = memberService.findAllMember()
val questions =
currentQuestionSet.questionIds.map { questionOrder ->
questionService.getQuestionType(questionOrder.questionId)
}
val allMemberQuestionSheets =
allMemberRecords.flatMap { member ->
// 각 질문별로 후보자를 선택
val questionSheets =
questions.map { questionOrder ->
val candidatesOfFriend = memberRelationService.findCandidateOfFriend(member.id)
val candidatesOfAccompany = memberRelationService.findCandidateOfAccompany(member.id)
val questionType = questionOrder.type
// 질문의 타입에 따라 다른 후보자 리스트를 전달
questionService.createQuestionSheetForSingleQuestion(
questionSetId = currentQuestionSet.id,
questionId = questionOrder.id,
questionType = questionType,
resolver = member.id,
candidatesOfFriend = candidatesOfFriend,
candidatesOfAccompany = candidatesOfAccompany
)
}
questionSheets
}
val questionSheetList = questionService.saveQuestionSheets(allMemberQuestionSheets)
// QSheet 생성 완료된 QSet 상태 변경
questionService.updateQuestionSetToReady(currentQuestionSet)
return questionSheetList
}
1. 미리 질문들을 친구/비친구 정보로 구분해놓는다.
2. 한 회원에 대해 질문들에 대한 후보자가 매 질문마다 달라야하므로 어쩔 수 없이 질문마다 후보자를 검색하는 N 번의 select query를 발생시킨다.
3. 한 회원에 대해서, 모든 질문을 반복하는데, 한 질문에 대한 후보자를 만들기 위해 createQuestionSheetFOrSingleQuestion() 를 사용한다.
4. 모든 회원에 대해 12개의 질문에 대해 각 후보자를 만들어 하나의 QuestionSheet를 생성한다. 즉 한 번의 투표시간 9시 혹은 6시에 한 회원에 대해 12개의 질문 Sheet가 만들어진다.
5. 이렇게 될경우 현재 81명의 회원이 있으므로 972개의 QuestionSheet가 생성된다.
후보자를 조회하고 질문지에 대해 미리 QuestionSheet를 도메인 단계(Persistence X)의 객체로 만들어둔다.
/* QuestionUseCase */
val questionSheetList = questionService.saveQuestionSheets(allMemberQuestionSheets)
/* DefaultQuestionService */
@Transactional
override fun saveQuestionSheets(allMemberQuestionSheets: List<QuestionSheet>): List<QuestionSheet> {
val questionSheetEntities = allMemberQuestionSheets.map { it.toEntity() }
val saveQuestionSheetEntities = questionSheetRepository.saveAll(questionSheetEntities)
return saveQuestionSheetEntities.map { it.toQuestionSheetWithCandidatesId() }
}
972개의 객체를 위와 같이 한 번에 데이터를 저장하여 벌크 처리를 통해 최대한 효율적으로 사용했다.
기존에는 하나의 질문지를 만들고, 추가적으로 select 쿼리를 사용한 후 다시 insert하고, update하는 과정에서 배치 처리가 되지 않아 스케줄러 작업이 13.613ms가 걸렸다.
하지만, 이를 개선하여 애플리케이션 연산을 4880ms초로 단축시킬 수 있었다.
개선 전
개선 후
이와 같이 배치를 사용하여 벌크성 쿼리를 하나의 배치로 해결하고 싶은 경우, 중간에 select 쿼리가 있어서는 안 된다.
그 이유는 배치 처리는 여러 insert, update, delete 쿼리를 한 번에 처리함으로써 데이터베이스와의 통신 횟수를 줄이고 성능을 극대화한다.
하지만 중간에 select 쿼리가 들어가면, 배치 처리의 흐름이 끊기고, 트랜잭션 컨텍스트가 분리되어 배치 처리의 성능 이점을 잃을 수 있다.
select 쿼리는 데이터베이스와의 추가적인 통신을 유발해, 데이터 처리의 연속성을 방해하고, 결국 전체 성능을 저하시킨다.
따라서 select와 update 작업을 분리하여 처리하는 것이 중요하다. 먼저, 필요한 데이터를 select 쿼리로 미리 조회하고, 그 다음에 update나 insert 작업을 배치로 처리하는 방식으로 진행하면, 배치 처리의 성능 이점을 최대한으로 활용할 수 있다.
'프로젝트 > Maship' 카테고리의 다른 글
Maship 4. MVP 발표, 서버 터짐 🥵 (Mash-Up Maship Project) (0) | 2024.08.24 |
---|---|
Maship 3. ShedLock사용. Scale-out시 Scheduler 중복 실행 방지 (0) | 2024.08.23 |