JPQL을 이용하여 쿼리 성능 400% 향상시키기(JMeter)

원모어깨찰빵·2024년 2월 24일

트러블 슈팅

목록 보기
6/7
post-thumbnail

상황

현재 진행하는 프로젝트에서 단어를 입력받으면 해당 단어와 뜻이 국립국어원 API를 통하여 불러와지고 저장된다.
하지만 이 과정에서 다음의 쿼리가 3회 실행되는데,
1. Authentication(principle)을 통한 유저 인증(정보 불러오기)
2. User-Word의 Lazy연결로 인한 WordList 프록시 객체 초기화
3. WordList에 입력받은 Word 객체 저장
1번과 2번 쿼리를 1개의 쿼리로 단축하면 단어 저장 과정에서 쿼리가 3분의 2로 감소하는 효과를 얻을 수 있다!

해결책

가장 간단한 방법은 User과 Word를 Eager로 연결하면 되겠지만, 이는 N+1 side effect를 불러일으킬 수 있고, 그다지 이점이 없다고 생각하여서 패스하였다.
따라서 @AuthenticationPrincipal을 단어 저장 요청이 올 때만 사용하지 않고, Authentication객체를 직접 사용하여 User에 연결된 WordList가 초기화하는 JPQL을 사용하면 해결할 수 있을 것 같다고 생각하였다.

1. UserRepository에 JPQL작성하기

@Query("SELECT u FROM Users u JOIN FETCH u.words WHERE u.email = :email")
    Optional<Users> findByEmailWithJPQL(@Param("email") String email);

이로써 findByEmailWithJPQL이 호출되면 user과 연결된 word들을 한번에 영속성 컨텍스트에 올릴 수 있다!

2. 단어 저장 요청이 올 때 Authentication 객체 사용하기

기존의 단어 저장 컨트롤러 메소드

public ResponseEntity<String> saveWordByDto(@AuthenticationPrincipal Users user,
                                              @PathVariable("requestWord") String requestWord){
        return ResponseEntity.status(HttpStatus.CREATED).
                body(wordService.saveWord(user, requestWord));
    }

변경 후 Authentication객체 사용 코드

public ResponseEntity<String> saveWordByDto(Authentication authentication,
                                                @PathVariable("requestWord") String requestWord){
        String userEmail=authentication.getName();
        return ResponseEntity.status(HttpStatus.CREATED).
                body(wordService.saveWord(userEmail, requestWord));
    }

3. 변경사항에 맞게 UserService 코드 수정

 @Transactional
    public String saveWord(String userEmail, String requestWord) {
        Users logInUser = userRepository.findByEmailWithJPQL(userEmail)
                .orElseThrow(() -> new UsersException(NOT_FOUND));
        String mean= getDefinition(requestWord);
        Word word=Word.from(requestWord, mean);
        logInUser.addWord(word);
        wordRepository.save(word);
        return mean;
    }

가독성을 위하여 클린코드로 수정이 필요하지만, 위에서 작성한 JPQL로 User와 연결된 Word들을 함께 초기화하고, 국립국어원API를 통하여 불러온 단어정보를 repository에 저장하는 로직이다.

새로운 문제

위의 로직은 문제없이 작동하였지만, 여전히 쿼리는 3개가 발생하였다.
그 이유는 Authentication 객체를 통하여 유저가 인증되는 과정에서 쿼리가 발생한 후, 그 객체에서 가져온 email을 통하여 JPQL을 호출하고 단어를 저장하기 때문이다!
즉, Authentication 객체를 사용한다고 유저인증 쿼리가 발생하지 않는 것은 아니고 Authentication 객체 사용 시나 @AuthenticationPrincipal 어노테이션 사용 시 모두 사용자 인증 과정에서 사용자 정보를 로드하기 위한 초기 쿼리가 발생하기 때문이다.

이처럼 사용자 정보 로드쿼리 + 위에서 추가한 JPQL이 모두 발생한다.

새로운 해결 방법

그렇다면 인증 과정에서 사용자 정보를 로드할 때, 단어를 저장하는 요청이 들어오면 User에 연결된 Words까지 로드하여 반환하는 로직을 사용하면 될 것이라고 생각하였다.
그렇다면 컨트롤러에서 @AuthenticationPrincipal는 그대로 사용하고, JWT필터에서 단어를 저장하는 API의 도메인으로부터 요청이 들어오면 JPQL을 실행하면 된다.
이를 위해 JWT필터에 도메인 확인 기능을 추가하고, 컨트롤러와 서비스를 다시 수정한다.

