Project/Newsfeed

[성능 테스트][트러블슈팅] Artillery로 부하 테스트 하기(3/3), 성능 개선을 해보자

쉬지마 이굥진 2024. 4. 15. 23:03
 

[성능 테스트][트러블 슈팅] Artillery로 부하 테스트 하기(2/3), 성능 저하 원인을 찾아보자

[성능 테스트] Artillery로 부하 테스트 하기(1/3), Artillery 설치현재 진행하고 있는 뉴스피드 프로젝트의 기능들을 얼추 마무리하고 나서, 문득 내가 구현한 한 api에 대한 성능을 평가해보고 확장성

developer-jinnie.tistory.com

 

지난 글에서는 성능 테스트를 진행한 후에, 성능 저하 원인을 인지하고 이를 해결하기 위해서 어떤 방법들이 있을지 생각해봤다. 이번 글에서는 이 방법들을 이용해서 성능 개선을 해나가는 과정과 결과를 담아보려 한다!

 

++ 모든 테스트는 외부적인 요인이 결과에 영향을 주지 않도록 동일한 환경에서 진행했다.


시도 방법 1 - fetch join 

 

첫 번째로 시도한 방법은 fetch join인데, fetch join을 간단히 말하면 JPQL에서 성능 최적화를 위해 제공하는 기능으로 연관된 엔티티나 컬렉션을 한 번에 같이 조회할 수 있는 기능이다.

필자는 위 사진처럼 N+1 문제를 일으키는 필드를 모두 fetch join으로 엮어 쿼리문 수 자체를 단축하려고 했다.

일단 포스트맨으로 기능 테스트 해보면서 쿼리문 로깅해봤더니 결과는 대실패 ㅎ

 

  • fetch join 사용 시 문제점 1 - 컬렉션 문제

대충 이렇게 생겼습니다 ..

