
새로운 프로젝트에 들어가면서 게시판을 구현하게 되었다.
게시판의 글을 찾아 사용자에게 글을 어떻게 보여줄 수 있을까?
가장 쉬운 방법은 모든 글을 찾아 보내주는 것이다.
하지만 이 방법은 최악이라고 생각이 드는데
1. DB에 모든 데이터를 탐색해야하고
2. 사용자가 필요한 양 이상의 & 필요하지도 않을 정보를 보내는 낭비와
3. 한 번에 많은 데이터를 보내야해 네트워크 부하가 발생할 수 있다.
이를 보통 페이징을 통해 해결하는데,
페이징을 통해 한 번에 보여줄 게시글의 개수만큼만 데이터를 보낸다.
그 다음 요청에는 그 다음 데이터를 보내준다.

여러 웹사이트에서 위와 같은 형태로 찾아볼 수 있다.
페이징 또는 페이지네이션을 구현하는 방법은 두가지가 있다.
오프셋 기반 페이지네이션과 커서 기반 페이지 네이션이다.
오프셋 기반 페이지네이션
쿼리 Select 결과를 limit, offset 을 활용하여 어디부터, 몇 개를 가져올 지 지정하는 방식이다.
limit은 상위에서 몇 개를 가져올지 offset은 어디서부터 가져올 지를 정할 수 있다.
MYSQL 쿼리는 아래 글에 잘 설명되었있다.
[MYSQL] 📚 LIMIT / OFFSET 쿼리
limit 결과 중 처음부터 몇개만 가져오기 SELECT * FROM 테이블명 LIMIT 10; -- 처음 부터 10개만 출력하기 (1 ~ 10) SELECT * FROM 테이블명 LIMIT 100, 10; -- 100번째부터 그 후 10개 출력하기 (101 ~ 110) offest 어디서
inpa.tistory.com
오프셋 기반 페이지네이션은 큰 단점이 하나 있는데, 데이터 중복 및 유실이 발생할 수 있다는 것이다.
게시판에 글이 id 1~10까지 10개 존재하고, 최신순으로 글을 보여주고 1페이지에 글이 3개 포함되는 상황을 가정해보자.
유실
만약에 내가 1번 페이지를 보고있는 동안에 1번 페이지에 해당하는 글 하나가 삭제되었다면 다음페이지에는 어떤 글들이 나타날까?
select *
from post
order by id desc
limit 3
offset (0*3)
1번페이지는 10, 9, 8 번에 글이 조회되었고, 10번이 삭제되었다.
그 다음에 요청될 쿼리는 다음과 같다.
select *
from post
order by id desc
limit 3
offset (1*3)
최신순으로 정렬하면 9,8,7... 게시글이 있고 가장 상위 글 3개를 제외하고 3개를 불러오게 되면
6,5,4 번 글이 다음페이지에 조회된다.
7번 글이 누락되었다.
중복
앞의 예시와 반대로 1번 페이지를 보고 있는동안에 새로운 글이 작성되었다.
다음 페이지를 조회하는 쿼리가 요청되면 어떤 글들이 조회될까?
최신 순으로 정렬하면 11, 10, 9,... 게시글이 있고 오프셋으로 정보를 불러오게 되면
8,7,6번 글이 다음페이지에 조회되어서 이 사용자는 8번 글을 두 번 조회하게된다.
데이터 중복 & 유실 문제뿐만아니라 오프셋 기반 방식은 오프셋의 크기가 커지면 성능 저하의 이슈도 존재한다.
커서 기반 페이지 네이션
커서기반 페이지네이션은 오프셋을 사용하지 않아 No-offset 방식이라고도 불린다.
쿼리에서 offset 을 사용하지 않으며
쿼리 검색결과와 함께 커서정보를 함께 전달한다. 그리고 다음조회에 커서 정보를 함께 넘겨 커서 정보를 이용해서 그 다음 데이터를 조회한다.
위와 같은 상황을 가정해보자.
게시판에 글이 id 1~10까지 10개 존재하고, 최신순으로 글을 보여주고 1페이지에 글이 3개 포함되는 상황을 가정해보자.
이번에는 커서 방식으로 조회하면 요청될 쿼리는 다음과 같다.
select *
from post
order by id desc
limit 3;
1번 페이지에서 10, 9, 8번 글이 조회 되었고, 커서정보는 마지막 조회된 글의 id인 8이 된다.
2번 페이지 조회 시 요청될 쿼리는 다음과 같다.
select *
from post
where id < 8
limit 3;
1. 8번 글이 삭제되는 상황이라면?
그래도 조건이 8번보다 id가 작은 글을 불러오는 것이므로 7, 6, 5번 글이 2번 페이지에 조회된다.
2. 11번 글이 생성되는 상황이라면?
조건은 변경되지 않으므로 7, 6, 5번 글이 조회된다.
새로 작성된 11번 글은 첫 요청이 다시 발생하면 그 때 사용자에게 보여줄 수 있다.
구현하기
QueryDsl을 이용해서 구현해보기로 했다.
1. 커서 정보 설정하기
id 기반으로 정렬을 하고 커서를 생성하려고 보니 한 가지 문제아닌 문제가 있었다.
jpa 기본키 생성 전략으로 id값이 서버가 재시작될 때마다 널뛰기 되고 있었다.
1,2,3을 저장하다가 서버가 재시작되면 51부터 시작되고 있었다.
(기본키 생성 전략을 다른 글에서 정리를 해보려고 한다. )
@Id
@GeneratedValue
@Column(name = "postId")
private Long id;
물론 id값이 갑자기 증가된다고 해도 새로 생성되는 row에 부여되는 id값이 증가하는 것은 여전하기 때문에 순서가 보장되는 것은 여전하다.
하지만 id 생성이 내가 예상했던 방식으로 작동하지 않는 상황에서 id값을 커서로 사용한다는 것은 너무 낙관적이라고 판단했고,
명시적으로 생성된 시간을 저장하는 컬럼을 이용하는 것이 더 정확할 것이라고 생각해 생성시간과 id값 두 가지를 커서 정보로 활용하기로 했다.
생성시간에 id를 추가한 이유는 생성시간은 생성된 시간만을 보장할 뿐 유니크한 값을 가지지 않기 때문에 각 로우를 유니크하게 판단할 값으로 id를 이용하였다.
커서 조건은 이전 커서의 생성시간 보다 적거나 같고, id는 다른 값으로 해주었다.
생성된 시간 조건을 < 로 설정하게되면 이전 커서와 생성시간이 같은 경우 다음 조회 조건에서 생략될 수 있어 <= 로 조건을 달아주었고,
생성시간이 같지만 이전에 조회된 글의 중복 조회를 막기 위해서
두 번째 정렬 조건에 id를 추가하고 커서값의 id보다는 작은 row만 조회하게 하였다.
select *
from post
where created_date <= "2024-01-11 16:39:09.156449" and id < 235
order by created_date DESC, id DESC
limit 10;
2. repository 구현 방식
레포지토리 코드를 어떤 방식으로 구현할지 고민이 되었다.
두 가지 방식 중 고민하였다.
1. PostRepository Interface에 상속하여 이용
2. 별도의 페이지네이션만 담당하는 Repository 구현체를 만들어 이용할 것인지
1번 방식은 service 레이어에서 추가로 주입되는 repository 없이 이용할 수 있다는 장점이 있지만, 구현이 복잡하다는 단점이 있었다.
CustomRepository 인터페이스를 만들고
public interface PostCustomRepository {
List<Post> getByCursor(TimeIdCursor cursor, Integer boardId);
List<Post> getByCursor();
}
인터페이스를 구현한 구현체를 만들고
@Repository
@RequiredArgsConstructor
public class PostCustomRepositoryImpl implements PostCustomRepository {
private final JPAQueryFactory query;
public List<Post> getByCursor(TimeIdCursor cursor, Integer boardId) {
return query.select(post)
.from(post)
.join(post.member, member).fetchJoin()
.leftJoin(member.anonymous, anonymous).fetchJoin()
.leftJoin(member.school, school).fetchJoin()
.where(post.lastModifiedDate.loe(cursor.getTime()), post.id.lt(cursor.getId()), post.inUse)
.orderBy(post.lastModifiedDate.desc(),
post.id.desc())
.limit(PAGE_SIZE)
.fetch();
}
public List<Post> getByCursor() {
return query.select(post)
.from(post)
.join(post.member, member).fetchJoin()
.leftJoin(member.anonymous, anonymous).fetchJoin()
.leftJoin(member.school, school).fetchJoin()
.orderBy(post.lastModifiedDate.desc(),
post.id.desc())
.limit(PAGE_SIZE)
.fetch();
}
}
레포지토리에 인터페이스를 상속해야한다.
@Repository
public interface PostRepository extends JpaRepository<Post, Long>, PostCustomRepository {
Optional<Post> findByIdAndInUseIsTrue(Long id);
}
2번 방식은 서비스 레이어에 새로운 레포지토리를 주입해야하지만, 구현이 편리하고 다른 jpa코드와 분리할 수 있어 유지보수 난이도가 줄어든다는 장점이 있다.
@Repository
@RequiredArgsConstructor
public class CursorRepository {
private final JPAQueryFactory query;
/**
* 최신순 조회
*/
public List<Post> getByCursor(TimeIdCursor cursor){
return query.select(post)
.from(post)
.leftJoin(post.member, member).fetchJoin()
.leftJoin(member.anonymous, anonymous).fetchJoin()
.leftJoin(member.school, school).fetchJoin()
.where(post.createdDate.loe(cursor.getTime()), post.id.lt(cursor.getId()), post.inUse)
.orderBy(post.createdDate.desc(),
post.id.desc())
.limit(PAGE_SIZE)
.fetch();
}
public List<Post> getByCursor(){
return query.select(post)
.from(post)
.leftJoin(post.member, member).fetchJoin()
.leftJoin(post.anonymous, anonymous).fetchJoin()
.leftJoin(post.school, school).fetchJoin()
.where(post.inUse)
.orderBy(post.createdDate.desc(),
post.id.desc())
.limit(PAGE_SIZE)
.fetch();
}
/**
* 인기순 조회
*/
public List<Post> getHotByCursor(ScoreIdCursor cursor) {
/* Querydsl에서 조건절이 복잡해질때 BooleanExpression을 사용하여 가독성 있게 조건을 작성할 수 있다. */
BooleanExpression lessScore = post.score.lt(cursor.getScore());
BooleanExpression sameScore = post.score.eq(cursor.getScore()).and(post.id.lt(cursor.getId()));
BooleanExpression inUse = post.inUse;
BooleanExpression whereClause = (lessScore.or(sameScore)).and(inUse);
return query.select(post)
.from(post)
.join(post.member, member).fetchJoin()
.leftJoin(post.anonymous, anonymous).fetchJoin()
.leftJoin(post.school, school).fetchJoin()
.where(whereClause)
.orderBy(post.score.desc(),
post.id.desc())
.limit(PAGE_SIZE)
.fetch();
}
public List<Post> getHotByCursor(){
return query.select(post)
.from(post)
.join(post.member, member).fetchJoin()
.leftJoin(post.anonymous, anonymous).fetchJoin()
.leftJoin(post.school, school).fetchJoin()
.where(post.inUse)
.orderBy(post.score.desc(), post.id.desc())
.limit(PAGE_SIZE)
.fetch();
}
}
별로의 레포지토리를 하나만 구현해주면 되고 서비스 레이어에 그대로 주입해서 사용할 수 있다.
유지보수 편의성과 다른 게시판에서도 페이지네이션이 필요하고 인기순, 최신순 등 다양한 방법의 조회가 필요하여 확장성에 더 유리한 2번 방식을 선택했다.
3. 커서정보 전달 방식
조회 후 커서 정보를 생성해서 클라이언트에 전달해야한다.
두 가지 커서 정보를 가지고 있고, 두 정보를 각각의 키로 클라이언트에 전달하고 다시 받는 것은 커서 값의 일관성을 보장하기도 어렵고 클라이언트 측 입장에서 꽤나 번거로운 일이라고 생각했다.
또한 생성시간 데이터는 LocalDateTime을 사용해서 2024-01-19 18:07:36.866330
이런 방식으로 생성되고 있고,
커서정보는 쿼리파라미터로 /post?cursor=커서정보
로 전달받을 것이므로
LocalDateTime문자열과 게시글 아이디가 그대로 url에 드러나는 것은 좋지 않아보였다.
그래서
- id와 생성시간데이터를 하나의 문자열로 합치고
- base64 인코딩을 통해서 평문을 인코딩 해주었다.
@Getter
@Setter
public class TimeIdCursor {
private LocalDateTime time;
private Long id;
public TimeIdCursor(Teengle teengle) {
this.time = teengle.getLastModifiedDate();
this.id = teengle.getId();
}
public TimeIdCursor(String cursorString){
byte[] decodedBytes = Base64.getDecoder().decode(cursorString.getBytes());
String s = new String(decodedBytes);
String[] split = s.split(",");
this.id = Long.parseLong(split[0]);
this.time = LocalDateTime.parse(split[1]);
}
public String getCursorString(){
try {
String s = this.id + "," + this.time;
byte[] base64EncodedBytes = Base64.getEncoder().encode(s.getBytes());
return new String(base64EncodedBytes);
}
catch (Exception e) {
throw new TeengleException(INVALID_CURSOR.getCode(), INVALID_CURSOR.getErrorMessage());
}
}
}
커서 정보를 string으로 받고 디코딩하여 조회에 이용하고,
다음 커서정보를 다시 인코딩하여 string으로 응답한다.
@Transactional(readOnly = true)
public PostListResponseDto<Post> getListByBoard(String pid, String cursorString) {
Member member = memberService.findByPid(pid);
List<Post> postList;
if (cursorString == null) {
postList = postRepository.getByCursor();
} else {
TimeIdCursor cursor = new TimeIdCursor(cursorString);
postList = postRepository.getByCursor(cursor, boardId);
}
if (postList.size() == 0) return new PostListResponseDto<>(postList);
TimeIdCursor newCursor = new TimeIdCursor(postList.get(postList.size() - 1));
return new PostListResponseDto<>(postList, newCursor.getCursorString());
}
클라이언트에서 커서 정보와 함께 요청을 보낼 때,
/post?cursor=NiwyMDI0LTAxLTE4VDAzOjIxOjAwLjA1ODY2NQ==
이와 같은 형식으로 요청을 보낼 수 있게 되었다.
4. 클라이언트 응답 형식
마지막으로 클라이언트에 응답을 어떤 방식으로 해줄 것인가 고민했다.
맨 처음 조회 결과와 커서정보만 전달해주면 된다고 생각했다.
{ posts: [ 게시글 정보, 게시글 정보],
cursor: base64EncodedString
}
개발하다보니 한 가지 놓친 점이 있었다.
커서정보만으로는 다음 데이터가 존재하는 지 알 수가 없고, 그 다음 요청을 보내야만 다음 데이터의 존재 유무를 알 수 있다.
따라서 다음 데이터 유무를 알려주는 데이터를 추가하였다.
@Getter
public class PostListResponseDto {
List<PostListDto> posts;
@JsonInclude(JsonInclude.Include.NON_NULL)
String cursor;
Boolean next;
public TPostListResponseDto(String pid, List<Post> postList, String cursor){
this.posts = postList.stream()
.map(t -> new postListDto(pid, t))
.toList();
this.cursor = cursor;
this.next = posts.size() == PAGE_SIZE;
}
public PostListResponseDto(String pid, List<Post> postList){
this.posts = postList.stream()
.map(t -> new postListDto(pid, t))
.toList();
this.next = false;
}
}
포스트맨으로 게시글 조회 요청을 보낸 결과 의도한 방식대로 잘 작동하는 것을 확인할 수 있었다!

