프로젝트에서 내가 맡은 기능 이슈에서 게시글 목록 전체 조회 시 무한 스크롤 구현이 있었다.
작년에 프론트로 개발을 처음 시작했었을 때 무한 스크롤의 경우 백엔드에서 전체 데이터를 다 주면 안드로이드에서 recyclerView를 이용해서 무한스크롤을 구현했었는데 올해부터 백엔드 개발을 시작한 이래로 2월에 기본기를 다지기 위해 다른 프로젝트에서 CRUD를 맡다가 지금은 백엔드 입장이 되어서 백엔드 단에서 이 기능을 맡게 되어 감회가 새롭다.
백엔드로서 처음 맡는 기능인 만큼 단계적으로 진행하고자 무한 스크롤 없이 200OK가 응답되도록 전체적인 게시글 GET 요청에 대한 코드 설계는 완료를 해서 feature/#189 (이슈 번호) 브랜치에 push를 해둔 상황이고(엔티티 3개를 거쳐야 해서 구현이 재밌었당) 이제 무한 스크롤을 위한 수정을 앞둔 상황이었다. 이 기능을 백엔드에서 어떻게 구현하는 건가 싶어서 찾아봤더니 이에 대한 키워드가 Pageable도 있고 Slice도 있는데 이 요소들에 대한 이해가 선행이 되어야 프로젝트 기능에 맞는 로직 설계가 가능했기에 차이점을 살펴보고자 했다.
Pagination vs 무한 스크롤
Pagination
페이지네이션은 예를 들어 쿠팡 같은 사이트를 방문하면 하단에 페이지를 선택할 수 있는 컴포넌트가 있는데 이와 같이 서비스 내에서 보려는 목록이 많은 경우 페이지 단위로 분할하여 프론트에서 요청한 페이지만 볼 수 있도록 만들어준다.
infinite scroll
무한 스크롤은 스크롤을 내릴 때마다 새로운 데이터가 계속 load되는 방식이다.
Page vs Slice
JPA에서는 Pagination을 위해 Page와 Slice 객체를 제공하고 request DTO 혹은 requestParam에서 공통적으로 필요한 3가지 사항이 있다.
1. page: 페이지 번호
2. size: 한 페이지에 담는 데이터의 개수
3. sort: 정렬 조건
Slice
Page
Page는 Slice를 상속하므로 위 링크에서 Slice에서 제공하는 모든 메소드를 사용할 수 있다. 추가적으로 Page에서 이용할 수 있는 부분은 Slice에는 없는 getTotalElements, getTotalPaes이다. (이 부분이 Page와 Slice의 차이점이다!!) 전자의 경우 조회 쿼리 이후 전체 데이터 개수를 조회하는 count 쿼리가 한 번 더 실행되는 특징이 있다.
즉, Page를 사용하면 count 쿼리가 추가 실행되지만 Slice의 경우 count 쿼리는 실행되지 않는다.
Page vs Slice 둘 중 무엇을 선택할지에 대한 판단 기준이 뭘까
Page는 전체 데이터의 개수를 count 조회하기 때문에 전체 페이지 개수가 필요하거나 조회 결과 페이지 수를 노출해야하는 상황에 적합하다.
Slice의 경우 전체 데이터의 개수를 조회하지 않고 이전이나 다음 데이터가 존재하는지만 확인 가능할 때 적용한다. Slice는 추가 쿼리가 실행되지 않아 Page보다 성능상으로 유리하다는 특징이 있다. 이 프로젝트에서는 피그마 상에서도 게시글 전체 조회 시 별도의 게시글 개수가 필요하지 않기 때문에 Slice를 적용하기로 결정하였다.
무한 스크롤 기능 구현 완료(pr 링크) 이후 no-offset 방식을 통해 성능을 추가적으로 더 개선해볼 생각이다.
기존 List 반환 코드와 Slice 반환 코드 및 json response 비교
두 케이스에서 차이가 나는 부분만 담았다. (응답 record java 파일은 생략)
기존 List 반환 코드 및 응답 내역
StudyController.java
@Operation(summary = "게시글 목록 조회", description = "학교 수업 스터디 혹은 수업 외 활동 게시글 전체 목록을 반환합니다.")
@GetMapping("/post/all")
public List<StudyTotalPostResponse> getAllStudyPost(
@RequestParam(name = "choice") String choice) {
return studyService.getAllStudyPost(choice);
}
StudyService.java
@Transactional(readOnly = true)
public List<StudyTotalPostResponse> getAllStudyPost(String choice) {
List<Study> studyList = new ArrayList<>();
if (UNIVERSITY_LECTURE_STUDY.equals(choice)) {
studyList = studyRepository.findByType(StudyType.LECTURE);
return mapToStudyTotalPostResponse(studyList, StudyType.LECTURE);
}
if (EXTERNAL_ACTIVITY_STUDY.equals(choice)) {
studyList = studyRepository.findByType(StudyType.EXTERNAL_ACTIVITY);
return mapToStudyTotalPostResponse(studyList, StudyType.EXTERNAL_ACTIVITY);
}
return Collections.emptyList();
}
private List<StudyTotalPostResponse> mapToStudyTotalPostResponse(List<Study> studyList, StudyType studyType) {
return studyList.stream()
.map(study -> {
if (studyType == StudyType.LECTURE) {
LectureStudy lectureStudy = lectureStudyRepository.findByStudyId(study.getId())
.orElseThrow(() -> new CustomException(ErrorCode.LECTURE_AND_STUDY_NOT_CONNECTED));
String lectureName = lectureStudy.getLecture().getName();
return StudyTotalPostResponse.fromLectureStudy(study, lectureName);
} else if (studyType == StudyType.EXTERNAL_ACTIVITY) {
ExternalActivityStudy externalActivityStudy = externalActivityStudyRepository.findByStudyId(study.getId())
.orElseThrow(() -> new CustomException(ErrorCode.ACTIVITY_AND_STUDY_NOT_CONNECTED));
String activityCategoryName = externalActivityStudy.getExternalActivity().getName();
return StudyTotalPostResponse.fromExternalActivityStudy(study, activityCategoryName);
} else {
throw new CustomException(ErrorCode.STUDY_TYPE_NOT_FOUND);
}
})
.collect(Collectors.toList());
}
StudyRepository.java
public interface StudyRepository extends JpaRepository<Study, Long>, StudyRepositoryCustom {
List<Study> findByType(StudyType studyType);
}
response json
{
"success": true,
"status": 200,
"data": [
{
"id": 2,
"title": "교외 활동 사람 구해요",
"createdAt": "2024-05-15",
"hasImage": false,
"categoryName": "공모전"
}
],
"timestamp": "2024-05-18T15:30:12.3024177"
}
무한 스크롤을 위한 Slice 반환 코드 및 응답 내역
StudyController.java
@Operation(summary = "게시글 목록 조회", description = "학교 수업 스터디 혹은 수업 외 활동 게시글 전체 목록을 반환합니다.")
@GetMapping("/post/all")
public Slice<StudyTotalPostResponse> getAllStudyPost(
@RequestParam(name = "choice") String choice,
@RequestParam(defaultValue = "0") int page) {
return studyService.getAllStudyPost(choice, page);
}
pageable 객체를 만들기 위해 page, size, sort를 requestParam으로 받는 것이 좋으나 정렬 조건은 default로 백엔드 단에서 알아서 service layer에서 처리해주었고 size의 경우 repository에서 원하는 기간 동안 count 쿼리를 통해 게시글 개수를 동적으로 세어서 pageable size 파라미터에 할당해주기 위해 프론트로부터 요청을 받지 않는 방식을 설계했다.
StudyService.java
@Transactional(readOnly = true)
public Slice<StudyTotalPostResponse> getAllStudyPost(String choice, int page, int size) {
LocalDateTime sixMonthsAgo = LocalDateTime.now().minusMonths(6);
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
Slice<Study> studySlice;
if (UNIVERSITY_LECTURE_STUDY.equals(choice)) {
studySlice = studyRepository.findByTypeAndCreatedAtAfter(StudyType.LECTURE, sixMonthsAgo, pageable);
return mapToStudyTotalPostResponse(studySlice, StudyType.LECTURE);
}
if (EXTERNAL_ACTIVITY_STUDY.equals(choice)) {
studySlice = studyRepository.findByTypeAndCreatedAtAfter(StudyType.EXTERNAL_ACTIVITY, sixMonthsAgo, pageable);
return mapToStudyTotalPostResponse(studySlice, StudyType.EXTERNAL_ACTIVITY);
}
return new SliceImpl<>(Collections.emptyList(), pageable, false);
}
private Slice<StudyTotalPostResponse> mapToStudyTotalPostResponse(Slice<Study> studySlice, StudyType studyType) {
return studySlice.map(study -> {
if (studyType == StudyType.LECTURE) {
LectureStudy lectureStudy = lectureStudyRepository.findByStudyId(study.getId())
.orElseThrow(() -> new CustomException(ErrorCode.LECTURE_AND_STUDY_NOT_CONNECTED));
String lectureName = lectureStudy.getLecture().getName();
return StudyTotalPostResponse.fromLectureStudy(study, lectureName);
} else if (studyType == StudyType.EXTERNAL_ACTIVITY) {
ExternalActivityStudy externalActivityStudy = externalActivityStudyRepository.findByStudyId(study.getId())
.orElseThrow(() -> new CustomException(ErrorCode.ACTIVITY_AND_STUDY_NOT_CONNECTED));
String activityCategoryName = externalActivityStudy.getExternalActivity().getName();
return StudyTotalPostResponse.fromExternalActivityStudy(study, activityCategoryName);
} else {
throw new CustomException(ErrorCode.STUDY_TYPE_NOT_FOUND);
}
});
}
응답 내역이 없을 때 return Slice.empty();로 처음에 작성했으나
Static method may be invoked on containing interface class only
위와 같은 에러가 터졌다. Slice.empty() 메소드는 Slice 인터페이스에서 구현하는 PageImpl 클래스를 사용해서 빈 Slice 객체를 반환하는 방법으로 우회하여 return new SliceImpl<>을 통해서 에러를 해결하였다.
하지만 사실 위 코드의 문제점은 항상 현재 요청 시점을 기준으로 6개월 이내에 처음 등록된 게시글만 조회된다는 문제점이 있다. 내가 원하는 건 page 0을 요청하면 현재 기준으로 6개월 전까지를, page 1을 요청하면 현재 기준으로 6개월 전부터 12개월 전까지, page 2를 요청하면 현재부터 12개월 전부터 18개월 전까지.. 이렇게 순차적으로 연장이 되는 것이고 6개월 기본 단위 별 해당 게시글을 모두 조회하는 것이다. count 쿼리를 이용해 게시글 개수를 세고 위에서 언급했듯이 pageable 객체에 size를 기간 별로 유동적으로 할당할 수 있게끔 하였다. 그래서 아래와 같이 service 코드를 수정하였다. private 함수는 동일하다.
@Transactional(readOnly = true)
public Slice<StudyTotalPostResponse> getAllStudyPost(String choice, int page) {
LocalDateTime now = LocalDateTime.now();
LocalDateTime endDate = now.minusMonths(page * 6);
LocalDateTime startDate = endDate.minusMonths(6);
Pageable pageable;
Slice<Study> studySlice;
if (UNIVERSITY_LECTURE_STUDY.equals(choice)) {
int size = studyRepository.countByTypeAndCreatedAtBetween(StudyType.LECTURE, startDate, endDate).intValue();
pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
studySlice = studyRepository.findByTypeAndCreatedAtBetween(StudyType.LECTURE, startDate, endDate, pageable);
return mapToStudyTotalPostResponse(studySlice, StudyType.LECTURE);
}
if (EXTERNAL_ACTIVITY_STUDY.equals(choice)) {
int size = studyRepository.countByTypeAndCreatedAtBetween(StudyType.EXTERNAL_ACTIVITY, startDate, endDate).intValue();
pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
studySlice = studyRepository.findByTypeAndCreatedAtBetween(StudyType.EXTERNAL_ACTIVITY, startDate, endDate, pageable);
return mapToStudyTotalPostResponse(studySlice, StudyType.EXTERNAL_ACTIVITY);
}
return new SliceImpl<>(Collections.emptyList(), Pageable.unpaged(), false);
}
StudyRepository.java
아래는 처음에 항상 현재 요청 일자 기준으로 6개월 이전 게시글만 확인할 수 있도록 만들어진 메소드이다.
public interface StudyRepository extends JpaRepository<Study, Long>, StudyRepositoryCustom {
Slice<Study> findByTypeAndCreatedAtAfter(StudyType studyType, LocalDateTime createdAt, Pageable pageable);
}
page 추가 요청 시 6개월 단위로 그 이전 게시글을 타고 매번 거슬러 올라가도록, 그리고 그 기간 별 게시글 개수를 알 수 있게끔 수정한 코드는 아래와 같다.
public interface StudyRepository extends JpaRepository<Study, Long>, StudyRepositoryCustom {
Slice<Study> findByTypeAndCreatedAtBetween(StudyType studyType, LocalDateTime startDate, LocalDateTime endDate, Pageable pageable);
Long countByTypeAndCreatedAtBetween(StudyType studyType, LocalDateTime startDate, LocalDateTime endDate);
}
response json
{
"success": true,
"status": 200,
"data": {
"content": [
{
"id": 2,
"title": "교외 활동 사람 구해요",
"createdAt": "2024-05-15",
"hasImage": false,
"categoryName": "공모전"
}
],
"pageable": {
"pageNumber": 0,
"pageSize": 1,
"sort": {
"empty": false,
"sorted": true,
"unsorted": false
},
"offset": 0,
"unpaged": false,
"paged": true
},
"size": 1,
"number": 0,
"sort": {
"empty": false,
"sorted": true,
"unsorted": false
},
"first": true,
"last": true,
"numberOfElements": 1,
"empty": false
},
"timestamp": "2024-05-19T10:55:24.1562354"
}
Slice를 적용함으로써 기존 reponse와의 반환 내역 차이를 확연히 확인할 수 있었다. content에서 실제 데이터 목록(여기서는 게시글 목록)을 반환하고 pageable 페이징 정보로는 pageNumber 현재 페이지 번호, pageSize 페이지당 게시글 수, sort 정렬 정보를 반환한다.
countBy 쿼리를 통해 size에 페이지 별로 동적으로 개수 처리가 되었다.
또한 특이 사항은 first, last를 통해서 첫 페이지 및 마지막 페이지 여부를 반환한다는 점이고 numberOfElements를 통해 현제 페이지 항목 수를 확인할 수 있다.
이런 식으로 무한 스크롤을 구현할 수 있겠구나를 체화할 수 있었다.
오늘 왜 이렇게 늙었냐는 소리를 들었는데.. 이게 다 코딩 때문이다
Today What I Learn
'SpringBoot > 구현 고민들' 카테고리의 다른 글
[Spring] Facade Pattern 적용 계기 및 이유 (0) | 2024.08.07 |
---|---|
[Spring] AWS S3 Base64 인코딩된 이미지 처리 과정 고민 및 구현 과정 (0) | 2024.07.12 |
[Spring] soft delete와 임시 저장 구별 (0) | 2024.06.28 |
[Spring] AWS S3로 다중 이미지 업로드 (0) | 2024.05.26 |
[Spring] JPA Specification을 통해 다중 검색 조건에 따른 검색 API 구현(feat. 메소드 체이닝) (0) | 2024.05.19 |