7. 검색 / 필터링 조건의 정의

  • 검색 / 필터링 조건
    • 제목(title)과 작성자(writer)는 키워드(keyword)를 이용하는 검색 처리
    • 완료 여부를 필터링 처리
    • 특정 기간을 지정(from, to)한 필터링 처리
  • 검색 / 필터링에 필요한 데이터
    • 제목, 작성자 검색에 사용하는 문자열 - keyword
    • 완료 여부에 사용되는 boolean 타입 - finished
    • 특정 기간 검색을 위한 LocalDate 변수 2개 - from, to

 

 1) 검색 / 필터링 조건의 결정

  • 검색 기능의 경우의 수를 구분하는 작업 필요
  • 검색 종류를 types라고 지정, 제목(t)와 작성자(w)로 구분해서 검색의 실제값은 검색 종류에 따라 키워드(keyword)를 이용
  • PageRequestDTO에 필요한 변수들을 추가해서 구성
// PageRequestDTO
package org.zerock.springex.dto;

import ...

public class PageRequestDTO {

  ...
  
  private String[] types;
  private String keyword;
  private boolean finished;
  private LocalDate from;
  private LocalDate to;

  ...
  
}

 

 2) types에 따른 동적 쿼리

  • Mybatis에는 실행 시에 쿼리를 만들 수 있는 여러 태그들 제공
    • if
    • trim(where, set)
    • choose
    • foreach: 반복 처리를 위해 제공됨, List, Map, Set과 같은 컬렉션 계열이나 배열을 이용할 수 있음
  • TodoMapperTests 클래스에 새로운 테스트 메서드 추가
// TodoMapperTests
@Test
public void testSelectSearch() {
    PageRequestDTO pageRequestDTO = PageRequestDTO.builder()
        .page(1)
        .size(10)
        .types(new String[]{"t", "w"})
        .keyword("AAAA")
        .build();
    List<TodoVO> voList = todoMapper.selectList(pageRequestDTO);
    voList.forEach(vo -> log.info(vo));
}
  • TodoMapper의 selectList()는 PageRequestDTO를 파라미터로 받고 있으므로 변경없이 바로 사용 가능하므로 TodoMapper.xml만 수정
<!-- TodoMapper.xml -->
<select id = "selectList" resultType = "org.zerock.springex.domain.TodoVO">
    select * from tbl_todo
    <!-- <select id = "selectList">태그에 Mybatis의 <foreach>를 적용 -->
    <foreach collection = "types" item = "type">
        #type
    </foreach>
    order by tno desc limit #{skip}, #{size}
</select>
  • 테스트 코드 실행 시, 쿼리문이 정상적이지 않아 에러가 나지만 출력된 쿼리문을 확인하면 다음과 같이 출력됨

select * from tbl_todo ? ? order by tno desc limit ?, ?

 

  • if 적용 시 더 현실적인 쿼리를 만들어낼 수 있음
