개발/[Spring] 블로그 만들기

[코드로 배우는 스프링 웹 프로젝트] 15강. 검색 처리 (동적 SQL(prefix/suffix/prefixOverrides/suffixOverrides), <sql>, UriComponentsBuilder )

ee2ee2 2022. 2. 28. 00:54
728x90
반응형

해당 프로젝트는 코드로 배우는 스프링 웹 프로젝트(개정판)을 기반으로 진행됩니다.


게시물 관리의 마지막은 "검색 처리"이다.

select 태그를 이용해서 검색 기능과 화면을 처리해보자.


15.1 검색 기능과 SQL

게시물의 검색 기능은 아래와 같이 분류가 가능하다.

1) 제목 / 내용 / 작성자와 같이 단일 항목 검색

2) 제목 or 내용 / 제목 or 작성자 / 내용 or 작성자 / 전체 와 같은 다중 항목 검색


15.2 MyBatis의 동적 SQL

: MyBatis는 동적(Dynamic) 태그 기능을 통해서 SQL을 파라미터들의 조건에 맞게 조정할 수 있는 기능을 제공한다.

15.2.1 MyBatis의 동적 태그들

- if : 특정한 조건이 True가 되었을 때 포함된 SQL을 사용하고자 할 때 작성. (True가 된 조건들에 대해 모두 동작)

- choose (when, otherwise) : if와 달리 여러 상황들 중 하나의 상황에서만 동작함

- trim (where, set) : 태그의 내용을 앞의 내용과 관련되어 원하는 접두/접미를 처리 가능

더보기
  • prefix : <trim>문에 의해 생성되는 SQL 구문 앞에 추가
  • suffix : <trim>문의 의해 생성되는 SQL 구문 뒤에 추가
  • prefixOverrides : <trim>문에 의해 생성되는 SQL의 가장 앞에 해당 문자가 있으면 지워버림.
  • suffixOverrides : <trim>문에 의해 생성되는 SQL의 가장 뒤에 해당 문자가 있으면 지워버림.

- foreach : List, 배열, 맵 등을 이용해서 루프를 처리


15.3 검색 조건 처리를 위한 Criteria의 변화

: 앞서 페이징 처리에 사용되었던 Critera의 의도는 단순히 'pageNum'과 'amount'라는 파라미터를 수집하기 위해서였다. 이제 검색조건을 처리하기 위해서는 검색조건(type)과 검색에 사용되는 키워드가 필요하므로 해당 클래스를 확장해본다.

Criteria.java 클래스 수정

package org.zerock.domain;

import org.springframework.web.util.UriComponentsBuilder;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
public class Criteria {
	
	private int pageNum;
	private int amount;	//한 페이지에 보일 게시글의 개수
	
	private String type;
	private String keyword;
	
	public Criteria() {
		this(1, 20);
	}
	
	public Criteria(int pageNum, int amount) {
		this.pageNum = pageNum;
		this.amount = amount;
	}
	
	public String[] getTypeArr() {
		//검색조건이 T(Title), W(Writer), C(Content)로 구성되어 있으므로 검색 조건을 배열로 만들어서 한 번에 처리하기 위함
		return type == null ? new String[] {}: type.split("");
	}
}

15.3.1 BoardMapper.xml에서 Criteria 처리

: getListWithPaging()을 수정해서 동적 SQL을 처리해본다.

BoardMapper.xml

