평가할 웹툰을 사용자에게 보여줘야 하는 부분은 사용자 경험을 생각해야 했다.
우선 네이버 웹툰 한정이고, 현재 연재중인 작품들을 대상으로 하는데도 웹툰의 개수가 540개 가량 된다.
그리고 이미 사용자가 평가한 웹툰은 보여지면 안된다.
평가 페이지에서 웹툰을 전부 한 번에 렌더링하게 되는데 상황에 따라 버벅임이 발생할 수 있다.
그리고 평가할 웹툰들을 계속 순서대로 보여준다면 대부분의 유저가 처음 나타나는 웹툰들만 보고 그 일부만을 평가할 것이기 때문에 계속 섞어서 보여줄 필요가 있었다.
문제
- 전부 한 번에 렌더링을 하지 않게 하도록 방지
- 무한 스크롤 기능 구현 (프론트)
- 스크롤 되지 않은 부분에 도달할 때 마다 이미지를 불러온다.
- 무한 스크롤 기능을 구현하기 위해 페이징이 필요하다.
- 페이징 처리를 하면서 데이터를 섞기가 어렵다.
- 그렇다고 페이징 객체를 반환하기 전 데이터를 섞는다면 페이징 처리가 제대로 동작하지 않아 중복된 데이터를 보게되거나 하는 문제가 될 수 있다.
1
2
@Query("select e.webtoon.id from Evaluation e where e.account.id = :accountId")
List<Long> findRatedWebtoonIdsByAccountId(@Param("accountId") Long accountId);
1
2
@Query("select w from Webtoon w where w.id not in :ratedWebtoonIds")
Page<Webtoon> findWebtoonsNotIn(@Param("ratedWebtoonIds") List<Long> ratedWebtoonIds, Pageable pageable);
처음 시도했던 방법이다. 간단하지만, Page를 반환하기 때문에 shuffle을 적용할 수 없다.
사용자 경험과 제대로 된 평가데이터를 얻기 위해서는 페이징도 하면서 데이터를 섞는 작업을 해야하기 때문에 방법을 찾아보았다.
해결
- Pre-shuffle and Store 전략의 사용
- DB에 미리 셔플된 데이터를 저장해 랜덤한 결과를 반환한다.
- 데이터를 셔플한 후 데이터베이스에 저장하고 클라이언트는 셔플된 데이터를 가져오는 방식이다.
- 데이터가 미리 셔플되어 저장되어, 요청 시 마다 셔플 로직이 수행되는 것이 아니기 때문에 성능상의 이점이 있다.
- 하지만 데이터가 변경될 때 마다 셔플 로직을 다시 수행하고 저장해야 한다는 단점이 있다.
- 평가된 웹툰을 필터링하면서 데이터의 변경이 잦기 때문에 데이터를 통째로 다시 저장하는 이 방법은 택하지 않았다.
- 페이지 계산 로직 따로 구현
- Collections.shuffle() 메서드를 호출하기 때문에 데이터베이스에서 모든 대상 데이터를 메모리로 가져와 셔플한다.
- 이 점 때문에 대량의 데이터 처리에는 부적합할 수 있지만, 데이터의 크기가 크지 않기 때문에 이 방법을 택했다.
- 참고로 반드시 Querydsl을 사용할 필요는 없지만, 코드의 안정성과, 해당 로직의 관리를 유연하게 하도록 하기 위해 Querydsl로 작성했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Override
public Page<RatingWebtoonDto> findWebtoonsNotEvaluatedAndShuffled(Long userId, Pageable pageable) {
QEvaluation qEvaluation = QEvaluation.evaluation;
QWebtoon qWebtoon = QWebtoon.webtoon;
List<Long> evaluatedWebtoonIds = queryFactory
.select(qEvaluation.webtoon.id)
.from(qEvaluation)
.where(qEvaluation.account.id.eq(userId))
.fetch();
List<RatingWebtoonDto> content = queryFactory
.select(Projections.constructor(RatingWebtoonDto.class,
qWebtoon.id.as("id"),
qWebtoon.imgSrc.as("imgSrc")))
.from(qWebtoon)
.where(qWebtoon.id.notIn(evaluatedWebtoonIds))
.fetch();
//offset, limit 사용 X
Collections.shuffle(content);
int fromIndex = (int) pageable.getOffset();
int toIndex = Math.min(fromIndex + pageable.getPageSize(), content.size());
List<RatingWebtoonDto> subList = content.subList(fromIndex, toIndex);
return new PageImpl<>(subList, pageable, content.size());
}
- 이미 평가한 웹툰을 필터링 한 후 그 웹툰 목록들을 shuffle 해준다.
- pageable에서 페이지네이션 정보를 가져와 해당 부분을 추출하고, PageImpl 객체를 생성해 반환한다.
핵심
1
2
3
4
5
int fromIndex = (int) pageable.getOffset();
int toIndex = Math.min(fromIndex + pageable.getPageSize(), content.size());
List<RatingWebtoonDto> subList = content.subList(fromIndex, toIndex);
return new PageImpl<>(subList, pageable, content.size());
- 이 부분이 핵심이다.
- 원본 리스트를 참조하고 새로운 리스트를 생성하지 않는다. 메모리 사용에서의 이점이 있다.
1
2
3
4
5
6
7
List<RatingWebtoonDto> content = queryFactory
.select(Projections.constructor(RatingWebtoonDto.class,
qWebtoon.id.as("id"),
qWebtoon.imgSrc.as("imgSrc")))
.from(qWebtoon)
.where(qWebtoon.id.notIn(evaluatedWebtoonIds))
.fetch();
- 만약 이 부분에서 offset(), limit()을 사용했다면?
- 애초에 DB에서 결과 집합의 일부를 가져오게 된다.
- 그런데 Collections.shuffle()을 적용을 해버리면, 다음 데이터를 조회할 때 이미 조회한 데이터가 보여질 수 있다.
- 이를 방지하기 위해 데이터를 정렬하고 페이징을 적용할 수 있는데, 이렇게 구현하면 중복 데이터 문제는 없지만, 랜덤으로 섞는 방안이 아닌 말 그대로 정렬이기 때문에 개발 의도와는 다르게 동작한다.
따라서 offset(), limit()을 이용하여 페이징을 처리하지 않고, 따로 로직을 구현하여 문제를 해결했다. 물론 메모리 사용에 대한 부분에 단점이 있으므로 상황에 따라 수정을 고려해야 할 수 있다.
프론트서버에서의 대처
- 위의 페이징한 결과와 IntersectionObserver를 이용한다.
- 무한 스크롤을 구현하고, 스크롤이 될 때 해당 부분을 렌더링한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
async getWebtoons() {
try {
const response = await axios.get(`/api/evaluation/card?page=${this.page}&size=${this.size}`);
this.webtoons.push(...response.data.content);
this.page++;
} catch (error) {
console.error(error);
}
},
createObserver() {
const options = {
root: null,
threshold: 0.1,
};
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.getWebtoons();
}
});
}, options);
observer.observe(this.$refs.loader);
},
},
};
그리고 렌더링에 관련된 부분에 아래와 같은 태그를 추가해주면 된다.
1
<div ref="loader"></div>