1. JWT필터 수정

수정 전

jwtService.extractAccessToken(request)
                .filter(jwtService::isTokenValid)
                .ifPresent(accessToken -> jwtService.extractUuid(accessToken)
                        .ifPresent(id -> userRepository.findByEmail(id)
                                .ifPresent(this::saveAuthentication)));
        filterChain.doFilter(request, response);

수정 후

String requestURI = request.getRequestURI();
        String method = request.getMethod();
        boolean isPostWordRequest = "POST".equalsIgnoreCase(method) && requestURI.startsWith("/api/v1/word/");
        jwtService.extractAccessToken(request)
                .filter(jwtService::isTokenValid)
                .ifPresent(accessToken -> jwtService.extractUuid(accessToken)
                        .ifPresent(id -> {
                            if (isPostWordRequest) {
                                userRepository.findByEmailWithJPQL(id)
                                        .ifPresent(this::saveAuthentication);
                            } else
                            userRepository.findByEmail(id)
                                    .ifPresent(this::saveAuthentication);
                        }));
        filterChain.doFilter(request, response);

이제 유저의 정보를 불러오는 과정에서 단어를 저장하는 요청에서만 따로 작성한 JPQL이 실행된다.

2. 컨트롤러 코드 수정

수정 전

public ResponseEntity<String> saveWordByDto(Authentication authentication,
                                                @PathVariable("requestWord") String requestWord){
        String userEmail = authentication.getName();
        return ResponseEntity.status(HttpStatus.CREATED).
                body(wordService.saveWord(userEmail, requestWord));

수정 후

public ResponseEntity<String> saveWordByDto(@AuthenticationPrincipal Users user,
                                                @PathVariable("requestWord") String requestWord){
        return ResponseEntity.status(HttpStatus.CREATED).
                body(wordService.saveWord(user, requestWord));
    }

다시 원래대로 AutehnticationPrincipal을 사용하여 유저 인증을 진행하였습니다.

3. 서비스 코드 수정

수정 전

@Transactional
    public String saveWord(String userEmail, String requestWord) {
        Users logInUser = userRepository.findByEmailWithJPQL(userEmail)
                .orElseThrow(() -> new UsersException(NOT_FOUND));
        String mean= getDefinition(requestWord);
        Word word=Word.from(requestWord, mean);
        logInUser.addWord(word);
        wordRepository.save(word);
        return mean;
    }

수정 후

@Transactional
    public String saveWord(Users logInUser, String requestWord) {
        String mean= getDefinition(requestWord);
        Word word=Word.from(requestWord, mean);
        logInUser.addWord(word);
        wordRepository.save(word);
        return mean;
    }

JPQL이 JWT필터에서 적용되기 때문에 서비스단에서 JPQL을 실행하는 코드를 제거하였습니다.

결과

아래의 사진과 같이 유저정보 불러오는 쿼리+User에 연결된 Word들 불러오는 쿼리가 한개의 쿼리로 합쳐졌습니다!

JMeter 성능분석_최종결론

JMeter 사용법 참고 : https://effortguy.tistory.com/164
먼저, 테스트의 스레드 설정은 다음과 같습니다.

쿼리 개선 후

쿼리 개선 전

테스트 서버와 웹 서버가 한 환경에서 돌아갔기에 100% 신뢰할 수 없다는 것은 알고 있지만, 모든 지표에서 개선 후가 우월하였고, 평균 처리시간은 이 테스트케이스에서 무려 400%가량 향상되었다!

회고

처음으로 진행한 쿼리개선 작업이다 보니 다소 해매긴 했지만, 앞으로 실행되는 쿼리들을 보며 불필요한 쿼리는 줄이고, 조금이라도 더 효율적인 쿼리를 작성하는 것이 엄청난 성능차이를 야기할 수 있음을 깨달았다.
이번 프로젝트의 리팩토링을 시작한 만큼, 쿼리의 성능향상, 코드의 클린화에 힘써보고자 한다.

profile
https://fuzzy-hose-356.notion.site/1ee34212ee2d42bdbb3c4a258a672612

1개의 댓글

comment-user-thumbnail
2024년 3월 16일

읽으면서 많은 감명을 받았습니다.

답글 달기