Spring-MVC 게시판 예제 10 - 검색처리, 동적SQL

 

본 포스팅은 코드로 배우는스프링 웹프로젝트를 참조하여 작성한 내용입니다. 개인적으로 학습한 내용을 복습하기 위해 기록한 내용이기 때문에 오류가 있다면 지적 부탁드리겠습니다. 포스팅의 예제는 STS 또는 Eclipse를 사용하지 않고 IntelliJ를 통해 구현하고 있습니다. 그래서 기존의 STS에서 생성된 Spring 프로젝트의 스프링관련 설정 파일명과 프로젝트 구조가 약간 다를 수 있습니다. IntelliJ를 통한 Spring MVC 프로젝트 생성 포스팅을 참고해주시면 감사하겠습니다. 현재 프로젝트의 전체코드는 Github 저장소에서 확인하실 수 있습니다.


Spring-MVC 기본 개념 및 테스트 예제 관련 포스팅 링크

  1. IntelliJ에서 Spring MVC Project 생성하기
  2. Spring MVC - MySQL 연결테스트
  3. Spring MVC - MyBatis 설정 및 테스트
  4. Spring MVC 구조
  5. Spring MVC - Controller 작성 연습, WAS없이 Controller 테스트 해보기
  6. SpringMVC + MyBatis, DAO구현 테스트

Spring-MVC 게시판 예제 관련 포스팅 링크

  1. IntelliJ에서 Spring MVC Project 생성하기
  2. Bootstrap 템플릿 세팅 (AdminLTE)
  3. 기본적인 CRUD 구현 : 영속계층, 비지니스계층
  4. 기본적인 CRUD 구현 : Control, Presentation 계층
  5. 예외처리
  6. 페이징처리 : Persistence, Business 계층
  7. 페이징처리 : Control, Presentation 계층
  8. 페이징처리 개선, 목록페이지 정보 유지
  9. 프로젝트 구조 변경 및 수정사항

1. 검색에 필요한 데이터와 SearchCriteria 클래스 작성하기

1.1 검색에 필요한 데이터

페이징처리와 함께 검색 기능을 구현하기 위해서는 아래와 같이 4가지 정보가 필요하다.

  • 현재 페이지 번호(page)
  • 페이지 당 출력할 게시글의 갯수(perPageNum)
  • 검색 조건(searchType)
  • 검색 키워드(keyword)

위의 정보 중에서 pageperPageNum의 경우 앞서 페이징 처리를 구현하면서 사용한 Criteria클래스에 이미 멤버변수로 선언해 사용하고 있다. 하지만 searchTypekeyword는 어디에도 사용한 적이 없기 때문에 새로 추가해야만 사용이 가능하다. 그래서 Criteria클래스에 추가적으로 멤버변수로 선언해 사용하면 된다. 그러나 검색 처리 구현 이전의 코드와의 차이점을 구분하기 위해 Criteria에 멤버변수를 추가적으로 선언하는 방식 대신 Criteria클래스를 상속받은 SearchCriteria라는 클래스를 새로 생성해 새로운 멤버 변수로 작성해준다. 이렇게 함으로써 기존의 코드와 차이점을 구분하는 것 뿐만아니라, 기존코드의 수정을 최소화 할 수 있게된다.

SearchCriteria(자손클래스) -------------> Criteria(부모클래스)

1.2 SearchCriteria 클래스 작성하기

앞서 설명한 것처럼 SearchCriteria클래스를 src/main/java/기본패키지/commons/paging패키지에 생성하고, 검색조건과 검색키워드를 멤버변수로 선언해준다.

public class SearchCriteria extends Criteria {

    private String searchType;
    private String keyword;

    // Getter, Setter, toString() 생략
}

2. 컨트롤러(ArticlePagingSearchController) 작성하기

이전 포스팅에서 정리한 것처럼 기존의 컨트롤러에 메서드를 추가하거나, 수정하는 방식이 아닌 새로운 컨트롤러를 생성해서 기존 코드와 차이점을 구분할 수 있도록 src/main/java/기본패키지/article/controller패키지에 클래스 새로 생성해 아래와 같이 작성해준다.

@Controller
@RequestMapping("/article/paging/search")
public class ArticlePagingSearchController {

    private static final Logger logger = LoggerFactory.getLogger(ArticlePagingSearchController.class);

    private final ArticleService articleService;

    @Inject
    public ArticlePagingSearchController(ArticleService articleService) {
        this.articleService = articleService;
    }

