Springboot로 디자인이 하나도 없고 매우 간단한 게시판을 구현하는 시리즈입니다.

최종 소스는 깃헙에서 확인하실 수 있습니다.


이 글에서는 게시글 검색과 페이징에 대해 알아보겠습니다.


검색은 Repository에서 함수만 잘 작성해주면 Like 검색이 되고,

페이징은 Service에서 Page 객체를 활용하는 방법과 알고리즘을 구현하면 됩니다!






1. 검색

1) 검색 - 퍼블리싱

src/main/resources/templates/board/list.html

<!-- 검색 form -->
<form action="/board/search" method="GET">
<div>
<input name="keyword" type="text" placeholder="검색어를 입력해주세요">
</div>

<button>검색하기</button>
</form>
  • 기존에 있던 파일이며, 푸터 위에 검색 form만 추가해줬습니다.
  • 초기 페이지 진입 시 리스트를 보여주는 파일인 동시에 검색 결과로도 사용되는 페이지입니다.




2) 검색 - Controller

src/main/java/com/victolee/board/controller/BoardController.java

@GetMapping("/board/search")
public String search(@RequestParam(value="keyword") String keyword, Model model) {
List<BoardDto> boardDtoList = boardService.searchPosts(keyword);

model.addAttribute("boardList", boardDtoList);

return "board/list.html";
}

  • 기존에 존재했던 컨트롤러에서 매핑함수 search() 를 작성합니다.
  • 특별한 것은 없고, 클라이언트에서 넘겨주는 keyword를 검색어로 활용합니다.





3) 검색 - Service

src/main/java/com/victolee/board/service/BoardService.java

@Transactional
public List<BoardDto> searchPosts(String keyword) {
List<BoardEntity> boardEntities = boardRepository.findByTitleContaining(keyword);
List<BoardDto> boardDtoList = new ArrayList<>();

if (boardEntities.isEmpty()) return boardDtoList;

for (BoardEntity boardEntity : boardEntities) {
boardDtoList.add(this.convertEntityToDto(boardEntity));
}

return boardDtoList;
}

private BoardDto convertEntityToDto(BoardEntity boardEntity) {
return BoardDto.builder()
.id(boardEntity.getId())
.title(boardEntity.getTitle())
.content(boardEntity.getContent())
.writer(boardEntity.getWriter())
.createdDate(boardEntity.getCreatedDate())
.build();
}
  • searchPosts()
    • Repository에서 검색 결과를 받아와 비즈니스 로직을 실행하는 함수입니다.
    • 마찬가지로 특별한 것은 없으며, Controller <---> Service 간에는 Dto 객체로 전달하는 것이 좋으므로 이와 관련된 로직만 있을 뿐입니다.
  • convertEntityToDto()
    • 시리즈의 이전 게시물에서 Entity를 Dto로 변환하는 작업이 중복해서 발생했었는데, 이를 함수로 처리하도록 개선하였습니다.





4) 검색 - Repository

src/main/java/com/victolee/board/domain/repository/BoardRepository.java

import com.victolee.board.domain.entity.BoardEntity;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface BoardRepository extends JpaRepository<BoardEntity, Long> {
List<BoardEntity> findByTitleContaining(String keyword);
}
  • findByTitleContaining()
    • 드디어 검색을 직접적으로 호출하는 메서드가 나왔습니다.
    • JpaRepository에서 메서드명의 By 이후는 SQL의 where 조건 절에 대응되는 것인데, 이렇게 Containing을 붙여주면 Like 검색이 됩니다.
      • 즉, 해당 함수는 %{keyword}% 이렇게 표현이 됩니다.

여기를 참고하시면, JPA like query를 사용하는 다양한 방법이 나옵니다.
  • StartsWith
    • 검색어로 시작하는 Like 검색
    • {keyword}%
  • EndsWith
    • 검색어로 끝는 Like 검색
    • %{keyword}
  • IgnoreCase
    • 대소문자 구분 없이 검색
  • Not
    • 검색어를 포함하지 않는 검색




