[테스트, JPA] 부하테스트와 N+1문제.

hynnch2·2022년 3월 22일
7

현재 프로젝트를 진행하면서 어느정도 코드는 작성되었고, 실제 배포를 위한 작업을 하기 위해 부하 테스트를 하던 중 다음과 같은 상황이 발생했습니다.


(테스트 - artillery.io )

초당 Request 요청: 5회
테스트 시간: 60초
최소: 21ms, 최대: 739ms
http.200 응답률: 100%

전체 그룹을 조회하는 request의 요청은 초당 5회 정도는 잘 작동하였습니다.

그러나, 현재 진행할 서비스는 최소 1000명 정도가 서비스를 이용하는데 불편함이 없어야 한다고 생각했고, 초당 1000회의 요청을 보내보기로 했습니다.

초당 Request 요청: 1000회
테스트 시간: 20초 (10초에서 끊음)
최소: 1ms, 최대: 9505
http.200 응답률: 약 24%

갑자기 서버가 난리가 나기 시작했고, 평균적으로 4초라는 어마무시한 응답속도를 주었습니다. 간단한 Request 였기 때문에 이상함을 눈치채고 어디서 무엇이 잘못되었는지 찾기 시작했습니다.


🤔 서버 문제?

먼저 의심한 곳은 AWS 서버였습니다.

free tire의 EC2를 사용하고 있었기 때문에, 2GB의 램에서는 계산하지 못하는 것이라고 추측했습니다.

(모니터링 지표)

확인 결과 cpu는 7%밖에 사용하지 않았고, 네트워크 문제도 아니었습니다. 그러면 서버의 코드가 이상이 생겼다는 것이었고, 이를 찾기위해 Debug를 시작했습니다.


😢 N+1 문제 발생.

여러명이 요청했을때 갑자기 무리가 되는 것을 통해, GET함수가 비 효율적으로 작동하고 있을 것이라고 추측을 했습니다.

그러나 제가 사용한 방법은 JPA의 Repository의 기본 함수를 사용하고 있었고, 의심할 부분은 N+1밖에 없었습니다.

한 요청에서 대충 7~10 만큼의 쿼리가 나가고 있었고, 흔히 말하는 N+1문제가 발생하고 있었습니다.


🔥 N+1 문제란.

N+1 문제는 JPA에서 Entity의 정보를 맵핑할 때 발생하는 문제로
1:N 연관관계에 있는 정보에서 N개의 정보를 가져오기 위해 다시 select 쿼리를 날리게 되는데 이를 N+1이라고 한다.

JPA에서 연관관계에 있는 정보를 가져오기 위해 N+1 문제가 발생하는 이유는 다음과 같습니다.

group (1) - people (N) 관계에서 한 그룹에 여러 사람들이 있는 연관관계를 생각해보면, group 정보를 가져 올 때 people에 대한 정보도 필요하다.

이 때, JPA에서는 group에 대한 정보를 가져 올 때, 연관관계에 있는 people은 proxy객체로 가져오게 됩니다.

즉, 다음과 같은 정보를 가져오게 됩니다.

JPA에서는 먼저 Group에 대한 정보를 가져오기 위해
1개의 select 쿼리가 나가게 되고, group의 정보와 people List를 proxy(임의의 대리 객체)로 group 객체를 생성해서 EntityManager가 저장을 합니다.

여기서 people 정보를 가져오기 위해, 연관된 people에 select를 하면서 N개의 query가 추가적으로 발생하게 됩니다.


🤔 왜 N+1이 발생했을까?

이렇게 기본적으로 JPA가 Entity를 관리하는 방법을 알고 있는데, 왜 이러한 문제를 마주하게 되었을까요

JPA가 연관관계의 정보를 가져오는 방식은 대표적으로 2가지 방식이 있습니다.

  1. FetchType.Lazy
  2. FetchType.Eager

두 Fetch type의 차이는 연관관계 조회를 언제 하는지에 따라 나뉘게 됩니다.

