본문 바로가기
Spring Boot

스프링 부트(Spring Boot) JPA 게시판 - 비동기(Ajax) 페이징(Paging) 및 검색(Search) 처리하기 (With. MySQL)

by 도뎡 2022. 1. 24.
반응형

본 JPA 게시판 프로젝트는 단계별(step by step)로 진행됩니다.


페이징에 대한 기본적인 지식을 가지고 있지 않으시다면, 페이징 알아보기를 읽어보시기를 권장드립니다 :)


이전 글에서는 게시글 상세 페이지(수정/삭제)를 구현해 보았습니다.

이번 글에서는 게시글 리스트 페이지에 페이지네이션 기능을 적용해볼 건데요.

JPA의 Pageble이라는 녀석을 이용해도 되지만, 검색 조건 등을 쉽게 처리하기 위해서는

개인적으로 MyBatis를 이용하는 것이 효율적이라고 생각합니다.

JPA에서 검색 조건이나 JOIN 등을 처리하기 위해서는

Repository에 직접 SQL을 작성하는 네이티브(Natvie) 쿼리를 이용하거나,

Query DSL이라는 프레임워크를 이용해야 하는데요.

사실, 저는 Query DSL을 한 번도 이용해본 적이 없기도 하고,

"통계 쿼리나 긴 문장의 SQL, 그리고 조건이 포함되어 있는 SQL은 MyBatis를 이용하자!"

라는 마인드를 가지고 있기에, MyBatis를 이용해서 페이징을 처리해 보도록 할게요 :)

그럼, 바로 시작해 볼까요?

 

1. Spring - MyBatis 연동하기

우리는 프로젝트를 생성할 때, MyBatis 라이브러리를 추가했었습니다.

그렇기에, SqlSessionFactory와 SqlSession 객체만 애플리케이션의 빈(Bean)으로 등록해주면 되는데요.

우선은, DatabaseConfig 클래스를 다음과 같이 변경해 주세요.

package com.study.config;

import javax.sql.DataSource;

import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

import lombok.RequiredArgsConstructor;

@Configuration
@PropertySource("classpath:/application.properties")
@RequiredArgsConstructor
public class DatabaseConfig {

    private final ApplicationContext context;

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.hikari")
    public HikariConfig hikariConfig() {
        return new HikariConfig();
    }

    @Bean
    public DataSource dataSource() {
        return new HikariDataSource(hikariConfig());
    }

    @Bean
    public SqlSessionFactory sqlSessionFactory() throws Exception {
        SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
        factoryBean.setDataSource(dataSource());
        // factoryBean.setMapperLocations(context.getResources("classpath:/mappers/**/*Mapper.xml"));
        return factoryBean.getObject();
    }

    @Bean
    public SqlSessionTemplate sqlSession() throws Exception {
        return new SqlSessionTemplate(sqlSessionFactory());
    }

}

 

ApplicationContext

스프링의 컨테이너(Container) 중 하나로, 빈(Bean)의 생성과 사용, 관계, 생명 주기 등을 관리합니다.

여기서는 MyBatis의 Mapper XML 경로를 처리하기 위해 사용됩니다.

 

SqlSessionFactory

DB의 커넥션과, SQL 실행에 대한 모든 것을 갖는 객체입니다.

41번 라인의 factoryBean.setMapperLocations를 통해 Mapper XML의 경로를 지정하는데요.

classpath는 src/main/resources 디렉터리를 의미하며,

우리는 해당 경로에 Mapepr XML을 추가하게 됩니다.

지금은 XML 파일이 없기 때문에, 주석이 해제된 상태에서는 애플리케이션이 실행되지 않습니다.

 

SqlSession

SQL 실행에 필요한 모든 메서드(INSERT, UPDATE, DELETE, SELECT)를 갖는 객체입니다.

 

2. 공통 파라미터 클래스 생성하기

페이징과 검색 처리에는 필수적으로 전달받아야 하는 파라미터가 있습니다.

우선, com.study 패키지에 paging 패키지와 CommonParams 클래스를 추가해 주세요.

com.study.paging 패키지 구조

 

다음은 CommonParams 클래스 구조입니다.

설명이 필요하지 않을 정도로 심플하네요 :)

package com.study.paging;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class CommonParams {

    private int page;           // 현재 페이지 번호
    private int recordPerPage;  // 페이지당 출력할 데이터 개수
    private int pageSize;       // 화면 하단에 출력할 페이지 개수
    private String keyword;     // 검색 키워드
    private String searchType;  // 검색 유형

}

 

page

현재 페이지 번호를 의미하며, 페이지네이션에 필수적으로 필요한 멤버 변수입니다.

 

recordPerPage

페이지당 출력할 데이터 개수를 의미하며, 페이지네이션에 필수적으로 필요한 멤버 변수입니다.

 

pageSize

화면 하단에 출력할 페이지 개수를 의미하며, 페이지네이션에 필수적으로 필요한 멤버 변수입니다.

 

keyword