    @RequestMapping(value = "/list", method = RequestMethod.GET)
    public String list(@ModelAttribute("searchCriteria") SearchCriteria searchCriteria,
                       Model model) throws Exception {

        logger.info("search list() called ...");

        PageMaker pageMaker = new PageMaker();
        pageMaker.setCriteria(searchCriteria);
        pageMaker.setTotalCount(articleService.countArticles(searchCriteria));

        model.addAttribute("articles", articleService.listCriteria(searchCriteria));
        model.addAttribute("pageMaker", pageMaker);

        return "article/search/list";
    }

}

이전에 작성했던 컨트롤러와 비교해보면 list()의 매개변수의 타입이 Criteria에서 SearchCriteria로 변경되었다는 것 외에는 차이점은 없다.

3. 목록 페이지(list.jsp) : 검색창 만들기, 페이지이동URI 수정, 검색버튼 동작 처리

/WEB-INF/views/article/search디렉토리를 생성하고, 기존의 list.jsp를 복사하여 붙어 넣어준다. 이것도 마찬가지로 기존의 list.jsp파일을 수정하지 않고, 새로 만들어주는 이유는 기존의 코드와 차이점을 구분하기 위해서이다.

3.1 검색창 만들기

게시글 페이지 번호 영역 바로 아래에 <div class="box-footer"></div>을 작성하고, 안에 아래와 같이 코드를 작성한다.

<div class="box-footer">
    <div class="form-group col-sm-2">
        <select class="form-control" name="searchType" id="searchType">
            <option value="n" <c:out value="${searchCriteria.searchType == null ? 'selected' : ''}"/>>:::::: 선택 ::::::</option>
            <option value="t" <c:out value="${searchCriteria.searchType eq 't' ? 'selected' : ''}"/>>제목</option>
            <option value="c" <c:out value="${searchCriteria.searchType eq 'c' ? 'selected' : ''}"/>>내용</option>
            <option value="w" <c:out value="${searchCriteria.searchType eq 'w' ? 'selected' : ''}"/>>작성자</option>
            <option value="tc" <c:out value="${searchCriteria.searchType eq 'tc' ? 'selected' : ''}"/>>제목+내용</option>
            <option value="cw" <c:out value="${searchCriteria.searchType eq 'cw' ? 'selected' : ''}"/>>내용+작성자</option>
            <option value="tcw" <c:out value="${searchCriteria.searchType eq 'tcw' ? 'selected' : ''}"/>>제목+내용+작성자</option>
        </select>
    </div>
    <div class="form-group col-sm-10">
        <div class="input-group">
            <input type="text" class="form-control" name="keyword" id="keywordInput" value="${searchCriteria.keyword}" placeholder="검색어">
            <span class="input-group-btn">
                <button type="button" class="btn btn-primary btn-flat" id="searchBtn">
                    <i class="fa fa-search"></i> 검색
                </button>
            </span>
        </div>
    </div>
    <div class="pull-right">
        <button type="button" class="btn btn-success btn-flat" id="writeBtn">
            <i class="fa fa-pencil"></i> 글쓰기
        </button>
    </div>
</div>

검색 조건은 selectbox를 통해 사용자가 값을 선택할 수 있도록 구현하는데 값이 가지는 의미는 아래와 같다.

  • 검색조건 없음 : n
  • 제목 : t
  • 내용 : c
  • 작성자 : w
  • 제목 or 내용 : tc
  • 내용 or 작성자 : cw
  • 제목 or 내용 or 작성자 : tcw
검색한 게시글 목록 페이지 ------> 특정 게시글 조회/수정/삭제 처리 ------> 검색한 게시글 목록 페이지

그리고 검색된 목록에서 사용자가 특정게시글을 조회하거나 수정/삭제한 뒤 다시 검색한 게시글 목록으로 돌아가기 위해서는 검색한 목록의 정보(검색조건, 검색키워드)를 유지할 필요가 있다. 그래서 정보를 list.jsp에 다시 세팅해주도록 jstl의 <c:out>을 이용한다.

3.2 페이지 이동 URI 수정

기존의 게시글 목록 페이지 번호와 게시글 제목에는 아래와 같이 URI링크가 걸려있는데 검색한 게시글의 목록 정보를 유지하기 위해서는 &searchType=값&keyword=값과 같은 GET방식으로 URI에 붙이는 작업이 필요하다.

  • 게시글 목록 페이지 번호의 링크 : <a href="/article/paging/search/list?page=2&pagePageNum=10">
  • 게시글 제목의 링크 : <a href="/article/paging/read?page=1&perPageNum=10&articleNo=1000">

3.2.1 PageMaker 클래스에 URI 자동생성 메서드 추가하기