Lazy의 경우 1:N 관계에서 N의 객체 정보를 proxy로 대체를 하고, 실제로 데이터가 필요한 때에 추가적으로 DB에 요청을 해서 데이터를 얻어옵니다. 반대로 Eager의 경우 연관관계에 있는 정보를 한 시점에 가져옵니다.

즉, Fetch Type을 Eager로 하면, Entity 조회 할 때 모든 정보를 한 시점에 가져옵니다. 여기서 개념을 잠시 혼동하여,
한 시점에 가져오는 것을 한 쿼리에 가져온다고 착각을 했습니다.

Eager의 경우 한 시점에 가져오는 것은 맞지만, 만약 1:N 같은 경우에는 여러번의 select를 통해 조합한 결과를 우리에게 준다는 것이었습니다.

그러므로, N+1과 관련된 문제는 Fetch Type이 Eager인지 Lazy인지는 상관이 없는 다른 문제였고, 이를 해결 할 방법을 다시 찾아보기로 했습니다.


🤗 N+1 문제 해결방법.

그럼 JPA에서 N+1 문제가 발생을 막기 위해서는 어떻게 해야 할까요

JPA는 ORM 기술 명세로써 객체 지향적인 코드를 요청하면, 알아서 SQL 형식으로 데이터를 요청해서 맵핑을 합니다. 즉, JPA가 우리가 요청한 객체 정보를 확인하고 JDBC를 통해서 SQL에게 정보를 요청하는 일련의 과정을 가지고 있습니다.

예시)

(출처: JPA란 tistory 블로그)

JPA는 우리가 요청한 객체 정보를 토대로 JDBC에 요청을 하기 떄문에, 우리가 따로 JDBC에 요청을 하는 방식으로 해결할 수 있습니다. 즉, 여기서 fetch join이라는 방법을 통해 SQL문의 join으로 한 쿼리에서 객체의 모든 정보를 가져 올 수 있습니다.

@Repository
public interface ChallengeRepository extends JpaRepository<Challenge, Long> {

	@Query("select distinct c from challenge c join fetch c.userChallengeList")
	Optional<Challenge> findById(Long id);
    ...
}

fetch join 방법은 위와 같이 @Query 어노테이션을 통해 직접 SQL을 작성하여, JDBC에게 원하는 쿼리를 전달하면 됩니다. 그러면, challenge 안에 있는 모든 user를 join에 담아서 한 쿼리에서 정보를 가져 올 수 있습니다.

* 주의: fetch join이 아닌 일반 join을 이용하면, JPA가 JDBC에게 여러번의 select를 요청하고 이 결과를 eneityManager가 내부적으로 join해서 결과를 반환함. 즉 N+1 문제는 해결되지 않음.


Lazy, Eager 방식으로 N+1 문제를 해결 할 수 있다고 생각했는데, 이는 저의 착각이었습니다. 벌써 배포까지 한 상태였는데, 실제 서비스에서 이런 과부하가 생겼다면 큰일이었을 것입니다.

또한, 관련 글을 찾아보면서 Eager와 Lazy에 관한 글도 찾아보게 되었는데,
~ToOne의 경우 default 값이 Eager로 되어 있고,
~ToMany의 경우 default 값이 Lazy로 되어 있다고 합니다.

위의 결과로 알 수 있듯이, ~ToMany의 경우 연관관계의 주인이 아닐 확률이 높아서 default 값이 Lazy라고 생각합니다.
SQL에서도 연관관계 주인이 아닐 경우 select 한번에 연관관계에 있는 entity를 가져올 수 있는 방법이 없기 때문에, 위와 같은 세팅이 되어 있는 것 같습니다.


+ 추가 할 내용이나 부족한 부분이 있다면, 댓글 작성 부탁드립니다! :)

profile
more than yesterday

2개의 댓글

comment-user-thumbnail
2023년 3월 17일

좋은 글 감사합니당

답글 달기
comment-user-thumbnail
2023년 3월 17일

fetch join이라는 어마어마한 기술이 있었군요.. FetchType.Lazy로하면 N+1문제가 해결되는 줄 알았는데 이해 쏙쏙되는 글 감사합니다

답글 달기