검색 키워드를 의미합니다.

 

searchType

keyword와 함께 사용되는 멤버 변수로, 제목, 내용, 작성자 중 하나 또는

세 가지 전부를 기준으로 LIKE 검색을 하는 데 사용됩니다.

 

3. 페이지네이션(Pagination) 처리용 클래스 생성하기

웹에서는 화면 하단에 페이지 번호를 출력하는 기능의 이름을 페이지네이션이라고 부르는데요.

페이지네이션 계산을 위해서는 앞에서 생성한 CommonParams의 파라미터들이 필요합니다.

우선은 코드를 작성한 뒤에 설명을 드리도록 할게요 :)

com.study.paging 패키지에 Pagination 클래스를 추가해 주세요.

com.study.paging 패키지 구조

 

다음은 Pagination 클래스의 구조입니다.

package com.study.paging;

import lombok.Getter;

@Getter
public class Pagination {

    private int totalRecordCount;   // 전체 데이터 수
    private int totalPageCount;     // 전체 페이지 수
    private int startPage;          // 첫 페이지 번호
    private int endPage;            // 끝 페이지 번호
    private int limitStart;         // LIMIT 시작 위치
    private boolean existPrevPage;  // 이전 페이지 존재 여부
    private boolean existNextPage;  // 다음 페이지 존재 여부

    public Pagination(int totalRecordCount, CommonParams params) {
        if (totalRecordCount > 0) {
            this.totalRecordCount = totalRecordCount;
            this.calculation(params);
        }
    }

    private void calculation(CommonParams params) {

        // 전체 페이지 수 계산
        totalPageCount = ((totalRecordCount - 1) / params.getRecordPerPage()) + 1;

        // 현재 페이지 번호가 전체 페이지 수보다 큰 경우, 현재 페이지 번호에 전체 페이지 수 저장
        if (params.getPage() > totalPageCount) {
            params.setPage(totalPageCount);
        }

        // 첫 페이지 번호 계산
        startPage = ((params.getPage() - 1) / params.getPageSize()) * params.getPageSize() + 1;

        // 끝 페이지 번호 계산
        endPage = startPage + params.getPageSize() - 1;

        // 끝 페이지가 전체 페이지 수보다 큰 경우, 끝 페이지 전체 페이지 수 저장
        if (endPage > totalPageCount) {
            endPage = totalPageCount;
        }

        // LIMIT 시작 위치 계산
        limitStart = (params.getPage() - 1) * params.getRecordPerPage();

        // 이전 페이지 존재 여부 확인
        existPrevPage = startPage != 1;

        // 다음 페이지 존재 여부 확인
        existNextPage = (endPage * params.getRecordPerPage()) < totalRecordCount;
    }

}

 

totalRecordCount

전체 게시글의 개수를 의미합니다.

예를 들어, 테이블에 1,000개의 레코드가 있다고 가정했을 때

검색 조건이 없는 경우에는 전체 데이터 개수가 되고,

검색 조건이 있는 경우에는 조건에 해당되는 데이터 개수가 됩니다.

 

totalPageCount

페이지 하단에 출력할 전체 페이지 개수를 의미합니다.

테이블에 1,000개의 레코드가 있고, 페이지당 출력할 데이터 개수가 10개라고 가정했을 때

(1,000 / 10)의 결과인 100이 됩니다.

 

startPage

페이지 하단에 출력할 페이지 수(pageSize)가 10이고,

현재 페이지 번호(page)가 5라고 가정했을 때 1을 의미합니다.

다른 예로,  페이지 번호가 15라면, startPage는 11이 됩니다.

 

endPage

페이지 하단에 출력할 페이지 수(pageSize)가 10이고,

현재 페이지 번호(page)가 5라고 가정했을 때 10을 의미합니다.

다른 예로,  페이지 번호가 15라면, endPage는 20이 됩니다.

 

limitStart

MySQL의 LIMIT 구문에 사용되는 멤버 변수입니다.

LIMIT의 첫 번째 파라미터에는 시작 위치, 즉 몇 번째 데이터부터 조회할지를 지정하고,

두 번째 파라미터에는 시작 limitStart를 기준으로 조회할 데이터의 개수를 지정합니다.

예를 들어, 현재 페이지 번호가 1이고, 페이지당 출력할 데이터 개수가 10이라고 가정했을 때

(1 - 1) * 10 = 0이라는 결과가 나오게 되고, LIMIT 0, 10으로 쿼리가 실행됩니다.

다른 예로, 페이지 번호가 5라면, LIMIT 40, 10으로 쿼리가 실행됩니다.

 

existPrevPage

이전 페이지의 존재 여부를 확인하는 데 사용되는 멤버 변수입니다.

startPage가 1이 아니라면, 이전 페이지는 무조건적으로 존재하게 됩니다.

 

existNextPage

다음 페이지의 존재 여부를 확인하는 데 사용되는 멤버 변수입니다.

예를 들어, 페이지당 출력할 데이터 개수가 10개, 끝 페이지 번호가 10이라고 가정했을 때

