관리 메뉴

Just Do it

Part 3) 기본적인 웹 게시물 관리 (Ch 15) 본문

신입 개발자가 되기 위해 공부했던 독학 자료들/코드로 배우는 스프링 웹프로젝트

Part 3) 기본적인 웹 게시물 관리 (Ch 15)

Seojoo21 2022. 2. 24. 16:06

* 이 카테고리의 글은 <코드로 배우는 스프링 웹 프로젝트> 에서 각 파트별로 진행하는 프로젝트의 흐름을 보기 위해 각 장의 내용을 간단히 요약한 것이다.

* 출처: 코드로 배우는 스프링 웹 프로젝트 개정판, 구멍가게 코딩단, 남가람북스 

 

Ch 15 검색 처리 

- 예제는 가장 흔한 <select> 태그를 이용해서 검색 기능과 화면을 처리하겠다. 

 

15.1 검색 기능과 SQL

- 게시물의 검색 기능은 다음과 같이 분류가 가능하다.

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

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

- 검색 항목은 제목/내용/작성자와 같은 단일 항목 검색과 제목 or 내용과 같이 복합적인 항목으로 검색하는 방식이 존재한다. 

- 게시물의 검색이 붙으면 가장 신경 쓰이는 부분은 역시 SQL 쪽이다. 오라클은 페이징 처리에 인라인뷰를 이용하기 때문에 실제로 검색 조건에 대한 처리는 인라인뷰의 내부에서 이루어져야 한다. 

- 단일 항목의 검색은 검색 조건에 따라서 칼럼이 달라지고, LIKE 처리를 통해서 키워드를 사용하게 된다

- 만약 2페이지에 해당하는 데이터를 '제목'으로 검색하고, 키워드는 'Test'라고 한다면 다음과 같이 작성될 수 있다. 

SELECT
*
FROM 
  ( 
    SELECT /*+ INDEX_DESC(TBL_BOARD PK_BOARD) */  
     ROWNUM AS RN, BNO, TITLE, CONTENT, WRITER, REGDATE, UPDATEDATE
    FROM
      TBL_BOARD
    WHERE 
      TITLE LIKE '%TEST%' -- 변경 부분
      AND ROWNUM <= 20
  )
WHERE RN > 10;

 

15.1.1 다중 항목 검색

- 문제는 2개 이상의 다중 항목의 검색이다. 예를 들어, 제목(title)이나 내용(content) 중에 'TEST'라는 문자열이 있는 게시물들을 검색하고 싶다면 다음과 같이 작성될 것이라고 예상한다.

SELECT
*
FROM 
  ( 
    SELECT /*+ INDEX_DESC(TBL_BOARD PK_BOARD) */  
     ROWNUM AS RN, BNO, TITLE, CONTENT, WRITER, REGDATE, UPDATEDATE
    FROM
      TBL_BOARD
    WHERE 
      TITLE LIKE '%TEST%' OR CONTENT LIKE '%TEST%'-- 변경 부분
      AND ROWNUM <= 20
  )
WHERE RN > 10;

- 'TITLE LIKE '%TEST%' OR CONTENT LIKE '%TEST'' 이 구문 자체에는 이상이 없지나 실제로 동작시켜 보면 10개의 데이터가 아니라 많은 양의 데이터가 나오는 것을 볼 수 있다. 

- 이렇게 많은 양의 데이터가 나오는 이유는 위 SQL문에서 AND 연산자가 OR 연산자보다 우선 순위가 높기 때문에 'ROWNUM'이 20보다 작거나 같으면서(AND) 내용에 'TEST'라는 문자열이 있거나(OR) 제목에 'TEST'라는 문자열이 있는 게시물들을 검색하게 된다. 제목에 'TEST'라는 문자열이 있는 경우는 많기 때문에 많은 양의 데이터를 가져오게 된다.

- AND와 OR가 섞여있는 SQL을 작성할 때는 우선 순위 연산자인 '()'를 이용해서 OR 조건들을 처리해야 한다. 

SELECT
*
FROM 
  ( 
    SELECT /*+ INDEX_DESC(TBL_BOARD PK_BOARD) */  
     ROWNUM AS RN, BNO, TITLE, CONTENT, WRITER, REGDATE, UPDATEDATE
    FROM
      TBL_BOARD
    WHERE 
      (TITLE LIKE '%TEST%' OR CONTENT LIKE '%TEST%')-- 변경 부분. () 추가 
      AND ROWNUM <= 20
  )
WHERE RN > 10;

 

15.2 MyBatis의 동적 SQL