참고
Cursor based Pagination(커서 기반 페이지네이션)이란? - Querydsl로 무한스크롤 구현하기
Cursor based Pagination, 커서 기반 페이징, 무한스크롤
velog.io
1. 페이징 성능 개선하기 - No Offset 사용하기
일반적인 웹 서비스에서 페이징은 아주 흔하게 사용되는 기능입니다. 그래서 웹 백엔드 개발자분들은 기본적인 구현 방법을 다들 필수로 익히시는데요. 다만, 그렇게 기초적인 페이징 구현 방
jojoldu.tistory.com
[Spring boot] JPA 기본 키 생성 전략(AUTO, IDENTITY, SEQUENCE, TABLE, UUID)
Index에 대해 공부하다가 JPA의 기본 키 생성 전략들은 어떤 방법으로 생성하는 편리한 어노테이션들인지에 대해 공부하게 되었다. JPA 기본 키 생성 전략 JPA(Java Persistence API)에서는 엔티티의 기본
fourjae.tistory.com
'Projects' 카테고리의 다른 글
게시글 추천 시스템 도입을 고려하며 조사한 내용 (0) | 2024.02.04 |
---|---|
게시판 전체 글 랭킹 점수 데이터 업데이트 속도 77.72% 개선 (bulk update vs batch update) (1) | 2024.01.23 |
[코드실행기능 개발기 #3] 코드 실행 서버 부하테스트 (1) | 2023.12.17 |
NestJS에 winston으로 로그 남기기 (0) | 2023.12.17 |
refresh token 도입기 (1) | 2023.12.17 |

새로운 프로젝트에 들어가면서 게시판을 구현하게 되었다.
게시판의 글을 찾아 사용자에게 글을 어떻게 보여줄 수 있을까?
가장 쉬운 방법은 모든 글을 찾아 보내주는 것이다.
하지만 이 방법은 최악이라고 생각이 드는데
1. DB에 모든 데이터를 탐색해야하고
2. 사용자가 필요한 양 이상의 & 필요하지도 않을 정보를 보내는 낭비와
3. 한 번에 많은 데이터를 보내야해 네트워크 부하가 발생할 수 있다.
이를 보통 페이징을 통해 해결하는데,
페이징을 통해 한 번에 보여줄 게시글의 개수만큼만 데이터를 보낸다.
그 다음 요청에는 그 다음 데이터를 보내준다.

여러 웹사이트에서 위와 같은 형태로 찾아볼 수 있다.
페이징 또는 페이지네이션을 구현하는 방법은 두가지가 있다.
오프셋 기반 페이지네이션과 커서 기반 페이지 네이션이다.
오프셋 기반 페이지네이션
쿼리 Select 결과를 limit, offset 을 활용하여 어디부터, 몇 개를 가져올 지 지정하는 방식이다.
limit은 상위에서 몇 개를 가져올지 offset은 어디서부터 가져올 지를 정할 수 있다.
MYSQL 쿼리는 아래 글에 잘 설명되었있다.
[MYSQL] 📚 LIMIT / OFFSET 쿼리
limit 결과 중 처음부터 몇개만 가져오기 SELECT * FROM 테이블명 LIMIT 10; -- 처음 부터 10개만 출력하기 (1 ~ 10) SELECT * FROM 테이블명 LIMIT 100, 10; -- 100번째부터 그 후 10개 출력하기 (101 ~ 110) offest 어디서
inpa.tistory.com
오프셋 기반 페이지네이션은 큰 단점이 하나 있는데, 데이터 중복 및 유실이 발생할 수 있다는 것이다.
게시판에 글이 id 1~10까지 10개 존재하고, 최신순으로 글을 보여주고 1페이지에 글이 3개 포함되는 상황을 가정해보자.
유실
만약에 내가 1번 페이지를 보고있는 동안에 1번 페이지에 해당하는 글 하나가 삭제되었다면 다음페이지에는 어떤 글들이 나타날까?
select *
from post
order by id desc
limit 3
offset (0*3)
1번페이지는 10, 9, 8 번에 글이 조회되었고, 10번이 삭제되었다.
그 다음에 요청될 쿼리는 다음과 같다.
select *
from post
order by id desc
limit 3
offset (1*3)
최신순으로 정렬하면 9,8,7... 게시글이 있고 가장 상위 글 3개를 제외하고 3개를 불러오게 되면
6,5,4 번 글이 다음페이지에 조회된다.
7번 글이 누락되었다.
중복
앞의 예시와 반대로 1번 페이지를 보고 있는동안에 새로운 글이 작성되었다.
다음 페이지를 조회하는 쿼리가 요청되면 어떤 글들이 조회될까?
최신 순으로 정렬하면 11, 10, 9,... 게시글이 있고 오프셋으로 정보를 불러오게 되면
8,7,6번 글이 다음페이지에 조회되어서 이 사용자는 8번 글을 두 번 조회하게된다.
데이터 중복 & 유실 문제뿐만아니라 오프셋 기반 방식은 오프셋의 크기가 커지면 성능 저하의 이슈도 존재한다.
커서 기반 페이지 네이션
커서기반 페이지네이션은 오프셋을 사용하지 않아 No-offset 방식이라고도 불린다.
쿼리에서 offset 을 사용하지 않으며
쿼리 검색결과와 함께 커서정보를 함께 전달한다. 그리고 다음조회에 커서 정보를 함께 넘겨 커서 정보를 이용해서 그 다음 데이터를 조회한다.
위와 같은 상황을 가정해보자.
게시판에 글이 id 1~10까지 10개 존재하고, 최신순으로 글을 보여주고 1페이지에 글이 3개 포함되는 상황을 가정해보자.
이번에는 커서 방식으로 조회하면 요청될 쿼리는 다음과 같다.
select *
from post
order by id desc
limit 3;
1번 페이지에서 10, 9, 8번 글이 조회 되었고, 커서정보는 마지막 조회된 글의 id인 8이 된다.
2번 페이지 조회 시 요청될 쿼리는 다음과 같다.
select *
from post
where id < 8
limit 3;
1. 8번 글이 삭제되는 상황이라면?
그래도 조건이 8번보다 id가 작은 글을 불러오는 것이므로 7, 6, 5번 글이 2번 페이지에 조회된다.
2. 11번 글이 생성되는 상황이라면?
조건은 변경되지 않으므로 7, 6, 5번 글이 조회된다.
새로 작성된 11번 글은 첫 요청이 다시 발생하면 그 때 사용자에게 보여줄 수 있다.
구현하기
QueryDsl을 이용해서 구현해보기로 했다.
1. 커서 정보 설정하기
id 기반으로 정렬을 하고 커서를 생성하려고 보니 한 가지 문제아닌 문제가 있었다.
jpa 기본키 생성 전략으로 id값이 서버가 재시작될 때마다 널뛰기 되고 있었다.
1,2,3을 저장하다가 서버가 재시작되면 51부터 시작되고 있었다.
(기본키 생성 전략을 다른 글에서 정리를 해보려고 한다. )
@Id
@GeneratedValue
@Column(name = "postId")
private Long id;
물론 id값이 갑자기 증가된다고 해도 새로 생성되는 row에 부여되는 id값이 증가하는 것은 여전하기 때문에 순서가 보장되는 것은 여전하다.
하지만 id 생성이 내가 예상했던 방식으로 작동하지 않는 상황에서 id값을 커서로 사용한다는 것은 너무 낙관적이라고 판단했고,
명시적으로 생성된 시간을 저장하는 컬럼을 이용하는 것이 더 정확할 것이라고 생각해 생성시간과 id값 두 가지를 커서 정보로 활용하기로 했다.
생성시간에 id를 추가한 이유는 생성시간은 생성된 시간만을 보장할 뿐 유니크한 값을 가지지 않기 때문에 각 로우를 유니크하게 판단할 값으로 id를 이용하였다.
커서 조건은 이전 커서의 생성시간 보다 적거나 같고, id는 다른 값으로 해주었다.
생성된 시간 조건을 < 로 설정하게되면 이전 커서와 생성시간이 같은 경우 다음 조회 조건에서 생략될 수 있어 <= 로 조건을 달아주었고,
생성시간이 같지만 이전에 조회된 글의 중복 조회를 막기 위해서
두 번째 정렬 조건에 id를 추가하고 커서값의 id보다는 작은 row만 조회하게 하였다.
select *
from post
where created_date <= "2024-01-11 16:39:09.156449" and id < 235
order by created_date DESC, id DESC
limit 10;
2. repository 구현 방식
레포지토리 코드를 어떤 방식으로 구현할지 고민이 되었다.
두 가지 방식 중 고민하였다.
1. PostRepository Interface에 상속하여 이용
2. 별도의 페이지네이션만 담당하는 Repository 구현체를 만들어 이용할 것인지
1번 방식은 service 레이어에서 추가로 주입되는 repository 없이 이용할 수 있다는 장점이 있지만, 구현이 복잡하다는 단점이 있었다.
CustomRepository 인터페이스를 만들고
public interface PostCustomRepository {
List<Post> getByCursor(TimeIdCursor cursor, Integer boardId);
List<Post> getByCursor();
}
인터페이스를 구현한 구현체를 만들고
@Repository
@RequiredArgsConstructor
public class PostCustomRepositoryImpl implements PostCustomRepository {
private final JPAQueryFactory query;
public List<Post> getByCursor(TimeIdCursor cursor, Integer boardId) {
return query.select(post)
.from(post)
.join(post.member, member).fetchJoin()
.leftJoin(member.anonymous, anonymous).fetchJoin()
.leftJoin(member.school, school).fetchJoin()
.where(post.lastModifiedDate.loe(cursor.getTime()), post.id.lt(cursor.getId()), post.inUse)
.orderBy(post.lastModifiedDate.desc(),
post.id.desc())
.limit(PAGE_SIZE)
.fetch();
}
public List<Post> getByCursor() {
return query.select(post)
.from(post)
.join(post.member, member).fetchJoin()
.leftJoin(member.anonymous, anonymous).fetchJoin()
.leftJoin(member.school, school).fetchJoin()
.orderBy(post.lastModifiedDate.desc(),
post.id.desc())
.limit(PAGE_SIZE)
.fetch();
}
}
레포지토리에 인터페이스를 상속해야한다.
@Repository
public interface PostRepository extends JpaRepository<Post, Long>, PostCustomRepository {
Optional<Post> findByIdAndInUseIsTrue(Long id);
}
2번 방식은 서비스 레이어에 새로운 레포지토리를 주입해야하지만, 구현이 편리하고 다른 jpa코드와 분리할 수 있어 유지보수 난이도가 줄어든다는 장점이 있다.
@Repository
@RequiredArgsConstructor
public class CursorRepository {
private final JPAQueryFactory query;
/**
* 최신순 조회
*/
public List<Post> getByCursor(TimeIdCursor cursor){
return query.select(post)
.from(post)
.leftJoin(post.member, member).fetchJoin()
.leftJoin(member.anonymous, anonymous).fetchJoin()
.leftJoin(member.school, school).fetchJoin()
.where(post.createdDate.loe(cursor.getTime()), post.id.lt(cursor.getId()), post.inUse)
.orderBy(post.createdDate.desc(),
post.id.desc())
.limit(PAGE_SIZE)
.fetch();
}
public List<Post> getByCursor(){
return query.select(post)
.from(post)
.leftJoin(post.member, member).fetchJoin()
.leftJoin(post.anonymous, anonymous).fetchJoin()
.leftJoin(post.school, school).fetchJoin()
.where(post.inUse)
.orderBy(post.createdDate.desc(),
post.id.desc())
.limit(PAGE_SIZE)
.fetch();
}
/**
* 인기순 조회
*/
public List<Post> getHotByCursor(ScoreIdCursor cursor) {
/* Querydsl에서 조건절이 복잡해질때 BooleanExpression을 사용하여 가독성 있게 조건을 작성할 수 있다. */
BooleanExpression lessScore = post.score.lt(cursor.getScore());
BooleanExpression sameScore = post.score.eq(cursor.getScore()).and(post.id.lt(cursor.getId()));
BooleanExpression inUse = post.inUse;
BooleanExpression whereClause = (lessScore.or(sameScore)).and(inUse);
return query.select(post)
.from(post)
.join(post.member, member).fetchJoin()
.leftJoin(post.anonymous, anonymous).fetchJoin()
.leftJoin(post.school, school).fetchJoin()
.where(whereClause)
.orderBy(post.score.desc(),
post.id.desc())
.limit(PAGE_SIZE)
.fetch();
}
public List<Post> getHotByCursor(){
return query.select(post)
.from(post)
.join(post.member, member).fetchJoin()
.leftJoin(post.anonymous, anonymous).fetchJoin()
.leftJoin(post.school, school).fetchJoin()
.where(post.inUse)
.orderBy(post.score.desc(), post.id.desc())
.limit(PAGE_SIZE)
.fetch();
}
}
별로의 레포지토리를 하나만 구현해주면 되고 서비스 레이어에 그대로 주입해서 사용할 수 있다.
유지보수 편의성과 다른 게시판에서도 페이지네이션이 필요하고 인기순, 최신순 등 다양한 방법의 조회가 필요하여 확장성에 더 유리한 2번 방식을 선택했다.
3. 커서정보 전달 방식
조회 후 커서 정보를 생성해서 클라이언트에 전달해야한다.
두 가지 커서 정보를 가지고 있고, 두 정보를 각각의 키로 클라이언트에 전달하고 다시 받는 것은 커서 값의 일관성을 보장하기도 어렵고 클라이언트 측 입장에서 꽤나 번거로운 일이라고 생각했다.
또한 생성시간 데이터는 LocalDateTime을 사용해서 2024-01-19 18:07:36.866330
이런 방식으로 생성되고 있고,
커서정보는 쿼리파라미터로 /post?cursor=커서정보
로 전달받을 것이므로
LocalDateTime문자열과 게시글 아이디가 그대로 url에 드러나는 것은 좋지 않아보였다.
그래서
- id와 생성시간데이터를 하나의 문자열로 합치고
- base64 인코딩을 통해서 평문을 인코딩 해주었다.
@Getter
@Setter
public class TimeIdCursor {
private LocalDateTime time;
private Long id;
public TimeIdCursor(Teengle teengle) {
this.time = teengle.getLastModifiedDate();
this.id = teengle.getId();
}
public TimeIdCursor(String cursorString){
byte[] decodedBytes = Base64.getDecoder().decode(cursorString.getBytes());
String s = new String(decodedBytes);
String[] split = s.split(",");
this.id = Long.parseLong(split[0]);
this.time = LocalDateTime.parse(split[1]);
}
public String getCursorString(){
try {
String s = this.id + "," + this.time;
byte[] base64EncodedBytes = Base64.getEncoder().encode(s.getBytes());
return new String(base64EncodedBytes);
}
catch (Exception e) {
throw new TeengleException(INVALID_CURSOR.getCode(), INVALID_CURSOR.getErrorMessage());
}
}
}
커서 정보를 string으로 받고 디코딩하여 조회에 이용하고,
다음 커서정보를 다시 인코딩하여 string으로 응답한다.
@Transactional(readOnly = true)
public PostListResponseDto<Post> getListByBoard(String pid, String cursorString) {
Member member = memberService.findByPid(pid);
List<Post> postList;
if (cursorString == null) {
postList = postRepository.getByCursor();
} else {
TimeIdCursor cursor = new TimeIdCursor(cursorString);
postList = postRepository.getByCursor(cursor, boardId);
}
if (postList.size() == 0) return new PostListResponseDto<>(postList);
TimeIdCursor newCursor = new TimeIdCursor(postList.get(postList.size() - 1));
return new PostListResponseDto<>(postList, newCursor.getCursorString());
}
클라이언트에서 커서 정보와 함께 요청을 보낼 때,
/post?cursor=NiwyMDI0LTAxLTE4VDAzOjIxOjAwLjA1ODY2NQ==
이와 같은 형식으로 요청을 보낼 수 있게 되었다.
4. 클라이언트 응답 형식
마지막으로 클라이언트에 응답을 어떤 방식으로 해줄 것인가 고민했다.
맨 처음 조회 결과와 커서정보만 전달해주면 된다고 생각했다.
{ posts: [ 게시글 정보, 게시글 정보],
cursor: base64EncodedString
}
개발하다보니 한 가지 놓친 점이 있었다.
커서정보만으로는 다음 데이터가 존재하는 지 알 수가 없고, 그 다음 요청을 보내야만 다음 데이터의 존재 유무를 알 수 있다.
따라서 다음 데이터 유무를 알려주는 데이터를 추가하였다.
@Getter
public class PostListResponseDto {
List<PostListDto> posts;
@JsonInclude(JsonInclude.Include.NON_NULL)
String cursor;
Boolean next;
public TPostListResponseDto(String pid, List<Post> postList, String cursor){
this.posts = postList.stream()
.map(t -> new postListDto(pid, t))
.toList();
this.cursor = cursor;
this.next = posts.size() == PAGE_SIZE;
}
public PostListResponseDto(String pid, List<Post> postList){
this.posts = postList.stream()
.map(t -> new postListDto(pid, t))
.toList();
this.next = false;
}
}
포스트맨으로 게시글 조회 요청을 보낸 결과 의도한 방식대로 잘 작동하는 것을 확인할 수 있었다!

참고
Cursor based Pagination(커서 기반 페이지네이션)이란? - Querydsl로 무한스크롤 구현하기
Cursor based Pagination, 커서 기반 페이징, 무한스크롤
velog.io
1. 페이징 성능 개선하기 - No Offset 사용하기
일반적인 웹 서비스에서 페이징은 아주 흔하게 사용되는 기능입니다. 그래서 웹 백엔드 개발자분들은 기본적인 구현 방법을 다들 필수로 익히시는데요. 다만, 그렇게 기초적인 페이징 구현 방
jojoldu.tistory.com
[Spring boot] JPA 기본 키 생성 전략(AUTO, IDENTITY, SEQUENCE, TABLE, UUID)
Index에 대해 공부하다가 JPA의 기본 키 생성 전략들은 어떤 방법으로 생성하는 편리한 어노테이션들인지에 대해 공부하게 되었다. JPA 기본 키 생성 전략 JPA(Java Persistence API)에서는 엔티티의 기본
fourjae.tistory.com
'Projects' 카테고리의 다른 글
게시글 추천 시스템 도입을 고려하며 조사한 내용 (0) | 2024.02.04 |
---|---|
게시판 전체 글 랭킹 점수 데이터 업데이트 속도 77.72% 개선 (bulk update vs batch update) (1) | 2024.01.23 |
[코드실행기능 개발기 #3] 코드 실행 서버 부하테스트 (1) | 2023.12.17 |
NestJS에 winston으로 로그 남기기 (0) | 2023.12.17 |
refresh token 도입기 (1) | 2023.12.17 |