본문 바로가기

Server Programming/Spring Boot Backend Programming

4장-4. 스프링 Web MVC 구현 (3) 검색과 필터링 조건 (+ 동적 쿼리, 쿼리 스트링, URLEncoder)

반응형

요구사항

  • 단순한 검색
    • 제목, 작성자는 키워드를 이용해 검색
  • 필터링을 이용한 복잡한 검색
    • 완료 여부를 필터링
    • 특정한 기간을 지정한 필터링

구현 순서

  1. 검색/필터링 조건 결정
  2. type에 따른 동적 쿼리 작성
  3. 검색 조건을 위한 화면 처리

검색/필터링 조건 정의

 

검색과 필터링에 필요한 데이터

  • keyword: 제목, 작성자 검색에 사용하는 문자열
  • finished : 완료 여부에 사용되는 boolean
  • from, to : 특정 기간 검색을 위한 LocalDate

검색/필터링 조건 결정

검색 기능의 경우의 수를 구분

-'완료 여부', '제목', '작성자', '기간'의 조합으로 구성

 

검색의 실제값은 검색 종류에 따라 키워드를 이용하는데

-PageRequestDTO에 필요한 변수를 추가해서 구성한다.

 

PageRequestDTO에 추가할 데이터

  • types : 검색 종류
  • keyword : 검색 종류에 따라 사용할 키워드
  • finished : 완료 여부
  • from : 구간 시작값
  • to : 구간 종료값
    private String[] types;

    private String keyword;

    private boolean finished;

    private LocalDate from;

    private LocalDate to;

 


type에 따른 동적 쿼리 작성

 

요구사항

  • 제목 ('t'), 작성자 ('w') 검색 처리
  • 완료 여부('f'), 만료 기한('d')의 필터링 처리
  • 동일한 SQL 쿼리는 SQL 조각으로 변경

 

MyBatis에서 지원하는 태그를 이용해 동적 쿼리를 작성한다.

  • if : 조건문을 위한 태그
  • trim(where, set) : prefix, suffix를 이용해 접두사 접미사를  붙여주는 태그
  • choose(when, otherwise)
  • foreach : 반복문을 위한 태그로 -Array, List, Map, Set에 이용

 

 


1. TodoMapperTests 클래스에 동적쿼리를 사용하는 테스트 코드 작성

@Test
public void testSelectSearch(){

    PageRequestDTO pageRequestDTO=PageRequestDTO.builder()
            .size(10)
            .types(new String[]{"t", "w"})
            .keyword("AAAA")
            .build();

    List<TodoVO> voList=todoMapper.selectList(pageRequestDTO);

    voList.forEach(vo -> log.info(vo));
}

 

2. TodoMapper의 selectList 변경

 

변경 전, selectList

<select id="selectList" resultType="org.zerock.springex.domain.TodoVO">
    select * from tbl_todo order by tno desc limit #{skip}, #{size};
</select>

 

(1) 검색 종류에 대한 반복문 추가 후, selectList

 

  • MyBatis의 <forEach>태그는 EL표현식이 아닌, #{~}로 표현한다.
    • collection : 사용할 컬렉션 명으로 여기서는 검색 종류(String[])를 사용한다.
    • item : 컬렉션에서 사용할 변수명으로 여기서는 문자열 배열의 문자열을 사용한다.
<select id="selectList" resultType="org.zerock.springex.domain.TodoVO">
    select * from tbl_todo
             <foreach collection="types" item="type">
             #{type}
             </foreach>
             order by tno desc limit #{skip}, #{size};
</select>

 

현재 실행되는 SQL 쿼리

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

 

(2) {"t", "w"}에 유효한 문자열을 대입하기 위해 조건문 추가

  • MyBatis의 <if>태그는 EL표현식이 아닌, #{~}로 표현한다.
    • test="아이템=='비교대상'.toString()"조건문이 들어가는 자리로 문자열 비교
  • %키워드%와 같은 형태로 구성해 여러개의 조건을 수행할 수 있도록 쿼리를 작성한다.
<select id="selectList" resultType="org.zerock.springex.domain.TodoVO">
    select * from tbl_todo
             <foreach collection="types" item="type">
                 <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>

 

현재 실행되는 SQL 쿼리

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

 

(3) 반복문에 속성 추가

  • <forEach> 태그의 속성
    • open
    • close
    • separator

 

  • WHERE 키워드
  • OR 키워드
  • '()' 처리
<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>

-> WHERE 키워드는 경우의 수를 따져야 한다.

 

  • types가 없는 경우 : where 생성하지 않는다.  
  • types가 있는 경우 : 태그 안쪽에서 문자열이 생성되면 where 키워드를 추가한다.
    -> type가 null이 아닌 경우

 

<select id="selectList" resultType="org.zerock.springex.domain.TodoVO">
    select * from tbl_todo
        <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>
             order by tno desc limit #{skip}, #{size};
</select>

 

현재 실행되는 SQL 쿼리

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

 