- SQL문에서 느끼는 점은 검색 조건이 변하면 SQL의 내용 역시 변하기 때문에 XML이나 어노테이션과 같이 고정된 문자열을 작성하는 방식으로는 제대로 처리할 수 없다는 사실이다.

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

- MyBatis의 동적 태그는 약간의 구문을 이용해서 전달되는 파라미터를 가공해서 경우에 따라 다른 SQL을 만들어서 실행할 수 있다.

 

15.2.1 MyBatis의 동적 태그들 

- MyBatis는 다음과 같이 몇 가지의 태그들만을 이용한다.

- if, choose(when, otherwise), trim(where,set), foreach 

 

1) <if>

- if는 test라는 속성과 함께 특정한 조건이 true가 되었을 때 포함된 SQL을 사용하고자 할 때 작성한다. 

- 예를 들어, 단일 항목으로 제목(title), 내용(content), 작성자(writer)에 대해서 검색해야 하는 상황이라고 가정하자.

* 검색 조건이 'T'이면 제목(title)이 키워드(keyword)인 항목을 검색 

* 검색 조건이 'C'이면 내용(content)이 키워드(keyword)인 항목을 검색 

* 검색 조건이 'W'이면 작성자(writer)가 키워드(keyword)인 항목을 검색 

- 위와 같은 경우 MyBatis에서는 XML에서 다음과 같이 작성할 수 있다.

- If 안에 들어가는 표현식은 OGNL 표현식이라는 것을 이용한다.

OGNL 표현식: https://commons.apache.org/proper/commons-ognl/language-guide.html 

https://jehuipark.github.io/java/mybatis_ognl

<if test="type=='T'.toString()">
  (title like '%'||#{keyword}||'%')
</if>
<if test="type=='C'.toString()">
  (content like '%'||#{keyword}||'%')
</if>
<if test="type=='W'.toString()">
  (writer like '%'||#{keyword}||'%')
</if>

 

2) <choose>

- if 와 달리 choose는 여러 상황들 중 하나의 상황에서만 동작한다. Java 언어의 'if~else'나 JSTL의 <choose>와 유사하다.

- <otherwise>는 위의 모든 조건이 충족되지 않을 경우에 사용한다. 

<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>
<otherwise>
  (title like '%'||#{keyword}||'%' OR content like '%'||#{keyword}||'%')
</otherwise>
</choose>

 

3) <trim>, <where>, <set>

- trim, where, set은 단독으로 사용되지 않고 <if>, <choose>와 같은 태그들을 내포하여 SQL들을 연결해 주고, 앞 뒤에 필요한 구문들(AND, OR, WHERE 등)을 추가하거나 생략하는 역할을 한다.

- SQL을 작성하다 보면 상황에 따라서 WHERE나 AND, OR 등이 문제가 되는 상황이 발생할 수도 있다.예를 들어, WHERE ROWNUM <= 20'은 문제가 없지만 검색 조건이 들어가면 문제가 될 수 있다.

 

3-1) <where>

- <where>의 경우 태그 안쪽에서 SQL이 생성될 때는 WHERE 구문이 붙고, 그렇지 않은 경우에는 생성되지 않는다.

select * from tbl_board
  <where>
    <if test="bno != null">
      bno = #{bno}
    </if>
  </where>

- 위와 같은 경우는 bno 값이 null인 경우에는 WHERE 구문이 없어지고, bno 값이 존재하는 경우에만 'WHERE bno = xx' 와 같이 생성된다. 

bno 값이 존재하는 경우  select * from tbl_board WHERE bno = 33
bno 가 null인 경우  select * from tbl_board

 

3-2) <trim>

- <trim> 은 태그의 내용을 앞의 내용과 관련되어 원하는 접두/접미를 처리할 수 있다.

- trim은 prefix, suffix, prefixOverrides, suffixOverrides 속성을 지정할 수 있다. 

select * from tbl_board
  <where>
    <if test="bno != null">
      bno = #{bno}
    </if>
    <trim prefixOverrides = "and">
      rownum = 1
    </trim>
  </where>
bno 값이 존재하는 경우  select * from tbl_board WHERE bno = 33 and rownum = 1 
bno 값이 null인 경우 select * from tbl_board WHERE rownum = 1

 

3-3) <foreach> 

- <foreach>는 List, 배열, 맵 등을 이용해서 루프를 처리할 수 있다.

- 주로 IN 조건에서 많이 사용하지만, 경우에 따라서는 복잡한 WHERE 조건을 만들때에도 사용할 수 있다.

- 예를 들어, 제목('T')은 'TTTT'로 내용('C')은 'CCCC'라는 값을 이용한다면 Map의 형태로 작성이 가능하다. 

