본문 바로가기
프로젝트/트러블 슈팅

[트러블 슈팅] JPA 성능 최적화: N+1 문제 해결 및 nGrinder를 사용한 성능 테스트

by taetae99 2025. 3. 15.
반응형

 

 

 

 

목차

     

     

     

     

     

     


    개요

     

    게시판 프로젝트의 핵심 기능 개발을 어느 정도 마무리하고, 성능 개선을 목표로 코드 리팩터링을 진행했다.

     

    이 과정에서 N+1 문제가 발생할 가능성이 있는 지점을 확인하여, 이를 개선하고 성능 테스트를 진행하려고 한다.

     

     

     

     

    문제가 발생한 지점은 다음과 같다.

    @GetMapping("/board/post/{postId}")
        public String post(@PathVariable Long postId,
                           @ModelAttribute CommentForm commentForm,
                           @ModelAttribute CommentEditForm commentEditForm,
                           Model model) {
    
            Post post = postService.viewPost(postId);
            model.addAttribute("post", post);
    
            List<Comments> comments = commentService.findAllByPostOrderByCreateDate(postId);
            model.addAttribute("comments", comments);
    
            return "post/postForm";
        }

     

    • 위의 코드는 특정 게시글의 상세 페이지를 가져오는 컨트롤러 메서드이다.
    • 이 메서드는 요청받은 postId(게시글ID)를 이용해 해당 게시글과 게시글에 작성된 댓글 목록(생성일 기준 정렬)을 조회한 뒤 모델에 담아 post/postForm뷰로 반환한다.

    findAllByPostOrderByCreateDate를 사용해 댓글을 조회하는 과정에서, 댓글과 회원 간의 연관관계로 인해 문제가 발생했다.

     

     


    아래는 댓글 엔티티의 일부이다.

    @Entity
    @Getter
    public class Comments extends BaseTimeEntity {
    
        @Id
        @GeneratedValue
        @Column(name = "comments_id")
        private Long id;
    
        @Column(length = 100,nullable = false)
        private String comment;
    
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "member_id")
        private Member member;
    
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "post_id")
        private Post post;
    
    
    }

     

    • 댓글 엔티티에는 회원(member)이 지연로딩(FetchType.Lazy)으로 설정되어 있다.

     


     

    문제 원인 파악

     

     

    지연 로딩을 사용하고 있고, 코드에서 직접 회원을 조회하는 부분이 없기 때문에 회원을 조회해서 SQL문이 추가로 발생할 일이 없다고 생각했다.

     

    따라서 N+1문제가 발생하지 않을 것이라 생각했다.

     

    하지만 Thymleaf에서 글을 렌더링 할 때 회원 정보에 접근하고 있었다.

     

     

     

    다음 게시글 조회 시 반환되는 post/postForm의 일부분을 확인해 보자.

    <div th:each="comment : ${comments}" class="card-body">
    	<div class="border-bottom rounded">
    		<div class="d-flex justify-content-between align-items-center">
            	<div class="d-flex align-items-center">
                	<strong th:text="${comment.member.nickname}"></strong>
                </div>
    
                        
                <div class="dropdown" th:if="${#authentication.principal.username == comment.member.email}">
                	<button class="btn btn-sm btn-light" type="button" data-bs-toggle="dropdown" aria-expanded="false">
                    	<strong>⋯</strong>
                    </button>
                </div>
    		</div>
    	</div>                
    </div>

     

    • 댓글 목록을 렌더링 할 때, comment.member를 통해 회원 객체의 정보를 참조하고 있다.
    • 지연 로딩(Lazy Loading) 설정이 되어 있더라도, 회원 객체를 직접 조회하는 코드가 포함되어 있기 때문에 객체가 조회된다.

     

     

     

    테스트 코드를 작성하여 SQL문이 정말 추가로 발생하는지 알아보았다.

    @SpringBootTest
    @Transactional
    @AutoConfigureMockMvc
    public class BoardControllerTest {
    
        ...
    
        @Test
        @WithMockUser
        public void N_1_테스트() throws Exception {
            //given
            Member member1 = createMember("유저1", "user1@spring.io", "12345678", "유저1");
            Member member2 = createMember("유저2", "user2@spring.io", "12345678", "유저2");
            Member member3 = createMember("유저3", "user3@spring.io", "12345678", "유저3");
    
            Post post = createPost(member1, "게시글 제목", "게시글 내용");
            createComments("댓글1", member1, post);
            createComments("댓글2", member2, post);
            createComments("댓글3", member3, post);
            em.flush();
            em.clear();
            
    
            //when & then
            mockMvc.perform(get("/board/post/{postId}", post.getId()))
                    .andExpect(status().isOk())
                    .andExpect(view().name("post/postForm"));
        }
    
    
    
        ...
    }

     

    • 3명의 회원을 생성한 후, 유저 1이 게시글을 작성한다. 
    • 각 회원이 해당 게시글에 댓글을 하나씩 작성한다. 
    • 작성된 데이터가 영속성 컨텍스트에 반영되도록 flush()하고 clear()를 호출하여 영속성 컨텍스트를 초기화한다. 
    • 특정 게시글 상세 보기 페이지를 가져오는 해당 엔드포인트로 GET 요청을 보내어 N+1 문제가 발생하는지 로그를 통해 확인한다.

    **1**                                
    select c1_0.comments_id,c1_0.comment,c1_0.created_date,c1_0.member_id,c1_0.modified_date,c1_0.post_id from comments c1_0 where c1_0.post_id=47 order by c1_0.created_date desc;
    
    **2**                              
    select m1_0.member_id,m1_0.created_date,m1_0.email,m1_0.modified_date,m1_0.name,m1_0.nickname,m1_0.password from member m1_0 where m1_0.member_id=2;
    
    **3**
    select m1_0.member_id,m1_0.created_date,m1_0.email,m1_0.modified_date,m1_0.name,m1_0.nickname,m1_0.password from member m1_0 where m1_0.member_id=4;
    
    **4**
    select m1_0.member_id,m1_0.created_date,m1_0.email,m1_0.modified_date,m1_0.name,m1_0.nickname,m1_0.password from member m1_0 where m1_0.member_id=3;

     

    List<Comments> comments = commentService.findAllByPostOrderByCreateDate(postId);

     

    • 문제가 발생했던 댓글을 가져오는 코드가 실행되었을 때 1번 SQL문이 실행되었다.
    • 각 댓글에서 getMember()를 호출하면서 추가로 3번의 SQL 쿼리가 발생했다.(2,3,4번 SQL문)
    • 즉 N+1문제가 발생하였다.
    • 만약 게시글에 100명의 회원, 1000명의 회원이 댓글을 달았다면 게시글 조회시마다 수많은 SQL문이 실행된다.
    • 이로 인해 데이터베이스의 부하가 커지고, 성능에 영향을 미칠 것이다.

     


     

    문제 해결

     

     

     

    해결을 하는 방법은 페치 조인을 사용하여 댓글을 검색할 때 회원 정보를 한 번에 SQL문으로 불러올 것이다.

    한 게시물에 서로 다른 100명의 회원이 댓글을 작성했다고 가정했을 때

    기존 코드에서는 댓글 조회 1회 + 100명의 회원 정보 100회로 총 101번의 SQL문이 실행되었지만

    페치 조인으로 개선하면 SQL문 실행이 1회로 감소할 것이다. 

     

    이를 테스트하기 위해 특정 게시글에 100개의 임의의 회원 정보로 각각 댓글을 작성하여 준비한다.

    ngrinder라는 오픈소스를 활용하여 N+1이 발생하던 기존 코드와 fetch join을 사용하여 개선한 코드의 성능테스트를 진행할 것이다. 

     

     


    개선 코드를 확인해 보자.

    /**
     * 기존 코드
     * */
    public List<Comments> findByPostIdOrderByCreatedDate(Long postId) {
        return em.createQuery("select c from Comments c where c.post.id = :postId" +
                        " order by c.createdDate desc", Comments.class)
                .setParameter("postId", postId)
                .getResultList();
    }
    
    /**
    * 개선 코드
    * */
    public List<Comments> findWithMemberByPostIdOrderByCreatedDate(Long postId) {
        return em.createQuery("select c from Comments c join fetch c.member" +
                        " where c.post.id = :postId" +
                        " order by c.createdDate desc", Comments.class)
                .setParameter("postId", postId)
                .getResultList();
    }

     

    • findByPostIdOrderByCreatedDate (기존 코드): 게시글 ID를 사용하여 댓글 생성일을 기준으로 내림차순으로 조회한다.
    • findWithMemberByPostIdOrderByCreatedDate (개선 코드) : 게시글ID를 사용하여 댓글 생성일을 기준으로 내림차순으로 조회하며 회원정보를 fetch join으로 함께 가져온다.

     

    이때 로그를 확인해 보면 댓글, 연관된 회원 정보를 하나의 SQL문으로 가져온다.

    select c1_0.comments_id,c1_0.comment,c1_0.created_date,m1_0.member_id,m1_0.created_date,m1_0.email,m1_0.modified_date,m1_0.name,m1_0.nickname,m1_0.password,c1_0.modified_date,c1_0.post_id from comments c1_0 join member m1_0 on m1_0.member_id=c1_0.member_id where c1_0.post_id=1 order by c1_0.created_date desc;

     


     

    nGrinder를 사용한 성능 테스트 시작

     

    테스트 환경은 MacBook Air 15 모델로 M2칩에 16GB를 사용 중이며 로컬 환경에서 테스트를 진행했다.

     

    nGrinder를 사용하여 두 코드의 성능을 비교해 보겠다.

     

    nGrinder설정은 다음 블로그에 친절하게 설명이 되어 있어서 링크를 첨부하겠다.

    https://curiousjinan.tistory.com/entry/apple-m1-ngrinder-setup

     

    [nGrinder] M1 Mac에 설치하기

    부하 테스트를 위해 nGrinder를 사용해 보자 📌 서론 열심히 레시피아를 만들어서 원스토어에 배포했지만 아직 유저가 별로 없다. (거의 없다... 또륵) 사용하는 유저는 별로 없지만 AWS에서 Cloud Nat

    curiousjinan.tistory.com

     

     

     

     

     

    2가지 용어의 의미를 알고 테스트 결과를 분석해 보자.

     

    • TPS : Transaction Per Second으로 시스템이 일정 시간 동안 얼마나 많은 요청을 처리할 수 있는지를 측정한다.
    • TPS가 높을수록 성능이 좋다고 평가한다.

     

    • 평균 테스트 시간: 시스템이 요청을 처리하고 응답하는 데 걸린 시간의 평균값을 측정한다.
    • 응답 시간이 짧을수록 성능이 좋다고 평가한다.

     

     




    첫 번째 테스트 설정 (가상 사용자 99명, 테스트 시간 1분)

    에이전트 가상 사용자 프로세스 쓰레드 테스트 시간 Ramp-up 사용 증가 단위  주기
    1 99 3 33 1분 1 1 1000

     

     

     

     

    1. 기존 코드

    기존 코드를 사용한 테스트

     

     

     

     

    2. 개선 코드

    개선 코드를 사용한 테스트

     

      TPS 최고 TPS 평균 테스트시간 총 실행 테스트 성공한 테스트 에러
    기존 코드 83.9 121 1,125.62 4,717 4,717 0
    개선 코드 305.2 347 304.33 17,142 17,142 0

     

     

    N+1 문제의 해결로 인해 DB에서 쿼리 실행의 수가 줄어들어 성능 향상이 있었다.

    TPS가 약 3.6배 증가하고, 평균 응답 시간이 약 73% 감소하였다.

     


     

    두 번째 테스트 설정 (가상 사용자 1000명, 테스트 시간 3분)

    에이전트 가상 사용자 프로세스 쓰레드 테스트 시간 Ramp-up 사용 증가 단위  주기
    1 1000 10 100 3분 1 1 1000

     

     

    두 번째 테스트에서는 가상 사용자와 테스트 시간을 증가시켜 부하를 증가시켰다.

     

     

     

    1. 기존 코드

    기존 + 부하 증가

     

     

     

    2. 개선 코드

    개선 + 부하 증가

     

      TPS 최고 TPS 평균 테스트시간 총 실행 테스트 성공한 테스트 에러
    기존 코드 116.7 157 7,796.75 20,590 20,590 0
    개선 코드 269.6 314 3,435.87 47,575 47,575 0

     

    두 번째 테스트에서는 TPS가 약 2.3배 증가하고, 평균 응답 시간은 약 55.9% 감소하였다.

    사용자 수를 증가시킨 환경에서도 성능 개선이 유지되는 것을 확인할 수 있었다.

     

     

     

    반응형