상황
현재 진행하는 프로젝트에서 단어를 입력받으면 해당 단어와 뜻이 국립국어원 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%가량 향상되었다!
회고
처음으로 진행한 쿼리개선 작업이다 보니 다소 해매긴 했지만, 앞으로 실행되는 쿼리들을 보며 불필요한 쿼리는 줄이고, 조금이라도 더 효율적인 쿼리를 작성하는 것이 엄청난 성능차이를 야기할 수 있음을 깨달았다.
이번 프로젝트의 리팩토링을 시작한 만큼, 쿼리의 성능향상, 코드의 클린화에 힘써보고자 한다.
읽으면서 많은 감명을 받았습니다.