Map<String, String> map = new HashMap<>();
map.put("T", "TTTT");
map.put("C", "CCCC");

- 작성된 Map을 파라미터로 전달하고, foreach를 이용하면 다음과 같은 형식이 가능하다.

select * from tbl_board

  <trim prefix="where (" suffix=")" prefixOverrides="OR" >
    <foreach item="val" index="key" collection="map">
    
      <trim prefix="OR">
        <if test="key == 'C'.toString()">
          content = #{val}
        </if>
        <if test="key == 'T'.toString()">
          title = #{val}
        </if>
        <if test="key == 'W'.toString()"> 
          writer = #{val}
        </if>
       </trim>
     </foreach>
   </trim>

- foreach를 배열이나 List를 이용하는 경우에는 item 속성만을 이용하면 되고, Map의 형태로 key와 value를 이용할 때는 index와 item 속성을 둘 다 이용한다. 전달된 값에 따라서 다음과 같이 처리된다.

select * from tbl_board

  where ( content = ?
  
    OR title = ? )
    
INFO: jdbc.sqlonly - select * from tbl_board where (content = 'CCCC' OR title 'TTTT')

 

 

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

- 페이징 처리에 사용했던 org.zerock.domain 패키지의 Criteria 클래스의 의도는 단순히 'pageNum'과 'amount'라는 파라미터를 수집하기 위해서였다. 페이징 처리에 검색 조건 처리가 들어가면 Criteria 역시 변화가 필요하다.

- 검색 조건을 처리하기 위해서는 검색 조건(type)과 검색에 사용하는 키워드(keyword)가 필요하므로, 기존의 Criteria를 확장할 필요가 있다. 

- 확장 방법으로는 상속 방법을 이용하거나 직접 Criteria 클래스를 수정하는 방식을 생각해 볼 수 있는데, 예제에서는 직접 Criteria 클래스를 수정하겠다.

 

15.3.1 BoardMapper.xml에서 Criteria 처리

- Criteria 클래스에 검색을 위한 조건 type과 키워드 keyword라는 변수를 추가하고 getTypeArr() 메서드를 추가한다. 

- BoardMapper.xml은 기존의 getListWithPaging()을 수정해서 동적 SQL을 처리한다. 

 

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

- <foreach>를 이용해서 검색 조건들을 처리하는데 typeArr라는 속성을 이용한다.

- MyBatis는 원하는 속성을 찾을 때 getTypeArr()와 같이 이름에 기반을 두어서 검색하기 때문에 Criteria에서 만들어둔 getTypeArr() 결과인 문자열의 배열이 <foreach>의 대상이 된다.

 

- <choose> 안쪽의 동적 SQL은 'Or title... Or content... Or writer...'와 같은 구문을 만들어내게 된다.

- 따라서 바깥쪽에서는 <trim>을 이용해서 맨 앞에서 생성되는 'OR'를 없애준다.

 

- 동적 SQL은 경우에 따라서 여러 종류의 SQL이 생성될 수 있으므로 제대로 동작하는지 반드시 여러 번의 확인을 거쳐야 한다.

- 기존에 BoardMapperTests를 만들어 두었으니 이를 이용해서 테스트 코드를 작성한다.

- 중요한 것은 실행 결과가 아니라 실행할 때 만들어지는 SQL이다. 아래와 같이 각 상황에 맞게 SQL이 올바르게 만들어지는지 확인해야한다.

 

* 검색 조건이 없는 경우 SQL 관련 로그: 

 

* 단일 검색 (제목)인 경우 SQL 관련 로그:

 

* 다중 검색(제목 OR 내용)인 경우 SQL 관련 로그: 

 

1) <sql> <include>와 검색 데이터의 개수 처리 

- 동적 SQL을 이용해서 검색 조건을 처리하는 부분은 해당 데이터의 개수를 처리하는 부분에서도 동일하게 적용되어야만 한다. 

- 이 경우 가장 간단한 방법은 동적 SQL을 처리하는 부분을 그대로 복사해서 넣어줄 수 있지만, 만일 동적 SQL을 수정하는 경우에는 매번 목록을 가져오는 SQL과 데이터 개수를 처리하는 SQL 쪽을 같이 수정해야한다.

- MyBatis는 <sql>이라는 태그를 이용해서 SQL의 일부를 별도로 보관하고, 필요한 경우에 include시키는 형태로 사용할 수 있다.

<sql id="criteria">
별도로 보관할 SQL 코드
...
</sql>
<include refid="criteria"></include>

 

15.4 화면에서 검색 조건 처리  

- 화면에서 검색은 다음과 같은 사항들을 주의해서 개발해야한다.

