0. 배경
JPA로 개발을 하면서, 페이징을 사용하지 않았을 때 성능 저하, 서비스 장애로 이어질 수 있는 부분을 깨닫고 정리하였습니다.
1. Paging을 사용하지 않고 모든 게시글을 불러왔을 때 문제 발생
데이터가 많으면 장애 발생
프로젝트에서 사용한 코드
@Query(value = "select p.*, COUNT(pl.post_like_id) as LikeCount " +
"from post p " +
"inner join post_like pl on p.post_id = pl.post_id " +
"group by p.post_id " +
"order by LikeCount Desc, p.post_id Desc", nativeQuery = true)
List<Post> customFindByLikeCountLessThanOrderByLikeCountDescAndIdDesc();
게시글을 좋아요 순으로, 좋아요가 같다면 최신순으로 정렬하여 불러오는 쿼리입니다.
게시글이 많지 않으면 문제 없이 사용할 수 있지만, 저는 N + 1 문제를 batch_size 를 이용하여 해결하였고, 게시글이 백만개 이상이 넘는다고 하였을 때, 응답으로 불러오지 못하는 에러가 발생하였습니다.
발생하는 쿼리수가 어마어마하며, 충분히 서비스 장애가 일어날 수 있습니다.
데이터가 많지 않을 때 문제 없음
데이터가 10,000개 정도라고 하였을 때는 문제 없이 작동하였지만, 한 번에 데이터를 많이 조회하였고 당장 불필요한 데이터가 포함되어있기 때문에 성능 저하가 발생합니다.
데이터가 1,000,000개 이상이라면 게시글 목록을 좋아요 개수순으로 조회할 때, 10초 이상이 걸리게 되며 사용자 입장에서 말도 안 되는 상황이기 때문에 개선이 필요합니다.
2. Paging을 사용하여 필요한 데이터를 조회
기존 페이징 방식은 페이지 번호(offset)와 페이지 사이즈(limit)를 기반으로 합니다.
흔히 사용하는 페이징 방식입니다.
페이지 번호와 그 페이지에 뿌려줄 게시글 개수를 정하여 나타냅니다.
바로 코드로 적용하여 얼마나 개선이 되었는지 알아보겠습니다.
페이지 번호 0, 페이지 사이즈 20으로 설정하여 첫 번째 페이지에 존재하는 게시글 20개만 조회한 결과입니다.
조회하는 속도가 2배 이상이 되었음을 확인할 수 있습니다.
2.1 Paging 쿼리가 느린 이유 (직접 구현 및 비교)
기존에 사용하는 페이징 쿼리 예시입니다.
SELECT *
FROM post
WHERE 조건문
ORDER BY id DESC
OFFSET 페이지번호
LIMIT 페이지사이즈
이런 형태의 페이징 쿼리가 뒤로갈수록 느린 이유는 앞에서 읽었던 행을 다시 읽어야 하기 때문입니다.
offset 49990, limit 20 이라고 하면 총 50,010개의 행을 읽어야 합니다.
그리고 앞에 49,990개 행을 버리게 됩니다. 필요한 것은 20개뿐이기 때문입니다.
뒤로 갈수록 필요없지만 읽어야 할 행의 개수가 많아 느려지는 것입니다.
약 1,000,000개의 데이터로 테스트 한 결과입니다.
offset = 0일 때 결과입니다.
offset = 49,990일 때 결과입니다.
offset에 따라 수행 시간 차이가 나는 것을 확인할 수 있습니다.
데이터의 개수가 많으면 많아질수록 더 큰 차이가 나고, 현재 저희 프로젝트에서는 이러한 페이징 방식을 사용하지 않기 때문에 No-Offset 방식을 이용하여 개선하였습니다.
3. No-Offset을 이용한 페이징 성능 개선
No-Offset 정의
No Offset 방식은 조회 시작 부분을 인덱스로 빠르게 찾아 첫 페이지만 읽도록 하는 방식입니다.
SELECT *
FROM post
WHERE 조건문
AND id < 마지막조회ID # 직전 조회 결과의 마지막 id
ORDER BY id DESC
LIMIT 페이지사이즈
마지막 조회 결과의 ID를 조건문에 사용하여 이전 페이지 전체를 건너 뛸 수 있습니다.
offset을 이용할 때는 앞에 게시글을 읽어야 했다면, No Offset 방식은 처음 페이지 읽은 것과 동일한 성능을 가지게 됩니다.
3.1 장점 1 - 불필요한 count 쿼리가 발생하지 않는다.
일반적인 페이징 방식은 데이터 조회하 함께 count 쿼리가 수행됩니다.
총 건수를 알아야 PageNo를 알 수 있기 때문입니다.
count 쿼리는 데이터 조회만큼 오래 걸릴 수 있습니다.
Page를 이용하여 게시글 목록을 최신으로 불러 올 때 발생하는 쿼리입니다.
현재 쿼리는 복잡하지 않기 때문에 복잡한 연산은 없지만, inner join으로 여러 관계가 얽혀있다면 count 쿼리를 수행하는데 여러 join 연산이 추가되므로 그만큼 쿼리 수행 시간이 더 오래 걸리게 됩니다.
No-Offset 방식은 마지막 조회 ID를 기준으로 불러오는 방식이기 때문에, Page로 반환할 필요가 없어 count 쿼리가 발생하지 않습니다.
3.2 장점 2 - count 쿼리 발생으로 인한 예측 불가능한 에러 걱정을 할 필요가 없다.
위에 사용된 쿼리랑 다른 쿼리입니다.
게시글 좋아요 순으로 조회하여 마지막 좋아요 개수를 기준으로 추가적인 게시글을 받는 로직입니다.
@Query(value = "select p.*, COUNT(pl.post_like_id) as LikeCount " +
"from post p " +
"inner join post_like pl on p.post_id = pl.post_id " +
"group by p.post_id " +
"having LikeCount < :lastLikeCount " +
"order by LikeCount Desc, p.post_id Desc", nativeQuery = true)
Page<Post> customFindByLikeCountLessThanOrderByLikeCountDescAndIdDesc(Long lastLikeCount, Pageable pageable);
이 쿼리를 수행했을 때 발생하는 문제는 count 쿼리에서 having절을 인식하지 못하여 SQL Error: 1054, SQLState: 42S22가 발생합니다.
count 쿼리가 어떻게 발생하는지에 대해 나중에 다루겠습니다.
현재 count쿼리가 발생할 때, native 쿼리를 기준으로 발생하는데, native 쿼리에서 사용된 LikeCount를 count 쿼리에서 제대로 불러오지 못하며, 이러한 이유로 count 쿼리에서 에러가 발생하여 서비스 장애 문제를 겪었습니다.
No Offset 방식을 이용하면, Page를 이용할 필요가 없기 때문에 이러한 에러를 걱정할 필요가 없습니다.
3.3 장점 3 - count 쿼리 발생X로 인한 성능 향상
Page를 이용한 쿼리를 사용하였을 때 게시글 20개 불러오기 - 830ms 소요
Page -> List를 이용한 쿼리 사용하였을 때 게시글 20개 불러오기 - 524ms 소요
쉬운 설명을 위하여 간단한 쿼리로 테스트 하였습니다.
join 연산이 하나도 없는 count 쿼리여도 시간 차이가 발생하는 것을 확인할 수 있습니다.
join이 여러개 존재하고 데이터가 많아진다면, 더 큰 시간 차이가 발생할 것입니다.
count 쿼리가 발생하지 않음으로 인하여 성능 개선이 가능합니다.
4. 결론
기존 페이징 방식과 No-Offset 방식을 비교하여 페이징 성능을 개선하였습니다.
제 블로그에서는 다루지 않았지만 기존 페이징도 성능을 개선할 수 있으며, No-Offset으로만 성능을 개선할 수 있음을 얘기하는 것이 아닙니다.
프로젝트 기획에 따라 어떠한 방식을 이용할 것인지 거기에 적합한 방식이 무엇인지 생각하며 적용하는 것이 중요하다고 생각합니다.
No-Offset 방식은 무한 스크롤 방식 또는 More 버튼을 눌러 다음 게시글을 불러오는 화면에서만 가능하며, 1페이지에서 갑자기 12페이지로 가는 방식에서는 사용할 수 없습니다.
페이징을 적용하지 않았을 때 서비스에 문제가 발생하였고, 그러한 문제를 해결하면서 페이징의 중요성과 기획에 맞는 방식, 성능 개선을 경험할 수 있었습니다.
'JPA' 카테고리의 다른 글
JPA: 알람 기능과 댓글 기능, 도메인 로직과 서비스 로직의 트랜잭션 분리 작업 (0) | 2024.06.11 |
---|---|
JDBC, JPA) 싱글벙글 JPA 프로젝트에서 이미지 업로드 쿼리를 최소화 해보자 (0) | 2023.08.08 |
Querydsl: OneToMany 관계에서 Projection DTO 값 List를 어떻게 갖고올까? (0) | 2023.04.01 |
JPA: 게시글 전체 조회 N + 1를 어떻게 처리할까? (0) | 2023.03.06 |