<!-- 게시글의 페이지 번호와 읽어올 개수에 따라 게시글을 조회하는 함수 -->
<!-- CDATA 섹션은 XML에서 사용할 수 없는 부등호를 사용하기 위함. XML의 경우 '<,>'는 태그로 인식하는데, 이를 막기 위함이다. -->
<select id ="getListWithPaging" resultType="org.zerock.domain.BoardVO">
	<![CDATA[
		select *
		from
			(select /*+ INDEX_DESC(tbl_board pk_board) */
			 		rownum rn, bno, title, content, writer, cdate, udate
			 from tbl_board
			 where 
	]]>
	
	<trim prefix="(" suffix=") AND " prefixOverrides="OR">
	  <foreach item='type' collection="typeArr">
	  	<trim prefix="OR">
	  		<choose>
	  			<when test="type =='T'.toString()">
	  				title like '%'||#{keyword}||'%'
	  			</when>
	  			<when test="type =='C'.toString()">
	  				content like '%'||#{keyword}||'%'
	  			</when>
	  			<when test="type =='W'.toString()">
	  				writer like '%'||#{keyword}||'%'
	  			</when>
	  		</choose>
	  	</trim>
	  </foreach>
	 </trim>
	 
	 <![CDATA[
	 	rownum <= #{pageNum} * #{amount}
			)
		where rn > (#{pageNum} - 1) * #{amount}
	 ]]>
</select>

검색 조건이 3가지 이므로 총 6가지의 조합이 가능하지만, 각 문자열을 이용해서 검색 조건을 결합하는 형태로 하면 3개의 동적 SQL구문만으로도 처리할 수 있다.

동적 SQL이 잘 동작하는지 테스트 코드를 만들어 테스트 해보자.

 

BoardMapperTests.java의 일부

테스트1

	@Test
	public void testSearch() {
		Criteria cri = new Criteria();
		cri.setKeyword("새로");
		cri.setType("TCW");
		
		List<BoardVO> list = mapper.getListWithPaging(cri);
		
		list.forEach(board -> log.info(board));
	}

실행 결과1

: 컨텐츠, 작성자, 제목 모두에서 검색될 수 있도록 쿼리가 생성됨.


테스트2

	@Test
	public void testSearch() {
		Criteria cri = new Criteria();
		cri.setKeyword("안녕");
		cri.setType("T");
		
		List<BoardVO> list = mapper.getListWithPaging(cri);
		
		list.forEach(board -> log.info(board));
	}

실행결과2

: 제목에서만 검색됨을 확인


MyBatis에서는 이라는 태그를 이용해서 SQL일부를 별도로 보관하고, 필요할 때 시키는 형태로 사용가능하다.

 

BoardMapper.xml <sql>, <include> 적용하기

..생략

<!-- sql 태그는 id라는 속성을 이용해서 필요한 경우에 동일한 SQL의 일부를 재사용할 수 있다. -->
<sql id = "criteria">
	 <trim prefix="(" suffix=") AND " prefixOverrides="OR">
	  <foreach item='type' collection="typeArr">
	  	<trim prefix="OR">
	  		<choose>
	  			<when test="type =='T'.toString()">
	  				title like '%'||#{keyword}||'%'
	  			</when>
	  			<when test="type =='C'.toString()">
	  				content like '%'||#{keyword}||'%'
	  			</when>
	  			<when test="type =='W'.toString()">
	  				writer like '%'||#{keyword}||'%'
	  			</when>
	  		</choose>
	  	</trim>
	  </foreach>
	 </trim>
</sql>

<!-- 게시글의 페이지 번호와 읽어올 개수에 따라 게시글을 조회하는 함수 -->
<!-- CDATA 섹션은 XML에서 사용할 수 없는 부등호를 사용하기 위함. XML의 경우 '<,>'는 태그로 인식하는데, 이를 막기 위함이다. -->
<select id ="getListWithPaging" resultType="org.zerock.domain.BoardVO">
	<![CDATA[
		select *
		from
			(select /*+ INDEX_DESC(tbl_board pk_board) */
			 		rownum rn, bno, title, content, writer, cdate, udate
			 from tbl_board
			 where 
	]]>
	
	<include refid="criteria"></include>
	 
	 <![CDATA[
	 	rownum <= #{pageNum} * #{amount}
			)
		where rn > (#{pageNum} - 1) * #{amount}
	 ]]>
</select>


.. 생략

<!-- 게시글의 전체 개수 구하기 -->
<select id="getTotalCount" resultType="int">
	select count(*) 
	from tbl_board 
	where 
	<include refid="criteria"></include>
	bno > 0
</select>

15.4 화면에서 검색 조건 처리

: 화면에서 검색은 아래와 같은 사항들을 주의해서 개발해야 한다.

  • 페이지 번호가 파라미터로 유지되었던 것처럼 검색 조건과 키워드 역시 항상 화면 이동 시 같이 전송되어야 함.
  • 화면에서 검색 버튼을 클릭하면 새로 검색을 한다는 의미이므로 1페이지로 이동해야 함.
  • 한글의 경우 GET 방식으로 이동하는 경우 문제가 생길 수 있으므로 주의가 필요함.

15.4.1 목록 화면에서의 검색 처리

: 목록 화면인 list.jsp에서 검색 조건과 키워드가 들어갈 수 있게 HTML 수정이 필요하다.

list.jsp

 ..생략

<!-- 수정 후 : 검색 조건 처리 부분 시작 -->
 <div class='row'>
 	<div class="col-lg-12">
 		<form id='searchForm' action="/board/list" method="get">
 			<select name='type'>
 				<option value="">---</option>
 				<option value="T">제목</option>
 				<option value="C">내용</option>
 				<option value="W">작성자</option>
 				<option value="TC">제목 or 내용</option>
 				<option value="TW">제목 or 작성자</option>
 				<option value="WC">내용 or 작성자</option>
 				<option value="TCW">제목 or 내용 or 작성자</option>
 			</select>
 			
 			<input type='text' name='keyword' />
 			<input type='hidden' name='pageNum' value='<c:out value="${pageMaker.cri.pageNum}"/>'/>
 			<input type='hidden' name='amount' value='<c:out value="${pageMaker.cri.amount}"/>'/>
 			<button class='btn btn-default'>Search</button>
 		</form>
 	</div>
 </div>
 <!-- 수정 후 : 검색 조건 처리 부분 끝 -->
 
 <!-- start Pagination -->
 <div class='pull-right'>
 
 ..생략

 

실행 화면 

: 현재는 아래와 같은 문제가 발생한다.

  1. 예를 들어, 3페이지를 보다가 검색을 하면 3페이지에서 결과가 나온는 문제
  2. 검색 후 페이지를 이동하면 검색 조건이 사라지는 문제
  3. 검색 후 화면에서는 어떤 검색 조건과 키워드를 이용했는지 알 수 없는 문제

 

list.jsp의 검색 버튼의 이벤트 처리

: 검색 버튼을 클릭하면 검색을 1페이지를 향하도록 수정하고, 화면에 검색 조건과 키워드가 보이게 처리 해보자.

<script>

..생략
    
    var searchForm = $("#searchForm");
		
		$("#searchForm button").on("click", function(e) {
			
			if(!searchForm.find("option:selected").val()){
				alert("검색종류를 선택하세요");
				return false;
			}
			
			if(!searchForm.find("input[name='keyword']").val()){
				alert("키워드를 입력하세요");
				return false;
			}
			
			searchForm.find("input[name='pageNum']").val("1");	//검색 후 페이지 번호는 1이 되도록 세팅
			e.preventDefault();
			
			searchForm.submit();
		});
	});	
	
