📄 들어가며
"전체 사용자 100만 명을 한 번에 조회하면 어떻게 될까요?"
서버는 뻗고, 사용자는 무한 로딩을 보게 될 것입니다. 페이지네이션(Pagination)은 대용량 데이터를 작은 단위로 나누어 처리하는 필수 기술입니다.
이 글에서는 Spring Boot에서 페이지네이션을 구현하는 다양한 방법과 실무에서 마주치는 문제들의 해결책을 제시합니다. 단순한 페이징부터 무한 스크롤, 커서 기반 페이징까지 모든 것을 다룹니다.
📑 목차
페이지네이션 기초
🎯 페이지네이션이란?
페이지네이션은 대량의 데이터를 작은 단위(페이지)로 나누어 제공하는 기술입니다.
전체 데이터: 10,000개
페이지 크기: 20개
총 페이지 수: 500페이지
1페이지: 1-20번 데이터
2페이지: 21-40번 데이터
...
페이징 방식 비교
방식장점단점사용 사례
오프셋 기반 | 구현 간단, 임의 페이지 접근 | 대용량 시 성능 저하 | 일반적인 게시판 |
커서 기반 | 성능 우수, 일관성 | 임의 페이지 접근 불가 | 타임라인, 피드 |
키셋 기반 | 매우 빠름 | 정렬 조건 제한 | 대용량 로그 |
Spring Data JPA 페이징
1. 기본 페이징 구현
Entity
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String email;
private LocalDateTime createdAt;
private boolean active;
// 연관관계
@OneToMany(mappedBy = "user")
private List<Post> posts = new ArrayList<>();
}
Repository
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// 기본 페이징
Page<User> findAll(Pageable pageable);
// 조건부 페이징
Page<User> findByActiveTrue(Pageable pageable);
// 복잡한 쿼리 + 페이징
@Query("SELECT u FROM User u WHERE u.createdAt > :date")
Page<User> findRecentUsers(@Param("date") LocalDateTime date, Pageable pageable);
// 정렬 포함
@Query("SELECT u FROM User u WHERE u.active = true")
Page<User> findActiveUsers(Pageable pageable);
// Slice 사용 (다음 페이지 존재 여부만 확인)
Slice<User> findByEmailContaining(String email, Pageable pageable);
// 카운트 쿼리 분리
@Query(value = "SELECT u FROM User u JOIN u.posts p WHERE p.published = true",
countQuery = "SELECT COUNT(u) FROM User u WHERE EXISTS (SELECT 1 FROM Post p WHERE p.user = u AND p.published = true)")
Page<User> findUsersWithPublishedPosts(Pageable pageable);
}
2. Service 계층
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
// 기본 페이징
public Page<UserDto> getUsers(int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
Page<User> userPage = userRepository.findAll(pageable);
return userPage.map(UserDto::from);
}
// 동적 정렬
public Page<UserDto> getUsersWithSort(int page, int size, String sortBy, String direction) {
Sort.Direction sortDirection = "desc".equalsIgnoreCase(direction)
? Sort.Direction.DESC : Sort.Direction.ASC;
Pageable pageable = PageRequest.of(page, size, Sort.by(sortDirection, sortBy));
return userRepository.findAll(pageable)
.map(UserDto::from);
}
// 다중 정렬
public Page<UserDto> getUsersWithMultiSort(int page, int size) {
Sort sort = Sort.by(
Sort.Order.desc("active"),
Sort.Order.asc("username")
);
Pageable pageable = PageRequest.of(page, size, sort);
return userRepository.findAll(pageable)
.map(UserDto::from);
}
// 검색 + 페이징
public Page<UserDto> searchUsers(String keyword, Pageable pageable) {
Specification<User> spec = Specification.where(null);
if (StringUtils.hasText(keyword)) {
spec = spec.and((root, query, cb) ->
cb.or(
cb.like(root.get("username"), "%" + keyword + "%"),
cb.like(root.get("email"), "%" + keyword + "%")
)
);
}
return userRepository.findAll(spec, pageable)
.map(UserDto::from);
}
}
3. DTO와 매핑
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserDto {
private Long id;
private String username;
private String email;
private LocalDateTime createdAt;
private boolean active;
public static UserDto from(User user) {
return UserDto.builder()
.id(user.getId())
.username(user.getUsername())
.email(user.getEmail())
.createdAt(user.getCreatedAt())
.active(user.isActive())
.build();
}
}
// 페이지 응답 DTO
@Data
@Builder
public class PageResponse<T> {
private List<T> content;
private int pageNumber;
private int pageSize;
private long totalElements;
private int totalPages;
private boolean last;
private boolean first;
private int numberOfElements;
private boolean empty;
public static <T> PageResponse<T> of(Page<T> page) {
return PageResponse.<T>builder()
.content(page.getContent())
.pageNumber(page.getNumber())
.pageSize(page.getSize())
.totalElements(page.getTotalElements())
.totalPages(page.getTotalPages())
.last(page.isLast())
.first(page.isFirst())
.numberOfElements(page.getNumberOfElements())
.empty(page.isEmpty())
.build();
}
}
REST API 페이징 구현
1. Controller 구현
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
@Slf4j
public class UserController {
private final UserService userService;
// 기본 페이징
@GetMapping
public ResponseEntity<PageResponse<UserDto>> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
if (size > 100) {
size = 100; // 최대 크기 제한
}
Page<UserDto> userPage = userService.getUsers(page, size);
return ResponseEntity.ok(PageResponse.of(userPage));
}
// Pageable 직접 사용
@GetMapping("/v2")
public Page<UserDto> getUsersV2(
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable) {
log.info("Pageable: page={}, size={}, sort={}",
pageable.getPageNumber(), pageable.getPageSize(), pageable.getSort());
return userService.getUsers(pageable);
}
// 커스텀 페이지 요청
@GetMapping("/search")
public ResponseEntity<Page<UserDto>> searchUsers(
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(defaultValue = "DESC") String sortDirection) {
Sort.Direction direction = Sort.Direction.fromString(sortDirection);
Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sortBy));
Page<UserDto> result = userService.searchUsers(keyword, pageable);
// 커스텀 헤더 추가
HttpHeaders headers = new HttpHeaders();
headers.add("X-Total-Count", String.valueOf(result.getTotalElements()));
headers.add("X-Total-Pages", String.valueOf(result.getTotalPages()));
return ResponseEntity.ok()
.headers(headers)
.body(result);
}
// Slice 사용 (무한 스크롤용)
@GetMapping("/slice")
public Slice<UserDto> getUsersSlice(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Pageable pageable = PageRequest.of(page, size);
return userService.getUsersAsSlice(pageable);
}
// HATEOAS 지원
@GetMapping("/hateoas")
public PagedModel<EntityModel<UserDto>> getUsersWithLinks(
@PageableDefault(size = 20) Pageable pageable) {
Page<UserDto> userPage = userService.getUsers(pageable);
PagedModel<EntityModel<UserDto>> pagedModel = pagedResourcesAssembler
.toModel(userPage, user -> EntityModel.of(user,
linkTo(methodOn(UserController.class).getUser(user.getId())).withSelfRel()
));
return pagedModel;
}
}
2. 전역 페이징 설정
@Configuration
public class PageableConfig {
@Bean
public PageableHandlerMethodArgumentResolverCustomizer pageableCustomizer() {
return customizer -> {
customizer.setOneIndexedParameters(true); // 1부터 시작
customizer.setMaxPageSize(100); // 최대 크기 제한
customizer.setFallbackPageable(PageRequest.of(0, 20));
};
}
}
3. API 응답 예시
// GET /api/users?page=0&size=10&sort=username,asc
{
"content": [
{
"id": 1,
"username": "alice",
"email": "alice@example.com",
"createdAt": "2024-01-20T10:00:00",
"active": true
},
// ... 9 more items
],
"pageable": {
"sort": {
"sorted": true,
"ascending": true,
"unsorted": false
},
"pageNumber": 0,
"pageSize": 10,
"offset": 0,
"paged": true,
"unpaged": false
},
"totalElements": 1000,
"totalPages": 100,
"last": false,
"first": true,
"numberOfElements": 10,
"size": 10,
"number": 0,
"sort": {
"sorted": true,
"ascending": true,
"unsorted": false
},
"empty": false
}
프론트엔드 연동
1. JavaScript (Vanilla)
class PaginationHandler {
constructor(apiUrl, containerId, options = {}) {
this.apiUrl = apiUrl;
this.container = document.getElementById(containerId);
this.currentPage = 0;
this.pageSize = options.pageSize || 20;
this.sortBy = options.sortBy || 'id';
this.sortDirection = options.sortDirection || 'ASC';
}
async loadPage(page) {
try {
const url = new URL(this.apiUrl);
url.searchParams.set('page', page);
url.searchParams.set('size', this.pageSize);
url.searchParams.set('sort', `${this.sortBy},${this.sortDirection}`);
const response = await fetch(url);
const data = await response.json();
this.renderData(data.content);
this.renderPagination(data);
} catch (error) {
console.error('Failed to load page:', error);
}
}
renderData(items) {
this.container.innerHTML = items.map(item => `
<div class="user-item">
<h3>${item.username}</h3>
<p>${item.email}</p>
</div>
`).join('');
}
renderPagination(pageData) {
const pagination = document.getElementById('pagination');
let html = '';
// Previous button
html += `
<button ${pageData.first ? 'disabled' : ''}
onclick="pagination.loadPage(${pageData.number - 1})">
Previous
</button>
`;
// Page numbers
const totalPages = pageData.totalPages;
const currentPage = pageData.number;
for (let i = 0; i < totalPages; i++) {
if (
i === 0 ||
i === totalPages - 1 ||
(i >= currentPage - 2 && i <= currentPage + 2)
) {
html += `
<button class="${i === currentPage ? 'active' : ''}"
onclick="pagination.loadPage(${i})">
${i + 1}
</button>
`;
} else if (i === currentPage - 3 || i === currentPage + 3) {
html += '<span>...</span>';
}
}
// Next button
html += `
<button ${pageData.last ? 'disabled' : ''}
onclick="pagination.loadPage(${pageData.number + 1})">
Next
</button>
`;
pagination.innerHTML = html;
}
}
// 사용
const pagination = new PaginationHandler('/api/users', 'user-list', {
pageSize: 10,
sortBy: 'username',
sortDirection: 'ASC'
});
pagination.loadPage(0);
2. React 구현
import React, { useState, useEffect } from 'react';
import axios from 'axios';
const UserList = () => {
const [users, setUsers] = useState([]);
const [pageInfo, setPageInfo] = useState({
totalElements: 0,
totalPages: 0,
number: 0,
size: 20
});
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [sortConfig, setSortConfig] = useState({
field: 'createdAt',
direction: 'DESC'
});
const fetchUsers = async (page = 0) => {
setLoading(true);
try {
const response = await axios.get('/api/users', {
params: {
page,
size: pageInfo.size,
sort: `${sortConfig.field},${sortConfig.direction}`,
keyword: searchTerm
}
});
setUsers(response.data.content);
setPageInfo({
totalElements: response.data.totalElements,
totalPages: response.data.totalPages,
number: response.data.number,
size: response.data.size
});
} catch (error) {
console.error('Failed to fetch users:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, [sortConfig]);
const handleSort = (field) => {
setSortConfig(prev => ({
field,
direction: prev.field === field && prev.direction === 'ASC' ? 'DESC' : 'ASC'
}));
};
const renderPagination = () => {
const pages = [];
const currentPage = pageInfo.number;
const totalPages = pageInfo.totalPages;
// Previous button
pages.push(
<button
key="prev"
onClick={() => fetchUsers(currentPage - 1)}
disabled={currentPage === 0}
className="pagination-btn"
>
Previous
</button>
);
// Page numbers
for (let i = 0; i < totalPages; i++) {
if (
i === 0 ||
i === totalPages - 1 ||
(i >= currentPage - 2 && i <= currentPage + 2)
) {
pages.push(
<button
key={i}
onClick={() => fetchUsers(i)}
className={`pagination-btn ${i === currentPage ? 'active' : ''}`}
>
{i + 1}
</button>
);
} else if (i === currentPage - 3 || i === currentPage + 3) {
pages.push(<span key={`dots-${i}`}>...</span>);
}
}
// Next button
pages.push(
<button
key="next"
onClick={() => fetchUsers(currentPage + 1)}
disabled={currentPage === totalPages - 1}
className="pagination-btn"
>
Next
</button>
);
return <div className="pagination">{pages}</div>;
};
return (
<div className="user-list-container">
<div className="controls">
<input
type="text"
placeholder="Search users..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && fetchUsers(0)}
/>
<select
value={pageInfo.size}
onChange={(e) => {
setPageInfo(prev => ({ ...prev, size: parseInt(e.target.value) }));
fetchUsers(0);
}}
>
<option value="10">10 per page</option>
<option value="20">20 per page</option>
<option value="50">50 per page</option>
</select>
</div>
{loading ? (
<div className="loading">Loading...</div>
) : (
<table className="user-table">
<thead>
<tr>
<th onClick={() => handleSort('username')}>
Username {sortConfig.field === 'username' && (
sortConfig.direction === 'ASC' ? '▲' : '▼'
)}
</th>
<th onClick={() => handleSort('email')}>
Email {sortConfig.field === 'email' && (
sortConfig.direction === 'ASC' ? '▲' : '▼'
)}
</th>
<th onClick={() => handleSort('createdAt')}>
Created At {sortConfig.field === 'createdAt' && (
sortConfig.direction === 'ASC' ? '▲' : '▼'
)}
</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.id}>
<td>{user.username}</td>
<td>{user.email}</td>
<td>{new Date(user.createdAt).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
)}
<div className="pagination-container">
<div className="pagination-info">
Showing {pageInfo.number * pageInfo.size + 1} to{' '}
{Math.min((pageInfo.number + 1) * pageInfo.size, pageInfo.totalElements)} of{' '}
{pageInfo.totalElements} entries
</div>
{renderPagination()}
</div>
</div>
);
};
// Custom Hook for Pagination
const usePagination = (url, options = {}) => {
const [data, setData] = useState([]);
const [page, setPage] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const fetchPage = async (pageNumber) => {
setLoading(true);
setError(null);
try {
const response = await axios.get(url, {
params: {
page: pageNumber,
size: options.pageSize || 20,
...options.params
}
});
setData(response.data.content);
setPage(response.data.number);
setTotalPages(response.data.totalPages);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPage(0);
}, [url]);
return {
data,
page,
totalPages,
loading,
error,
fetchPage,
hasNext: page < totalPages - 1,
hasPrevious: page > 0
};
};
3. 무한 스크롤 구현
import React, { useState, useEffect, useCallback, useRef } from 'react';
const InfiniteScrollList = () => {
const [items, setItems] = useState([]);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const observer = useRef();
const lastItemElementRef = useCallback(node => {
if (loading) return;
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && hasMore) {
setPage(prevPage => prevPage + 1);
}
});
if (node) observer.current.observe(node);
}, [loading, hasMore]);
useEffect(() => {
loadMore();
}, [page]);
const loadMore = async () => {
setLoading(true);
try {
const response = await fetch(`/api/users/slice?page=${page}&size=20`);
const data = await response.json();
setItems(prev => [...prev, ...data.content]);
setHasMore(!data.last);
} catch (error) {
console.error('Failed to load more items:', error);
} finally {
setLoading(false);
}
};
return (
<div className="infinite-scroll-container">
{items.map((item, index) => {
if (items.length === index + 1) {
return (
<div ref={lastItemElementRef} key={item.id} className="item">
{item.username}
</div>
);
} else {
return (
<div key={item.id} className="item">
{item.username}
</div>
);
}
})}
{loading && <div className="loading">Loading more...</div>}
{!hasMore && <div className="end-message">No more items to load</div>}
</div>
);
};
고급 페이징 기법
1. 커서 기반 페이징
@RestController
@RequestMapping("/api/users/cursor")
public class CursorPaginationController {
@GetMapping
public CursorPageResponse<UserDto> getUsersByCursor(
@RequestParam(required = false) Long cursor,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "DESC") String direction) {
return userService.getUsersByCursor(cursor, size, direction);
}
}
@Service
public class UserService {
public CursorPageResponse<UserDto> getUsersByCursor(Long cursor, int size, String direction) {
List<User> users;
if (cursor == null) {
// 첫 페이지
users = userRepository.findTopNByOrderByIdDesc(size + 1);
} else {
// 다음 페이지
if ("DESC".equals(direction)) {
users = userRepository.findByIdLessThanOrderByIdDesc(cursor, PageRequest.of(0, size + 1));
} else {
users = userRepository.findByIdGreaterThanOrderByIdAsc(cursor, PageRequest.of(0, size + 1));
}
}
boolean hasNext = users.size() > size;
if (hasNext) {
users = users.subList(0, size);
}
List<UserDto> dtos = users.stream()
.map(UserDto::from)
.collect(Collectors.toList());
Long nextCursor = hasNext && !users.isEmpty()
? users.get(users.size() - 1).getId()
: null;
return CursorPageResponse.<UserDto>builder()
.content(dtos)
.cursor(nextCursor)
.hasNext(hasNext)
.build();
}
}
@Data
@Builder
public class CursorPageResponse<T> {
private List<T> content;
private Long cursor;
private boolean hasNext;
}
2. 키셋 페이징 (Keyset Pagination)
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
@Query("""
SELECT u FROM User u
WHERE (:lastId IS NULL OR u.id < :lastId)
AND (:lastDate IS NULL OR u.createdAt < :lastDate OR
(u.createdAt = :lastDate AND u.id < :lastId))
ORDER BY u.createdAt DESC, u.id DESC
""")
List<User> findWithKeyset(
@Param("lastId") Long lastId,
@Param("lastDate") LocalDateTime lastDate,
Pageable pageable
);
}
@Service
public class KeysetPaginationService {
public KeysetPageResponse<UserDto> getUsersWithKeyset(
Long lastId,
LocalDateTime lastDate,
int size) {
List<User> users = userRepository.findWithKeyset(
lastId,
lastDate,
PageRequest.of(0, size + 1)
);
boolean hasNext = users.size() > size;
if (hasNext) {
users = users.subList(0, size);
}
List<UserDto> dtos = users.stream()
.map(UserDto::from)
.collect(Collectors.toList());
KeysetPageResponse.Keyset nextKeyset = null;
if (hasNext && !users.isEmpty()) {
User lastUser = users.get(users.size() - 1);
nextKeyset = new KeysetPageResponse.Keyset(
lastUser.getId(),
lastUser.getCreatedAt()
);
}
return KeysetPageResponse.<UserDto>builder()
.content(dtos)
.nextKeyset(nextKeyset)
.hasNext(hasNext)
.build();
}
}
3. 하이브리드 페이징
@Service
public class HybridPaginationService {
// 처음 몇 페이지는 오프셋, 이후는 커서 기반
public Page<UserDto> getHybridPage(int page, int size) {
if (page < 5) {
// 오프셋 기반 (처음 5페이지)
return userRepository.findAll(PageRequest.of(page, size))
.map(UserDto::from);
} else {
// 커서 기반으로 전환
Long cursor = calculateCursorForPage(page, size);
List<User> users = userRepository.findByIdGreaterThan(
cursor,
PageRequest.of(0, size)
);
return new PageImpl<>(
users.stream().map(UserDto::from).collect(Collectors.toList()),
PageRequest.of(page, size),
calculateTotalElements()
);
}
}
}
성능 최적화
1. 카운트 쿼리 최적화
@Repository
public interface OptimizedUserRepository extends JpaRepository<User, Long> {
// 카운트 쿼리 캐싱
@QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
@Query("SELECT COUNT(u) FROM User u WHERE u.active = true")
long countActiveUsers();
// 근사치 사용 (PostgreSQL)
@Query(value = "SELECT reltuples::BIGINT FROM pg_class WHERE relname = 'users'",
nativeQuery = true)
long getApproximateCount();
// 카운트 쿼리 생략
default Page<User> findAllWithoutCount(Pageable pageable) {
List<User> users = findAll(pageable).getContent();
return new PageImpl<>(users, pageable, -1); // total count = -1
}
}
2. 인덱스 활용
@Entity
@Table(name = "users", indexes = {
@Index(name = "idx_created_at", columnList = "created_at"),
@Index(name = "idx_active_created", columnList = "active, created_at"),
@Index(name = "idx_email", columnList = "email")
})
public class User {
// ...
}
// 커버링 인덱스 활용
@Query("""
SELECT new com.example.dto.UserSummaryDto(u.id, u.username, u.email)
FROM User u
WHERE u.active = true
ORDER BY u.createdAt DESC
""")
Page<UserSummaryDto> findActiveUserSummaries(Pageable pageable);
3. 프로젝션 최적화
// DTO Projection으로 필요한 필드만 조회
public interface UserProjection {
Long getId();
String getUsername();
String getEmail();
@Value("#{target.posts.size()}")
int getPostCount();
}
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Page<UserProjection> findAllProjectedBy(Pageable pageable);
}
4. 캐싱 전략
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(5, TimeUnit.MINUTES)
.maximumSize(1000));
return manager;
}
}
@Service
public class CachedUserService {
@Cacheable(value = "userPages",
key = "#pageable.pageNumber + '-' + #pageable.pageSize + '-' + #pageable.sort.toString()")
public Page<UserDto> getCachedUsers(Pageable pageable) {
return userRepository.findAll(pageable)
.map(UserDto::from);
}
@CacheEvict(value = "userPages", allEntries = true)
public void evictCache() {
// 캐시 무효화
}
}
5. 병렬 처리
@Service
public class ParallelPaginationService {
@Autowired
private ThreadPoolTaskExecutor taskExecutor;
public CompletableFuture<PageResponse<UserDto>> getPageAsync(int page, int size) {
return CompletableFuture.supplyAsync(() -> {
Page<User> userPage = userRepository.findAll(PageRequest.of(page, size));
return PageResponse.of(userPage.map(UserDto::from));
}, taskExecutor);
}
// 여러 페이지 동시 조회
public List<PageResponse<UserDto>> getMultiplePages(List<Integer> pageNumbers, int size) {
List<CompletableFuture<PageResponse<UserDto>>> futures = pageNumbers.stream()
.map(page -> getPageAsync(page, size))
.collect(Collectors.toList());
return futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
}
}
실전 예제
1. 검색 + 필터 + 페이징
@Data
public class UserSearchRequest {
private String keyword;
private List<String> departments;
private Boolean active;
private LocalDateTime startDate;
private LocalDateTime endDate;
private int page = 0;
private int size = 20;
private String sortBy = "createdAt";
private String sortDirection = "DESC";
}
@Service
public class AdvancedSearchService {
public Page<UserDto> searchUsers(UserSearchRequest request) {
Specification<User> spec = Specification.where(null);
// 키워드 검색
if (StringUtils.hasText(request.getKeyword())) {
spec = spec.and((root, query, cb) -> {
String pattern = "%" + request.getKeyword().toLowerCase() + "%";
return cb.or(
cb.like(cb.lower(root.get("username")), pattern),
cb.like(cb.lower(root.get("email")), pattern)
);
});
}
// 부서 필터
if (request.getDepartments() != null && !request.getDepartments().isEmpty()) {
spec = spec.and((root, query, cb) ->
root.get("department").in(request.getDepartments())
);
}
// 활성 상태 필터
if (request.getActive() != null) {
spec = spec.and((root, query, cb) ->
cb.equal(root.get("active"), request.getActive())
);
}
// 날짜 범위 필터
if (request.getStartDate() != null) {
spec = spec.and((root, query, cb) ->
cb.greaterThanOrEqualTo(root.get("createdAt"), request.getStartDate())
);
}
if (request.getEndDate() != null) {
spec = spec.and((root, query, cb) ->
cb.lessThanOrEqualTo(root.get("createdAt"), request.getEndDate())
);
}
// 페이징 및 정렬
Sort sort = Sort.by(
Sort.Direction.fromString(request.getSortDirection()),
request.getSortBy()
);
Pageable pageable = PageRequest.of(request.getPage(), request.getSize(), sort);
return userRepository.findAll(spec, pageable)
.map(UserDto::from);
}
}
2. Thymeleaf 페이징 UI
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>User List</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
</head>
<body>
<div class="container mt-5">
<h1>User List</h1>
<!-- Search Form -->
<form th:action="@{/users}" method="get" class="mb-4">
<div class="form-row">
<div class="col-md-4">
<input type="text" name="keyword" class="form-control"
placeholder="Search..." th:value="${keyword}">
</div>
<div class="col-md-2">
<select name="size" class="form-control">
<option value="10" th:selected="${page.size == 10}">10</option>
<option value="20" th:selected="${page.size == 20}">20</option>
<option value="50" th:selected="${page.size == 50}">50</option>
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary">Search</button>
</div>
</div>
</form>
<!-- User Table -->
<table class="table table-striped">
<thead>
<tr>
<th>
<a th:href="@{/users(page=${page.number}, size=${page.size}, sort='username,' + ${sortDir})}">
Username
</a>
</th>
<th>
<a th:href="@{/users(page=${page.number}, size=${page.size}, sort='email,' + ${sortDir})}">
Email
</a>
</th>
<th>
<a th:href="@{/users(page=${page.number}, size=${page.size}, sort='createdAt,' + ${sortDir})}">
Created At
</a>
</th>
</tr>
</thead>
<tbody>
<tr th:each="user : ${page.content}">
<td th:text="${user.username}"></td>
<td th:text="${user.email}"></td>
<td th:text="${#temporals.format(user.createdAt, 'yyyy-MM-dd HH:mm')}"></td>
</tr>
</tbody>
</table>
<!-- Pagination -->
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
<!-- First -->
<li class="page-item" th:classappend="${page.first ? 'disabled' : ''}">
<a class="page-link" th:href="@{/users(page=0, size=${page.size})}"
th:text="'First'"></a>
</li>
<!-- Previous -->
<li class="page-item" th:classappend="${page.first ? 'disabled' : ''}">
<a class="page-link"
th:href="@{/users(page=${page.number - 1}, size=${page.size})}"
th:text="'Previous'"></a>
</li>
<!-- Page Numbers -->
<li class="page-item"
th:each="pageNum : ${#numbers.sequence(0, page.totalPages - 1)}"
th:if="${pageNum >= page.number - 2 and pageNum <= page.number + 2}"
th:classappend="${pageNum == page.number ? 'active' : ''}">
<a class="page-link"
th:href="@{/users(page=${pageNum}, size=${page.size})}"
th:text="${pageNum + 1}"></a>
</li>
<!-- Next -->
<li class="page-item" th:classappend="${page.last ? 'disabled' : ''}">
<a class="page-link"
th:href="@{/users(page=${page.number + 1}, size=${page.size})}"
th:text="'Next'"></a>
</li>
<!-- Last -->
<li class="page-item" th:classappend="${page.last ? 'disabled' : ''}">
<a class="page-link"
th:href="@{/users(page=${page.totalPages - 1}, size=${page.size})}"
th:text="'Last'"></a>
</li>
</ul>
</nav>
<!-- Page Info -->
<div class="text-center">
<p th:text="'Showing ' + ${page.number * page.size + 1} + ' to ' +
${page.number * page.size + page.numberOfElements} + ' of ' +
${page.totalElements} + ' entries'"></p>
</div>
</div>
</body>
</html>
트러블슈팅
1. N+1 문제 해결
// 문제: 각 유저의 posts를 조회할 때 N+1 쿼리 발생
Page<User> users = userRepository.findAll(pageable);
users.forEach(user -> user.getPosts().size()); // N개의 추가 쿼리
// 해결 1: Fetch Join (단, 페이징과 함께 사용 시 주의)
@Query("SELECT DISTINCT u FROM User u LEFT JOIN FETCH u.posts")
List<User> findAllWithPosts(); // 페이징 불가
// 해결 2: @EntityGraph
@EntityGraph(attributePaths = {"posts"})
Page<User> findAll(Pageable pageable); // 경고 발생, 메모리에서 페이징
// 해결 3: Batch Size
@BatchSize(size = 100)
@OneToMany(mappedBy = "user")
private List<Post> posts;
// 해결 4: DTO Projection
@Query("""
SELECT new com.example.dto.UserWithPostCountDto(
u.id, u.username, COUNT(p)
)
FROM User u LEFT JOIN u.posts p
GROUP BY u.id, u.username
""")
Page<UserWithPostCountDto> findUsersWithPostCount(Pageable pageable);
2. 대용량 데이터 처리
// 문제: OutOfMemoryError
List<User> allUsers = userRepository.findAll(); // 100만 건
// 해결 1: 스트림 처리
@Query("SELECT u FROM User u")
@QueryHints(@QueryHint(name = "org.hibernate.fetchSize", value = "1000"))
Stream<User> streamAll();
@Transactional(readOnly = true)
public void processAllUsers() {
try (Stream<User> users = userRepository.streamAll()) {
users.forEach(this::processUser);
}
}
// 해결 2: 청크 단위 처리
public void processInChunks() {
Pageable pageable = PageRequest.of(0, 1000);
Page<User> page;
do {
page = userRepository.findAll(pageable);
page.getContent().forEach(this::processUser);
pageable = pageable.next();
} while (page.hasNext());
}
// 해결 3: ScrollableResults (Hibernate)
@PersistenceContext
private EntityManager em;
public void processWithScroll() {
Session session = em.unwrap(Session.class);
ScrollableResults results = session.createQuery("FROM User", User.class)
.setFetchSize(1000)
.scroll(ScrollMode.FORWARD_ONLY);
while (results.next()) {
User user = (User) results.get(0);
processUser(user);
if (++count % 1000 == 0) {
session.clear(); // 메모리 정리
}
}
results.close();
}
3. 동적 정렬 보안
// 문제: SQL Injection 위험
String sort = request.getParameter("sort"); // "username; DROP TABLE users;--"
Pageable pageable = PageRequest.of(0, 20, Sort.by(sort));
// 해결: 화이트리스트 검증
@Component
public class SortValidator {
private static final Set<String> ALLOWED_SORT_FIELDS = Set.of(
"id", "username", "email", "createdAt"
);
public Sort validateAndCreateSort(String sortField, String sortDirection) {
if (!ALLOWED_SORT_FIELDS.contains(sortField)) {
throw new IllegalArgumentException("Invalid sort field: " + sortField);
}
Sort.Direction direction;
try {
direction = Sort.Direction.fromString(sortDirection);
} catch (Exception e) {
direction = Sort.Direction.ASC;
}
return Sort.by(direction, sortField);
}
}
마무리
페이지네이션은 단순해 보이지만 실제로는 많은 고려사항이 있는 기술입니다. 이 가이드를 통해 다양한 상황에서 적절한 페이징 전략을 선택하고 구현할 수 있기를 바랍니다.
핵심 체크리스트
- ✅ 적절한 페이지 크기 설정 (보통 20-50)
- ✅ 최대 페이지 크기 제한
- ✅ 카운트 쿼리 최적화
- ✅ 인덱스 활용
- ✅ N+1 문제 방지
- ✅ 보안 (정렬 필드 검증)
- ✅ 캐싱 전략 수립
페이징 방식 선택 가이드
- 오프셋 기반: 일반 게시판, 관리자 페이지
- 커서 기반: 타임라인, 피드, 무한 스크롤
- 키셋 기반: 대용량 로그, 실시간 데이터
다음 단계
- Elasticsearch를 활용한 검색 페이징
- GraphQL에서의 페이지네이션
- 반응형 스트림(Reactive Streams) 페이징
- Redis를 활용한 페이지 캐싱
태그: #SpringBoot #Pagination #JPA #REST #Performance