1) 페이지 번호가 파라미터로 유지되었던 것처럼 검색 조건과 키워드 역시 항상 화면 이동 시 같이 전송되어야 한다.

2) 화면에서 검색 버튼을 클릭하면 새로 검색을 한다는 의미이므로 1페이지로 이동한다.

3) 한글의 경우 GET 방식으로 이동하는 경우 문제가 생길 수 있으므로 주의해야한다.

 

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

- 목록 화면인 list.jsp에서는 검색 조건과 키워드가 들어 갈 수 있게 HTML을 수정해야 한다.

- views 폴더 내의 list.jsp를 수정해서 페이지 처리 바로 위쪽에 검색 조건과 키워드를 입력할 수 있는 폼을 만들기 위한 HTML 코드를 추가한다. 

- 브라우저로 테스트 시 항상 영문과 한글을 모두 테스트 한다. 

 

- 검색이 처리된 후에는 아래와 같이 몇 가지 문제가 있다는 사실을 알게 된다.

1) 예를 들어 3페이지를 보다가 검색을 하면 3페이지로 이동하는 문제

2) 검색 후 페이지를 이동하면 검색 조건이 사라지는 문제

3) 검색 후 화면에서는 어떤 검색 조건과 키워드를 이용했는지 알 수 없는 문제 

 

1) 검색 버튼의 이벤트 처리 

- 먼저 검색 버튼을 클릭하면 검색은 1페이지를 하도록 수정하고, 화면에 검색 조건과 키워드가 보이게 처리하는 작업을 우선으로 진행한다. 

- list.jsp에서 검색 버튼의 자바스크립트 이벤트 처리를 한다. 

 

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

- 목록 페이지에서 조회 페이지로의 이동은 이미 <form> 태그를 이용해서 처리했기 때문에 별도의 처리가 필요하지 않다.

- 다만 조회 페이지는 아직 Criteria의 type과 keyword에 대한 처리가 없기 때문에 이 부분을 수정해 줄 필요가 있다.

- get.jsp 에서 조회 페이지에서의 다시 목록 페이지로 이동 시 검색 처리 결과 유지를 위하여, 페이징 처리 시 기존의 <form> 태그 밑에 <input> 태그로 Criteria 클래스의 변수 pageNum, amount를 추가했던 것과 동일하게 type과 keyword의 처리를 추가한다. 

 

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

- 조회 페이지에서 수정/삭제 페이지로의 이동은 GET 방식을 통해서 이동하고, 이동 방식 역시 <form> 태그를 이용하는 방식이므로 기존의 <form> 태그 밑에 <input> 태그로 추가적인 type과 keyword 조건만을 추가한다. (앞의 조회 페이지에서의 검색 처리와 동일)

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

- 리다이렉트는 GET방식으로 이루어지기 때문에 추가적인 파라미터를 처리해야한다.

- modify.jsp에서는 다시 목록으로 이동하는 경우에 필요한 파라미터만 전송하기 위해서 <form> 태그의 모든 내용을 지우고 다시 추가하는 방식을 이용했으므로 keyword와 type 역시 추가하도록 관련 자바스크립트 코드를 수정한다. 

 

1) UriComponentsBuilder를 이용하는 링크 생성

 

- 웹페이지에서 매번 파라미터를 유지하는 일이 번거롭고 힘들다면 한 번쯤 UriComponentsBuilder라는 클래스를 이용해볼 필요가 있다.

- org.springframework.web.util.UriComponentsBuilder는 여러 개의 파라미터들을 연결해서 URL의 형태로 만들어주는 기능을 가지고 있다.

- URL을 만들어주면 리다이렉트를 하거나, <form> 태그를 사용하는 상황을 많이 줄여줄 수 있다.

 

-검색 조건을 유지하는 org.zerock.domain.Criteria 클래스에 링크를 생성하는 기능을 추가한다.

	//UriComponentsBuilder를 이용하여 링크를 생성하는 기능을 추가 
	public String getListLink() {
		
		UriComponentsBuilder builder = UriComponentsBuilder.fromPath("")
				.queryParam("pageNum", this.pageNum)
				.queryParam("amount", this.getAmount())
				.queryParam("type", this.getType())
				.queryParam("keyword",this.getKeyword());
		
		return builder.toUriString();
	}

- UriComponentsBuilder는 queryParam()이라는 메서드를 이용해서 필요한 파라미터들을 손쉽게 추가할 수 있다.

- 예를 들어, 아래와 같은 조건들로 Criteria가 생성된다고 가정해보자

Criteria cri = new Criteria();
  cri.setPageNum(3);
  cri.setAmount(20);
  cri.setKeyword("새로");
  cri.setType("TC");