(4) <trim>과 완료 여부/만료일 필터링

  • 완료 여부 : PageRequestDTO의 finish 변수 값이 true 인 경우에, 'and finished=1' 문자열 생성
    • 검색 조건이 존재하는 경우 : 'and finished=1'
    • 검색 조건이 존재하지 않는 경우 :  'finished=1'
  • 만료 기한 :  PageRequestDTO의 from 변수와 to 변수가 null이 아닌 경우에, 'and dueDate between #{from} and  #{to}' 문자열 생성
    • 다른 조건도 존재하는 경우 :  'and dueDate between #{from} and  #{to}'
    • 구간 조건만 존재하는 경우 : 'dueDate between #{from} and  #{to}'

완료 여부 처리

    <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>
            </where>
                 order by tno desc limit #{skip}, #{size};
    </select>

 

완료 여부 테스트 코드 작성

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

    }

 

검색 조건 있는 경우 SQL 쿼리문

select * from tbl_todo 
where
(
title like concat('%', ?, '%')
OR
writer like concat('%', ?, '%')
)
and finished=1
order by tno desc limit ?, ?

 

 

검색 조건 없는 경우 SQL 쿼리문

select * from tbl_todo 
where finished=1
order by tno desc limit ?, ?

 

 

 

 

만료 기한 처리

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

 

아무 조건 없이 from, to만 지정되는 경우 SQL 쿼리문

select * from tbl_todo 
where finished=1
order by tno desc limit ?, ?

 

(5) 목록 데이터 반환과 전체 개수 반환하는 메서드에 동적 쿼리 적용

 

<sql>과 <include>

 동일한 SQL 조각을 재사용하기 위해 MyBatis에서 제공하는 태그

  • <sql> : 동적 쿼리 부분을 분리
  • <include> : 동일하게 동적 쿼리 적용
<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>

 

테스트 코드 작성

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

select * 
from tbl_todo 
WHERE ( title like concat('%', ?, '%') OR writer like concat('%', ?, '%') ) 
and finished = 1 
and dueDate between ? and ? 
order by tno desc limit ?, ?

 

getCount()

select count(tno) 
from tbl_todo 
WHERE ( title like concat('%', ?, '%') OR writer like concat('%', ?, '%') ) 
and finished = 1 
and dueDate between ? and ?

 


검색 조건을 위한 화면 처리

요구사항

  • 목록 페이지에 카드 컴포넌트를 이용해 검색에 필요한 내용 담아 검색 화면 구현
    • 화면에 검색 조건 표시
    • 조회를 위한 링크 처리 (getLink())
    • 페이지 이동 링크 처리
  • 조회 화면에서 검색 / 필터링 유지
    • 조회를 위한 링크 처리 (getLink())
  • 수정 화면에서의 링크 처리
    • List, Modify, Remove 버튼 링크 처리

목록 페이지에 카드 컴포넌트를 이용해 검색에 필요한 내용 담아 검색 화면 구현

카드 컴포넌트

https://getbootstrap.kr/docs/5.1/components/card/

 

카드

여러 가지 종류와 옵션을 가진 유연하고 확장 가능한 콘텐츠를 제공합니다.

getbootstrap.kr

 

카드 컴포넌트 기본 문법

<div class="card" style="width: 18rem;">
  <img src="..." class="card-img-top" alt="...">
  <div class="card-body">
    <h5 class="card-title">Card title</h5>
    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
    <a href="#" class="btn btn-primary">Go somewhere</a>
  </div>
</div>

 

(1) 카드 컴포넌트를 이용해 검색 화면 기본 틀 구현

<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="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 clearBtn" type="reset">Clear</button>
                        </div>
                    </div>
                </form>
            </div>
        </div>

    </div>
</div>

 

(2) <form> 태그를 이용해 TodoController에 검색 조건 전송 확인

PageRequestDTO(page=1, size=10, link=page=1&size=10, 
types=[t, w], keyword=, finished=true, from=2022-03-01, to=2022-12-30)

 

selectList()

select * from tbl_todo 
WHERE ( title like concat('%', ?, '%') OR writer like concat('%', ?, '%') ) 
and finished = 1 
and dueDate between ? and ? 
order by tno desc limit ?, ?

 

getCount()

select count(tno) 
from tbl_todo WHERE ( title like concat('%', ?, '%') OR writer like concat('%', ?, '%') ) 
and finished = 1 
and dueDate between ? and ?

 

검색 조건이 GET 방식의 쿼리스트링으로 생성

http://localhost:8080/

todo/list?size=10&finished=on&types=t&types=w&keyword=&from=2022-03-01&to=2022-12-30

 


1. 화면에 검색 조건 표시

PageRequestDTO 정보가 EL로 처리되지 않으므로 검색 후 검색 조건이 초기화

-> <div>의 검색조건을 배열로 처리하면 EL을 적용하기 어려우므로, 화면에서 처리를 위해 별도의 메서드 작성

 

