JPA N+1 문제, 이거 정말 성능 이슈 있는거 맞나?

Hooman
8 min readNov 30, 2023

--

제목 그대로다.

JPA N+1 문제라고 검색하면 다들 성능에 이슈가 있다고 그러던데 다들 말만 하고 실제로 실행 시간을 보여주지는 않는다. 내가 못 찾았을 수도 있지만.

이에 대해서 이전에 실험을 했다.

하지만 무언가 부족했다.

그래서 실험해봤다.

우선 코드들부터 보자.

public List<PostListDto> findPostsDto() {
return em.createQuery(
"SELECT new jpapractice.jpapractice.dto.PostListDto(p.id, p.postSubject, p.postDate, s.name) "
+ "FROM Post p "
+ "JOIN p.student s "
+ "ORDER BY p.id DESC",
PostListDto.class)
.getResultList();
}

public List<Post> findPostsEntity() {
return em.createQuery(
"SELECT p "
+ "FROM Post p "
+ "JOIN p.student s "
+ "ORDER BY p.id DESC",
Post.class)
.getResultList();
}

public List<Post> findPostsFetch() {
return em.createQuery(
"SELECT p "
+ "FROM Post p "
+ "JOIN FETCH p.student s "
+ "ORDER BY p.id DESC",
Post.class)
.getResultList();
}

각각 Dto로 리턴, 일반 join문 사용, fetch join 사용 메서드이다.

아래는 테스트 코드이다.

 @Transactional
@Test
public void loadListTest() {
// 테스트 데이터 생성
String heavy = null;
for (int i = 0; i < 200; i++) {
heavy += "1";
}
List<Student> 테스트학생 = new ArrayList<>();
for (int i = 0; i < 100; i++) {
Student student = new Student()
.builder()
.name("name" + i)
.age(17)
.email("testemail" + i + "@test.test")
.school(informationRepository.getSchoolReference(2))
.club(informationRepository.getClubReference(2))
.position(informationRepository.getClubPositionReference(2))
.type(1)
.build();

Student saveStudent = memberRepository.saveStudent(student);
테스트학생.add(saveStudent);

}

for (int i = 0; i < 100; i++) {
for (int j = 0; j < 테스트학생.size(); j++) {
Post post = new Post().builder()
.subject(테스트학생.get(j) + " 테스트 제목" + i + j)
.postContent(테스트학생.get(j) + " 테스트 내용" + i + j)
.student(테스트학생.get(j))
.postDate(LocalDateTime.now())
.view(0)
.build();

boardRepository.savePost(post);
}

}

em.clear();
// Dto Mapping
Long start1 = System.currentTimeMillis();
List<PostListDto> result = boardRepository.findPostsDto();

for (int i = 0; i < result.size(); i++) {
PostListDto dto = result.get(i);
System.out.println("DTO: " + dto.toString());
}

Long end1 = System.currentTimeMillis();
em.clear();

// N+1
Long start2 = System.currentTimeMillis();
List<Post> result2 = boardRepository.findPostsEntity();

for (int i = 0; i < result2.size(); i++) {
Post dto = result2.get(i);
System.out.println("ENTITY: " + dto.toString());
}
Long end2 = System.currentTimeMillis();
em.clear();

// Fetch Join
Long start3 = System.currentTimeMillis();
List<Post> result3 = boardRepository.findPostsFetch();

for (int i = 0; i < result3.size(); i++) {
Post dto = result3.get(i);
System.out.println("FETCH: " + dto.toString());
}
Long end3 = System.currentTimeMillis();
em.clear();

Long t1 = end1 - start1;
Long t2 = end2 - start2;
Long t3 = end3 - start3;
System.out.println("DTO: " + t1 + ", Entity: " + t2 + ", Fetch: " + t3);

}

가상의 학생 100명을 만들고 한 학생당 100개의 글을 작성했다고 가정한다.

이를 각각의 방식으로 불러와서 출력을 시키고, 전체 소요되는 시간을 측정했다.

혹시 몰라서 각 메서드 마다 주석을 치고 하나의 메서드씩 실행을 시켰다.

로그가 너무 길어져서 전체 로그를 다 가져올 수 없기 때문에 일단 findPostsEntity() 메서드의 JPQL이 N+1 문제를 발생시킨다는 것을 미리 알린다.

전체 데이터를 가져오는데 걸리는 시간은 다음과 같다.

데이터 양의 다소에도 영향을 받기 때문에 따로 가상의 학생 10명이 학생 1명당 10개의 글을 썼을 때의 조건으로도 테스트 했다.

/*postContent 필드는 Dto Mapping에서 제외된 필드*/

------------100건의 데이터---------------

postSubject 필드와 postContent 필드에 같은 데이터를 넣은 경우

DTO: 862, Entity: 857, Fetch: 1073

postContent 필드에 postSubject 필드보다 비교적 큰 데이터를 넣은 경우

DTO: 776, Entity: 884, Fetch: 782

-------------1만건의 데이터---------------

postSubject 필드와 postContent 필드에 같은 데이터를 넣은 경우

DTO: 1939, Entity: 2397, Fetch: 2722

postContent 필드에 postSubject 필드보다 비교적 큰 데이터를 넣은 경우

DTO: 1856, Entity: 2790, Fetch: 2469

뭔가 이상하다는 것은 나도 알고 있다.

테스트시의 PC 내부 조건 같은 것이 변수로 들어갈 수도 있겠지만 그 변수를 최소화 하기 위해 몇분간 텀을 두고 테스트를 수행하였고, 같은 테스트를 3번 반복하여 가장 낮은 값을 기재하였다.

평균을 내지 않은 이유는 첫번째 실행에서는 세 조건 다 터무니없이 큰 값을 보였기 때문이었다.

애초에 정확한 실행시간을 구하고자 하는 것이 아니고 JPQL을 통해 데이터를 가져오는 방식 간의 시간 차를 보고자 했기 때문에 세 결과값의 대소 관계만 보도록 하자.

N+1 문제가 발생하는 경우 데이터 수가 많아질수록 실행 시간이 급격히 늘어났다.

데이터가 적을 경우에는 join 연산이 들어가는 다른 연산들보다 N+1 연산이 빠르지만, 데이터 수가 많아질 경우 Fetch Join, Dto Mapping 방식이 더 빠른 실행 시간을 보여준다.

더해서 Fetch Join으로 가져오는 컬럼 중 Dto Mapping에서 제외되는 컬럼의 수, 데이터의 크기가 많아지고 커질수록 Dto Mapping이 더 빠른 실행시간을 보인다.

결론은 다음과 같다.

데이터 수가 적을 때는 N+1 문제가 발생해도 큰 문제가 발생하지 않는다. 하지만 데이터 수가 많아질 수록 성능 문제가 두드러지기 때문에 Fetch Join 등의 방법을 적용해야한다.

끝이다.

처음부터 Dto Mapping을 사용하지 말고 Fetch Join 등의 방법을 사용해본 후, 그럼에도 성능 개선이 필요할 경우 Dto Mapping, MyBatis, JDBC Template 등의 방법을 적용해보는 것이 좋다고 생각한다.

특정 컬럼만 찝어서 가져오는 경우, 재사용성이 떨어지고 요구사항 변경에 따른 코드 수정량이 늘어날 가능성이 있기 때문이다.

--

--