</script>

브라우저에서 검색 버튼을 클릭하면 <form>태그의 전송은 막고, 페이지의 번호는 1이 되도록 처리한다.

 

검색 후에는 주소창에 검색 조건과 키워드가 같이 GET 방식으로 처리되므로 이를 이용해서 <select> 태그나 <input> 태그의  내용 수정이 필요하다.

list.jsp에서 검색 조건과 키워드를 보여주는 부분

<!-- 수정 후 : 검색 조건 처리 부분 시작 -->
<div class='row'>
	<div class="col-lg-12">
		<form id='searchForm' action="/board/list" method="get">
			<select name='type'>
				<option value="" <c:out value="${pageMaker.cri.type == null?'selected':'' }"/>>---</option>
				<option value="T" <c:out value="${pageMaker.cri.type eq 'T' ? 'selected':'' }"/>>제목</option>
				<option value="C" <c:out value="${pageMaker.cri.type eq 'C' ? 'selected':'' }"/>>내용</option>
				<option value="W" <c:out value="${pageMaker.cri.type eq 'W' ? 'selected':'' }"/>>작성자</option>
				<option value="TC" <c:out value="${pageMaker.cri.type eq 'TC' ? 'selected':'' }"/>>제목 or 내용</option>
				<option value="TW" <c:out value="${pageMaker.cri.type eq 'TW' ? 'selected':'' }"/>>제목 or 작성자</option>
				<option value="WC" <c:out value="${pageMaker.cri.type eq 'WC' ? 'selected':'' }"/>>내용 or 작성자</option>
				<option value="TCW" <c:out value="${pageMaker.cri.type eq 'TCW' ? 'selected':'' }"/>>제목 or 내용 or 작성자</option>
			</select>
			
			<input type='text' name='keyword' value='<c:out value="${pageMaker.cri.keyword}"/>'/>
			<input type='hidden' name='pageNum' value='<c:out value="${pageMaker.cri.pageNum}"/>'/>
			<input type='hidden' name='amount' value='<c:out value="${pageMaker.cri.amount}"/>'/>
			<button class='btn btn-default'>Search</button>
		</form>
	</div>
</div>
<!-- 수정 후 : 검색 조건 처리 부분 끝 -->

 

실행 결과

검색 항목이 선택되지 않은 경우
키워드를 입력하지 않은 경우
검색 시에는 무조건 1페이지로 이동

 

