https://dean83.tistory.com/285 여기에서 Paging 에 대한걸 간략하게 정리 하였다. 그러나 실제로는 Cursor 기반을 많이 사용하기도 하고, 좀 더 까다롭기도 하다. 이 부분 또한 위의 페이지에 정리 하려 하다가 너무 복잡해져 따로 정리한다.
예는 유저를 pageing 하는 예 이다.
중요한 내용 정리 먼저,
- 엔티티에 1:N, N:M 등 List 형태의 컬럼이 있을경우 해당 테이블과 fetch join을 하면 안된다.
- fetch join으로 인해 row의 갯수가 증가해 버린다 -> limit에 의해 의도치 않은 데이터 갯수가 생성된다.
- 예 ) 유저 조회가 메인인 경우, 유저에 roles 라는 List 컬럼이 있을경우
- 동일 유저인데 role이 다를 경우 row 갯수 증가 -> 그러나 페이지네이션에선 동일한 유저이므로 limit보다 적은 유저 리턴
- distinct를 걸어도 row 자체는 중복이 아니기 때문에 소용 없음.
- 이후 별도로 ids를 통해 fetch join을 수행해야 한다.
- Cursor은 정렬 조건과 동일한 필드를 사용한다.
- 만일 정렬 조건 필드가 name, email 등 여러개 일 경우, cursor 또한 각 조건과 부합한다.
- 즉, 클라이언트에서 정렬조건으로 name을 주었다면, cursor 또한 클라이언트에서 name 값을 준다.
- Distinct를 사용하지 않는다
Service에서 호출하는 Repository 메서드 예 (메인 진입점)
public List<User> findAllUsers(UserCursorRequest request) {
return findUserExpression(true)
.where(
getEmailExpression(request),
getRoleExpression(request),
getLockedExpression(request),
cursorExpression(request)
)
.orderBy(orderByDirection(request), orderByDirection(request.sortDirection(), user.uuid))
.limit(request.limit()+1)
.fetch();
}
- Where 조건문을 완성해 주는 메서드를 호출하여 사용한다.
- 각 조건문이 null 일 경우에도 NPE 터지지 않고 처리해 줘서 상관없다.
- orderby 조건문을 완성해주는 메서드를 호출하여 사용한다. 또한 id를 보조 커서로 사용한다.
- limit 은 +1을 하여 nextCursor 정보 등을 담을 수 있도록 한다.
- Slice가 아니라 List로 리턴하고, Service에서 Dto를 생성하여 컨트롤러로 리턴한다.
- 예에서는 일반쿼리(fetch join필요), Slice쿼리 두 경우가 다 있다보니 따로 Select 쿼리를 메소드로 분리하였다.
Repository의 Select 부분 예
private JPAQuery<User> findUserExpression(boolean isSlice)
{
JPAQuery<User> query = jpaQueryFactory
.select(user)
.from(user);
//list 인 경우, slice 할때에는 fetch join을 쓰면 안된다. (limit 등 쿼리 select 오동작)
if(isSlice)
{
query.join(user.userRoles, userRole);
query.join(user.profile,profile);
query.join(userRole.role,role);
}
else
{
query.join(user.userRoles, userRole).fetchJoin();
query.join(user.profile,profile).fetchJoin();
query.join(userRole.role,role).fetchJoin();
query.distinct();
}
return query;
}
- 만일 Repository 에서 일반 select와 Paging을 둘 다 쓰고 있을경우, 그리고 엔티티에 1:N 관계의 List 멤버변수가 있을경우 fetch join을 하지 않기 위해 위와 같이 메서드로 따로 분리해 주었다.
일반적인 Where 조건절 예
private BooleanExpression getEmailExpression(UserCursorRequest request)
{
return StringUtils.hasText(request.emailLike()) ? user.email.like(request.emailLike()) : null;
}
- BooleanExpression을 리턴한다.
- null이 아닌경우 조건에 맞는 문장리턴, null일 경우 null을 리턴한다.
Cursor Where 조건절 예
private BooleanExpression cursorExpression(UserCursorRequest request) {
if (request.cursor() == null || request.idAfter() == null) {
return null; // 최초 페이지 → 커서 조건 없음
}
if(request.sortDirection() == UserSortDirection.ASCENDING)
return cursorExpressionAsc(request);
return cursorExpressionDesc(request);
}
private BooleanExpression cursorExpressionDesc(UserCursorRequest request)
{
switch (request.sortBy())
{
case name:
return profile.name.lt(request.cursor())
.or(
profile.name.eq(request.cursor())
.and(user.uuid.lt(request.idAfter()))
);
case email:
return user.email.lt(request.cursor()).
or(
user.email.eq(request.cursor())
.and(user.uuid.lt(request.idAfter())
));
case role:
case isLocked:
return user.uuid.lt(request.idAfter());
case createdAt:
Instant cursorTime = Instant.parse(request.cursor());
return user.createdAt.lt(cursorTime)
.or(
user.createdAt.eq(cursorTime)
.and(user.uuid.lt(request.idAfter())
));
default:
return null;
}
}
private BooleanExpression cursorExpressionAsc(UserCursorRequest request)
{
switch (request.sortBy())
{
case name:
return profile.name.gt(request.cursor())
.or(
profile.name.eq(request.cursor())
.and(user.uuid.gt(request.idAfter()))
);
case email:
return user.email.gt(request.cursor()).
or(
user.email.eq(request.cursor())
.and(user.uuid.gt(request.idAfter())
));
case role:
case isLocked:
return user.uuid.gt(request.idAfter());
case createdAt:
Instant cursorTime = Instant.parse(request.cursor());
return user.createdAt.gt(cursorTime)
.or(
user.createdAt.eq(cursorTime)
.and(user.uuid.gt(request.idAfter())
));
default:
return null;
}
}
- 정렬 조건이 Desc 이냐, Asc 이냐에 따라 검색 조건이 달라진다. (값이 커야 하는지 작아야 하는지 조건이 달라지기 때문)
- 또한 정렬 조건이 여러개 일 경우, Cursor 또한 여러 조건값이 될 수 있기 때문에 이를 고려해야 한다.
- Cursor 비교값과 결과가 동일할 경우가 있을 수 있기 때문에 후행에 or 절을 이용하여 보조커서(보통 ID)를 이용해 조건을 걸어준다.
- 최초 검색 조건에는 Cursor 등이 없으므로 예외처리를 해주어야 한다.
OrderBy 예
private OrderSpecifier orderByDirection(UserCursorRequest request)
{
switch (request.sortBy())
{
case name:
return orderByDirection(request.sortDirection(), profile.name);
case email:
return orderByDirection(request.sortDirection(), user.email);
case role:
return orderByDirection(request.sortDirection(), role.name);
case isLocked:
return orderByDirection(request.sortDirection(), user.locked);
case createdAt:
return orderByDirection(request.sortDirection(), user.createdAt);
default:
return null;
}
}
private OrderSpecifier orderByDirection(UserSortDirection direction, ComparableExpressionBase field)
{
if(direction == UserSortDirection.ASCENDING)
return field.asc();
return field.desc();
}
- OrderSpecifier를 리턴한다.
- 어떤 필드든 정렬 조건을 리턴하는 메소드를 이용한다. (정렬 조건이 여러개 올 수 있기 때문에 각 이 메소드를 호출한다)
Service 예
@Transactional(readOnly = true)
public CursorResponseUserDto getAllUsers(UserCursorRequest request)
{
//QueryDsl 메서드 호출
List<User> users = userRepository.findAllUsers(request);
boolean hasNext = false;
String nextCursor = null;
UUID idAfter = null;
//JpaRepository 기본 메서드 count 호출
Long totalCount = userRepository.count();
if(users.size() > request.limit())
{
hasNext = true;
users.remove(users.size() - 1);
idAfter = users.get(users.size() - 1).getUuid();
nextCursor = getNextCursor(request, users);
}
return CursorResponseUserDto.builder()
.data(orderedUsers.stream()
.map(x -> UserDto.builder()
.user(x)
.build()).toList())
.hasNext(hasNext)
.nextCursor(nextCursor)
.nextIdAfter(idAfter)
.totalCount(totalCount)
.sortDirection(request.sortDirection().name())
.sortBy(request.sortBy().name())
.build();
}
private String getNextCursor(UserCursorRequest request, List<User> users)
{
switch (request.sortBy())
{
case name:
return users.get(users.size() - 1).getProfile().getName();
case email:
return users.get(users.size() - 1).getEmail();
case role:
return users.get(users.size() - 1).getUserRoles().get(0).getRole().getName().name();
case isLocked:
return users.get(users.size() - 1).getLocked().toString();
case createdAt:
return users.get(users.size() - 1).getCreatedAt().toString();
default:
return null;
}
}
- Slice를 이용하지 않고 따로 Dto를 통해 결과를 리턴한다.
- Repository 조회 수가 limit 보다 적을경우 next 는 없다고 간주하여 처리한다.
- Cursor의 경우 정렬조건이 여러개 일 수 있으므로 이에 각각 해당하는 값을 넣어준다.
Fetch Join을 위한 처리
- 일반적인 Paging 에선 Fetch join을 하면 안된다. 따라서 이후 fetch join을 통해 데이터를 한번 더 가져와야 한다. (N+1 방지)
- 결국 Entity에서 List 형태로 멤버변수를 갖고 있다면 2번의 쿼리를 하게 되는 셈이다.
Repositoty 예
//N+1 을 해결하기 위한 페이지네이션 후속 쿼리
@Override
public List<User> findUsersByIds(List<UUID> ids) {
return findUserExpression(false)
.where(user.uuid.in(ids))
.fetch();
}
- ids를 통해 in 을 이용하여 모든 항목을 fetch join 하여 가져온다.
Service 예 (Fetch Join 예)
@Transactional(readOnly = true)
public CursorResponseUserDto getAllUsers(UserCursorRequest request)
{
//QueryDsl 메서드 호출
...
//userroles n+1 해결을 위해 fetch join을 해오기 위한 부분
List<UUID> ids = users.stream().map(User::getUuid).toList();
if(ids.isEmpty())
return new CursorResponseUserDto(
null,
nextCursor,
idAfter,
hasNext,
totalCount,
request.sortBy().name(),
request.sortDirection().name());
//fetch join 후 정렬이 깨짐
List<User> fetchedUsers = userRepository.findUsersByIds(ids);
//uuid를 key로 하는 userMap을 생성 -> 정렬된 리스트를 재구성할떄 사용
Map<UUID, User> userMap = fetchedUsers.stream()
.collect(Collectors.toMap(User::getUuid, u -> u));
//ids 는 paging 조건에 맞는 정렬형태. userMap에서 가져와 기존 정렬된 형태로 복구
List<User> orderedUsers = ids.stream().map(userMap::get).toList();
...
}
- 1차로 Repository에서 Paging된 데이터를 조회한다.
- 이 List 에서 id 값만 따로 추출한다.
- 추출한 id값들을 인자로 하여 fetch join을 진행하는 repository 메서드를 호출한다.
- fetch join된 결과는 정렬되지 않으므로, 기존 id List 순서 기준으로 값을 재배치 하여 원본 순서를 맞춘다.
- 이를 이용해 controller에 리턴한다.
'Backend > SpringBoot' 카테고리의 다른 글
| [Entity] GeneratedValue 대신 TSID 사용하기 (0) | 2026.02.10 |
|---|---|
| OAuth 연동 (0) | 2026.02.06 |
| 임시 비번 발급 후 비교할때 주의점(PasswordEncoder) (0) | 2026.02.02 |
| Hexagonal Architecture 일부적용 및 느낀점 (0) | 2026.01.30 |
| Application.yaml 값 불러오기 (Class에 매핑) (0) | 2026.01.29 |