makeSearch()메서드에 검색조건과 검색키워드를 처리를 추가로 작성해주고, encoding()메서드에서는 검색키워드를 인코딩처리를 위한 로직을 구현해준다.

public String makeSearch(int page) {

    UriComponents uriComponents = UriComponentsBuilder.newInstance()
            .queryParam("page", page)
            .queryParam("pagePageNum", criteria.getPerPageNum())
            .queryParam("searchType", ((SearchCriteria) criteria).getSearchType())
            .queryParam("keyword", encoding(((SearchCriteria) criteria).getKeyword()))
            .build();

    return uriComponents.toUriString();
}

private String encoding(String keyword) {
    if (keyword == null || keyword.trim().length() == 0) {
        return "";
    }

    try {
        return URLEncoder.encode(keyword, "UTF-8");
    } catch (UnsupportedEncodingException e) {
        return "";
    }
}

3.2.2 list.jsp의 페이지 번호 링크 수정

위에서 새로 추가한 메서드를 호출하여 목록 페이지의 번호 링크에 검색조건과 검색키워드가 추가된 URI가 생성되도록 아래와 같이 수정해준다.

<div class="box-footer">
    <div class="text-center">
        <ul class="pagination">
            <c:if test="${pageMaker.prev}">
                <li><a href="${path}/article/paging/search/list${pageMaker.makeSearch(pageMaker.startPage - 1)}">이전</a></li>
            </c:if>
            <c:forEach begin="${pageMaker.startPage}" end="${pageMaker.endPage}" var="idx">
                <li <c:out value="${pageMaker.criteria.page == idx ? 'class=active' : ''}"/>>
                    <a href="${path}/article/paging/search/list${pageMaker.makeSearch(idx)}">${idx}</a>
                </li>
            </c:forEach>
            <c:if test="${pageMaker.next && pageMaker.endPage > 0}">
                <li><a href="${path}/article/paging/search/list?${pageMaker.makeSearch(pageMaker.endPage + 1)}">다음</a></li>
            </c:if>
        </ul>
    </div>
</div>

3.2.3 list.jsp의 게시글 조회 링크 수정

게시글 제목에 걸린 게시글 조회 링크도 아래와 같이 수정해준다.

<td>
  <a href="${path}/article/paging/search/read${pageMaker.makeSearch(pageMaker.criteria.page)}&articleNo=${article.articleNo}">
    ${article.title}
  </a>
</td>

3.3 검색버튼 동작 처리

검색버튼 클릭 이벤트가 발생하면 기존의 URI에 검색조건과 검색키워드를 GET방식으로 붙여 요청하게 처리한다. 그리고 encodeURIComponent()은 URI로 데이터를 전달하기 위해서 문자열을 인코딩해준다.

$(document).ready(function () {

    $("#searchBtn").on("click", function (event) {
        self.location =
            "/article/paging/search/list${pageMaker.makeQuery(1)}"
            + "&searchType=" + $("select option:selected").val()
            + "&keyword=" + encodeURIComponent($("#keywordInput").val());
    });
});

3.4 검색처리를 위한 목록페이지 완성 모습

list

4. 검색 처리를 위한 영속 계층 구현 : MyBatis 동적 SQL

4.1 ArticleDAO 인터페이스

검색된 목록과 검색된 게시글의 갯수를 리턴하는 추상 메서드를 선언해준다.

List<ArticleVO> listSearch(SearchCriteria searchCriteria) throws Exception;

int countSearchedArticles(SearchCriteria searchCriteria) throws Exception;

4.2 ArticleDAOImpl 클래스

ArticleDAO인터페이스에 선언된 추상메서드를 오버라이드하여 구현해준다.

@Override
public List<ArticleVO> listSearch(SearchCriteria searchCriteria) throws Exception {
    return sqlSession.selectList(NAMESPACE + ".listSearch", searchCriteria);
}

@Override
public int countSearchedArticles(SearchCriteria searchCriteria) throws Exception {
    return sqlSession.selectOne(NAMESPACE + ".countSearchedArticles", searchCriteria);
}

4.3 MyBatis의 표현식