(1) PageRequestDTO에 checkType 메서드 작성

    public boolean checkType(String type){

        if(types == null || types.length == 0){
            return false;
        }
        return Arrays.stream(types).anyMatch(type::equals);
    }

 

(2) 화면에서 EL 적용

 

EL 적용 전, list.jsp

 <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="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 clearBtn" type="reset">Clear</button>
                                </div>
                            </div>
                        </form>
                    </div>
                </div>

            </div>
        </div>

 

EL 적용 후, list.jsp

<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="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 clearBtn" type="reset">Clear</button>
                        </div>
                    </div>
                </form>
            </div>
        </div>

    </div>
</div>

 


2. 조회를 위한 링크 처리 (getLink())

검색 기능 추가시, 조회나 수정 화면에 있는 'List' 버튼도 검색 조건들을 그대로 유지해야한다.

PageRequest의 getLink()에 검색 조건을 추가하는 방식으로 구현

 

(1) PageRequest의 getLink() 메서드에 해당 검색 조건을 반영하도록 변경

-모든 검색 / 필터링 조건을 쿼리 스트링으로 구성

-한글이 가능한 keyword부분은 URLEncoder를 이용해 링크로 처리

 

변경 전, getLink()

public String getLink() {
    if(link == null){
        StringBuilder builder = new StringBuilder();

        builder.append("page=" + this.page);

        builder.append("&size=" + this.size);
        link = builder.toString();
    }
    return link;
}

 

변경 후, getLink()

public String getLink() {
    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]);
        }
    }

    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();
}

 

 

(2) 화면에서 '/todo/read?tno=xxx'와 같은 링크가 올바른 검색조건을 반영하는 것을 확인

-검색 조건이 없으면 변경 전과 같이 페이지 관련 쿼리 스트링만 처리한다.

 


3. 페이지 이동 링크 처리

페이지 이동할 때에도 검색/필터링 조건을 유지하도록 스크립트 변경

즉, 검색/필터링 조건 유지하면서 페이지 번호만 변경하도록 한다.

 

변경 전, 스크립트

-직접 쿼리 스트링 추가

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")

        self.location = `/todo/list?page=\${num}` //백틱(` `)을 이용해서 템플릿 처리
    },false)

 

변경 후, 스크립트

-검색/필터링 부분에 name이 page인 부분만 추가해 <form> 태그를 submit 처리

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")

    formObj.innerHTML += `<input type='hidden' name='page' value='\${num}'>`

    formObj.submit();

},false)

 


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

getLink()를 이용해 링크 처리를 수행하므로 별도의 작업이 필요하지 않다.

 


수정 화면에서의 링크 처리

List, Modify, Remove 버튼 링크 처리

(1) List 버튼 링크 처리

document.querySelector(".btn-secondary").addEventListener("click",function(e) {

    e.preventDefault()
    e.stopPropagation()

    self.location = "/todo/list?${pageRequestDTO.link}"

},false);

 

(2) Modify 버튼 링크 처리

-수정 후, 내용이 변경되어 검색/필터링 조건에 맞지 않게 되는 경우 발생

 

요구 사항

  • 수정 후에는 조회 페이지로 이동하고, 검색/필터링 조건을 없앤다.
  1. 검색/필터링의 경우 수정한 후에 조회 페이지로 이동
  2. 검색/필터링 조건을 유지하지 않기 때문에, PageRequestDTO 정보를 전달하는 <input type='hidden'> 제거

변경 전, TodoController의 modify()메서드

@PostMapping("/modify")
public String modify(@Valid TodoDTO todoDTO,
                     PageRequestDTO pageRequestDTO,
                     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());

    return "redirect:/todo/list";
}

 

변경 후, TodoController의 modify()메서드

@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("tno", todoDTO.getTno());

    return "redirect:/todo/read";
}

 

(3) Remove 버튼 링크 처리

document.querySelector(".btn-danger").addEventListener("click",function(e) {
    e.preventDefault()
    e.stopPropagation()

    formObj.action ="/todo/remove"
    formObj.method ="post"

    formObj.submit()

},false);

 

TodoController에서 삭제 처리 후, 리다이렉트 경로에 getLink() 결과 반영

변경 전, remove()

@PostMapping("/remove")
public String remove(Long tno, PageRequestDTO pageRequestDTO, RedirectAttributes redirectAttributes){

    log.info("-------------remove------------------");
    log.info("tno: " + tno);

    todoService.remove(tno);

    redirectAttributes.addAttribute("page", 1);
    redirectAttributes.addAttribute("size", pageRequestDTO.getSize());
    return "redirect:/todo/list";
}

 

변경 후, remove()

@PostMapping("/remove")
public String remove(Long tno, PageRequestDTO pageRequestDTO, RedirectAttributes redirectAttributes){

    log.info("-------------remove------------------");
    log.info("tno: " + tno);

    todoService.remove(tno);

    return "redirect:/todo/list?" + pageRequestDTO.getLink();
}

 

반응형