(10 * 10) = 100이라는 결과가 나오게 되는데요.

만약, 전체 데이터 개수가 105개라면, 다음 페이지 존재 여부는 true가 됩니다.

 

4. BoardMapper 인터페이스 생성하기

이제, SQL을 작성할 Mapper XML과 매핑할 인터페이스를 생성할 차례입니다.

com.study.board.model 패키지에 BoardMapper 인터페이스를 추가해 주세요.

com.study.board.model 패키지 구조

 

다음은 BoardMapper 인터페이스의 구조입니다.

package com.study.board.model;

import java.util.List;

import org.apache.ibatis.annotations.Mapper;

import com.study.board.dto.BoardResponseDto;
import com.study.paging.CommonParams;

@Mapper
public interface BoardMapper {

    /**
     * 게시글 수 조회
     */
    int count(final CommonParams params);

    /**
     * 게시글 리스트 조회
     */
    List<BoardResponseDto> findAll(final CommonParams params);

}

 

@Mapper

MyBatis는 @Mapper가 선언된 인터페이스와 연결된 XML Mapper에서

메서드명과 동일한 SQL을 찾아 쿼리를 실행합니다.

 

count( ) 메서드

앞에서 말씀드린 totalRecordCount와 연관되는 메서드입니다.

검색 조건의 유무에 따라, 테이블에서 데이터 수를 카운팅 합니다.

카운팅 된 데이터 수(totalRecordCount)를 기준으로 페이지 번호를 계산합니다.


totalRecordCount 설명


 

findAll( ) 메서드

count( )와 마찬가지로, 검색 조건의 유무를 기준으로 게시글 데이터를 조회합니다.

 

5. BoardMapper XML 생성하기

BoardMapper 인터페이스와 연결되는 Mapper XML을 생성할 차례입니다.

src/main/resources에 mappers 폴더와 BoardMapper.xml을 추가해 주세요.

src/main/resources/mappers 폴더 구조

 

