1. 페이징 (paging) 기능이란 ?
- 데이터를 일정한 크기로 나누어서 (페이지화 시켜서) 보여주는 기능
- 많은 양의 데이터를 한 번에 보여주는 것이 아니므로 스크롤을 내리는 등의 불편함, 긴 로딩시간 등을 해소 가능
- 페이징 기능에는 정렬, 검색 등의 기능이 포함되어 있으므로 함께 사용하면 사용자가 더 빠르고 정확하게 데이터를 찾을 수 있음
- 즉, 대량의 데이터를 효율적으로 관리하고 사용자에게 편리한 접근성을 제공하는 기능
2. 페이징 구현 방법 (Spring Boot)
- 스프링 부트에서는 페이징을 구현하는 방법 2가지 존재
- 컨트롤러 (Controller) 단에서 사용 => Pageable, PageRequest
- 레포지토리 (Repository), 서비스 (Service) 단 등에도 리턴 타입을 Page로 바꿔주는 등의 코드 조작이 필요
2-1. 레포지토리 (Repository) 단
- JpaRepository를 extends하는 인터페이스 구현
- findAll 등의 메서드를 리턴 타입이 Page가 되도록 설정
- 메서드의 매개변수로 Pageable 변수를 넣어줘야 함
public interface UserRepository extends JpaRepository<User, Long> {
Page<User> findAll(Pageable pageable);
}
2-2. 서비스 (Service) 단
- 레포지토리와 동일하게 리턴 타입은 Page, 매개변수로 Pageable 변수 설정
@Service
public class UserService {
private final UserRepository userRepository;
public Page<User> findAll(Pageable pageable){
return userRepository.findAll(pageable);
}
}
2-3. 컨트롤러 (Controller) 단
2-3-1) Pageable
- @PageableDefault 어노테이션 사용
- size : 페이지 당 표시할 데이터 개수 (기본값 : 10)
- page : 현재 페이지 (기본값 : 0)
- sort : 정렬 기준
- direction : 정렬 방향 (오름차순, 내림차순)
@GetMapping("")
public Page<User> findAll(@PageableDefault(size = 5, page = 1,
sort = "name", direction = Sort.Direction.DESC) {
return userService.findAll(pageable);
}
2-3-2) PageRequest (Pageable의 구현체)
- PageRequest 객체를 생성해서 직접 페이징 설정
- 직접 쿼리 파라미터를 받는 작업 해야 함
- PageRequest.of(pageNumber, pageSize, sort)
- pageNumber : 현재 페이지
- pageSize : 페이지 당 표시할 데이터 개수
- sort : 정렬 기준
@GetMapping("")
public Page<User> findAll(@RequestParam(required = false, defaultValue = "1") int page) {
PageRequest pageRequest = PageRequest.of(page - 1, 5, Sort.by("name").descending());
return userService.findAll(pageRequest);
}
2-4. 페이지 내부 정보
- Page 는 페이지에 대한 정보도 가지고 있음
- 현재 페이지
- result.getNumber()
- 전체 페이지 수
- result.getTotalPages()
- 페이지 당 출력되는 데이터 개수
- result.getSize()
- 이전 / 다음 페이지 존재 여부
- result.hasPrevious() / result.hasNext()
- 현재 페이지가 첫 페이지 / 마지막 페이지 인지 여부
- result.isFirst() / result.isLast()
3. 페이징, 정렬, 검색, 에러 메시지 포함 예제
- (스프링 부트, gradle, MySQL 사용 예제)
- Player 엔티티에 축구 선수의 이름, 소속팀, 연봉 데이터가 저장됨
- 유명 축구선수 15명의 정보 입력
- 이름 / 연봉(사이값) / 이름 + 연봉(사이값) 으로 검색 가능
- 첫 화면에서는 엔티티에 존재하는 모든 선수들의 정보 출력 => 검색 시 검색 결과에 적합한 선수들의 정보만 출력
- 연봉 사이값 입력이 잘못된 경우 ( '100억~900억'의 입력이 아닌 '900억~100억' 등의 입력 ) 에러 메시지 출력
3-0. build.gradle, application.yml
- build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.1'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'study'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '21'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
- application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/pagingDB?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
username: [username]
password: [password]
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
properties:
hibernate:
format_sql: true
database-platform: org.hibernate.dialect.MySQL8Dialect
database: mysql
thymeleaf:
prefix: classpath:/templates/
suffix: .html
3-1. Player (domain)
@Entity
@Getter @Setter
public class Player {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String team;
private Integer salary;
}
3-2. SearchForm (domain)
- 검색 폼을 위한 클래스
@Getter @Setter
public class SearchForm {
private String name; // 검색할 선수 이름
private Integer startSal; // 검색할 연봉 시작점
private Integer endSal; // 검색할 연봉 끝점
}
3-3. PlayerRepository (repository)
public interface PlayerRepository extends JpaRepository<Player, Long> {
// findPlayersBySalaryBetween : 기존의 findAll 과 동일하다고 생각 가능!
Page<Player> findPlayersBySalaryBetween(Integer startSal, Integer endSal, Pageable pageable);
// findPlayersByNameContainingAndSalaryBetween : 기존의 findByNameContaining 과 동일하다고 생각 가능!
Page<Player> findPlayersByNameContainingAndSalaryBetween(String name, Integer startSal, Integer endSal, Pageable pageable);
}
- JpaRepository
- find~By~Between(범위 시작점, 범위 끝점) : 범위의 시작 ~ 끝을 만족하는 데이터 반환
- find~ByNameContaining(String name) : name을 "포함" 하는 데이터 반환
- 컨트롤러에서 연봉의 시작, 끝이 입력되지 않으면 다음과 같이 설정함
- 시작 : 0
- 끝 : 99999
- 따라서 연봉의 시작, 끝의 변수는 항상 값이 존재하게 됨
- 따라서 1) 아무것도 입력 받지 않은 경우 2) 이름만 입력 받은 경우 3) 연봉만 입력받은 경우 4) 모두 입력받은 경우가 아닌
- 1) 연봉만 값이 있는 경우 2) 이름과 연봉 모두 값이 있는 경우로 나눌 수 있음
- 1) 연봉만 값이 있는 경우 : 기존의 findAll 과 동일하게 생각
- 2) 이름과 연봉 모두 값이 있는 경우 : findByNameContaining 과 동일하게 생각
3-4. PlayerService (service)
@Service
@RequiredArgsConstructor
public class PlayerService {
private final PlayerRepository playerRepository;
// findAll : 모든 선수 찾기
public Page<Player> findAll(Integer startSal, Integer endSal, Pageable pageable){
return playerRepository.findPlayersBySalaryBetween(startSal, endSal, pageable);
}
// findPlayersByName : 이름으로 선수 찾기
public Page<Player> findPlayersByName(String name, Integer startSal, Integer endSal, Pageable pageable) {
return playerRepository.findPlayersByNameContainingAndSalaryBetween(name, startSal, endSal, pageable);
}
}
3-5. PlayerController (controller)
@Controller
@RequiredArgsConstructor
public class PlayerController {
private final PlayerService playerService;
@GetMapping("")
public String home(@RequestParam(required = false, defaultValue = "1") int page,
@ModelAttribute SearchForm searchForm,
Model model){
// 연봉 시작 조건이 빈 경우 : 0으로 설정
if(searchForm.getStartSal() == null) searchForm.setStartSal(0);
// 연봉 끝 조건이 빈 경우 : 99999로 설정
if(searchForm.getEndSal() == null) searchForm.setEndSal(99999);
// 연봉 시작 > 연봉 끝 (검색 조건 위반)
if(searchForm.getStartSal() > searchForm.getEndSal()){
model.addAttribute("errorMessage", "검색할 수 없는 범위 입니다.");
model.addAttribute("nextUrl", "/");
return "/error";
}
// PageRequest.of(페이지 번호, 페이지 당 5개씩 출력, 연봉 기준 내림차순)
PageRequest pageRequest = PageRequest.of(page-1, 5, Sort.by("salary").descending());
Page<Player> players;
if(searchForm.getName() == null){
// 이름이 빈 경우 : findAll 호출
players = playerService.findAll(searchForm.getStartSal(), searchForm.getEndSal(), pageRequest);
} else {
// 이름이 있는 경우 : findPlayersByName 호출
players = playerService.findPlayersByName(searchForm.getName(), searchForm.getStartSal(), searchForm.getEndSal(), pageRequest);
}
model.addAttribute("players", players);
model.addAttribute("searchForm", searchForm);
return "/home";
}
}
- PlayerRepository 에서 언급한 대로 연봉 시작, 끝 변수가 항상 값이 있게끔 설정함으로써 검색 경우를 반으로 줄임
- 페이지 당 5명의 선수 출력
- 연봉을 기준으로 내림차순 정렬
3-6. home.html (templates)
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>페이징 연습</title>
</head>
<body>
<h2>선수를 검색합니다.</h2>
<hr/>
<form th:object="${searchForm}" th:method="get" action="">
<div>
<input type="text" th:field="*{name}" th:value="*{name}" placeholder="이름" style="margin-right: 20px">
<input type="number" th:field="*{startSal}" th:value="*{startSal}" placeholder="연봉 시작값 (단위 : 억)" style="margin-right: 20px">
<input type="number" th:field="*{endSal}" th:value="*{endSal}" placeholder="연봉 끝값 (단위 : 억)" style="margin-right: 20px">
<button type="submit">검색</button>
</div>
</form>
<br><br><br>
<h2>검색한 조건은 다음과 같습니다. (정렬 : 연봉 내림차순)</h2>
<hr/>
<h3>선수 이름 : '[[${searchForm.name}]]' 을 포함하는 이름</h3>
<h3>선수 연봉 : [[${searchForm.startSal}]] 억과 [[${searchForm.endSal}]] 억 사이</h3>
<br>
<table style="text-align: center">
<thead>
<tr>
<th>선수 이름</th>
<th>소속 팀</th>
<th>선수 연봉 (단위 : 억)</th>
</tr>
</thead>
<tbody>
<tr th:each="player : ${players}">
<td th:text="${player.name}"></td>
<td th:text="${player.team}"></td>
<td th:text="${player.salary}"></td>
</tr>
</tbody>
</table>
<br><br>
<button th:disabled="${!players.hasPrevious()}"
th:onclick="|location.href='@{/(page=${players.getNumber()}, name=${searchForm.getName()}, startSal=${searchForm.getStartSal()}, endSal=${searchForm.getEndSal()} )}'|">
이전</button>
<span>[[${players.getNumber() + 1}]] / [[${players.getTotalPages()}]] 페이지</span>
<button th:disabled="${!players.hasNext()}"
th:onclick="|location.href='@{/(page=${players.getNumber() + 2}, name=${searchForm.getName()}, startSal=${searchForm.getStartSal()}, endSal=${searchForm.getEndSal()} )}'|">
다음</button>
<br><br>
</body>
</html>
- th:disabled 태그 : 조건이 만족하면 (버튼) 클릭 비활성화
- 페이지 시작을 1로 설정했기 때문에 다음과 같이 설정
- 이전 페이지 : players.getNumber()
- 현재 페이지 : players.getNumber() + 1
- 다음 페이지 : players.getNumber() + 2
3-7. error.html (templates)
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>페이징 연습</title>
</head>
<body>
<script th:inline="javascript">
window.onload = function () {
alert([[${errorMessage}]])
window.location.href = [[${nextUrl}]]
}
</script>
</body>
</html>
- 자바 스크립트 문법 사용
- 컨트롤러에서 Model에 추가한 속성인 errorMessage 출력
- 컨트롤러에서 Model에 추가한 속성이 nextUrl로 이동하게 설정
4. 예제 결과 확인
4-1. 첫 화면
4-1-1) 페이징 기능 확인 (한 페이지 당 5개의 데이터 출력, 연봉 기준 내림차순 정렬, 이전/다음 버튼)
4-2. 검색 기능 확인
4-2-1) 이름만 검색
4-2-2) 연봉만 검색
4-2-3) 이름과 연봉 모두 검색
4-3. 에러 메시지 확인
'Spring Boot' 카테고리의 다른 글
[Spring Boot] 로그인 기능 구현 (1) - 쿠키 로그인 (0) | 2024.01.07 |
---|---|
[Spring Boot] 로그인 기능 구현 (0) - 공통 기능, 코드 구현 (2) | 2024.01.07 |
[SpringBoot] MySQL 연동 (maven, gradle) (0) | 2023.12.12 |
[Spring Boot] 스프링 구동 시에 특정 코드 자동 실행시키기 (Command Line Runner, Application Runner) (1) | 2023.11.22 |
[Spring Boot] Gradle 빌드해서 Jar 파일 생성, 실행 (0) | 2023.11.22 |