케네스로그

JPA, 왜 쿼리가 이리도 많이 발생할까? feat 트랜잭션 본문

Dev/JPA

JPA, 왜 쿼리가 이리도 많이 발생할까? feat 트랜잭션

kenasdev 2023. 2. 14. 01:14
반응형

JPA, 왜 쿼리가 이리도 많이 발생할까?

 

최근 팀프로젝트를 진행하면서, 처음으로 JPA를 활용해서 프로젝트를 진행했다.

모든게 처음이었던지라 동작하는 코드를 목표로 주먹구구식으로 개발을 진행했다.

 

누가 코드를 이렇게 짰나 봤더니, 내가 짠 코드더군요.

뚝딱거리며 만들고보니 어떻게.. 간신히 동작은 하는(?) API서버를 만들 수 있었다. 이제 내가 해야야할일은 리토팩링. 지금에서야 작성된 코드를 리뷰해보니 굉장히 부끄러운 점이 많았다.

  • Controller - Service 구분이 명확히 되어 있지 않다.
  • Transaction 범위를 고려하지 않고 작성된 코드들로 인해 불필요한 쿼리가 발생한다.
  • 지연로딩에 대한 N+1문제가 발생한다.

현재 파악된 문제는 위와 같으며 하나씩 문제를 풀어나가고 있다. 이 포스팅에서는 불필요한 쿼리를 제거하는 작업에 대한 기록을 남겨본다.

 

문제의 파악!

컨트롤러, 서비스 레이어의 모든 메소드에 대해 확인 작업이 필요했다.

각 API마다 메소드콜을 해서 어떤 쿼리들이 발생하고 있는지 파악했다.

1개의 create 작업을 위해서 중첩된 select 및 update 쿼리들이 발생하는 것을 확인했다.

해당 작업을 위해 필요한 최소쿼리가 무엇인지 확인하고, 하나씩 리팩토링을 진행했다.

 

실제 코드

수정 전

@PostMapping
@ApiOperation(value="포스트 작성", notes="새로운 포스트 작성")
public ResponseEntity<ApiResponse> registerPost(@RequestBody @Valid CreatePostRequest request) {
    try {
        Member findMember = memberService.findMemberById(request.getPosterId());
        Posts newPost = Posts.builder()
			// set entity field
                .build();

        postService.registerPost(newPost);
        postService.updateDesignateStacks(newPost.getPostId(), request.getDesignatedStacks());

        return ApiResponse.of(HttpStatus.OK, ResponseMessage.CREATED_POST,
                CreatePostResponse.toDto(newPost)
                        .posterNickname(findMember.getNickname())
                        .designatedStacks(request.getDesignatedStacks()));
    } catch(Exception e) { ... }
}

게시글을 작성하는 작업에 대한 Controller의 메소드이다.

예외처리, 로그 등에 대한 코드는 생략하고 핵심으로 생각되는 부분만 남겨보았다.

비즈니스 로직은 서비스 레이어의 메소드를 호출해서 사용하는 방식이라 생각하고 작성했는데, 해당 비즈니스 로직이 한 트랜잭션 내에서 동작하도록 유도했어야 했다. 그래야 비즈니스로직 수행 중 문제가 생겼을 때, 롤백을 할 수 있기 때문이다.

위의 컨트롤러에서는 다음과 동작들을 수행하고 있다.

  1. 요청의 회원이 존재하는지 확인
  2. 새로운 게시글 엔티티 생성
  3. 새 게시글 등록을 위해 서비스 메소드 호출
  4. 새 게시글의 연관 엔티티를 등록하기 위한 서비스 메소드 호출
  5. 새 게시글을 DTO로 변환하여 응답으로 리턴

이에 대한 서비스 메소드의 예시는 아래와 같다.

 

@Transactional
public Long registerPost(Posts post) {
    log.info("[PostService:registerPost] register method init");
    postRepository.save(post);
    post.updateModifiedDate();
    post.updateCreatedDate();
    log.info("[PostService:registerPost] successfully registered");
    return post.getPostId();
}

서비스 메소드는 정말 새 게시글을 등록하고 생성 및 수정일을 수정하는 작업만을 수행했다.

 

