저희가 진행하고 있는 프로젝트는 여행 동행 커뮤니티 플랫폼으로 여행을 같이 가고 싶은 사람을 구하기 위해 본인이 직접 글을 작성하여 사람을 모집하거나, 작성된 글을 확인하여 같이 여행 갈 사람을 찾을 수 있는 커뮤니티 플랫폼입니다.
따라서 이 커뮤니티 플랫폼 특성 상 게시글 목록 조회를 주로 사용합니다. 이미지 처리 작업을 진행 중 이미지를 등록 및 삭제하기 위해서 DB에 이미지를 등록, 삭제하려는 게시물이 존재하는 지 확인하는 작업을 거쳐야 하는데, 로그를 확인하는 중 select 조회가 의도한 바와 다르게 1+1번 총 2번 조회되는 것을 확인했습니다.
PostImageController
@DeleteMapping("/{postId}/images")
public ResponseEntity<HttpStatus> deleteImages(@PathVariable Long postId) {
Post post = postService.findPostById(postId);
imageFileUploadService.delete(post);
return successResponse("게시글 이미지 삭제 성공!");
}
PostRepositoryQuerydslImpl
// PostRepositoryQuerydslImpl.findPostById
@Override
public Optional<Post> findPostById(Long postId) {
Post post1 = queryFactory
.selectFrom(post)
.join(post.postInfo, postInfo)
.where(
post.id.eq(postId),
postInfo.status.id.eq(1L)
).fetchOne();
return Optional.ofNullable(post1);
}
imageFileUploadService
@Transactional
public void delete(Post post) {
List<PostImage> postImages = findImageListByPost(post);
if(!postImages.isEmpty()) {
for (PostImage postImage : postImages) {
String path = postImage.getUrl();
// 파일의 Url에서 55를 기준으로 문자열을 나누면 파일 키가 나옵니다.
String filename = path.substring(55);
// 위에서 구한 파일 키를 통해서 S3에서 해당 파일 삭제
awsS3Service.deleteFile(filename);
}
postImageRepository.deleteAllByPost(post);
}
}
public List<PostImage> findImageListByPost(Post post) {
return postImageRepository.findAllByPost(post);
}
메서드를 실행시킨 후 나오는 로그를 확인하시면 다음과 같은 쿼리들이 보입니다.
Hibernate:
select
user0_.USER_ID as user_id1_13_,
user0_.created_at as created_2_13_,
user0_.updated_at as updated_3_13_,
user0_.age as age4_13_,
user0_.description as descript5_13_,
user0_.email as email6_13_,
user0_.gender as gender7_13_,
user0_.nickname as nickname8_13_,
user0_.oAuth2Id as oauth9_13_,
user0_.password as passwor10_13_,
user0_.role as role11_13_
from
USER user0_
where
user0_.email=?
Hibernate:
select
post0_.POST_ID as post_id1_3_,
post0_.created_at as created_2_3_,
post0_.updated_at as updated_3_3_,
post0_.context as context4_3_,
post0_.like_count as like_cou5_3_,
post0_.POST_INFO_ID as post_inf7_3_,
post0_.TITLE as title6_3_,
post0_.USER_ID as user_id8_3_
from
POST post0_
inner join
POST_INFO postinfo1_
on post0_.POST_INFO_ID=postinfo1_.POST_INFO_ID
where
post0_.POST_ID=?
and postinfo1_.STATUS_ID=?
Hibernate:
select
postinfo0_.POST_INFO_ID as post_inf1_5_0_,
postinfo0_.created_at as created_2_5_0_,
postinfo0_.updated_at as updated_3_5_0_,
postinfo0_.city as city4_5_0_,
postinfo0_.state as state5_5_0_,
postinfo0_.STATUS_ID as status_11_5_0_,
postinfo0_.TRAVEL_AGE as travel_a6_5_0_,
postinfo0_.travelDateEnd as travelda7_5_0_,
postinfo0_.travelDateStart as travelda8_5_0_,
postinfo0_.TRAVEL_GENDER as travel_g9_5_0_,
postinfo0_.TRAVEL_MEMBER as travel_10_5_0_,
post1_.POST_ID as post_id1_3_1_,
post1_.created_at as created_2_3_1_,
post1_.updated_at as updated_3_3_1_,
post1_.context as context4_3_1_,
post1_.like_count as like_cou5_3_1_,
post1_.POST_INFO_ID as post_inf7_3_1_,
post1_.TITLE as title6_3_1_,
post1_.USER_ID as user_id8_3_1_
from
POST_INFO postinfo0_
left outer join
POST post1_
on postinfo0_.POST_INFO_ID=post1_.POST_INFO_ID
where
postinfo0_.POST_INFO_ID=?
Hibernate:
select
post0_.POST_ID as post_id1_3_1_,
post0_.created_at as created_2_3_1_,
post0_.updated_at as updated_3_3_1_,
post0_.context as context4_3_1_,
post0_.like_count as like_cou5_3_1_,
post0_.POST_INFO_ID as post_inf7_3_1_,
post0_.TITLE as title6_3_1_,
post0_.USER_ID as user_id8_3_1_,
postinfo1_.POST_INFO_ID as post_inf1_5_0_,
postinfo1_.created_at as created_2_5_0_,
postinfo1_.updated_at as updated_3_5_0_,
postinfo1_.city as city4_5_0_,
postinfo1_.state as state5_5_0_,
postinfo1_.STATUS_ID as status_11_5_0_,
postinfo1_.TRAVEL_AGE as travel_a6_5_0_,
postinfo1_.travelDateEnd as travelda7_5_0_,
postinfo1_.travelDateStart as travelda8_5_0_,
postinfo1_.TRAVEL_GENDER as travel_g9_5_0_,
postinfo1_.TRAVEL_MEMBER as travel_10_5_0_
from
POST post0_
inner join
POST_INFO postinfo1_
on post0_.POST_INFO_ID=postinfo1_.POST_INFO_ID
where
post0_.POST_INFO_ID=?
Hibernate:
select
postimage0_.POST_IMAGE_ID as post_ima1_9_,
postimage0_.created_at as created_2_9_,
postimage0_.updated_at as updated_3_9_,
postimage0_.POST_IMAGE_NAME as post_ima4_9_,
postimage0_.POST_ID as post_id6_9_,
postimage0_.POST_IMAGE_URL as post_ima5_9_
from
PostImage postimage0_
where
postimage0_.POST_ID=?
Hibernate:
select
postimage0_.POST_IMAGE_ID as post_ima1_9_,
postimage0_.created_at as created_2_9_,
postimage0_.updated_at as updated_3_9_,
postimage0_.POST_IMAGE_NAME as post_ima4_9_,
postimage0_.POST_ID as post_id6_9_,
postimage0_.POST_IMAGE_URL as post_ima5_9_
from
PostImage postimage0_
where
postimage0_.POST_ID=?
Hibernate:
delete
from
PostImage
where
POST_IMAGE_ID=?
위 로그에서 확인하실 수 있듯이 Join을 진행한 post 와 postInfo 의 정보가 1번만 select되는 것이 아닌 1+1번으로 총 2번 select되는 것을 확인하실 수 있습니다.
원인이 되는 메서드는 이 메서드인데
Post post = postService.findPostById(postId);
해당되는 메서드는 게시글 수정, 삭제 등 사용되는 곳이 많기 때문에 수정할 필요가 있습니다.
수정하지 않으면 최악의 경우, 게시글을 가져오는 select 1,000번, 게시글 정보를 가져오는 select 1,000번이 호출될 수 있습니다.
이를 N+1 문제라 합니다.
해결하는 방법은 페치 조인(fetch join)과 DTO 조회가 있습니다. 저는 비교적 간단한 페치 조인으로 해결하였습니다.
페치조인은 JPA를 사용하기 위해서 반드시 숙지해야 할 스킬 중 하나입니다.
위에서 발생한 N+1 문제를 Querydsl로 해결하는 방법은 다음과 같습니다.
PostRepositoryQuerydslImpl
@Override
public Optional<Post> findPostById(Long postId) {
Post post1 = queryFactory
.selectFrom(post)
.join(post.postInfo, postInfo).fetchJoin()
.where(
post.id.eq(postId),
postInfo.status.id.eq(1L)
).fetchOne();
return Optional.ofNullable(post1);
}
위와 같이 변경 후 메서드를 실행시킨 후 나오는 로그를 확인해보았습니다.
Hibernate:
select
user0_.USER_ID as user_id1_13_,
user0_.created_at as created_2_13_,
user0_.updated_at as updated_3_13_,
user0_.age as age4_13_,
user0_.description as descript5_13_,
user0_.email as email6_13_,
user0_.gender as gender7_13_,
user0_.nickname as nickname8_13_,
user0_.oAuth2Id as oauth9_13_,
user0_.password as passwor10_13_,
user0_.role as role11_13_
from
USER user0_
where
user0_.email=?
Hibernate:
select
post0_.POST_ID as post_id1_3_0_,
postinfo1_.POST_INFO_ID as post_inf1_5_1_,
post0_.created_at as created_2_3_0_,
post0_.updated_at as updated_3_3_0_,
post0_.context as context4_3_0_,
post0_.like_count as like_cou5_3_0_,
post0_.POST_INFO_ID as post_inf7_3_0_,
post0_.TITLE as title6_3_0_,
post0_.USER_ID as user_id8_3_0_,
postinfo1_.created_at as created_2_5_1_,
postinfo1_.updated_at as updated_3_5_1_,
postinfo1_.city as city4_5_1_,
postinfo1_.state as state5_5_1_,
postinfo1_.STATUS_ID as status_11_5_1_,
postinfo1_.TRAVEL_AGE as travel_a6_5_1_,
postinfo1_.travelDateEnd as travelda7_5_1_,
postinfo1_.travelDateStart as travelda8_5_1_,
postinfo1_.TRAVEL_GENDER as travel_g9_5_1_,
postinfo1_.TRAVEL_MEMBER as travel_10_5_1_
from
POST post0_
inner join
POST_INFO postinfo1_
on post0_.POST_INFO_ID=postinfo1_.POST_INFO_ID
where
post0_.POST_ID=?
and postinfo1_.STATUS_ID=?
Hibernate:
select
postimage0_.POST_IMAGE_ID as post_ima1_9_,
postimage0_.created_at as created_2_9_,
postimage0_.updated_at as updated_3_9_,
postimage0_.POST_IMAGE_NAME as post_ima4_9_,
postimage0_.POST_ID as post_id6_9_,
postimage0_.POST_IMAGE_URL as post_ima5_9_
from
PostImage postimage0_
where
postimage0_.POST_ID=?
Hibernate:
select
postimage0_.POST_IMAGE_ID as post_ima1_9_,
postimage0_.created_at as created_2_9_,
postimage0_.updated_at as updated_3_9_,
postimage0_.POST_IMAGE_NAME as post_ima4_9_,
postimage0_.POST_ID as post_id6_9_,
postimage0_.POST_IMAGE_URL as post_ima5_9_
from
PostImage postimage0_
where
postimage0_.POST_ID=?
Hibernate:
delete
from
PostImage
where
POST_IMAGE_ID=?
Join을 진행한 post와 postInfo의 정보가 의도한 대로 1번만 select되는 것이 확인되었습니다.
JPA를 사용하며 한 번쯤은 마주하게 되는 N+1 문제를 페치 조인을 통해 해결하고, 성능 최적화를 하였습니다. 위의 페치 조인과 더불어 DTO 조회 해결 방법도 있습니다. 이는 페치 조인으로도 해결이 안 되면 시도해보시는 것도 좋은 방안입니다.
추가로 지연로딩(LAZY)과 즉시로딩(EAGER)의 개념도 반드시 알아야 N+1 문제를 완벽하게 제어할 수 있다고 하네요.. 조만간 공부해야 할 것 같습니다.
가장 추천되는 방법은 지연로딩을 기본으로 설정하고 그때그때 페치 조인을 사용하는 것이 가장 좋다고 합니다.
이상 글 마치겠습니다.
참고한 글
진행하는 프로젝트
https://github.com/Team-hangout/backend/tree/develop