페이지 번호를 클릭해서 이동할 때에도 검색 조건과 키워드를 같이 전달되어야 하므로 페이지 이동에 사용한 <form> 태그를 아래와 같이 수정한다.

list.jsp의 일부

: 검색 조건과 키워드에 대한 처리가 되면 검색 후 페이지를 이동해서 동일한 검색 사항들이 계속 유지되는 것을 볼 수 있다.

<form id='actionForm' action="/board/list" method='get'>
	<input type='hidden' name='pageNum' value = '${pageMaker.cri.pageNum}'>
	<input type='hidden' name='amount' value = '${pageMaker.cri.amount}'>
	<input type='hidden' name='type' value = '${pageMaker.cri.type}'>
	<input type='hidden' name='keyword' value = '${pageMaker.cri.keyword}'>
</form>

 

실행 결과

: 어느 페이지에서도 제목에 "새로"가 들어간 게시글만 보임을 확인


15.4.2 조회 페이지에서 검색 처리

목록 페이지에서 조회 페이지로의 이동은 이미 <form> 태그를 이용해서 처리 했기 때문에 Criteria의 type과 keyword 값만 추가로 전달해주면 된다.

get.jsp

.. 생략

<button data-oper='modify' class="btn btn-default">Modify</button>
<button data-oper='list' class="btn btn-info">List</button>

<form id='operForm' action="/board/modify" method="get">
	<input type="hidden" id='bno' name='bno' value='<c:out value="${board.bno }"/>'>
	<input type="hidden" id='pageNum' name='pageNum' value='<c:out value="${cri.pageNum }"/>'>
	<input type="hidden" id='amount' name='amount' value='<c:out value="${cri.amount }"/>'>
	<input type="hidden" id='type' name='type' value='<c:out value="${cri.type }"/>'>
	<input type="hidden" id='keyword' name='keyword' value='<c:out value="${cri.keyword }"/>'>
</form>
<!-- /.table-responsive -->


..생략

15.4.3 수정/삭제 페이지에서 검색 처리

조회 페이지에서 수정/삭제 페이지로의 이동은 GET 방식을 통해서 이동하고, 이동방식 또한 <form> 태그를 이용하는 방식이므로, Criteria의 type과 keyword 값만 추가로 전달해주면 된다.

modify.jsp

..생략


<div class="panel-heading"> Board Register </div>
<!-- /.panel-heading -->
<div class="panel-body">

<form role="form" action="/board/modify" method="post" >
	<input type="hidden" id='pageNum' name='pageNum' value='<c:out value="${cri.pageNum }"/>'>
	<input type="hidden" id='amount' name='amount' value='<c:out value="${cri.amount }"/>'>
	<input type="hidden" id='pageNum' name='type' value='<c:out value="${cri.type }"/>'>
	<input type="hidden" id='amount' name='keyword' value='<c:out value="${cri.keyword }"/>'>


..생략

 

수정/삭제 처리는 BoradController에서 redirect 방식으로 동작하므로 type과 keyword 조건을 같이 리다이렉트 시에 포함시켜야 한다.

 

BoardController.java 일부

    ..생략
    
	//글 수정 메소드
	@PostMapping("/modify")
	public String modify(BoardVO board, RedirectAttributes rttr, @ModelAttribute("cri") Criteria cri) {
		log.info("modify.." + board);
		
		if(service.modify(board)) {
			rttr.addFlashAttribute("result", "success");
		}
		
		
		rttr.addAttribute("pageNum", cri.getPageNum());
		rttr.addAttribute("amount", cri.getAmount());
		rttr.addAttribute("type", cri.getType());
		rttr.addAttribute("keyword", cri.getKeyword());
		
		return "redirect:/board/list";
	}
	
	//글 삭제 메소드
	@PostMapping("/remove")
	public String remove(@RequestParam("bno") Long bno, Model model, RedirectAttributes rttr, @ModelAttribute("cri") Criteria cri) {
		log.info("/remove..");
		
		if( service.remove(bno)) {
			rttr.addFlashAttribute("result", "success");
		}
		
		rttr.addAttribute("pageNum", cri.getPageNum());
		rttr.addAttribute("amount", cri.getAmount());
		rttr.addAttribute("type", cri.getType());
		rttr.addAttribute("keyword", cri.getKeyword());
		
		return "redirect:/board/list";	
	}

 

modify.jsp에서는 다시 목록으로 이동하는 경우에 필요한 파라미터만 전송하기 위해서 <form> 태그의 모든 내용을 지우고 다시 추가하는 방식을 이용하는데 이 때, type과 keyword도 전달할 수 있도록 수정이 필요하다. 

 