5) 검색 - 테스트
이제 데이터를 추가하고, 검색을 해보시면 검색이 잘 되는것을 확인할 수 있습니다.






2. 페이징
1) 페이징 - 퍼블리싱
src/main/resources/templates/board/list.html
<div>
<span th:each="pageNum : ${pageList}" th:inline="text">
<a th:href="@{'/?page=' + ${pageNum}}">[[${pageNum}]]</a>
</span>
</div>
  • 위에서 작성한 검색 영역 위에 페이징 div 엘리먼트를 작성합니다.
  • 서버로부터 페이징 번호를 리스트 pageList 변수에 받아와서 번호를 뿌려줍니다.
    • 각 번호를 누르면, 새로운 페이지 번호를 서버로 전달합니다.




2) 페이징 - Controller
src/main/java/com/victolee/board/controller/BoardController.java

/* 게시글 목록 */
@GetMapping("/")
public String list(Model model, @RequestParam(value="page", defaultValue = "1") Integer pageNum) {
List<BoardDto> boardList = boardService.getBoardlist(pageNum);
Integer[] pageList = boardService.getPageList(pageNum);

model.addAttribute("boardList", boardList);
model.addAttribute("pageList", pageList);

return "board/list.html";
}

  • 기존에 존재했던 컨트롤러의 매핑함수 list()를 수정한 것입니다.
  • @RequestParam(value="page", defaultValue = "1") Integer pageNum
    • page 이름으로 넘어오면 파라미터를 받아주고, 없으면 기본 값으로 1을 설정합니다.
    • 페이지 번호는 서비스 계층의 getPageList() 함수로 넘겨줍니다.




3) 페이징 - Service
src/main/java/com/victolee/board/service/BoardService.java

public class BoardService {
...

private static final int BLOCK_PAGE_NUM_COUNT = 5; // 블럭에 존재하는 페이지 번호 수
private static final int PAGE_POST_COUNT = 4; // 한 페이지에 존재하는 게시글 수

@Transactional
public List<BoardDto> getBoardlist(Integer pageNum) {
Page<BoardEntity> page = boardRepository.findAll(PageRequest.of(pageNum - 1, PAGE_POST_COUNT, Sort.by(Sort.Direction.ASC, "createdDate")));

List<BoardEntity> boardEntities = page.getContent();
List<BoardDto> boardDtoList = new ArrayList<>();

for (BoardEntity boardEntity : boardEntities) {
boardDtoList.add(this.convertEntityToDto(boardEntity));
}

return boardDtoList;
}

@Transactional
public Long getBoardCount() {
return boardRepository.count();
}

public Integer[] getPageList(Integer curPageNum) {
Integer[] pageList = new Integer[BLOCK_PAGE_NUM_COUNT];

// 총 게시글 갯수
Double postsTotalCount = Double.valueOf(this.getBoardCount());

// 총 게시글 기준으로 계산한 마지막 페이지 번호 계산 (올림으로 계산)
Integer totalLastPageNum = (int)(Math.ceil((postsTotalCount/PAGE_POST_COUNT)));

// 현재 페이지를 기준으로 블럭의 마지막 페이지 번호 계산
Integer blockLastPageNum = (totalLastPageNum > curPageNum + BLOCK_PAGE_NUM_COUNT)
? curPageNum + BLOCK_PAGE_NUM_COUNT
: totalLastPageNum;

// 페이지 시작 번호 조정
curPageNum = (curPageNum <= 3) ? 1 : curPageNum - 2;

// 페이지 번호 할당
for (int val = curPageNum, idx = 0; val <= blockLastPageNum; val++, idx++) {
pageList[idx] = val;
}

return pageList;
}

...
}

  • getBoardList() 메서드는 기존에 존재했던 메서드이며, 페이징을 할 수 있게 수정하였습니다.
    • boardRepository.findAll(PageRequest.of(pageNum - 1, PAGE_POST_COUNT, Sort.by(Sort.Direction.ASC, "createdDate")));
    • repository의 find() 관련 메서드를 호출할 때 Pageable 인터페이스를 구현한 클래스(PageRequest.of())를 전달하면 페이징을 할 수 있습니다. ( 참고 )
      • 첫 번째 인자
        • limit을 의미합니다.
        • "현재 페이지 번호 - 1"을 계산한 값이며, 실제 페이지 번호와 SQL 조회시 사용되는 limit은 다르기 때문입니다.
      • 두 번째 인자
        • offset을 의미합니다.
        • 몇 개를 가져올 것인가?
      • 세 번째 인자
        • 정렬 방식을 결정합니다.
        • createDate 컬럼을 기준으로 오름차순으로 정렬하여 가져옵니다.
    • 반환된 Page 객체의 getContent() 메서드를 호출하면, 엔티티를 리스트로 꺼낼 수 있습니다.
  • getBoardCount() 메서드는 신규로 추가한 메서드이며, 전체 게시글 개수를 가져옵니다.
  • getPageList() 메서드도 신규로 추가한 메서드이며, 프론트에 노출시킬 페이지 번호 리스트를 계산하는 로직입니다.
    • UI를 어떻게 구현하냐에 따라 페이징 로직이 조금씩 다른데요, 제가 구현한 방식은 다음과 같습니다.
      • 하나의 페이지에는 4개의 게시글을 가져온다.
      • 총 5개의 번호를 노출한다.
      • 번호를 5개 채우지 못하면(= 게시글이 20개가 안되면), 존재하는 번호까지만 노출한다.

