목차
개요
게시판 프로젝트의 핵심 기능 개발을 어느 정도 마무리하고, 성능 개선을 목표로 코드 리팩터링을 진행했다.
이 과정에서 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% 감소하였다.
사용자 수를 증가시킨 환경에서도 성능 개선이 유지되는 것을 확인할 수 있었다.
'프로젝트 > 트러블 슈팅' 카테고리의 다른 글
[DDD] 회원 엔티티가 비밀번호 암호화 기술인 PasswordEncoder에 의존하는 문제 개선 (0) | 2025.05.10 |
---|---|
[트러블 슈팅] HikariCP 데드락 발생 원인과 해결 과정 (feat:nGrinder) (1) | 2025.03.22 |
[트러블 슈팅] JPA 연관 관계 및 영속성 관리 문제 해결 (0) | 2025.01.30 |
[트러블 슈팅] JPA 연관 관계와 테스트 코드 : delete 쿼리가 실행되지 않는 현상 해결 (0) | 2025.01.05 |