다음은 BoardMapper.xml의 구조입니다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.study.board.model.BoardMapper">

    <!-- SELECT 결과 Map -->
    <resultMap id="BoardResultMap" type="com.study.board.dto.BoardResponseDto">
        <result property="id"            column="id" />
        <result property="title"         column="title" />
        <result property="content"       column="content" />
        <result property="writer"        column="writer" />
        <result property="hits"          column="hits" />
        <result property="deleteYn"      column="delete_yn" />
        <result property="createdDate"   column="created_date" />
        <result property="modifiedDate"  column="modified_date" />
    </resultMap>

    <!-- WHERE 조건 -->
    <sql id="conditions">
        <where>
            delete_yn = 'N'
            <if test="keyword != null and keyword.equals('') == false">
            <choose>
                <when test="searchType == null or searchType.equals('')">
                 AND (
                   title LIKE CONCAT('%', #{keyword}, '%')
                   OR content LIKE CONCAT('%', #{keyword}, '%')
                   OR writer LIKE CONCAT('%', #{keyword}, '%')
                )
                </when>
                <otherwise>
                    <choose>
                        <when test="searchType.equals('title')">
                        AND title LIKE CONCAT('%', #{keyword}, '%')
                        </when>

                        <when test="searchType.equals('content')">
                        AND content LIKE CONCAT('%', #{keyword}, '%')
                        </when>

                        <when test="searchType.equals('writer')">
                        AND writer LIKE CONCAT('%', #{keyword}, '%')
                        </when>
                    </choose>
                </otherwise>
            </choose>
            </if>
        </where>
    </sql>

    <!-- 게시글 수 조회 -->
    <select id="count" parameterType="com.study.paging.CommonParams" resultType="int">
        SELECT
            COUNT(*)
        FROM
            board
        <include refid="conditions" />
    </select>

    <!-- 게시글 리스트 조회 -->
    <select id="findAll" parameterType="com.study.paging.CommonParams" resultMap="BoardResultMap">
        SELECT
            id
          , title
          , content
          , writer
          , hits
          , delete_yn
          , created_date
          , modified_date
        FROM
            board
        <include refid="conditions" />
        ORDER BY
            id DESC, created_date DESC
        LIMIT #{pagination.limitStart}, #{recordPerPage}
    </select>

</mapper>

 

<mapper></mapper>

MyBatis에서 모든 설정과 SQL은 mapper 태그 사이에 선언하는데요.

namespace에는 BoardMapper 인터페이스의 경로를 지정해 주어야 한다는 것을 꼭! 기억해 주세요.

 

<resultMap></resultMap>

데이터베이스에서 테이블의 컬럼은 언더바(_)로 단어를 구분하는 스네이크 케이스(_)로 네이밍 하고,

JAVA에서 변수는 대문자로 단어를 구분하는 카멜 케이스 방식으로 네이밍 합니다.

즉, board 테이블에서 SELECT 한 컬럼과,

BoardResponseDto에 선언된 멤버를 매핑하기 위해 resultMap을 이용했습니다.

사실, 자동으로 Alias를 처리해주는 기능이 있긴 하지만,

MyBatis 공식 문서에는 resultMap이 아주 강력한 기능이라고 명시되어 있으니 한번 써보자구요 :)

 

<sql id="conditions"></sql>

board 테이블의 WHERE 조건을 처리해주는 SQL 조각입니다.

기본적으로 삭제되지 않은 데이터만 조회하도록 삭제 여부가 'N'인 데이터만 조회하며,

keyword를 파라미터로 전달받은 경우에만 LIKE 검색을 실행합니다.

검색 조건이 파라미터로 넘어왔다고 한들, 키워드가 없으면 말짱 도루묵이잖아요?

간략히 설명을 드리자면, 검색 유형이 선택되지 않은 경우에는

제목, 내용, 작성자를 기준으로 LIKE 쿼리를 실행하고,

검색 유형이 선택된 경우에는 제목, 내용, 작성자 중 한 가지에 대해 LIKE 쿼리를 실행합니다.

 

count

BoardMapper 인터페이스의 count( ) 메서드를 의미합니다.

말씀드렸듯이, Mapper 인터페이스의 메서드명과 Mapper XML의 쿼리 아이디는 동일해야 합니다.

파라미터 타입은 앞에서 생성한 CommonParams를 지정했고, 결과 타입은 int로 지정했습니다.

 

findAll

BoardMapper 인터페이스의 findAll( ) 메서드를 의미합니다.

LIMIT 구문을 보시면 첫 번째 인자로 limitStart를, 두 번째 인자로 recordPerPage를 전달하는데요.

자세한 내용은 Service 영역을 처리하는 과정에서 다시 설명드리도록 할게요 :)

 

6. BoardService 수정하기

마지막으로 페이지네이션 정보 계산과 게시글 리스트를 조회하는 메서드를 추가해줄 차례입니다.

메서드를 추가하기 전에, BoardService의 멤버로 BoardMapper 빈(Bean)을 선언해 주세요.

private final BoardRepository boardRepository;
private final BoardMapper boardMapper;

 

이제, BoardService에 다음의 메서드를 추가해 주세요. (당장은 에러가 발생하는 게 맞습니다!)

/**
 * 게시글 리스트 조회 - (With. pagination information)
 */
public Map<String, Object> findAll(CommonParams params) {

    // 게시글 수 조회
    int count = boardMapper.count(params);

    // 등록된 게시글이 없는 경우, 로직 종료
    if (count < 1) {
        return Collections.emptyMap();
    }

    // 페이지네이션 정보 계산
    Pagination pagination = new Pagination(count, params);
    params.setPagination(pagination);

    // 게시글 리스트 조회
    List<BoardResponseDto> list = boardMapper.findAll(params);

    // 데이터 반환
    Map<String, Object> response = new HashMap<>();
    response.put("params", params);
    response.put("list", list);
    return response;
}

 

전체 로직 해석


첫 번째로 검색 조건의 유무에 따라, 테이블에서 게시글 개수를 카운팅 합니다.

만약, 데이터가 하나도 없는 경우에는 NULL이 아닌, 비어있는 Map을 리턴합니다.

응답(Response)으로 NULL을 내려주는 습관은 버려주시는 게 좋습니다 :)

 

두 번째는 페이지네이션 정보 계산 로직입니다.

Pagination 클래스 생성자

Pagination 클래스의 생성자는 전체 데이터 수(totalRecordCount),

그리고 앞에서 생성한 페이징(검색) 처리용 클래스(CommonParams)를 필요로 합니다.

데이터가 있는 상황에서 Pagination의 calculation 메서드가 실행되면

페이지네이션 정보와 LIMIT 구문에 사용되는 limitStart의 값이 계산되고,

계산된 정보는 pagination 객체에 담기게 되는데요.

다음 설명에 앞서, BoardMapper.xml의 findAll 쿼리를 확인해 주세요.

 

BoardMapper.xml - findAll

마지막 LIMIT 구문을 보시면, 첫 번째 인자로는 Pagination의 limitStart를,

두 번째 인자로는 recordPerPage를 전달하고 있는데요.

게시글 데이터를 조회하기 전에 페이지네이션 정보를 계산하는 이유는 여기에 있습니다.

페이지네이션은 전체 데이터 개수를 기준으로 계산되는데,

limitStart의 값이 매겨지지 않은 상황에서 게시글을 조회한다고 하면

LIMIT 0, #{recordPerPage}과 같은 쿼리가 계속해서 실행되겠지요?

즉, 계산 이전에 쿼리가 실행된다면 계속해서 첫 번째 페이지인 1페이지만 보이게 되겠지요 :)

 

그리고, BoardMapper.xml의 findAll은 CommonParams를 파라미터로 전달받기 때문에

CommonParams는 Pagination 클래스를 멤버 변수로 가지고 있어야 합니다.

CommonParams가 Pagination을 멤버로 가지도록 CommonParams 클래스를 다음과 같이 변경해 주세요.

여기까지 따라오셨다면, findAll( ) 메서드의 에러는 사라졌을 거예요 :)

package com.study.paging;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class CommonParams {

    private int page;               // 현재 페이지 번호
    private int recordPerPage;      // 페이지당 출력할 데이터 개수
    private int pageSize;           // 화면 하단에 출력할 페이지 개수
    private String keyword;         // 검색 키워드
    private String searchType;      // 검색 유형
    private Pagination pagination;  // 페이지네이션 정보

}

 

마지막으로 게시글 리스트 조회와 데이터 반환에 관한 설명입니다.

두 번째 설명에서 말씀드렸듯이, 페이지네이션 정보가 계산된 후에 게시글 리스트를 조회하는데요.

Map 타입의 객체인 response에 params와 list를 담아서 응답(Response)으로 내려주고 있습니다.

params의 멤버인 pagination에 계산된 페이지네이션 정보를 저장했기 때문에

프론트엔드 영역에서는 response.params.pagination과 같이 페이지네이션 정보에 접근할 수 있습니다.

자세한 내용은 화면을 처리하는 과정에서 알아보도록 할게요 :)

 