- 위와 같은 데이터를 가진 Criteria의 getListLink()의 결과는 '?pageNum=3&amount=20&type=TC&keyword=....' 와 같이 GET방식에 적합한 URL 인코딩된 결과로 만들어진다. 

- 따라서 getListLink()를 이용하면 BoardController의 modify()와 remove()를 간단하게 정리할 수 있다.

예) 기존의 방법:  addAttribute()를 사용하여 파라미터를 전달해 리다이렉트를 처리. 

    //4. 게시물 수정 처리 
	//9. 페이징 처리 중 수정 페이지에서 다시 원래 목록 페이지로의 이동 시 페이지 번호 유지를 위해 Criteria를 파라미터로 추가한다.
	@PostMapping("/modify")
	public String modify(BoardVO board, @ModelAttribute("cri")Criteria cri, RedirectAttributes rttr) {
		log.info("/modify" +board);
		
		// 수정에 성공한 경우에만 RedirectAttributes에 추가  
		if (service.modify(board)) {
			rttr.addFlashAttribute("result", "sucess");
		}
		
		// 다시 원래 목록 페이지로의 이동을 위해 URL 뒤에 pageNum값과 amount값을 입력하여 이동할 수 있도록 수정.
		rttr.addAttribute("pageNum", cri.getPageNum());
		rttr.addAttribute("amount", cri.getAmount());
		// 검색 처리 결과를 유지하기 위해 type과 keyword 조건을 리다이렉트 시에 포함시켜야 한다.
		rttr.addAttribute("type", cri.getType());
		rttr.addAttribute("keyword", cri.getKeyword());
		
		return "redirect:/board/list";
		
	}
	
	//5. 게시물 삭제 처리 
	//10. 페이징 처리 중 삭제 후 다시 원래 목록 페이지로의 이동 시 페이지 번호 유지를 위해 Criteria를 파라미터로 추가한다.
	@PostMapping("/remove")
	public String remove(@RequestParam("bno") Long bno, @ModelAttribute("cri")Criteria cri, RedirectAttributes rttr ) {
		log.info("remove...." + bno);
		if(service.remove(bno)) {
			rttr.addFlashAttribute("result", "sucess");
		}
		
		// 다시 원래 목록 페이지로의 이동을 위해 URL 뒤에 pageNum값과 amount값을 입력하여 이동할 수 있도록 수정.
		rttr.addAttribute("pageNum", cri.getPageNum());
		rttr.addAttribute("amount", cri.getAmount());
		// 검색 처리 결과를 유지하기 위해 type과 keyword 조건을 리다이렉트 시에 포함시켜야 한다.
		rttr.addAttribute("type", cri.getType());
		rttr.addAttribute("keyword", cri.getKeyword());
		
		return "redirect:/board/list";

예) 새로운 방법: UriComponentsBuilder로 생성된 URL을 이용하여 리다이렉트를 처리.

	//4. 게시물 수정 처리 
	//9. 페이징 처리 중 수정 페이지에서 다시 원래 목록 페이지로의 이동 시 페이지 번호 유지를 위해 Criteria를 파라미터로 추가한다.
	@PostMapping("/modify")
	public String modify(BoardVO board, @ModelAttribute("cri")Criteria cri, RedirectAttributes rttr) {
		log.info("/modify" +board);
		
		// 수정에 성공한 경우에만 RedirectAttributes에 추가  
		if (service.modify(board)) {
			rttr.addFlashAttribute("result", "sucess");
		}
		
		// 2. UriComponentsBuilder로 생성된 URL을 이용하여 리다이렉트를 처리. 
		return "redirect:/board/list" + cri.getListLink();
		
	}
	
	//5. 게시물 삭제 처리 
	//10. 페이징 처리 중 삭제 후 다시 원래 목록 페이지로의 이동 시 페이지 번호 유지를 위해 Criteria를 파라미터로 추가한다.
	@PostMapping("/remove")
	public String remove(@RequestParam("bno") Long bno, @ModelAttribute("cri")Criteria cri, RedirectAttributes rttr ) {
		log.info("remove...." + bno);
		if(service.remove(bno)) {
			rttr.addFlashAttribute("result", "sucess");
		}

		// 2. UriComponentsBuilder로 생성된 URL을 이용하여 리다이렉트를 처리. 
		return "redirect:/board/list" + cri.getListLink();
	}

 

- UriComponentsBuilder로 생성된 URL은 화면에서도 유용하게 사용될 수 있는데, 주로 자바스크립트를 사용할 수 없는 상황에서 링크를 처리해야 하는 상황에서 사용된다.