이 글은 삼성청년소프트웨어아카데미 2학기에 공통 프로젝트로 진행한 학습내용 기록 및 공유 플랫폼 MODAC 서비스의 게시판 Pagination 리팩토링 과정에 대해 정리했습니다.
기능설명 및 개발환경
[개발환경]
- DB: MySQL 8.0.31
- Back-End: Java8, Spring Boot2.7.7, Spring Data JPA
- Tools: IntelliJ, Postman
[기능설명]
- 개선 대상: 게시판(피드) 게시글 조회 기능
- 기능 설명: 현재 로그인한 사용자가 작성한 게시글 정보를 조회하는 기능
- 기존 구현방법: 게시글 조회 시 게시글 데이터를 DB에서 조회하여 페이지 계산 로직을 통해 요청한 페이지에 해당하는 데이터 반환
[리팩토링 배경 설명 및 기존 구현 코드]
MODAC 프로젝트에서 유저의 피드 목록을 조회하는 API를 개발했습니다. 처음 로직을 구현할 때에는 Spring Data JPA를 활용한 Pagination 방식을 알지 못했습니다. 그래서 로직을 직접 구현했습니다. DB에서 전체 데이터를 조회하여 Collections.sort 메서드를 활용해 최신 날짜를 우선순위로 정렬했고 페이지를 계산하는 로직을 Offset 값과 Limit 값을 받아 구현했습니다.
하지만, 이후 프로젝트를 리뷰하는 과정에서 Spring Data JPA를 활용하여 Pagination 구현을 할 수 있다는 것을 알게되었습니다. 그래서 이 글에서는 기존에 구현한 로직과 Spring Data JPA를 이용해 Pagination을 구현한 로직을 비교해보고 각각의 성능을 확인해보겠습니다.
먼저, 기존에 작성한 로직의 코드를 확인해 보겠습니다.
[Controller]
@Api(tags = "Article Controller", description = "게시글 관련 API")
@RestController
@RequestMapping("/articles")
public class ArticleController {
...
// 사용자 게시글 전체 조회 (GET)
@Operation(summary = "사용자 게시글 전체 조회", description = "특정 사용자(user)가 작성한 게시글 목록 조회")
@GetMapping
public ResponseEntity<?> selectAllArticle(@RequestParam("user") final Long usersSeq,
@RequestParam("offset") final Integer offset, @RequestParam("limit") final Integer limit) {
return new ResponseEntity<ArticleResponse>(articleService.readArticlesByUsersSeq(usersSeq, offset, limit),
HttpStatus.OK);
}
...
}
[Service]
public interface ArticleService {
ArticleResponse readArticlesByUsersSeq(final Long usersSeq, final Integer offset, final Integer limit);
...
}
[ServiceImpl]
@Service
public class ArticleServiceImpl implements ArticleService {
@Resource(name = "articleRepository")
private final ArticleRepository articleRepository;
@Resource(name = "userRepository")
private final UserRepository userRepository;
...
@Override
public ArticleResponse readArticlesByUsersSeq(final Long usersSeq, final Integer offset, final Integer limit) {
User myUser = userRepository.findById(usersSeq).orElseThrow(() -> new NoSuchElementException("NoUser"));
List<Article> findArticles = articleRepository.findByUser(myUser);
// 최신 날짜를 우선으로 정렬
Collections.sort(findArticles, (o1, o2) -> o1.getRegisteredTime().isBefore(o2.getRegisteredTime()) ? 1 : -1);
final Integer totalArticleCnt = findArticles.size(); // 총 게시글 수
final Integer totalPageCnt = ((totalArticleCnt - 1) / limit) + 1; // 총 페이지 수
final Integer st = (offset - 1) * limit; // 해당 페이지 시작 게시글 인덱스
final Integer ed = Math.min(offset * limit, totalArticleCnt); // 해당 페이지 마지막 게시글 인덱스
return new ArticleResponse(findArticles.subList(st, ed)
.stream()
.map(ArticleResponse.ArticleInfo::new)
.collect(Collectors.toList()),
totalArticleCnt, totalPageCnt);
}
...
}
[Repository]
public interface ArticleRepository extends JpaRepository<Article, Long> {
List<Article> findByUser(User user);
...
}
[기존 구현 방식 성능 테스트]
기존 구현 방식의 성능을 확인하기 위해 20만개의 데이터를 기준으로 Postman으로 팔로잉 기반 게시글 페이지를 조회하는 요청을 보냈습니다. 그 결과, 아래와 같은 응답시간을 확인할 수 있었습니다.
[기존 코드의 문제점]
전체 데이터를 조회하기 때문에 데이터가 증가할수록 조회속도가 저하되는 성능 문제가 발생할 수 있었습니다.
Pagination 리팩토링 (offset, No-offset)
이어서 Spring Data JPA를 활용하여 Pagination을 구현하는 방식을 살펴보겠습니다. Spring Data JPA를 활용하는 방식에는 offset과 No-offset 두가지 방식이 있습니다. 순서대로 각 방식에 대해 살펴보겠습니다.
Pagination (offset 방식)
먼저, offset 방식의 Pagination 방식에 대해 확인해보겠습니다.
[Offset 동작방식과 문제점]
offset 방식이란 offset, 즉 페이지 번호를 이용해 가져올 데이터의 위치를 파악하는 방식으로 전체 조회한 데이터를 offset 값과 limit 값을 활용하여 Pagination을 구현하는 방식입니다. 이 때 offset 값은 SQL에서 조회할 페이지의 기준점을 의미합니다. limit 값은 조회할 개수 즉, 한 페이지에 들어갈 데이터의 개수를 의미합니다. 정리하면, offset 방식의 Pagination 구현방식은 offset 값을 기준으로 limit 값만큼 SQL에서 데이터를 조회하는 방식입니다.
offset 방식의 쿼리문은 일반적으로 아래와 같습니다.
SELECT *
FROM 테이블명
LIMIT 페이지크기
OFFSET 페이지기준점;
만약, 아래와 같은 쿼리가 있다면 500번째 행부터 10개의 행을 읽겠다는 의미입니다.
select *
from 테이블명
limit 10
offset 500;
offset 방식은 조회한 결과를 limit 값으로 지정한 개수만큼만 반환하고 나머지는 버리는 방식으로 동작합니다. 위의 쿼리를 예로 들면 500번째 데이터부터 10개를 조회하기 위해서 510개의 데이터를 모두 읽은 뒤, 앞의 필요하지 않은 500개는 버려야 합니다. 적은 양의 데이터를 조회할 때는 성능적인 문제가 발생하지 않을 수 있지만 전체 데이터의 개수가 많아질수록 읽어야하는 데이터의 양이 많아지기 때문에 성능저하가 발생할 수 있었습니다.
계속해서 offset 방식의 구현코드와 성능에 대해 살펴보겠습니다.
[Controller]
@Api(tags = "Article Controller", description = "게시글 관련 API")
@RestController
@RequestMapping("/articles")
public class ArticleController {
...
// 사용자 게시글 전체 조회 (GET)
@Operation(summary = "사용자 게시글 전체 조회", description = "특정 사용자(user)가 작성한 게시글 목록 조회")
@GetMapping
public ResponseEntity<?> selectAllArticle(@RequestParam("user") final Long usersSeq, final Pageable pageable) {
return new ResponseEntity<>(articleService.readArticlesByUsersSeq(usersSeq, pageable),
HttpStatus.OK);
}
...
}
[Service]
public interface ArticleService {
Page<ArticleDto> readArticlesByUsersSeq(final Long usersSeq, final Pageable pageable);
...
}
[ServiceImpl]
@Service
public class ArticleServiceImpl implements ArticleService {
...
@Override
public Page<ArticleDto> readArticlesByUsersSeq(final Long usersSeq, final Pageable pageable) {
User myUser = userRepository.findById(usersSeq).orElseThrow(() -> new NoSuchElementException("NoUser"));
Page<Article> byUser = articleRepository.findArticlesByUser(myUser, PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), pageable.getSort()));
return byUser.map(article -> ArticleDto.builder().article(article).build());
}
...
}
[Repository]
@Repository
public interface ArticleRepository extends JpaRepository<Article, Long> {
...
Page<Article> findArticlesByUser(User user, final PageRequest pageRequest);
...
}
[offset 구현 방식 성능 테스트]
offset 구현 방식의 성능을 확인하기 위해 20만개의 데이터를 기준으로 Postman으로 팔로잉 기반 게시글 페이지를 조회하는 요청을 보냈습니다. 그 결과, 아래와 같은 응답시간을 확인할 수 있었습니다.
기존에 구현했던 로직보다 약 79% 응답 속도가 빨라졌지만 아직도 많은 시간이 걸리는 것을 확인할 수 있었습니다.
Pagination (No-offset 방식)
마지막으로, No-offset 방식의 Pagination 방식에 대해 살펴해보겠습니다.
[No-offset 동작방식]
No-offset 페이지네이션은 전통적인 offset 기반 방식과 달리 페이지 번호를 사용하지 않고 마지막으로 조회한 레코드의 ID를 기준으로 다음 페이지를 가져오는 방식입니다. 이 방법은 크게 첫 페이지 조회와 이후 페이지 조회로 나눌 수 있습니다. 첫 페이지 조회 시에는 정렬 기준(주로 ID)에 따라 정렬하여 첫 N개의 레코드를 가져옵니다. 그 후 다음 페이지를 조회할 때는 이전 페이지의 마지막 레코드 ID를 기준으로 그 다음 N개의 레코드를 조회합니다.
No-offset 방식의 페이지의 데이터를 조회하는 쿼리문은 아래와 같습니다. (첫 페이지 이후 페이지를 조회하는 쿼리)
SELECT *
FROM items
WHERE 조건문
AND id < 마지막 조회 ID # 직전 조회 결과의 마지막 id
ORDER BY id DESC
LIMIT 페이지 사이즈
이러한 방식은 매 페이지 조회마다 일정한 성능을 유지하며, 대규모 데이터셋 에서 특히 효율적입니다. offset을 사용하지 않기 때문에 데이터베이스가 많은 레코드를 건너뛰지 않아도 되어 데이터베이스 부하가 감소하고, 결과적으로 각 페이지 조회가 첫 페이지를 읽는 것과 유사한 성능을 보입니다.
이어서 No-offset 방식의 구현 코드와 성능을 확인해보겠습니다.
[Controller]
@Api(tags = "Article Controller", description = "게시글 관련 API")
@RestController
@RequestMapping("/articles")
public class ArticleController {
...
// 사용자 게시글 전체 조회 (GET)
@Operation(summary = "사용자 게시글 전체 조회", description = "특정 사용자(user)가 작성한 게시글 목록 조회")
@GetMapping
public ResponseEntity<?> selectAllArticle(@RequestParam("user") final Long usersSeq,
@RequestParam("lastSeq") final Long lastSeq,
final Pageable pageable) {
return new ResponseEntity<>(articleService.readArticlesByUsersSeq(usersSeq, lastSeq, pageable), HttpStatus.OK);
}
...
}
[Service]
public interface ArticleService {
...
// 사용자 아이디로 게시글 목록 조회
Page<ArticleDto> readArticlesByUsersSeq(final Long usersSeq, final Long lastSeq, final Pageable pageable);
...
}
[ServiceImpl]
@Service
public class ArticleServiceImpl implements ArticleService {
...
// 사용자 아이디로 게시글 목록 조회
@Override
public Page<ArticleDto> readArticlesByUsersSeq(final Long usersSeq, final Long lastSeq, final Pageable pageable) {
User myUser = userRepository.findById(usersSeq).orElseThrow(() -> new NoSuchElementException("NoUser"));
Page<Article> byUser = articleRepository.findArticlesByUser(myUser,lastSeq, PageRequest.of(0, pageable.getPageSize()));
return byUser.map(article -> ArticleDto.builder().article(article).build());
}
...
}
[Repository]
@Repository
public interface ArticleRepository extends JpaRepository<Article, Long> {
...
// user가 일치하는 게시글 리스트 반환
@Query("SELECT a FROM Article a JOIN User u ON a.user.id = u.id WHERE u = :user AND a.seq > :lastSeq ORDER BY a.registeredTime DESC")
Page<Article> findArticlesByUser(@Param("user") User user, @Param("lastSeq") final Long lastSeq, final PageRequest pageRequest);
...
}
[No-offset 구현 방식 성능 테스트]
No-offset 구현 방식의 성능을 확인하기 위해 20만개의 데이터를 기준으로 Postman으로 팔로잉 기반 게시글 페이지를 조회하는 요청을 보냈습니다. 그 결과, 아래와 같은 응답시간을 확인할 수 있었습니다.
기존의 offset 방식의 Pagination 구현 방식에 비해 조회속도가 약 53% 향상한것을 확인할 수 있었습니다.
마무리
No-offset 방식의 페이지네이션을 구현하여 기존 로직 대비 응답 속도를 약 90% 향상시킬 수 있었습니다. 이 경험을 통해 같은 기능을 구현하는 데에도 다양한 접근 방식이 존재하며, 선택에 따라 성능에 큰 차이가 날 수 있다는 점을 다시 한번 느낄 수 있었습니다.