7. BoardApiController 수정하기

기존의 findAll( ) 메서드가 서비스에 새로 정의한 findAll( ) 메서드를 호출하도록 변경해 주세요.

/**
 * 게시글 리스트 조회
 */
@GetMapping("/boards")
public Map<String, Object> findAll(final CommonParams params) {
    return boardService.findAll(params);
}

 

8. BoardResponseDto 수정하기

BoardMapper.xml의 BoardResultMap은 타입이 BoardResponseDto로 선언되어 있습니다.

<resultMap id="BoardResultMap" type="com.study.board.dto.BoardResponseDto">
    <result property="id"            column="id" />
    <result property="title"         column="title" />
    <result property="content"       column="content" />
    <result property="writer"        column="writer" />
    <result property="hits"          column="hits" />
    <result property="deleteYn"      column="delete_yn" />
    <result property="createdDate"   column="created_date" />
    <result property="modifiedDate"  column="modified_date" />
</resultMap>

 

즉, SELECT 한 결과를 BoardResponseDto 타입으로 리턴하겠다는 의미인데요.

MyBatis에서 SELECT 결과를 객체에 매핑하기 위해서는 기본 생성자가 필요합니다.

그러나, BoardResponseDto는 Board Entity를 통한 객체 생성만을 허용하고 있습니다.

BoardResponseDto가 기본 생성자를 포함하도록, 클래스 레벨에 @NoArgsConstructor를 선언해 주세요.

 

package com.study.board.dto;

import java.time.LocalDateTime;

import com.study.board.entity.Board;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BoardResponseDto {

    private Long id; // PK
    private String title; // 제목
    private String content; // 내용
    private String writer; // 작성자
    private int hits; // 조회 수
    private char deleteYn; // 삭제 여부
    private LocalDateTime createdDate; // 생성일
    private LocalDateTime modifiedDate; // 수정일

    public BoardResponseDto(Board entity) {
        this.id = entity.getId();
        this.title = entity.getTitle();
        this.content = entity.getContent();
        this.writer = entity.getWriter();
        this.hits = entity.getHits();
        this.deleteYn = entity.getDeleteYn();
        this.createdDate = entity.getCreatedDate();
        this.modifiedDate = entity.getModifiedDate();
    }

}

 

9. DatabaseConfig 수정하기

XML Mapper를 생성했으니, MyBatis가 XML Mapper를 읽을 수 있도록 해주어야 합니다.

Spring - MyBatis 연동에서 주석 처리했던 factoryBean.setMapperLocations( )의 주석을 해제해 주세요.

@Bean
public SqlSessionFactory sqlSessionFactory() throws Exception {
    SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
    factoryBean.setDataSource(dataSource());
    factoryBean.setMapperLocations(context.getResources("classpath:/mappers/**/*Mapper.xml"));
    return factoryBean.getObject();
}

 

10. API 호출해보기

리스트 페이지를 구현하기 전에 API 호출 결과를 보여드리도록 할게요.

GET 방식의 메서드는 파라미터를 쿼리 스트링(Query String)으로 전달해야 하는데요,

페이지 번호, 페이지당 출력할 데이터 개수, 화면 하단에 출력할 페이지 개수를

각각 1, 10, 10으로 설정했습니다.

API 호출 설정

 

API 호출 결과는 다음과 같습니다.

params에는 페이징(검색) 처리용 파라미터와 계산된 페이지네이션 정보를,

list에는 게시글 정보를 담아서 응답(Response)으로 내려주고 있습니다.

API 호출 결과

 

11. 자가 복사(Self Copy) 이용하기

결과는 정상적으로 내려주지만, 게시글 개수가 부족해서 페이지네이션 테스트를 할 수가 없는 상황입니다.

우선은 게시글을 다섯 개 정도 추가로 등록해 주세요.