<!-- TodoMapper.xml -->
<select id = "selectList" resultType = "org.zerock.springex.domain.TodoVO">
    select * from tbl_todo
    <foreach collection = "types" item = "type">
        <!-- 검색 타입이 t(제목)일 때, 제목에 keyword가 포함된 데이터 검색 -->
        <if test = "type == 't'.toString()">
            title like concat('%', #{keyword}, '%')
        </if>
        <!-- 검색 타입이 w(작성자)일 때, 작성자에 keyword가 포함된 데이터 검색 -->
        <if test = "type == 'w'.toString()">
            writer like concat('%', #{keyword}, '%')
        </if>
    </foreach>
    order by tno desc limit #{skip}, #{size}
</select>
  • 테스트 코드 실행시 다음과 같은 쿼리문 출력

select * from tbl_todo
title like concat('%', ?, '%')
title like concat('%', ?, '%')
order by tno desc limit ?, ?

 

  • <foreach>에 open, close, separator 속성을 적용해서 쿼리문에 ()와 OR 처리
<!-- TodoMapper.xml -->
<select id = "selectList" resultType = "org.zerock.springex.domain.TodoVO">
    select * from tbl_todo
    <foreach collection = "types" item = "type" open = "(" close = ")" separator = " OR ">
        <if test = "type == 't'.toString()">
            title like concat('%', #{keyword}, '%')
        </if>
        <if test = "type == 'w'.toString()">
            writer like concat('%', #{keyword}, '%')
        </if>
    </foreach>
    order by tno desc limit #{skip}, #{size}
</select>
  • 테스트 코드 실행시 다음과 같은 쿼리문 출력

select * from tbl_todo
  (
    title like concat('%', ?, '%')
    OR
    title like concat('%', ?, '%')
  )
order by tno desc limit ?, ?

 

  - <where>

  • types가 null이 아닌 경우에만 where 키워드 추가
<!-- TodoMapper.xml -->
<select id = "selectList" resultType = "org.zerock.springex.domain.TodoVO">
    select * from tbl_todo
    <where>
        <if test = "types != null and types.length > 0">
            <foreach collection = "types" item = "type" open = "(" close = ")" separator = " OR ">
                <if test = "type == 't'.toString()">
                    title like concat('%', #{keyword}, '%')
                </if>
                <if test = "type == 'w'.toString()">
                    writer like concat('%', #{keyword}, '%')
                </if>
            </foreach>
        </if>
    </where>
    order by tno desc limit #{skip}, #{size}
</select>
  • 테스트 코드 실행시 다음과 같은 쿼리문 출력

  • types가 null인 경우
    - where 절 출력 안됨
    - select * from tbl_todo order by tno desc limit ?, ?
  • types가 't' 혹은 'w'인 경우('t'인 경우)
    - select * from tbl_todo WHERE (title like concat('%', ?, '%')) order by tno desc limit ?, ?

 

  - <trim>과 완료 여부 / 만료일 필터링

  • 완료 여부는 PageRequestDTO의 finished 변수 값이 true인 경우에만 'finished = 1'과 같은 문자열이 쿼리문에 추가되도록 구성
  • 앞에 다른 조건이 있는 경우 'and finished = 1'로, 다른 조건이 없는 경우 그냥 'finished = 1'로 추가되어야 함
  • 이런 경우 Mybatis에서 <trim>을 사용
<!-- TodoMapper.xml -->
<select id = "selectList" resultType = "org.zerock.springex.domain.TodoVO">
    select * from tbl_todo
    <where>
        <if test = "types != null and types.length > 0">
            <foreach collection = "types" item = "type" open = "(" close = ")" separator = " OR ">
                <if test = "type == 't'.toString()">
                    title like concat('%', #{keyword}, '%')
                </if>
                <if test = "type == 'w'.toString()">
                    writer like concat('%', #{keyword}, '%')
                </if>
            </foreach>
        </if>
        <!-- trim을 적용하여 prefix를 하게 되면 상황에 따라서 'and'가 추가됨 -->
        <if test = 'finished'>
            <trim prefix = "and">
                finished = 1
            </trim>
        </if>
    </where>
    order by tno desc limit #{skip}, #{size}
</select>
  • 테스트 코드에서 finished 조건 추가 후 실행시 다음과 같은 쿼리문 출력
package org.zerock.springex.mapper;

import ...

public class TodoMapperTests {
  
  ...

  @Test
  public void testSelectSearch() {
    PageRequestDTO pageRequestDTO = PageRequestDTO.builder()
        .page(1)
        .size(10)
        .types(new String[]{"t", "w"})
        .keyword("스프링")
        .finished(true)
        .build();
    List<TodoVO> voList = todoMapper.selectList(pageRequestDTO);
    voList.forEach(vo -> log.info(vo));
  }
}

  • types 조건을 제외하여 finished만이 조건으로 설정된 경우 다음과 같이 'and'가 없는 쿼리문 출력

 

  • 같은 방식으로 만료일 처리
<!-- TodoMapper.xml -->
<select id = "selectList" resultType = "org.zerock.springex.domain.TodoVO">
    select * from tbl_todo
    <where>
        <if test = "types != null and types.length > 0">
            <foreach collection = "types" item = "type" open = "(" close = ")" separator = " OR ">
                <if test = "type == 't'.toString()">
                    title like concat('%', #{keyword}, '%')
                </if>
                <if test = "type == 'w'.toString()">
                    writer like concat('%', #{keyword}, '%')
                </if>
            </foreach>
        </if>
        <if test = 'finished'>
            <trim prefix = "and">
                finished = 1
            </trim>
        </if>
        <if test = "from != null and to !== null">
            <trim prefix = "and">
                dueDate between #{from} and #{to}
            </trim>
        </if>
    </where>
    order by tno desc limit #{skip}, #{size}
</select>

 

  - <sql>과 <include>

  • Mybatis의 동적 쿼리 적용은 목록 데이터를 가져오는 부분에도 적용되지만 전체 개수를 가져오는 부분에도 적용되어야 함
  • 전체 개수를 가져오는 TodoMapper의 getCount()에 파라미터로 PageRequestDTO 타입을 지정한 이유는 동적 쿼리를 적용하기 위함
  • Mybatis에 <sql> 태그를 이용해서 동일한 SQL 조각을 재사용 가능
  • 동적 쿼이 부분을 <sql>로 분리, 동적 쿼리가 적용될 부분은 <include>를 이용
<!-- TodoMapper.xml -->
<sql id = "search">
    <where>
        <if test = "types != null and types.length > 0">
            <foreach collection = "types" item = "type" open = "(" close = ")" separator = " OR ">
                <if test = "type == 't'.toString()">
                    title like concat('%', #{keyword}, '%')
                </if>
                <if test = "type == 'w'.toString()">
                    writer like concat('%', #{keyword}, '%')
                </if>
            </foreach>
        </if>

        <if test = 'finished'>
            <trim prefix = "and">
                finished = 1
            </trim>
        </if>

        <if test = "from != null and to != null">
            <trim prefix = 'and'>
                dueDate between #{from} and #{to}
            </trim>
        </if>
    </where>
</sql>

<select id = "selectList" resultType = "org.zerock.springex.domain.TodoVO">
    select * from tbl_todo

    <include refid = "search"></include>

    order by tno desc limit #{skip}, #{size}
</select>

<select id = "getCount" resultType = "int">
    select count(tno) from tbl_todo
    <include refid = "search"></include>
</select>
  • 테스트 코드에서 from, to 조건 추가 후 실행시 다음과 같은 쿼리문 출력
// TodoMapperTests
@Test
public void testSelectSearch() {
PageRequestDTO pageRequestDTO = PageRequestDTO.builder()
    .page(1)
    .size(10)
    .types(new String[]{"t", "w"})
    .keyword("스프링")
    // .finished(true)
    .from(LocalDate.of(2021,12,01))
    .to(LocalDate.of(2022,12,31))
    .build();
List<TodoVO> voList = todoMapper.selectList(pageRequestDTO);
voList.forEach(vo -> log.info(vo));
log.info(todoMapper.getCount(pageRequestDTO));
}
  • selectList() 쿼리

  • getCount() 쿼리

 

 

8. 검색 조건을 위한 화면 처리

  • 검색 기능은 /WEB-INF/views/todo/list.jsp에서 이루어지므로 list.jsp에 검색 관련 화면을 작성하기 위해 <div class = 'card'>를 하나 추가하고 검색에 필요한 내용들을 담을 수 있도록 구성
<!-- list.jsp -->
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix ="c" uri = "http://java.sun.com/jsp/jstl/core" %>
<!doctype html>
<html lang="en">
<head>
    
    ...
    
</head>
<body>

<div class = "row content">
    <div class = "col">
        <div class = "card">
            <div class = "card-body">
                <h5 class = "card-title">Search</h5>
                <form action = "/todo/list" method = "get">
                    <input type = "hidden" name = "size" value = "${pageRequestDTO.size}">
                    <div class = "mb-3">
                        <input type = "checkbox" name = "types" value = "finished">완료여부
                    </div>
                    <div class = "mb-3">
                        <input type = "checkbox" name = "types" value = "t">제목
                        <input type = "checkbox" name = "types" value = "w">작성자
                        <input type = "text" name = "keyword" class = "form-control">
                    </div>
                    <div class = "input-group mb-3 dueDateDiv">
                        <input type = "date" name = "from" class = "form-control">
                        <input type = "date" name = "to" class = "form-control">
                    </div>
                    <div class = "input-group mb-3">
                        <div class = "float-end">
                            <button class = "btn btn-primary" type = "submit">Search</button>
                            <button class = "btn btn-info" type = "reset">Clear</button>
                        </div>
                    </div>
                </form>
            </div>
        </div>
    </div>
</div>

...

</body>
</html>
  • 화면 구성 결과

  • 검색 결과

주소창에 모든 검색 조건이 GET 방식의 쿼리 스트링으로 만들어짐, 검색 조건에 해당하는 데이터가 있다면 목록으로 출력될 것

 

 1) 화면에 검색 조건 표시하기

  • 검색이 처리되기는 하지만 PageRequestDTO의 정보를 EL로 처리하지 않아 검색 후 검색부분이 초기화되는 문제
  • 작성된 <div>에 EL을 적용할 때 제목(title)과 작성자(writer)를 배열로 처리하고 있어 문제가 됨
  • PageRequestDTO에 별도 메서드를 구성하여 더 편하게 사용할 수 있음
// PageRequestDTO
package org.zerock.springex.dto;

import ...

public class PageRequestDTO {

  ...
  
  public boolean checkType(String type) {
    if(types == null || types.length == 0) {
      return false;
    }
    return Arrays.stream(types).anyMatch(type::equals);
  }
}
  • 화면에서 EL 적용
<!-- list.jsp -->
<form action = "/todo/list" method = "get">
    <input type = "hidden" name = "size" value = "${pageRequestDTO.size}">
    <div class = "mb-3">
        <input type = "checkbox" name = "finished" ${pageRequestDTO.finished?"checked":""}>완료여부
    </div>
    <div class = "mb-3">
        <input type = "checkbox" name = "types" value = "t" ${pageRequestDTO.checkType("t")?"checked":""}>제목
        <input type = "checkbox" name = "types" value = "w" ${pageRequestDTO.checkType("w")?"checked":""}>작성자
        <input type = "text" name = "keyword" class = "form-control" value = '<c:out value = "${pageRequestDTO.keyword}"/>'>
    </div>
    <div class = "input-group mb-3 dueDateDiv">
        <input type = "date" name = "from" class = "form-control" value = "${pageRequestDTO.from}">
        <input type = "date" name = "to" class = "form-control" value = "${pageRequestDTO.to}">
    </div>
    <div class = "input-group mb-3">
        <div class = "float-end">
            <button class = "btn btn-primary" type = "submit">Search</button>
            <button class = "btn btn-info" type = "reset">Clear</button>
        </div>
    </div>
</form>

 

  - 검색 조건 초기화 시키기

  • 검색 영역에서 Clear 버튼을 누르면 모든 검색조건 무효화시켜 '/todo/list' 호출하도록 수정
  • 화면에 clearBtn이라는 class 속성 추가
<!-- list.jsp -->
<div class = "input-group mb-3">
    <div class = "float-end">
        <button class = "btn btn-primary" type = "submit">Search</button>
        <button class = "btn btn-info clearBtn" type = "reset">Clear</button>
    </div>
</div>
<script>
    document.querySelector(".clearBtn").addEventListener("click", function(e) {
      e.preventDefault()
      e.stopPropagation()

      self.location = '/todo/list'
    })
</script>

 

 2) 조회를 위한 링크 처리

  • 조회나 수정 화면에서 'List' 버튼을 클릭할 때 검색 조건들을 유지하도록 처리
  • PageRequestDTO의 getLink()를 사용, getLink()를 통해 생성되는 링크에서 검색 조건 등을 반영해 주도록 수정
// PageRequestDTO
package org.zerock.springex.dto;

import ...

public class PageRequestDTO {

  ...
  
  public String getLin() {
    StringBuilder builder = new StringBuilder();
    builder.append("page=" + this.page);
    builder.append("&size=" + this.size);
    if(finished) {
      builder.append("&finished=on");
    }
    if(types != null && types.length > 0){
      for (int i = 0 ; i < types.length ; i++) {
        builder.append("&types=" + types[i]);
      }
    }
    // keyword 부분은 URLEncoder를 이용해서 링크로 처리할 수 있도록 처리해야 함
    if(keyword != null) {
      try {
        builder.append("&keyword=" + URLEncoder.encode(keyword, "UTF-8"));
      } catch (UnsupportedEncodingException e) {
        e.printStackTrace();
      }
    }
    if (from != null) {
      builder.append("&from=" + from.toString());
    }
    if (to != null) {
      builder.append("&to=" + to.toString());
    }
    
    return builder.toString();
  }
}

 

 

 3) 페이지 이동 링크 처리

  • 페이지 이동에서 검색 / 필터링 조건 필요하므로 자바스크립트로 동작하는 부분을 수정
<!-- list.jsp -->
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix ="c" uri = "http://java.sun.com/jsp/jstl/core" %>
<!doctype html>
<html lang="en">
<head>
   
   ...
   
</head>
<body>

<div class = "row content">
    ...
</div>

<div class = "row content">
    <div class = "col">
        <div class="card">
            
            ...
            
                <script>
                    document.querySelector(".pagination").addEventListener("click", function(e) {
                      e.preventDefault()
                      e.stopPropagation()

                      const target = e.target

                      if(target.tagName !== 'A') {
                        return
                      }
                      const num = target.getAttribute("data-num")
                      
                      const formObj = document.querySelector("form")
                      
                      // 검색 / 필터링 부분에 name이 page인 부분만 추가
                      formObj.innerHTML += `<input type = 'hidden' name = 'page' value = '\${num}'>`
                      
                      // <form> 태그를 submit으로 처리해주면 검색 / 필터링 조건을 유지하면서 페이지 번호만 변경 가능
                      formObj.submit();
                    }, false)
                </script>
                
            ...

        </div>
    </div>
</div>

...

</body>
</html>

 

 4) 조회 화면에서 검색 / 필터링 유지

  • 조회화면(read.jsp)에서 목록 화면으로 이동하는 작업은 PageRequestDTO의 getLink()를 이용하므로 아무런 처리가 없어도 정상적으로 동작함
  • 수정(Modify) 버튼도 동일하게 동작하므로 추가 개발이 필요하지 않음

 

 5) 수정 화면에서의 링크 처리

  • 수정 화면인 modify.jsp에는 [Remove], [Modify], [List] 버튼이 존재하고 각 버튼에 대한 클릭 이벤트 처리가 되어있음

 

  - List 버튼 처리

  • List 버튼은 PageRequestDTO의 GetLink()를 이용해 처리

 

  - Remove 버튼 처리

  • Remove는 삭제된 후에 1페이지로 이동
  • 삭제 후 기존 페이지와 검색 / 필터링 조건을 유지하고 싶다면 PageRequestDTO를 이용
<!-- modify.jsp -->
<script>
    document.querySelector(".btn-danger").addEventListener("click", function(e) {
        e.preventDefault()
        e.stopPropagation()
        <!-- TodoController의 remove() 메서드가 이미 PageRequestDTO를 파라미터로 받고 있음 -->
        <!-- 따라서 리다이렉트 하는 경로에 getLink()의 결과를 반영하도록 수정 -->
        formObj.action = "/todo/remove?${pageRequestDTO.link}"
        formObj.method = "post"
        formObj.submit()
    }, false);
</script>

 

  - Modify 버튼 처리

  • 검색 / 필터링 조건에 따라 검색했는데 수정하면서 조건에 맞지 않게 될 수 있음
  • 따라서 안전하게 하려면 검색 / 필터링의 경우 수정한 후에 조회 페이지로 이동하게 하고, 검색 / 필터링 조건은 없애는 것이 안전
 <form action = "/todo/modify" method = "post">
 
    <%-- 검색 / 필터링 조건을 유지하지 않는다면 modify.jsp에 선언된 <input type = "hidden"> 태그의 내용은 필요하지 않으므로 삭제 --%>
    <%-- <input type = "hidden" name = "page" value = "${pageRequestDTO.page}"> --%>
    <%-- <input type = "hidden" name = "size" value = "${pageRequestDTO.size}"> --%>
    
    ...
    
</form>

 

  • TodoController에서는 '/todo/list'가 아닌 '/todo/read'로 이동하도록 수정
// TodoController
package org.zerock.springex.controller;

import ...

public class TodoController {

  ...

  @PostMapping("/modify")
   public String modify(PageRequestDTO pageRequestDTO,
                       @Valid TodoDTO todoDTO,
                       BindingResult bindingResult,
                       RedirectAttributes redirectAttributes) {
    if(bindingResult.hasErrors()) {
      log.info("has errors.......");
      redirectAttributes.addFlashAttribute("errors", bindingResult.getAllErrors());
      redirectAttributes.addAttribute("tno", todoDTO.getTno());
      return "redirect:/todo/modify";
    }

    log.info(todoDTO);
    todoService.modify(todoDTO);

    redirectAttributes.addAttribute("page", pageRequestDTO.getPage());
    redirectAttributes.addAttribute("size", pageRequestDTO.getSize());
    
    // 리다이렉트 경로를 '/todo/read'로 변경
    return "redirect:/todo/read";
  }
}
  • 조회에서 수정 / 삭제 이동 시에는 검색 / 필터링 조건 유지
  • 수정 후 조회 시에는 단순 조회

+ Recent posts