화면에서 사용자가 선택한 검색조건에 따라 검색요청을 처리하는 SQL문이 달라지기 때문에 이를 처리하기 위해서는 Mybatis SQL Mapper에서 동적 SQL문을 작성해줘야한다. 동적 SQL문을 작성하기 위해 표현식을 간단하게 정리해보자.

  • if : 코드로 작성할 때의 if구문에 대한 처리
    <if test="title != null">
      AND title like #{like}
    </if>
    
  • choose(when, otherwise) : switch와 같은 상황에 대한 처리
    <choose>
      <when test="title != null">
        AND title like #{like}
      </when>  
      <when test="author != null and author.name != null">
        AND author_name like #{author.name}
      </when>
      <otherwise>
        AND feathered = 1
      </otherwise>
    </choose>
    
  • trim(where, set) : 로직을 처리하면서 필요한 구문을 변경
    <trim prefix="WHERE" prefixOverrides="AND|OR">
      ...
    </trim>
    
  • foreach : 컬렉션에 대한 순환처리
    <foreach item="item" index="index" collection="list" open="(" seperator="," close="">
      #{item}
    </foreach>
    

4.4 articleMapper.xml에서 동적 SQL문 작성

<if>문을 통해 동적 SQL문을 통해 상황에 맞게 검색을 처리하도록 코드를 작성해준다. 그리고 SQL문이 중복되어 사용될 경우 <include>를 통해 SQL 중복을 제거하고, SQL의 재사용을 가능하게 처리해준다.

<select id="listSearch" resultMap="ArticleResultMap">
    <![CDATA[
    SELECT
        article_no,
        title,
        content,
        writer,
        regdate,
        viewcnt
    FROM tbl_article
    WHERE article_no > 0
    ]]>
      <include refid="search"/>
    <![CDATA[
    ORDER BY article_no DESC, regdate DESC
    LIMIT #{pageStart}, #{perPageNum}
    ]]>
</select>

<select id="countSearchedArticles" resultType="int">
    <![CDATA[
    SELECT
        COUNT(article_no)
    FROM tbl_article
    WHERE article_no > 0
    ]]>
    <include refid="search"/>
</select>

