하이어코딩 RSS 태그 관리 글쓰기 방명록 mahiru
2025-07-21 19:32:33
728x90
반응형

📄 들어가며

"전체 사용자 100만 명을 한 번에 조회하면 어떻게 될까요?"

서버는 뻗고, 사용자는 무한 로딩을 보게 될 것입니다. 페이지네이션(Pagination)은 대용량 데이터를 작은 단위로 나누어 처리하는 필수 기술입니다.

이 글에서는 Spring Boot에서 페이지네이션을 구현하는 다양한 방법과 실무에서 마주치는 문제들의 해결책을 제시합니다. 단순한 페이징부터 무한 스크롤, 커서 기반 페이징까지 모든 것을 다룹니다.

📑 목차

  1. 페이지네이션 기초
  2. Spring Data JPA 페이징
  3. REST API 페이징 구현
  4. 프론트엔드 연동
  5. 고급 페이징 기법
  6. 성능 최적화
  7. 실전 예제
  8. 트러블슈팅

페이지네이션 기초

🎯 페이지네이션이란?

페이지네이션은 대량의 데이터를 작은 단위(페이지)로 나누어 제공하는 기술입니다.

 
전체 데이터: 10,000개
페이지 크기: 20개
총 페이지 수: 500페이지

1페이지: 1-20번 데이터
2페이지: 21-40번 데이터
...

페이징 방식 비교

방식장점단점사용 사례

오프셋 기반 구현 간단, 임의 페이지 접근 대용량 시 성능 저하 일반적인 게시판
커서 기반 성능 우수, 일관성 임의 페이지 접근 불가 타임라인, 피드
키셋 기반 매우 빠름 정렬 조건 제한 대용량 로그

Spring Data JPA 페이징

1. 기본 페이징 구현

Entity

 
java
@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

 
java
@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 계층

 
java
@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와 매핑

 
java
@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 구현

 
java
@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. 전역 페이징 설정

 
java
@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 응답 예시

 
json
// 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)

 
javascript
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 구현

 
jsx
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. 무한 스크롤 구현

 
jsx
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. 커서 기반 페이징

 
java
@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)

 
java
@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. 하이브리드 페이징

 
java
@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. 카운트 쿼리 최적화

 
java
@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. 인덱스 활용

 
java
@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. 프로젝션 최적화

 
java
// 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. 캐싱 전략

 
java
@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. 병렬 처리

 
java
@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. 검색 + 필터 + 페이징

 
java
@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

 
html
<!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 문제 해결

 
java
// 문제: 각 유저의 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. 대용량 데이터 처리

 
java
// 문제: 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. 동적 정렬 보안

 
java
// 문제: 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

728x90
반응형
이 페이지는 리디주식회사에서 제공한 리디바탕 글꼴이 사용되어 있습니다.