org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [cohttp://m.sparta.newsfeed.domain.Board.boardLike, cohttp://m.sparta.newsfeed.domain.Board.multimediaList] 라는 에러가 뜬다.

 

이 에러인 즉슨 여러 개의 컬렉션을 동시에 fetch join 할 수 없어서 발생하는 오류인데, Hibernate에서는 일반적으로 Set 또는 List와 같은 컬렉션을 나타내는 데에 Bag를 사용한다고 한다. 컬렉션끼리의 충돌이나 중복을 방지하고자 한 번에 여러 개의 Bag를 가져오려고 하면 예외를 발생시키는 것이다.

 

일단 에러 해결을 해보자

  • multimedia나 boardlike 둘 중 하나만 fetch join하고 나머지는 따로 조회하는 쿼리문 남겨두기

이렇게 해 본 결과, 에러 없이 기능은 잘 돌아가지만 근본적인 해결책은 아니라고 판단했다. multimedia는 용량이 크고 boardlike는 단순히 count로 숫자만 가져온다고 해도, 모든 게시글에 필수적으로 들어가 있는 요소이기 때문에 게시글마다 모두 조회를 해야한다. 

 

혹시 몰라서 코드를 고치고 2차 부하테스트를 해보니 오히려 더 큰 성능저하가 발생했다 ㅎ .. user, multimedia 만 우선 fetch join으로 엮었는데 이렇게 두 가지 이상의 엔티티를 엮으면 다시 성능저하가 발생한다고 한다. 

 

  • fetch join 사용 시 문제점 2 - 페이징 오류

위에서 발생한 오류 관련해서 검색을 해보다 발견한 점이 있었다. 컬렉션을 fetch join하면 페이징 API(setFirstResult, setMaxResults)를 사용할 수 없다는 사실이다. (일대일, 다대일과 같은 단일값 연관 필드는 페이징 API를 사용할 수 있다)

 

하이버네이트에서 사용하게 되면 경고 로그를 남기고 모든 데이터를 불러와 메모리에서 페이징을 진행하기 때문에 매우 위험한 작업이라고 한다. 즉, 페이징 처리를 한 상태에서 fetch join을 사용하게 되면 조회한 쿼리의 결과를 모두 메모리에 적재한 이후에 Pagination 작업을 애플리케이션 레벨에서 하기 때문에 오히려 성능 저하가 발생한다는 것이다. 

 

실제 sql 로깅 결과를 봐도, fetch join 이전에는 limit 으로 페이징 처리가 된 데이터만 가지고 오고 있지만 fetch join 이후에는 limit 절에 사라진 것을 확인할 수 있었다.

fetch join 적용 전
fetch join 적용 후

 

따라서 fetch join 방법 대신 BatchSize 방법을 적용해보기로 했다.

 


 

시도 방법 2 - @BatchSize 

fetch join 쿼리문 작성한 것을 지우고 BatchSize 애너테이션을 달아보기로 했다.

BatchSize를 간단히 말하면, 처음 데이터를 조회할 때 부모 테이블의 데이터가 10개 조회되면 연관된 자식을 조회할 때 부모 id 10개를 알고 있으니 그 부모 id 10개를 분할해서 자식을 조회하는 방법이다.

 

데이터베이스로부터 데이터를 읽어올 때 한 번에 가져오는 데이터의 양을 결정할 수 있는 애너테이션으로, 나는 한 번에 10개 씩 데이터를 가져오도록 했다.

 

이렇게 하면 select 문의 where절에서 하나씩 조건을 조회하는 것이 아닌, in 을 이용해서 한꺼번에 조회할 수 있게 되어 조회 쿼리를 실행하는 절대적인 시간을 단축할 수 있다. 

게시글 조회 시 함께 조회되어야 하는 엔티티에 BatchSize 애너테이션을 달아주고, 원하는 데이터의 size도 명시해준다.

게시글 엔티티에서는 해당하는 필드 위에 동일한 BatchSize 애너테이션을 달아주면 된다.

 

다시 부하 테스트를 돌려보자 ..

2차 테스트

1차 테스트와 동일한 스크립트로 부하 테스트를 다시 진행했다. 결과는 성능 개선 성공이다 🥹 눈물 좔좔

전체적인 실행 속도나 중간값과 p95, p99 사이의 격차가 확연히 줄어들었다!

 

추가적으로 ManyToOne 관계인 User는 fetch join 전략을 써 줬고, OneToMany 관계인 경우에만 BatchSize 전략을 이용해줬다. 꺼 둔 sql 로깅 기능을 다시 키고 로그를 찍어보니 limit과 in을 활용해서 25개의 쿼리문에서 총 14개의 쿼리문으로 줄어듦을 확인할 수 있었다. 

 

수치로 보면, 1차 테스트에서의 중앙값과 p95 백분위 수의 차이는 10945, 개선 후의 차이는 4429.5로 59.57% 개선되었다.

 

또한 만들어 둔 더미 데이터 유저 1000명, 게시글 1000개 기준으로 게시글 전체 목록 조회 API 수행시간을 AOP를 이용해서 측정해보니 전 후 대비 53.87% 개선된 것을 확인할 수 있었다.

리팩토링 전
리팩토링 후

실행 시간 개선율 계산

  • 초기 실행 시간: 594ms
  • 개선된 실행 시간: 274ms
  • 개선율 = (초기 실행 시간 - 개선된 실행 시간) / 초기 실행 시간 x 100
  • = (594ms - 274ms) / 594ms x 100
  • = 53.87%

회고

그냥 포스트맨으로 api 테스트만 했으면 음 잘 돌아가네 ~ 하고 지나쳤을 기능들을 부하 테스트를 통해서 성능 저하의 문제를 직접 확인하니 개발은 단순히 기능만 잘 돌아가게 한다고 해서 능사가 아님을 다시 한 번 깨달았다.

 

또한 먼저 문제에 대한 해결 방법을 제대로 공부하고 진행했더라면 (ex. fetch join은 페이징 환경에선 쓸 수 없다는 것이라던가 ..) 리팩토링 할 때의 삽질 시간을 훨씬 단축할 수 있었을 것 같다.

 

다른 api 들도 성능 테스트를 해보고 싶단 생각이 든다 ㅎ 이번 포스팅은 이렇게 마무리!


References

https://woo-chang.tistory.com/38

https://www.inflearn.com/questions/247048

https://www.inflearn.com/questions/399849

https://writtenbyrla.tistory.com/79