modify.jsp

<script type="text/javascript">

	$(document).ready(function() {
		
		var formObj = $("form");
		
		$('button').on("click", function(e) {
			
			e.preventDefault();		// 기본 동작인 submit을 막고, 'data-oper' 속성을 이용해서 원하는 기능을 동작할 수 있도록 함. (마지막에 submit 처리)
			
			var operation = $(this).data("oper");
			
			console.log(operation);
			
			//변수의 타입까지 모두 값을 때
			if(operation === 'remove'){
			    formObj.attr("action", "/board/remove");
			} else if (operation === 'list'){
				//move to list 
				//self.location = "/board/list";   // self.location : 현재 페이지를 다른 페이지(URL)로 이동
				formObj.attr("action", "/board/list").attr("method", "get");
				
				//list 페이지로 이동할 때는 모든 값이 필요없고, pageNum, amount, type, keyword값만 필요함
				var pageNumTag = $("input[name='pageNum']").clone();	//pageNum값을 복사하여 pageNumTag 변수에 저장
				var amountTag = $("input[name='amount']").clone();		//amount값을 복사하여 pageNumTag 변수에 저장
				var typeTag = $("input[name='type']").clone();		//type값을 복사하여 typeTag 변수에 저장
				var keywordTag = $("input[name='keyword']").clone();	//keyword값을 복사하여 keywordTag 변수에 저장
				
				//form 태그 내 모든 내용 삭제 후, 필요 태그(pageNum, amount, type, keyword)만 다시 추가
				formObj.empty();
				formObj.append(pageNumTag);
				formObj.append(amountTag);
				formObj.append(typeTag);
				formObj.append(keywordTag);
			}
			
			formObj.submit();
		});	
		
	});
</script>

 

실행 화면

게시글 조회&nbsp;&nbsp;화면에서도 검색 조건 유지
게시글 수정 화면에서도 검색 조건 유지
게시글 수정/삭제에서도 검색 조건 유지

 


UriComponentsBuilder를 이용하는 링크 생성

웹 페이지에서 매번 파라미터를 유지하는 일이 번거로울 때 사용을 추천한다.

web.util.UriComponentsBuilder는 여러 개의 파라미터들을 연결해서 URL 형태로 만들어 주는 기능을 한다.

URL을 만들어주면 리다이렉트 혹은  <form>태그를 사용하는 상황을 많이 줄 일 수 있다.

 

Criteria.java 일부

: queryParam()이라는 메소드를 이용해서 필요한 파라미터들을 쉽게 추가 가능하다. (GET 방식에 적합한 URL 인코딩된 결과로 만들어지므로 한글 처리에 신경쓰지 않아도 된다는 장점이 있음)

	//여러 개의 파라미터들을 연결해서 URL의 형태로 만들어주는 기능을 가짐
	//URL을 만들어주면, 리다리렉트를 하거나, <form> 태그를 사용하는 상황을 많이 줄일 수 있는 장점이 있음
	public String getListLink() {
		UriComponentsBuilder builder = UriComponentsBuilder.fromPath("")
				.queryParam("pageNum", this.getPageNum())
				.queryParam("amount", this.getAmount())
				.queryParam("type", this.getType())
				.queryParam("keyword", this.getKeyword());
		
		return builder.toUriString();
	}

 

BoardController.java 수정

	//글 수정 메소드
	@PostMapping("/modify")
	public String modify(BoardVO board, RedirectAttributes rttr, @ModelAttribute("cri") Criteria cri) {
		log.info("modify.." + board);
		
		if(service.modify(board)) {
			rttr.addFlashAttribute("result", "success");
		}
		
		return "redirect:/board/list" + cri.getListLink();
	}
	
	//글 삭제 메소드
	@PostMapping("/remove")
	public String remove(@RequestParam("bno") Long bno, Model model, RedirectAttributes rttr, @ModelAttribute("cri") Criteria cri) {
		log.info("/remove..");
		
		if( service.remove(bno)) {
			rttr.addFlashAttribute("result", "success");
		}
		
		return "redirect:/board/list"  + cri.getListLink();	
	}

UriComponentsBuilder로 생성된 URL은 화면에서도 유용하게 사용될 수 있는데,

주로 JavaScript를 사용할 수 없는 상황에서 링크를 처리해야하는 상황에서 사용된다.

 


 

다음 시간에는 REST 방식과 Ajax를 이용하는 댓글 처리 방법에 대해 알아본다.