<sql id="search">
    <if test="searchType != null">
        <if test="searchType == 't'.toString()">
            AND title LIKE CONCAT('%', #{keyword}, '%')
        </if>
        <if test="searchType == 'c'.toString()">
            AND content LIKE CONCAT('%', #{keyword}, '%')
        </if>
        <if test="searchType == 'w'.toString()">
            AND writer LIKE CONCAT('%', #{keyword}, '%')
        </if>
        <if test="searchType == 'tc'.toString()">
            AND (
                title LIKE CONCAT('%', #{keyword}, '%')
                OR content LIKE CONCAT('%', #{keyword}, '%')
            )
        </if>
        <if test="searchType == 'cw'.toString()">
            AND (
                content LIKE CONCAT('%', #{keyword}, '%')
                OR writer LIKE CONCAT('%', #{keyword}, '%')
            )
        </if>
        <if test="searchType == 'tcw'.toString()">
            AND (
                title LIKE CONCAT('%', #{keyword}, '%')
                OR content LIKE CONCAT('%', #{keyword}, '%')
                OR writer LIKE CONCAT('%', #{keyword}, '%')
            )
        </if>
    </if>
</sql>

4.5 동적 SQL 테스트

테스트 코드

@Test
public void testDynamic1() throws Exception {

    SearchCriteria searchCriteria = new SearchCriteria();
    searchCriteria.setPage(1);
    searchCriteria.setKeyword("999");
    searchCriteria.setSearchType("t");

    logger.info("======================");

    List<ArticleVO> articles = articleDAO.listSearch(searchCriteria);

    for (ArticleVO article : articles) {
        logger.info(article.getArticleNo() + " : " + article.getTitle());
    }

    logger.info("======================");

    logger.info("searched articles count : " + articleDAO.countSearchedArticles(searchCriteria));
}

테스트 결과

INFO : com.doubles.mvcboard.article.ArticleDAOTest - ======================
INFO : jdbc.connection - 1. Connection opened
INFO : jdbc.audit - 1. Connection.new Connection returned
INFO : jdbc.audit - 1. Connection.getAutoCommit() returned true
INFO : jdbc.audit - 1. PreparedStatement.new PreparedStatement returned
INFO : jdbc.audit - 1. Connection.prepareStatement(SELECT
            article_no,
            title,
            content,
            writer,
            regdate,
            viewcnt
        FROM tbl_article
        WHERE article_no > 0
          AND title LIKE CONCAT('%', ?, '%')
        ORDER BY article_no DESC, regdate DESC
        LIMIT ?, ?) returned net.sf.log4jdbc.sql.jdbcapi.PreparedStatementSpy@2657d4dd
INFO : jdbc.audit - 1. PreparedStatement.setString(1, "999") returned
INFO : jdbc.audit - 1. PreparedStatement.setInt(2, 0) returned
INFO : jdbc.audit - 1. PreparedStatement.setInt(3, 10) returned
INFO : jdbc.sqlonly - SELECT article_no, title, content, writer, regdate, viewcnt FROM tbl_article WHERE article_no
> 0 AND title LIKE CONCAT('%', '999', '%') ORDER BY article_no DESC, regdate DESC LIMIT 0,
10

INFO : jdbc.sqltiming - SELECT article_no, title, content, writer, regdate, viewcnt FROM tbl_article WHERE article_no
> 0 AND title LIKE CONCAT('%', '999', '%') ORDER BY article_no DESC, regdate DESC LIMIT 0,
10
 {executed in 10 msec}
INFO : jdbc.audit - 1. PreparedStatement.execute() returned true
INFO : jdbc.resultset - 1. ResultSet.new ResultSet returned
INFO : jdbc.audit - 1. PreparedStatement.getResultSet() returned net.sf.log4jdbc.sql.jdbcapi.ResultSetSpy@475c9c31
INFO : jdbc.resultset - 1. ResultSet.getMetaData() returned com.mysql.jdbc.ResultSetMetaData@5276d6ee - Field level information:
	com.mysql.jdbc.Field@71687585[catalog=spring_ex,tableName=tbl_article,originalTableName=tbl_article,columnName=article_no,originalColumnName=article_no,mysqlType=3(FIELD_TYPE_LONG),flags= AUTO_INCREMENT PRIMARY_KEY, charsetIndex=63, charsetName=US-ASCII]
	com.mysql.jdbc.Field@1807f5a7[catalog=spring_ex,tableName=tbl_article,originalTableName=tbl_article,columnName=title,originalColumnName=title,mysqlType=253(FIELD_TYPE_VAR_STRING),flags= BINARY, charsetIndex=83, charsetName=UTF-8]
	com.mysql.jdbc.Field@1b919693[catalog=spring_ex,tableName=tbl_article,originalTableName=tbl_article,columnName=content,originalColumnName=content,mysqlType=253(FIELD_TYPE_VAR_STRING),flags= BINARY BLOB, charsetIndex=83, charsetName=UTF-8]
	com.mysql.jdbc.Field@7fb4f2a9[catalog=spring_ex,tableName=tbl_article,originalTableName=tbl_article,columnName=writer,originalColumnName=writer,mysqlType=253(FIELD_TYPE_VAR_STRING),flags= BINARY, charsetIndex=83, charsetName=UTF-8]
	com.mysql.jdbc.Field@4dc27487[catalog=spring_ex,tableName=tbl_article,originalTableName=tbl_article,columnName=regdate,originalColumnName=regdate,mysqlType=7(FIELD_TYPE_TIMESTAMP),flags= BINARY, charsetIndex=63, charsetName=US-ASCII]
	com.mysql.jdbc.Field@6a4f1a55[catalog=spring_ex,tableName=tbl_article,originalTableName=tbl_article,columnName=viewcnt,originalColumnName=viewcnt,mysqlType=3(FIELD_TYPE_LONG),flags=, charsetIndex=63, charsetName=US-ASCII]
INFO : jdbc.resultset - 1. ResultSet.getType() returned 1003
INFO : jdbc.resultset - 1. ResultSet.next() returned true
INFO : jdbc.resultset - 1. ResultSet.getInt(article_no) returned 999
INFO : jdbc.resultset - 1. ResultSet.wasNull() returned false
INFO : jdbc.resultset - 1. ResultSet.getString(title) returned 999번째 글 제목입니다...
INFO : jdbc.resultset - 1. ResultSet.wasNull() returned false
INFO : jdbc.resultset - 1. ResultSet.getString(content) returned 999번재 글 내용입니다...
INFO : jdbc.resultset - 1. ResultSet.wasNull() returned false
INFO : jdbc.resultset - 1. ResultSet.getString(writer) returned user09
INFO : jdbc.resultset - 1. ResultSet.wasNull() returned false
INFO : jdbc.resultset - 1. ResultSet.getTimestamp(regdate) returned 2018-03-06 19:01:55.0
INFO : jdbc.resultset - 1. ResultSet.wasNull() returned false
INFO : jdbc.resultset - 1. ResultSet.getInt(viewcnt) returned 0
INFO : jdbc.resultset - 1. ResultSet.wasNull() returned false
INFO : jdbc.resultsettable -
|-----------|-----------------|-----------------|-------|----------------------|--------|
|article_no |title            |content          |writer |regdate               |viewcnt |
|-----------|-----------------|-----------------|-------|----------------------|--------|
|999        |999번째 글 제목입니다... |999번재 글 내용입니다... |user09 |2018-03-06 19:01:55.0 |0       |
|-----------|-----------------|-----------------|-------|----------------------|--------|

INFO : jdbc.resultset - 1. ResultSet.next() returned false
INFO : jdbc.resultset - 1. ResultSet.close() returned void
INFO : jdbc.audit - 1. PreparedStatement.getConnection() returned net.sf.log4jdbc.sql.jdbcapi.ConnectionSpy@3aa078fd
INFO : jdbc.audit - 1. Connection.getMetaData() returned com.mysql.jdbc.JDBC4DatabaseMetaData@d23e042
INFO : jdbc.audit - 1. PreparedStatement.getMoreResults() returned false
INFO : jdbc.audit - 1. PreparedStatement.getUpdateCount() returned -1
INFO : jdbc.audit - 1. PreparedStatement.close() returned
INFO : jdbc.connection - 1. Connection closed
INFO : jdbc.audit - 1. Connection.close() returned
INFO : com.doubles.mvcboard.article.ArticleDAOTest - 999 : 999번째 글 제목입니다...
INFO : com.doubles.mvcboard.article.ArticleDAOTest - ======================
INFO : jdbc.connection - 2. Connection opened
INFO : jdbc.audit - 2. Connection.new Connection returned
INFO : jdbc.audit - 2. Connection.getAutoCommit() returned true
INFO : jdbc.audit - 2. PreparedStatement.new PreparedStatement returned
INFO : jdbc.audit - 2. Connection.prepareStatement(SELECT
            COUNT(article_no)
        FROM tbl_article
        WHERE article_no > 0
        AND title LIKE CONCAT('%', ?, '%')) returned net.sf.log4jdbc.sql.jdbcapi.PreparedStatementSpy@2254127a
INFO : jdbc.audit - 2. PreparedStatement.setString(1, "999") returned
INFO : jdbc.sqlonly - SELECT COUNT(article_no) FROM tbl_article WHERE article_no > 0 AND title LIKE CONCAT('%', '999',
'%')

INFO : jdbc.sqltiming - SELECT COUNT(article_no) FROM tbl_article WHERE article_no > 0 AND title LIKE CONCAT('%', '999',
'%')
 {executed in 3 msec}
INFO : jdbc.audit - 2. PreparedStatement.execute() returned true
INFO : jdbc.resultset - 2. ResultSet.new ResultSet returned
INFO : jdbc.audit - 2. PreparedStatement.getResultSet() returned net.sf.log4jdbc.sql.jdbcapi.ResultSetSpy@51891008
INFO : jdbc.resultset - 2. ResultSet.getMetaData() returned com.mysql.jdbc.ResultSetMetaData@2f953efd - Field level information:
	com.mysql.jdbc.Field@f68f0dc[catalog=,tableName=,originalTableName=,columnName=COUNT(article_no),originalColumnName=,mysqlType=8(FIELD_TYPE_LONGLONG),flags= BINARY, charsetIndex=63, charsetName=US-ASCII]
INFO : jdbc.resultset - 2. ResultSet.getType() returned 1003
INFO : jdbc.resultset - 2. ResultSet.next() returned true
INFO : jdbc.resultset - 2. ResultSet.getInt(COUNT(article_no)) returned 1
INFO : jdbc.resultset - 2. ResultSet.wasNull() returned false
INFO : jdbc.resultsettable -
|------------------|
|count(article_no) |
|------------------|
|1                 |
|------------------|

INFO : jdbc.resultset - 2. ResultSet.next() returned false
INFO : jdbc.resultset - 2. ResultSet.close() returned void
INFO : jdbc.audit - 2. PreparedStatement.getConnection() returned net.sf.log4jdbc.sql.jdbcapi.ConnectionSpy@24c1b2d2
INFO : jdbc.audit - 2. Connection.getMetaData() returned com.mysql.jdbc.JDBC4DatabaseMetaData@7dc19a70
INFO : jdbc.audit - 2. PreparedStatement.getMoreResults() returned false
INFO : jdbc.audit - 2. PreparedStatement.getUpdateCount() returned -1
INFO : jdbc.audit - 2. PreparedStatement.close() returned
INFO : jdbc.connection - 2. Connection closed
INFO : jdbc.audit - 2. Connection.close() returned
INFO : com.doubles.mvcboard.article.ArticleDAOTest - searched articles count : 1

위에서 작성한 동적 SQL문이 제대로 작동하는지 확인하기 위해 테스트 코드를 작성하고, 확인해보자.

5. 비지니스 계층 구현

검색처리를 위한 컨트롤러와 영속계층을 연결해주는 비지니스 로직은 아래와 같이 작성해준다.

5.1 ArticleService 인터페이스

List<ArticleVO> listSearch(SearchCriteria searchCriteria) throws Exception;

int countSearchedArticles(SearchCriteria searchCriteria) throws Exception;

5.2 ArticleServiceImpl 클래스

@Override
public List<ArticleVO> listSearch(SearchCriteria searchCriteria) throws Exception {
    return articleDAO.listSearch(searchCriteria);
}

@Override
public int countSearchedArticles(SearchCriteria searchCriteria) throws Exception {
    return articleDAO.countSearchedArticles(searchCriteria);
}

6. 컨트롤러(ArticlePagingSearchController) 수정

6.1 목록 페이지 매핑 메서드 수정

동적 SQL을 통해 검색처리된 게시글의 목록이 출력되게 아래와 같이 코드를 수정해준다.

@RequestMapping(value = "/list", method = RequestMethod.GET)
public String list(@ModelAttribute("searchCriteria") SearchCriteria searchCriteria,
                   Model model) throws Exception {

    logger.info("search list() called ...");

    PageMaker pageMaker = new PageMaker();
    pageMaker.setCriteria(searchCriteria);
    //pageMaker.setTotalCount(articleService.countArticles(searchCriteria));
    pageMaker.setTotalCount(articleService.countSearchedArticles(searchCriteria));

    //model.addAttribute("articles", articleService.listCriteria(searchCriteria));
    model.addAttribute("articles", articleService.listSearch(searchCriteria));
    model.addAttribute("pageMaker", pageMaker);

    return "article/search/list";
}

6.2 조회 페이지 매핑 메서드 수정

게시글의 검색정보가 유지되도록 read()메서드의 매개변수 타입을 SearchCriteria로 변경한다.

// 조화 페이지
@RequestMapping(value = "/read", method = RequestMethod.GET)
public String read(@RequestParam("articleNo") int articleNo,
                   @ModelAttribute("searchCriteria") SearchCriteria searchCriteria,
                   Model model) throws Exception {

    logger.info("search read() called ...");
    model.addAttribute("article", articleService.read(articleNo));

    return "article/search/read";
}

6.3 수정 페이지 매핑, 수정처리 매핑 메서드 수정

게시글의 검색정보가 유지되도록 modifyGET()메서드의 매개변수 타입을 SearchCriteria로 변경한다.

// 수정 페이지
@RequestMapping(value = "/modify", method = RequestMethod.GET)
public String modifyGET(@RequestParam("articleNo") int articleNo,
                        @ModelAttribute("searchCriteria") SearchCriteria searchCriteria,
                        Model model) throws Exception {

    logger.info("search modifyGet() called ...");
    logger.info(searchCriteria.toString());
    model.addAttribute("article", articleService.read(articleNo));

    return "article/search/modify";
}

수정처리가 완료되고, 목록페이지로 리다이렉트될 때 검색정보가 유지될 수 있도록 아래와 같이 검색조건과 검색키워드를 redirectAttributes.addAttribute()에 저장해준다.

// 수정 처리
@RequestMapping(value = "/modify", method = RequestMethod.POST)
public String modifyPOST(ArticleVO articleVO,
                         SearchCriteria searchCriteria,
                         RedirectAttributes redirectAttributes) throws Exception {

    logger.info("search modifyPOST() called ...");
    articleService.update(articleVO);
    redirectAttributes.addAttribute("page", searchCriteria.getPage());
    redirectAttributes.addAttribute("perPageNum", searchCriteria.getPerPageNum());
    redirectAttributes.addAttribute("searchType", searchCriteria.getSearchType());
    redirectAttributes.addAttribute("keyword", searchCriteria.getKeyword());
    redirectAttributes.addFlashAttribute("msg", "modSuccess");

    return "redirect:/article/paging/search/list";
}

6.3 삭제 처리 매핑 메서드 수정

수정처리와 마찬가지로 삭제처리가 완료되고, 목록페이지로 리다이렉트될 때 검색정보가 유지될 수 있도록 아래와 같이 검색조건과 검색키워드를 redirectAttributes.addAttribute()에 저장해준다.

// 삭제 처리
@RequestMapping(value = "/remove", method = RequestMethod.POST)
public String remove(@RequestParam("articleNo") int articleNo,
                     SearchCriteria searchCriteria,
                     RedirectAttributes redirectAttributes) throws Exception {

    logger.info("search remove() called ...");
    articleService.delete(articleNo);
    redirectAttributes.addAttribute("page", searchCriteria.getPage());
    redirectAttributes.addAttribute("perPageNum", searchCriteria.getPerPageNum());
    redirectAttributes.addAttribute("searchType", searchCriteria.getSearchType());
    redirectAttributes.addAttribute("keyword", searchCriteria.getKeyword());
    redirectAttributes.addFlashAttribute("msg", "delSuccess");

    return "redirect:/article/paging/search/list";
}

7. 조회/삭제, 수정 페이지 수정

7.1 조회 페이지, 삭제처리(read.jsp)

7.1.1 HTML코드

<input>타입의 hidden속성으로 검색조건과 검색키워드를 추가해준다.

<div class="box-footer">
    <form role="form" method="post">
        <input type="hidden" name="articleNo" value="${article.articleNo}">
        <input type="hidden" name="page" value="${searchCriteria.page}">
        <input type="hidden" name="perPageNum" value="${searchCriteria.perPageNum}">
        <input type="hidden" name="searchType" value="${searchCriteria.searchType}">
        <input type="hidden" name="keyword" value="${searchCriteria.keyword}">
    </form>
    <button type="submit" class="btn btn-primary listBtn"><i class="fa fa-list"></i> 목록</button>
    <div class="pull-right">
        <button type="submit" class="btn btn-warning modBtn"><i class="fa fa-edit"></i> 수정</button>
        <button type="submit" class="btn btn-danger delBtn"><i class="fa fa-trash"></i> 삭제</button>
    </div>
</div>

7.1.2 JS코드

각각 버튼의 클릭이벤트가 발생하면 해당 페이지로 이동하기 위해 attr()로 속성을 제어해준다.

$(document).ready(function () {

    var formObj = $("form[role='form']");
    console.log(formObj);

    $(".modBtn").on("click", function () {
        formObj.attr("action", "/article/paging/search/modify");
        formObj.attr("method", "get");
        formObj.submit();
    });

    $(".delBtn").on("click", function () {
        formObj.attr("action", "/article/paging/search/remove");
        formObj.submit();
    });

    $(".listBtn").on("click", function () {
        formObj.attr("action", "/article/paging/search/list");
        formObj.attr("method", "get");
        formObj.submit();
    });

});

7.2 수정 페이지(modify.jsp)

7.2.1 HTML코드

조회페이지와 마찬가지로 수정페이지에서도 <input>타입의 hidden속성으로 검색조건과 검색키워드를 추가해준다.

<div class="box-body">
    <input type="hidden" name="articleNo" value="${article.articleNo}">
    <input type="hidden" name="page" value="${searchCriteria.page}">
    <input type="hidden" name="perPageNum" value="${searchCriteria.perPageNum}">
    <input type="hidden" name="searchType" value="${searchCriteria.searchType}">
    <input type="hidden" name="keyword" value="${searchCriteria.keyword}">
    <div class="form-group">
        <label for="title">제목</label>
        <input class="form-control" id="title" name="title" placeholder="제목을 입력해주세요" value="${article.title}">
    </div>
    <div class="form-group">
        <label for="content">내용</label>
        <textarea class="form-control" id="content" name="content" rows="30"
                  placeholder="내용을 입력해주세요" style="resize: none;">${article.content}</textarea>
    </div>
    <div class="form-group">
        <label for="writer">작성자</label>
        <input class="form-control" id="writer" name="writer" value="${article.writer}" readonly>
    </div>
</div>

7.2.2 JS코드

목록 버튼 클릭이벤트가 발생하면 검색한 목록페이지로 이동하기 위해 유지된 정보값들을 아래와 같이 GET방식으로 URI에 작성해준다.

$(document).ready(function () {

    var formObj = $("form[role='form']");
    console.log(formObj);

    $(".modBtn").on("click", function () {
        formObj.submit();
    });

    $(".cancelBtn").on("click", function () {
        history.go(-1);
    });

    $(".listBtn").on("click", function () {
        self.location = "/article/paging/search/list?page=${searchCriteria.page}"
            + "&perPageNum=${searchCriteria.perPageNum}"
            + "&searchType=${searchCriteria.searchType}"
            + "&keyword=${searchCriteria.keyword}";
    });

});

8. 최종적으로 구현된 검색처리 모습

8.1 99로 게시글 검색 이후 목록페이지

list2

8.2 게시글 검색 이후 조회 페이지

read

8.3 게시글 검색 이후 수정 페이지

modify

8.4 수정 처리후 목록페이지로 리다이렉트

list3

8.5 삭제 처리후 목록페이지로 리다이렉트

list4