board 테이블 SELECT 결과

 

게시글을 추가하셨다면, DB 툴에서 다음의 스크립트를 10번 정도 실행해 주세요.

INSERT INTO board (title, content, writer, hits, delete_yn, created_date)
(SELECT title, content, writer, hits, delete_yn, created_date FROM board WHERE delete_yn = 'N');

 

자가 복사란, (SELECT 결과 데이터 X 2)만큼, 동일한 데이터를 테이블에  INSERT 하는 것을 의미하는데요.

다음과 같이 로우가 추가되는 것을 확인하실 수 있습니다.

자가 복사(Self Copy) 실행 결과

 

다시 API를 호출해보면, 페이지네이션 결괏값이 달라진 것을 확인하실 수 있어요 :)

API 호출 결과

 

12. list.html 수정하기

백엔드 영역은 더 이상 건드릴 것이 없습니다.

마지막으로 HTML과 자바스크립트만 조금 손봐주면 페이징 처리가 완료됩니다.

먼저, 검색 영역 HTML을 다음과 같이 변경해 주세요.

검색 조건과 검색 키워드 바깥으로 <form> 태그가 추가되었고,

검색 키워드의 id 속성이 "searchKeyword"에서 "keyword"로 변경되었습니다.

<!--/* 검색 영역 */-->
<div class="input-group" id="adv-search">
    <form id="searchForm" onsubmit="return false;">
        <select id="searchType" class="form-control" style="width: 100px;">
            <option value="">전체</option>
            <option value="title">제목</option>
            <option value="content">내용</option>
            <option value="writer">작성자</option>
        </select>
        <input type="text" id="keyword" class="form-control" placeholder="키워드를 입력해 주세요." style="width: 300px;" />
    </form>
    <button type="button" onclick="findAll(1);" class="btn btn-primary">
        <span aria-hidden="true" class="glyphicon glyphicon-search"></span>
    </button>
</div>

 

다음으로 40번 라인 쪽에 있는 <nav> 태그를 다음과 같이 변경해 주세요.

<!-- 페이지네이션 Rendering 영역 -->
<nav aria-label="Page navigation" class="text-center">
    <ul class="pagination">

    </ul>
</nav>

 

마지막으로 자바스크립트 영역의 코드를 다음과 같이 변경해 주세요.

자세한 내용은 코드를 작성하고 설명해 드리도록 할게요 :)

<script th:inline="javascript">
/*<![CDATA[*/

	/**
	 * 페이지 로딩 시점에 실행되는 함수
	 */
	window.onload = () => {

		findAll(1);
		addEnterSearchEvent();
	}

	/**
	 * 키워드 - 엔터 검색 이벤트 바인딩
	 */
	function addEnterSearchEvent() {

		document.getElementById('keyword').addEventListener('keyup', (e) => {
			if (e.keyCode === 13) {
				findAll(1);
			}
		});
	}

	/**
	 * 조회 API 호출
	 */
	async function getJson(uri, params) {

		if (params) {
			uri = uri + '?' + new URLSearchParams(params).toString();
		}

		const response = await fetch(uri);

		if (!response.ok) {
			await response.json().then(error => {
				throw error;
			});
		}

		return await response.json();
	}

	/**
	 * 게시글 리스트 조회
	 */
	function findAll(page) {

		const form = document.getElementById('searchForm');
		const params = {
			  page: page
			, recordPerPage: 10
			, pageSize: 10
			, searchType: form.searchType.value
			, keyword: form.keyword.value
		}

		getJson('/api/boards', params).then(response => {
			if (!Object.keys(response).length) {
				document.getElementById('list').innerHTML = '<td colspan="5">등록된 게시글이 없습니다.</td>';
				drawPages();
				return false;
			}

			let html = '';
			let num = response.params.pagination.totalRecordCount - ((response.params.page - 1) * response.params.recordPerPage);

       		response.list.forEach((obj, idx) => {
       			html += `
       				<tr>
  						<td>${num--}</td>
  						<td class="text-left">
  							<a href="javascript: void(0);" onclick="goView(${obj.id})">${obj.title}</a>
  						</td>
  						<td>${obj.writer}</td>
  						<td>${moment(obj.createdDate).format('YYYY-MM-DD HH:mm:ss')}</td>
  						<td>${obj.hits}</td>
       				</tr>
       			`;
       		});

			document.getElementById('list').innerHTML = html;
			drawPages(response.params);
		});
	}

	/**
	 * 게시글 조회
	 */
	function goView(id) {
		location.href = `/board/view/${id}`;
	}

	/**
	 * 페이지 HTML 렌더링
	 */
	 function drawPages(params) {

 		if (!params) {
 			document.querySelector('.pagination').innerHTML = '';
 			return false;
 		}

 		let html = '';
 		const pagination = params.pagination;

 		// 첫 페이지, 이전 페이지
 		if (pagination.existPrevPage) {
 			html += `
 				<li><a href="javascript:void(0)" onclick="findAll(1);" aria-label="Previous"><span aria-hidden="true">&laquo;</span></a></li>
 				<li><a href="javascript:void(0)" onclick="findAll(${pagination.startPage - 1});" aria-label="Previous"><span aria-hidden="true">&lsaquo;</span></a></li>
 			`;
 		}

 		// 페이지 번호
 		for (let i = pagination.startPage; i <= pagination.endPage; i++) {
 			const active = (i === params.page) ? 'class="active"' : '';
            html += `<li ${active}><a href="javascript:void(0)" onclick="findAll(${i})">${i}</a></li>`;
 		}

 		// 다음 페이지, 마지막 페이지
 		if (pagination.existNextPage) {
 			html += `
 				<li><a href="javascript:void(0)" onclick="findAll(${pagination.endPage + 1});" aria-label="Next"><span aria-hidden="true">&rsaquo;</span></a></li>
 				<li><a href="javascript:void(0)" onclick="findAll(${pagination.totalPageCount});" aria-label="Next"><span aria-hidden="true">&raquo;</span></a></li>
 			`;
 		}

 		document.querySelector('.pagination').innerHTML = html;
 	}