SELECT member WHERE member_id=?
INSERT INTO post VALUES (?)
UPDATE post WHERE post_id=?
SELECT post WHERE post_id=?
SELECT designated_stack FROM stack_relation WHERE post_id=?
INSERT INTO stack_relation VALUES (?)

해당 작업에 대해 발생된 커리는 위와 같았다.

불필요한 UPDATE 쿼리와 DTO 생성을 위한 불필요한 중첩 SELECT문이 발생했다.

여러 API 메소드들 중 그나마 직관적으로 파악하기쉽고 코드량이 적은 경우를 가져온것이며, 다른 API 메소드들은 훨씬 심각한 상황이었다. 지금 돌아봤을땐 너무도 부끄러운 코드다.

 

수정이후

@PostMapping
@ApiOperation(value="포스트 작성", notes="새로운 포스트 작성")
public ResponseEntity<ApiResponse> registerPost(@RequestBody @Valid CreatePostRequest request) {
    try {
        CreatePostResponse responseDto = postService.registerPost(request);

        return ApiResponse.of(HttpStatus.OK, ResponseMessage.CREATED_POST, responseDto);
    } catch (Exception e) { ... }
}

수정한 Controller단의 메소드이다. 마찬가지로 로그 및 예외처리 구문은 생략했다.

혼자 고민하며 도출한 방법은 Controller에서는 request에 대한 예외처리 등만 수행하며, 비즈니스 로직 전반은 service 레이어의 메소드로 위임했다. service레이어의 메소드는 해당 비즈니스 로직이 모두 정상 수행되면 응답을 위한 DTO를 반환하도록 했다.

 

@Transactional
public CreatePostResponse registerPost(CreatePostRequest request) {

    Member findMember = memberRepository.findOne(request.getPosterId());
		 ...
    Posts newPost = Posts.builder()
		// set entity field
            .build();

    newPost.updateModifiedDate();
    newPost.updateCreatedDate();

    postRepository.save(newPost);
		
		..

    findMember.addWrotePost(newPost); // 연관관계 적용 메소드
    return CreatePostResponse.toDto(newPost);
}

이제 Service레이어의 메소드로 넘어온 요청은 request에 대한 1차적 예외처리 작업을 끝낸것으로 간주한다. 요청에 문제가 없다는 가정 하에 다음과 같은 로직을 수행한다.

  1. 요청에서 명시된 회원이 존재하는지 확인
  2. 새로운 게시글 엔티티 생성 및 등록
  3. 새 게시글 엔티티와 연관관계를 지닌 엔티티들에 대한 매핑 작업 수행
  4. 응답을 위한 DTO 생성 후 반환

이전에 비해 조금 더 정제된듯한 느낌이 든다.

SELECT member WHERE member_id=?
INSERT INTO post VALUES (?)
INSERT INTO stack_relation VALUES (?)

수정 이후 발생된 쿼리를 확인해보았다.

회원이 존재하는지 확인하는 SELECT쿼리, 새 게시글과 게시글과 연관된 엔티티를 위한 INSERT쿼리만 발생하는 것을 확인했다.

 

결과적으로,

수정 이전의 수행 시간 측정
수정 이후의 수행 시간 측정

게시글 등록 API에 대한 수행 시간이 128ms에서 53ms로 단축되었다.

6개의 쿼리가 절반으로 줄었으니 그럴만도 하다.

게시글 및 유저 수정 API는 12개 이상의 쿼리가 발생했었고, 수정 후 4~5개정도로 줄일 수 있었다.

 

이를 통해,

스프링의 트랜잭션 범위를 피부코드로 느낄 수 있었다.

 

스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 한다.트랜잭션의 범위와 영속성 컨텍스트의 생존 범위가 같다는 의미인데, 내가 작성했던 코드에서는 이를 전혀 고려하지 않았다.트랜잭션이 끝난 이후에 해당 트랜잭션 내에서 존재하던 엔티티를 위해서 또다른 트랜잭션(영속성 컨텍스트)를 생성해서 작업을 수행했다. 당연히 다른 트랜잭션과 컨텍스트이니.. 엔티티 조회를 위한 쿼리가 또다시 발생하는 것이다.

 

이번에는 트랜잭션을 고려하여 리팩토링을 진행하였고, 다음에는 영속성 컨텍스트를 고려한 N+1 문제를 분석하고 해결했던 과정에 대한 기록을 남겨보도록 하겠다.

반응형