페이징 알고리즘을 구현하는 부분이 복잡해서 그렇지, JPA에서 페이징하여 조회하는 부분은 간단합니다.
페이징은 각자가 원하는 UI가 있으므로 상황에 맞게 구현하시면 되고, 참고만 하시길 바랍니다.
  • 다양한 방법
    • 한 블럭에 페이지 번호는 5개로 고정하고, 양 끝에 화살표 버튼을 위치시켜 5개가 한 셋트씩 움직이도록 할 수도 있습니다.
      • 최초 첫 번째 블럭 페이지 번호 : 1 ~ 5
      • 오른쪽 버튼 클릭시, 두 번째 블럭 페이지 번호 : 6 ~ 10
      • 블럭 내의 번호 클릭 시, 번호 변동없음
    • 또 예전에 구현했던 페이징 알고리즘이 있는데, 여기도 참고하시면 좋을 것 같네요.




4) 페이징 - 테스트 





이상으로 검색과 페이징을 하는 방법에 대해 알아보았습니다.
이 글에서는 구현하지 않았지만, 검색한 목록에 대해서도 페이징 처리를 할 수가 있습니다.
URL query string으로 keyword, page 값을 동시에 넘겨주면 됩니다.



[참고 자료]


댓글 펼치기 👇
  1. 신입 2020.01.21 16:27

    게시하신 SpringBoot 다 따라하며 공부하고있습니다!
    정말 감사드립니다!!

  2. 신입 2020.01.29 17:30

    @RequestParam(value="page", defaultValue = "1")에서
    page는 어디서 받아오는건가요? html에서 받아오는건 아니고.. 궁금합니다.

  3. 신입 2020.01.30 14:08

    안녕하세요.
    혹시 검색하는 과정에서 페이징 처리가 풀려버리는데
    이 부분 해결이 좀 어렵네요 ㅠ.ㅠ

  4. 초보 2020.03.12 15:38

    이 에러는 왜 뜨는 것일까요 ㅠㅠㅠㅠㅠ


    Description:

    Parameter 0 of constructor in com.ubivelox.service.BoardService required a bean of type 'com.ubivelox.domain.repository.BoardRepository' that could not be found.


    Action:

    Consider defining a bean of type 'com.ubivelox.domain.repository.BoardRepository' in your configuration.

    • Favicon of https://victorydntmd.tistory.com victolee 우르르응 2020.03.12 19:20 신고

      BoardService에서 BoardRepository Bean 주입이 안된 것 같습니다.

    • 초보 2020.03.13 09:36

      victolee님 코드 그대로 따라했는데 저 에러가 계속 발생하네요 ㅠㅠㅠㅠ

    • Favicon of https://victorydntmd.tistory.com victolee 우르르응 2020.03.13 10:04 신고

      글 상단에 깃헙 링크에서 소스를 확인해주세요~
      BoardService에서 @Service 어노테이션이 빠진듯합니다

    • 초보 2020.03.13 11:30

      @Service 어노테이션도 들어가 있고
      깃헙 소스 그대로 복붙한겁니다 ㅠㅠ
      근데 계속 그 에러가 뜨네요...

    • Favicon of https://victorydntmd.tistory.com victolee 우르르응 2020.03.13 11:53 신고

      BoardApplication.java 파일 위치랑 @SpringBootApplication이 추가되었는지, component scan이 잘 되는지 확인해주세요.
      제가 작성한 코드랑 다른부분이 있을거에요

    • 초보 2020.03.13 13:01

      현재 BoardApplication.java 이 아니고
      저는 bbsuiApplication.java라고 만들었는데 그것 때문일까요??

    • 초보 2020.03.13 15:43

      프로젝트 생성을 새로해서 했더니 됐습니다!
      감사합니다 ㅎㅎㅎㅎ

    • Favicon of https://victorydntmd.tistory.com victolee 우르르응 2020.03.13 18:21 신고

      다행이네요^^

  5. deer 2020.05.27 14:23

    감사합니다 ㅠ-ㅠ

  6. Favicon of https://2ham-s.tistory.com Xion 2020.08.09 16:21 신고

    컨트롤러에서는 "/" 로 받는데
    쿼리스트링으로 /?page= 3
    이렇게 보내면 컨트롤러 "/" 로 등록된곳으로 가나요 ?

    컨트롤러 한 개 더 생성해서 GetMapping("/{no}") 처럼 만들어야하지 않나요 ?
    저 page가 쿼리스트링으로 자동으로 " /" 로 등록된 컨트롤러로 가는게 약간 궁금합니다

  7. Favicon of https://victorydntmd.tistory.com victolee 우르르응 2020.08.13 08:17 신고

    @RequestParam(value="page", defaultValue = "1")
    어노테이션을 통해 매핑될수 있습니다!

  8. hans 2020.11.13 20:45

    안녕하세요, spring boot 공부하며 예제 잘 참고하고 있습니다.

    페이징 알고리즘을 그대로 구현할 시 페이지 번호 할당 과정에서 idx의 값이 5 이상이 되어 예외가 발생합니다.
    부족한 실력이지만 대충 수정하여 구현한 코드입니다.

    public Integer[] getPageList(Integer curPageNum) {
    Integer[] pageList = new Integer[BLOCK_PAGE_NUM_COUNT];

    // 총 게시글 갯수
    Double postsTotalCount = Double.valueOf(this.getBoardCount());

    // 총 게시글 기준으로 계산한 마지막 페이지 번호 계산 (올림으로 계산)
    Integer totalLastPageNum = (int)(Math.ceil((postsTotalCount/PAGE_POST_COUNT)));

    Integer blockStartPageNum =
    (curPageNum <= BLOCK_PAGE_NUM_COUNT / 2)
    ? 1
    : curPageNum - BLOCK_PAGE_NUM_COUNT / 2;

    // 현재 페이지를 기준으로 블럭의 마지막 페이지 번호 계산
    Integer blockLastPageNum =
    (totalLastPageNum > blockStartPageNum + BLOCK_PAGE_NUM_COUNT - 1 )
    ? blockStartPageNum + BLOCK_PAGE_NUM_COUNT - 1
    : totalLastPageNum;

    // 페이지 번호 할당
    for (int val = blockStartPageNum, idx = 0; val <= blockLastPageNum; val++, idx++) {
    pageList[idx] = val;
    }

    return pageList;
    }