/*]]>*/
</script>

 

addEnterSearchEvent( )

기존의 onload 함수에 addEnterSearchEvent( ) 함수가 추가되었는데요.

해당 함수는 검색 키워드, 즉 "keyword"라는 id를 가진 엘리먼트 안에서 엔터(Enter) 키가 입력되었을 때

findAll( ) 함수가 실행되도록 이벤트를 추가해주는 역할을 합니다.

 

getJson( )

기존의 findAll( ) 함수에서 사용했던 fetch API를 GET 방식 전용으로 정의한 함수입니다.

첫 번째 파라미터인 uri는 API 요청(Request) URI를 의미하고,

두 번째 파라미터인 params는 쿼리 스트링(Query String) 파라미터를 의미하는데요.

params로 객체(Object)를 전달해주면, new URLSearchParams( )를 이용해서

전달받은 객체를 쿼리 스트링 문자열로 변경합니다.

예를 들어, 삭제 여부(deleteYn)를 파라미터로 전달한다고 가정한다면

getJson('/api/boards', { deleteYn: 'N' }) <= 이러한 형태로 호출해 주시면 됩니다.

추가적으로 "async랑 await은 뭐 하는 놈들이지?" 생각하시는 분들도 계실 텐데요.

async와 await은 ECMAScript 2017에 추가된 기능으로,

비동기 코드를 동기 코드처럼 실행하기 위해 사용되는 키워드입니다.

즉, 비동기 처리에서 함수의 실행 순서를 보장해주는 역할을 합니다.

async와 await에 대한 더욱 자세한 내용은 여기를 참고해 보시기를 권장드립니다!

마지막으로 new URLSearchParams에 대한 간단한 예시를 하나 보여드리도록 할게요.

가볍게 참고만 해주세요 :)


new URLSearchParams( ).toString( ) 실행 결과


 

findAll( )

삭제 여부(deleteYn)만 파라미터로 전달했던 기존의 findAll( ) 함수와는 달리,

여러 가지 파라미터를 전달하는 형태로 변경되었는데요.

각 파라미터는 페이지네이션과 검색 기능에 필요한 파라미터들로

앞에서 생성한 CommonParams의 멤버들입니다. (여러분은 알고 계셨을 거예요, 그렇죠?!)

getJson( )을 호출하며 객체를 파라미터로 전달하는 것과,

drawPages( )를 호출하는 것을 제외하고는 기존 로직과 거의 유사합니다.

 

drawPages( )

네, 드디어 마지막으로 설명드릴 drawPages( ) 함수입니다.

해당 함수는 findAll( ) 함수의 Response로 전달받는 params 객체를 이용해서 페이지 번호를 그리는데요.

CommonParams 타입의 params는 계산된 페이지 정보인 pagination 객체를 가지고 있으며,

페이지 번호를 그리기 위해서는 pagination 객체의 멤버가 필요합니다.

여러분의 쉬운 이해를 위해 params 객체의 구조를 이미지로 첨부하도록 하겠습니다!

params 객체 구조

 

전체적으로 간단하게 로직을 해석해드려 보도록 하겠습니다.

첫 번째153 ~ 156번 라인입니다.

drawPages( ) 153 ~ 156번 라인

params를 파라미터로 전달받지 않은 경우에는

페이지네이션 영역, 즉 클래스 속성이 "pagination"인 엘리먼트의 내부를 초기화합니다.

예를 들어, 조회된 데이터가 없는 경우에는 페이지 번호를 굳이 보여주지 않아도 되겠지요?

 

두 번째158 ~ 175번 라인입니다. (이미지가 한눈에 담기지 않아 줄 바꿈을 조금 넣었습니다!)

drawPages( ) 158 ~ 175번 라인

해당 코드는 첫 페이지 버튼( << )이전 페이지 버튼( < )을 그리는 로직인데요.

계산된 pagination 객체의 멤버인 이전 페이지 존재 여부(existPrevPage)가 true인 경우,

즉 시작 페이지가 1페이지가 아닌 경우에만 해당 로직이 실행됩니다.

 

세 번째는 178~ 181번 라인입니다.

drawPages( ) 178 ~ 181번 라인

첫 페이지(startPage)끝 페이지(endPage) 사이에 포함된 페이지 번호를 그리는 로직입니다.

예를 들어, 첫 페이지가 11이고 끝 페이지가 20이라면, 11~20까지의 페이지 번호를 그리게 됩니다.

active 변수는 페이지 번호 중, 현재 페이지 번호와 같은 페이지를 활성화하는 역할을 합니다.

 

마지막 183 ~ 199번 라인입니다. (마찬가지로 이미지가 한눈에 담기지 않아 줄 바꿈을 조금 넣었습니다!)

drawPages( ) 183 ~ 199번 라인

첫 번째 설명과 반대로 마지막 페이지 버튼( >> ) 다음 페이지 버튼( > )을 그리는 로직인데요.

계산된 pagination 객체의 멤버인 다음 페이지 존재 여부(existNextPage)가 true인 경우,

즉 계산된 페이지 정보 중, 마지막 페이지가 아닌 경우에만 해당 로직이 실행됩니다.

마지막 199번 라인에서는 그려진 HTML을 페이지네이션 영역에 렌더링 합니다.

 

13. 결과 확인해보기

가장 먼저, 게시글 리스트 페이지에 최초로 접근했을 때의 결과입니다.

당연한 이야기이지만 1페이지가 활성화되어 있습니다.

혹시라도 데이터가 제대로 출력되는 건지 확인해보고 싶으시다면,

이클립스(STS) 콘솔에 출력되는 쿼리 로그를 확인해 보시면 되겠지요?!

리스트 1페이지

 

다음으로 7페이지를 클릭했을 때의 결과입니다.

마찬가지로 정상적으로 작동합니다.

리스트 7페이지

 

이번엔 7페이지에서 다음 페이지 버튼을 세 번 클릭한 결과입니다.

11, 21페이지를 지나, 정상적으로 31페이지로 이동했습니다.

리스트 31페이지

 

31페이지에서 끝 페이지 버튼을 클릭한 결과입니다.

가장 처음으로 등록했던 데이터들이 보이네요.

리스트 마지막 페이지

 

다음으로 끝 페이지(615)에서 이전 페이지 버튼을 세 번 클릭한 결과입니다.

610, 600페이지를 지나, 590페이지로 이동했습니다.

리스트 590페이지

 

590 페이지에서 첫 페이지 버튼을 클릭한 결과입니다.

순식간에 1페이지로 돌아왔네요 :)

리스트 첫 페이지

 

이번엔 검색 기능을 테스트할 차례입니다.

검색 조건이 "전체"로 선택된 상태에서 "1번"이라는 키워드로 검색한 결과입니다.

전체 검색 결과 리스트 1페이지

 

마찬가지로 페이지도 정상적으로 이동합니다.

전체 검색 결과 리스트 15페이지

 

검색 조건이 "제목"으로 선택된 상태에서 "3번"이라는 키워드로 검색한 결과입니다.

제목 검색 결과 리스트 1페이지

 

검색 조건이 "내용"으로 선택된 상태에서 "5번 내용"이라는 키워드로 검색한 결과입니다.

내용 검색 결과 리스트 1페이지

 

마지막으로 "작성자""수정한 도뎡이"라는 키워드로 검색한 결과입니다.

작성자 검색 결과 리스트 페이지

 

마무리

여기까지 비동기 페이징(검색) 처리가 완료되었습니다. (짝 짝 짝)

적지 않은 분량인데, 따라오시느라 정말로 고생 많으셨습니다 :)

다음 글에서는 게시글 상세 페이지와 수정 페이지에서 "뒤로가기" 버튼을 클릭했을 때,

또는 게시글을 수정하거나 삭제했을 때 이전 페이지 정보를 유지하는 방법에 대해 알아볼 건데요.

예를 들어, 20페이지에서 게시글 5950번 게시글을 수정 또는 삭제했다면,

리스트 페이지로 리다이렉트 하는 시점에 당연히 이전 페이지 정보가 유지되어야 합니다.

즉, 20페이지로 돌아와야 하는 것이지요. 검색 조건은 말할 것도 없구요!

오늘도 방문해 주신 여러분께 진심으로 감사드리고,

코로나 때문에 겁나게 힘든 시기이지만

모두 같이 힘내서 일상을 찾게 될 그날까지 조금만 더 견뎌보아요!

그럼, 다음 글에서 뵙도록 할게요 :)

 


진행에 어려움을 겪으시는 분들이 계실 수 있으니, 프로젝트를 첨부해 드리도록 하겠습니다.

application.properties의 데이터베이스 정보만 내 PC 환경과 일치하도록 변경해서 사용해 주세요 :)


Board.zip
2.16MB

반응형

댓글