계층형 게시판
계층형 게시판이란 어떤 게시글과 그에 대한 답변 글이 계층관계를 가지는 게시판이다.
이 절에서는 데이터베이스가 계층형 쿼리를 지원하지 않을 때 계층형 게시판을 구현하는 실습을 한 후, 오라클 계층형 쿼리를 사용해서 계층형 게시판을 구현하는 실습을 한다.
계층형 쿼리를 사용하지 않고 계층형 게시판 구현하기
아래 표에서 '25 게시글' , '26 게시글' , '27 게시글', '29 게시글'은 글쓰기(write_form.jsp, BoardWriter.java)를 통해 등록된 글이다. 이들은 그룹을 생성하고 자신이 그룹에서 최상위 게시글이 된다. 답변 글은 답변쓰기(아직 구현하지 않음)를 통해 작성된 글이다. 표 헤더에 나오는 no, title, wdate는 board 테이블의 컬럼이다.
no | title | wdate |
---|---|---|
29 | 29 게시글 | 2007/12/09 |
27 | 27 게시글 | 2007/11/27 |
32 | ⌙ 27 게시글의 첫 번째 답변 글 | 2007/12/22 |
26 | 26 게시글 | 2007/11/25 |
30 | ⌙ 26 게시글의 두 번째 답변 글 | 2007/12/20 |
31 | ⌙ 30 게시글의 첫 번째 답변 글 | 2007/12/21 |
33 | ⌙ 31 게시글의 첫 번째 답변 글 | 2007/12/23 |
28 | ⌙ 26 게시글의 첫 번째 답변 글 | 2007/12/01 |
25 | 25 게시글 | 2007/11/20 |
계층형 게시판을 위해 추가할 필드
계층형 게시판을 구현하기 위해서 각 게시글은 아래와 같은 정보를 가지고 있어야 한다.
- 게시글은 자신이 어떤 그룹에 속해있는지에 대한 정보를 가지고 있어야 한다.
- 게시글은 그룹 최상위 게시글에서 자신이 몇 번째인지 대한 정보를 가지고 있어야 한다.
- 게시글은 목록 페이지에서 자신의 제목이 얼마나 들여써야 하는지에 대한 정보를 가지고 있어야 한다.
1을 위해 family 컬럼을 추가한다. 2를 위해 depth 컬럼을 추가한다. 3을 위해 indent 컬럼을 추가한다.
컬럼명 | 데이터 타입 | 설명 |
---|---|---|
family | number | 그룹번호 |
depth | number | 그룹내 최상위 글로부터 매겨지는 순서 |
indent | number | 들여쓰기 수준 |
충분해 보인다. 하지만, 글을 삭제할 경우를 생각해 보자. 게시판에서 30번 글을 삭제할 때 31번과 33번 글을 그대로 두면, 사용자는 버그라고 생각할 수 있다. 30번 글을 삭제할 때 31번과 33번 글도 같이 삭제하려면, family, depth, indent만 가지고 해결할 수 없다. 따라서, 답변 글을 자식 글이라고 하면 답변 게시글은 자신의 부모 글에 대한 대한 정보를 가지고 있어야 한다. 이 정보를 저장할 컬럼으로 parent를 추가한다.
no | parent | title | wdate |
---|---|---|---|
29 | 29 | 29 게시글 | 2007/12/09 |
27 | 27 | 27 게시글 | 2007/11/27 |
32 | 27 | ⌙ 27 게시글의 첫 번째 답변 글 | 2007/12/22 |
26 | 26 | 26 게시글 | 2007/11/25 |
30 | 26 | ⌙ 26 게시글의 두 번째 답변 글 | 2007/12/20 |
31 | 30 | ⌙ 30 게시글의 첫 번째 답변 글 | 2007/12/21 |
33 | 31 | ⌙ 31 게시글의 첫 번째 답변 글 | 2007/12/23 |
28 | 26 | ⌙ 26 게시글의 첫 번째 답변 글 | 2007/12/01 |
25 | 25 | 25 게시글 | 2007/11/20 |
위와 같이 부모글에 대한 정보를 parent 컬럼이 가지고 있다면 30을 삭제할 때 30이 부모인 31를 삭제하고, 이어서 31이 부모인 33을 삭제하는 식으로 삭제를 하면 답변 글이 있는 글에 대한 삭제가 가능하다. 게시판 정책이 답변 글이 있는 게시글은 삭제하지 못한다 하더라도 마찬가지이다. 삭제하려는 게시글을 부모로 가진 글을 parent 컬럼에서 확인할 수 있다. 결론적으로, 계층형 게시판을 위해 필요한 컬럼은 아래와 같다.
컬럼명 | 데이터 타입 | 설명 |
---|---|---|
family | number | 그룹번호 |
parent | number | 부모 글 |
depth | number | 그룹내 최상위 글로부터 매겨지는 순서 |
indent | number | 들여쓰기 수준 |
계층형 게시판 알고리즘
글쓰기를 통해 등록되는 글의 컬럼값은 아래와 같이 삽입되도록 한다.
Field | Value |
---|---|
family | no 컬럼값과 같다. |
parent | 0 |
depth | 0 |
indent | 0 |
답변쓰기를 통해 등록되는 글의 컬럼값은 아래와 같이 삽입되도록 한다.
Field | Value |
---|---|
family | 부모글의 family |
parent | 부모글의 no |
depth | (부모글의 depth) + 1 |
indent | (부모글의 indent) + 1 |
계층형 게시판을 위한 board 테이블 변경
alter table board add (family number, parent number, depth number, indent number) / update board set family = no, parent = 0, depth = 0, indent = 0 /
기존 게시판을 계층형 게시판으로 수정: 목록
list.jsp를 열고 레코드를 가져오는 쿼리문을 아래와 같이 수정한다.
if (keyword.equals("")) { sql = "SELECT no,family,parent,depth,indent,title,wdate " + "FROM (SELECT ROWNUM R, A.* FROM (" + "SELECT no,family,parent,depth,indent,title,wdate FROM board " + "ORDER BY family DESC, depth ASC) A) " + "WHERE R BETWEEN ? AND ?"; } else { sql = "SELECT no,family,parent,depth,indent,title,wdate " + "FROM (SELECT ROWNUM R, A.* FROM (" + "SELECT no,family,parent,depth,indent,title,wdate FROM board " + "WHERE title LIKE '%" + keyword + "%' OR content LIKE '%" + keyword + "%' " + "ORDER BY family DESC, depth ASC) A) " + "WHERE R BETWEEN ? AND ?"; }
indent 컬럼을 이용해서 아래와 같이 들여쓰기하도록 list.jsp를 수정한다.
while (rs.next()) { int no = rs.getInt("no"); String title = rs.getString("title"); Date wdate = rs.getDate("wdate"); int indent = rs.getInt("indent"); for (int i = 0; i < indent; i++) { out.println(" "); } if(indent != 0) { out.println("⌙"); } //.. 이하 생략 ..
위 코드를 보면 indent 만큼 를 추가하여 들여쓰기를 한 다음 답변 글이면(즉, indent != 0 이면) "⌙" 를 제목 앞에 추가한다. 여기까지 수정하고 테스트해도 게시판은 정상적으로 동작한다.
기존 게시판을 계층형 게시판으로 수정: 글쓰기
BoardWriter.java에서 게시글을 등록하는 쿼리를 다음과 같이 수정한다.
String sql = "INSERT INTO board (no,title,content,wdate,family,parent,depth,indent) " + "VALUES (board_no_seq.nextval, ?, ?, sysdate, board_no_seq.nextval, 0, 0, 0)";
기존 게시판을 계층형 게시판으로 수정: 상세보기
view.jsp 파일을 열고 답변쓰기 기능을 추가하기 위해 적절한 위치에 아래 코드를 삽입한다.
<a href="reply_form.jsp?no=<%=no %>&curPage=<%=curPage %>&keyword=<%=keyword %>">답변쓰기</a>
기존 게시판을 계층형 게시판으로 수정: 답변쓰기 기능 추가
답변쓰기 양식을 제공하는 JSP 페이지인 reply_form.jsp를 아래와 같이 만든다.
/board/reply_form.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <%@ page import="java.sql.*" %> <%@ page import="net.java_school.util.*" %> <%@ page import="net.java_school.db.dbpool.*" %> <jsp:useBean id="dbmgr" scope="application" class="net.java_school.db.dbpool.OracleConnectionManager" /> <!DOCTYPE html> <%! static final String LINE_SEPARATOR = System.getProperty("line.separator"); %> <% request.setCharacterEncoding("UTF-8"); Log log = new Log(); int family = 0; int indent = 0; int depth = 0; String title = null; String content = null; Date wdate = null; int no = Integer.parseInt(request.getParameter("no")); String curPage = request.getParameter("curPage"); String keyword = request.getParameter("keyword"); Connection con = null; PreparedStatement stmt = null; ResultSet rs = null; String sql = "SELECT no, family, depth, indent, title, content, wdate " + "FROM board " + "WHERE no = ?"; try { con = dbmgr.getConnection(); stmt = con.prepareStatement(sql); stmt.setInt(1, no); rs = stmt.executeQuery(); rs.next(); no = rs.getInt("no"); family = rs.getInt("family"); depth = rs.getInt("depth"); indent = rs.getInt("indent"); title = rs.getString("title"); content = rs.getString("content"); //부모 게시글의 각 행마다 >를 추가한다. content = content.replaceAll(LINE_SEPARATOR, LINE_SEPARATOR + ">"); content = LINE_SEPARATOR + LINE_SEPARATOR +">" + content; wdate = rs.getDate("wdate"); } catch (SQLException e) { log.debug("Error Source : board/modify_form.jsp : SQLException"); log.debug("SQLState : " + e.getSQLState()); log.debug("Message : " + e.getMessage()); log.debug("Oracle Error Code : " + e.getErrorCode()); log.debug("sql : " + sql ); } finally { if (rs != null) { try { rs.close(); } catch (SQLException e) {} } if (stmt != null) { try { stmt.close(); } catch (SQLException e) {} } if (con != null) { try { con.close(); } catch (SQLException e) {} } } %> <html> <head> <meta charset="UTF-8" /> <title>답변쓰기</title> </head> <body> <h1>답변쓰기</h1> <form action="../servlet/BoardReplier" method="post"> <input type="hidden" name="no" value="<%=no %>" /> <input type="hidden" name="family" value="<%=family %>" /> <input type="hidden" name="indent" value="<%=indent %>" /> <input type="hidden" name="depth" value="<%=depth %>" /> <input type="hidden" name="curPage" value="<%=curPage %>" /> <input type="hidden" name="keyword" value="<%=keyword %>" /> 제목: <input type="text" name="title" size="45" value="<%=title %>" /><br /> <textarea name="content" rows="10" cols="60"><%=content %></textarea><br /> <input type="submit" value="전송" /> <input type="reset" value="취소" /><br /> </form> <a href="view.jsp?no=<%=no %>&curPage=<%=curPage %>&keyword=<%=keyword %>">상세보기</a> </body> </html>
답변쓰기 페이지의 textarea에 부모 글의 내용이 디폴트가 되도록 한다. 이때 부모 글과 답변 글이 구별되도록 부모 글의 각 행마다 >를 추가한다. 전송 버튼을 클릭하면 no, family, depth, indent, title, content 파라미터가 BoardReplier 서블릿에 전달된다.
아래와 같이 BoardReplier.java를 생성한다.
BoardReplier.java
package net.java_school.board; import java.io.*; import javax.servlet.*; import javax.servlet.http.*; import java.sql.*; import net.java_school.db.dbpool.*; import net.java_school.util.*; public class BoardReplier extends HttpServlet { OracleConnectionManager dbmgr = null; //depth 갱신용 쿼리 String sql1 = "UPDATE board SET depth = depth + 1 " + "WHERE family = ? AND depth > ? "; //답변쓰기용 쿼리 String sql2 = "INSERT INTO board " + "(no, family, parent, depth, indent, title, content, wdate) " + "VALUES (board_no_seq.nextval, ?, ?, ?, ?, ?, ?, sysdate)"; @Override public void init() throws ServletException { ServletContext sc = getServletContext(); dbmgr = (OracleConnectionManager)sc.getAttribute("dbmgr"); } @Override public void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); Log log = new Log(); // 파라미터를 받는다. int parent = Integer.parseInt(req.getParameter("no")); int family = Integer.parseInt(req.getParameter("family")); int depth = Integer.parseInt(req.getParameter("depth")); int indent = Integer.parseInt(req.getParameter("indent")); String title = req.getParameter("title"); String content = req.getParameter("content"); String curPage = req.getParameter("curPage"); String keyword = req.getParameter("keyword"); Connection con = null; PreparedStatement stmt1 = null; PreparedStatement stmt2 = null; try { con = dbmgr.getConnection(); con.setAutoCommit(false); stmt1 = con.prepareStatement(sql1); stmt1.setInt(1,family); stmt1.setInt(2,depth); stmt1.executeUpdate(); stmt2 = con.prepareStatement(sql2); stmt2.setInt(1, family); stmt2.setInt(2, parent); stmt2.setInt(3, depth+1); stmt2.setInt(4, indent+1); stmt2.setString(5, title); stmt2.setString(6, content); stmt2.executeUpdate(); con.commit(); } catch (SQLException e) { try { con.rollback(); } catch (SQLException e1) { e1.printStackTrace(); } log.debug("Error Source:BoardReplier.java : SQLException"); log.debug("SQLState : " + e.getSQLState()); log.debug("Message : " + e.getMessage()); log.debug("Oracle Error Code : " + e.getErrorCode()); log.debug("sql : " + sql2); } finally { if (stmt1 != null) { try { stmt1.close(); } catch (SQLException e) { e.printStackTrace(); } } if (stmt2 != null) { try { stmt2.close(); } catch (SQLException e) { e.printStackTrace(); } } if (con != null) { dbmgr.freeConnection(con); } log.close(); String path = req.getContextPath(); keyword = java.net.URLEncoder.encode(keyword,"UTF-8"); resp.sendRedirect(path + "/board/list.jsp?curPage=" + curPage + "&keyword=" + keyword); } } }
새로운 서블릿을 만들었으므로 web.xml에 서블릿 정의와 매핑설정을 아래와 같이 추가한다.
<servlet> <servlet-name>BoardReplier</servlet-name> <servlet-class>net.java_school.board.BoardReplier</servlet-class> </servlet> <servlet-mapping> <servlet-name>BoardReplier</servlet-name> <url-pattern>/servlet/BoardReplier</url-pattern> </servlet-mapping>
web.xml이 변경했으니 톰캣을 재실행한다. 상세보기를 방문하고 답변쓰기를 클릭하여 답변을 작성해 본다.
기존 게시판을 계층형 게시판으로 수정: 삭제
먼저 답변이 있는 글은 삭제되지 않도록 구현한다. 삭제 대상 게시글의 no 값을 parent로 가지는 게시글이 있는지 검사한 후, 만약 그러한 게시글이 없다면 삭제한다.
삭제 코드 힌트 (답변이 있는 글은 삭제할 수 없도록)
String sql1 = "SELECT count(*) FROM board WHERE parent = ?"; String sql2 = "DELETE FROM board WHERE no = ?"; boolean check = false;//true면 삭제 Connection con = null; PreparedStatement stmt1 = null; PreparedStatement stmt2 = null; ResultSet rs = null; con = dbmgr.getConnection(); stmt1 = con.prepareStatement(sql1); stmt1.setInt(1, no); rs = stmt1.executeQuery(); rs.next(); int num = rs.getInt(1); if (num == 0) { check = true; } if (check == true) { stmt2 = con.prepareStatement(sql2); stmt2.setInt(1, no); stmt2.executeUpdate(); }
BoardDeleter.java (답변이 있는 글은 삭제할 수 없도록)
package net.java_school.board; import java.io.*; import javax.servlet.*; import javax.servlet.http.*; import java.sql.*; import net.java_school.db.dbpool.*; import net.java_school.util.*; public class BoardDeleter extends HttpServlet { private OracleConnectionManager dbmgr; private static final String SQL1 = "SELECT count(*) FROM board WHERE parent = ?"; private static final String SQL2 = "DELETE FROM board WHERE no = ?"; @Override public void init() throws ServletException { ServletContext sc = getServletContext(); dbmgr = (OracleConnectionManager) sc.getAttribute("dbmgr"); } @Override public void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { doPost(req, resp); } @Override public void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); Log log = new Log(); int no = Integer.parseInt(req.getParameter("no")); String curPage = req.getParameter("curPage"); String keyword = req.getParameter("keyword"); Connection con = dbmgr.getConnection(); PreparedStatement stmt1 = null; PreparedStatement stmt2 = null; ResultSet rs = null; boolean check = false;//true면 삭제 try { stmt1 = con.prepareStatement(SQL1); stmt1.setInt(1, no); rs = stmt1.executeQuery(); rs.next(); int num = rs.getInt(1); if (num == 0) { check = true; } if (check == true) { stmt2 = con.prepareStatement(SQL2); stmt2.setInt(1, no); stmt2.executeUpdate(); } } catch (SQLException e) { log.debug("Error Source : BoardDeleter.java : SQLException"); log.debug("SQLState : " + e.getSQLState()); log.debug("Message : " + e.getMessage()); log.debug("Oracle Error Code : " + e.getErrorCode()); log.debug("sql1 : " + SQL1); log.debug("sql2 : " + SQL2); } finally { if (rs != null) { try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } } if (stmt1 != null) { try { stmt1.close(); } catch (SQLException e) { e.printStackTrace(); } } if (stmt2 != null) { try { stmt2.close(); } catch (SQLException e) { e.printStackTrace(); } } if (con != null) { dbmgr.freeConnection(con); } log.close(); String path = req.getContextPath(); keyword = java.net.URLEncoder.encode(keyword, "UTF-8"); resp.sendRedirect(path + "/board/list.jsp?curPage=" + curPage + "&keyword=" + keyword); } } }
자손 글 모두를 삭제
아래와 같이 게시글이 있다고 가정하자.
no | title |
---|---|
85 | 85 게시글 |
86 | ⌙ 85 게시글의 첫 번째 답변 글 |
87 | ⌙ 86 게시글의 첫 번째 답변 글 |
88 | ⌙ 87 게시글의 첫 번재 답변 글 |
85 글은 글쓰기를 사용해서 등록한 글이다. 글쓰기를 사용해서 등록한 글은 parent는 0이고 family는 no와 같다. 즉, family는 85라는 값을 가진다. 86 글은 85 글에 대한 답변 글이다. 따라서 86 글은 parent는 85이고 family는 85이다. 87 글은 86 글에 대한 답변 글로 parent는 86이고 family는 85이다. 88 글은 87 글에 대한 답변 글로 parent는 87이고 family는 85이다. 이를 표로 정리하면 다음과 같다.
no | parent | family | title |
---|---|---|---|
85 | 0 | 85 | 85 게시글 |
86 | 85 | 85 | ⌙ 85 게시글의 첫 번째 답변 글 |
87 | 86 | 85 | ⌙ 86 게시글의 첫 번재 답변 글 |
88 | 87 | 85 | ⌙ 87 게시글의 첫 번째 답변 글 |
자손 글까지 같이 삭제하는 삭제 알고리즘
- 삭제할 글의 고유번호를 삭제리스트에 추가한다
- 삭제 리스트에서 차례로 값을 하나씩 꺼내어 그 값을 parent로 가지고 있는 글을 찾아서 삭제 리스트에 추가한다
- 삭제 리스트에 저장된 글을 삭제한다
답변 글 삭제 알고리즘을 수행할 RecursiveDeleter.java
public class RecursiveDeleter { String sql1 = "SELECT no FROM board WHERE parent = ?"; String sql2 = "DELETE FROM board WHERE no = ?"; Connection con; Vector<Integer> v = new Vector<Integer>();//삭제 리스트 int idx = 1; //삭제리스트에 맨 처음 추가된 번호 바로 다음을 재귀호출하도록 public RecursiveDeleter(Connection con) { this.con = con; } public void addDeleteList(int no) { v.addElement(new Integer(no)); } public void getDeleteList(int no) { PreparedStatement stmt = null; ResultSet rs = null; try { stmt = con.prepareStatement(sql1); stmt.setInt(1, no); rs = stmt.executeQuery(); while (rs.next()) { int target = rs.getInt(1); v.addElement(new Integer(target)); } if(idx < v.size()) { Integer value = (Integer) v.elementAt(idx++); getDeleteList(value.intValue()); } } catch (SQLException e) { } finally { if (rs != null) { try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } } if (stmt != null) { try { stmt.close(); } catch (SQLException e) { e.printStackTrace(); } } } } public void delete() { PreparedStatement stmt = null; try { stmt = con.prepareStatement(sql2); for(int i = 0; i < v.size(); i++) { Integer value = (Integer) v.elementAt(i); stmt.setInt(1, value.intValue()); stmt.executeUpdate(); } } catch (SQLException e) { } finally { v.removeAllElements(); if (stmt != null) { try { stmt.close(); } catch (SQLException e) { e.printStackTrace(); } } if (con != null) { dbmgr.freeConnection(con); } } } }
자손 글까지 모두 삭제하기 위해서는 아래 순서로 메소드를 호출해야 한다.
- addDeleteList(삭제할 게시글의 고유번호);
- getDeleteList(삭제할 게시글의 고유번호);
- delete();
86 글을 삭제하려 한다면 addDeleteList() 메소드를 이용해서 우선 86을 삭제리스트에 추가한다. 삭제리스트에 86을 저장한 후 getDeleteList() 메소드를 호출한다. getDeleteList() 메소드는 삭제리스트에서 86을 꺼내어 86을 parent로 가지고 있는 87을 삭제리스트에 추가한다. 다음에 다시 자기 자신을 호출하면서 87을 삭제리스트에서 꺼내어 그 값을 parent로 가지고 있는 88을 삭제리스트에 추가한다. 다시 자기 자신을 호출하면서 삭제리스트에서 88을 꺼내어 그 값을 parent로 가지고 있는 글을 찾는다. 88을 parent로 가지고 있는 글이 없으므로 삭제리스트를 작성을 끝낸다. 다음 delete() 메소드를 호출해서 삭제리스트에 있는 글을 모두 삭제한다.
위에서 메소드 내에서 자기 자신을 부르는 재귀 메소드를 사용했다. 재귀 메소드를 호출할 때 삭제리스트에서 순서대로 차례로 하나씩 값을 꺼내야 한다. 또한 삭제리스트가 완성되면 재귀 메소드가 더 이상 호출되지 말아야 한다. 이를 위해서 idx라는 인덱스를 사용했다. getDeleteList() 재귀 메소드를 따라가면서 idx가 어떻게 변경되는지 확인해야 getDeleteList() 메소드를 완벽히 이해할 수 있다.
BoardDeleter.java (자손 글 모두 삭제)
package net.java_school.board; import java.io.*; import java.net.URLEncoder; import javax.servlet.*; import javax.servlet.http.*; import java.sql.*; import java.util.Vector; import net.java_school.db.dbpool.*; import net.java_school.util.*; public class BoardDeleter extends HttpServlet { private OracleConnectionManager dbmgr = null; class RecursiveDeleter { String sql1 = "SELECT no FROM board WHERE parent = ?"; String sql2 = "DELETE FROM board WHERE no = ?"; Connection con; Vector<Integer> v = new Vector<Integer>(); int idx = 1; //삭제리스트에 맨 처음 추가된 번호 바로 다음을 재귀호출하도록 public RecursiveDeleter(Connection con) { this.con = con; } public void addDeleteList(int no) { v.addElement(new Integer(no)); } public void getDeleteList(int no) { Log log = new Log(); PreparedStatement stmt = null; ResultSet rs = null; try { stmt = con.prepareStatement(sql1); stmt.setInt(1, no); rs = stmt.executeQuery(); while (rs.next()) { int target = rs.getInt(1); v.addElement(new Integer(target)); } if(idx < v.size()) { Integer value = (Integer) v.elementAt(idx++); getDeleteList(value.intValue()); } } catch (SQLException e) { log.debug("Error Source : BoardDeleter.getDeleteList() : SQLException"); log.debug("SQLState : " + e.getSQLState()); log.debug("Message : " + e.getMessage()); log.debug("Oracle Error Code : " + e.getErrorCode()); log.debug("sql : " + sql1); } finally { if (rs != null) { try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } } if (stmt != null) { try { stmt.close(); } catch (SQLException e) { e.printStackTrace(); } } log.close(); } } public void delete() { Log log = new Log(); PreparedStatement stmt = null; try { stmt = con.prepareStatement(sql2); for(int i = 0; i < v.size(); i++) { Integer value = (Integer) v.elementAt(i); stmt.setInt(1, value.intValue()); stmt.executeUpdate(); } } catch (SQLException e) { log.debug("Error Source : BoardDeleter.delete() : SQLException"); log.debug("SQLState : " + e.getSQLState()); log.debug("Message : " + e.getMessage()); log.debug("Oracle Error Code : " + e.getErrorCode()); log.debug("sql : " + sql2); } finally { v.removeAllElements(); if (stmt != null) { try { stmt.close(); } catch (SQLException e) { e.printStackTrace(); } } if (con != null) { dbmgr.freeConnection(con); } log.close(); } } } @Override public void init() throws ServletException { ServletContext sc = getServletContext(); dbmgr = (OracleConnectionManager)sc.getAttribute("dbmgr"); } @Override public void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { doPost(req, resp); } @Override public void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); int no = Integer.parseInt(req.getParameter("no")); String curPage = req.getParameter("curPage"); String keyword = req.getParameter("keyword"); Connection con = dbmgr.getConnection(); RecursiveDeleter deleter = new RecursiveDeleter(con); deleter.addDeleteList(no); deleter.getDeleteList(no); deleter.delete(); String path = req.getContextPath(); keyword = URLEncoder.encode(keyword, "UTF-8"); resp.sendRedirect(path + "/board/list.jsp?curPage=" + curPage + "&keyword=" + keyword); } }
오라클 계층형 쿼리를 사용하는 게시판
오라클 계층형 쿼리(Oracle Hierarchical Query)
emp 테이블은 사원 테이블이다. mgr 컬럼은 상사의 사번을 저장한다.
SELECT job, empno, ename, mgr FROM emp START WITH mgr is null CONNECT BY PRIOR empno = mgr;
JOB EMPNO ENAME MGR --------- ---------- ---------- ---------- PRESIDENT 7839 KING MANAGER 7566 JONES 7839 ANALYST 7902 FORD 7566 CLERK 7369 SMITH 7902 MANAGER 7698 BLAKE 7839 SALESMAN 7499 ALLEN 7698 SALESMAN 7521 WARD 7698 SALESMAN 7654 MARTIN 7698 SALESMAN 7844 TURNER 7698 CLERK 7900 JAMES 7698 MANAGER 7782 CLARK 7839 CLERK 7934 MILLER 7782
start with 절 다음에는 최상위 계층을 찾는 조건이 나온다. connect by 절은 부모와 자식노드들을 연결한다.
계층형 관계를 쉽게 파악하게 위해 계층형 쿼리에서 사용할 수 있는 level 가상 컬럼을 사용해 보자.
SELECT level, job, empno, ename, mgr FROM emp START WITH mgr is null CONNECT BY PRIOR empno = mgr;
LEVEL JOB EMPNO ENAME MGR ---------- --------- ---------- ---------- ---------- 1 PRESIDENT 7839 KING 2 MANAGER 7566 JONES 7839 3 ANALYST 7902 FORD 7566 4 CLERK 7369 SMITH 7902 2 MANAGER 7698 BLAKE 7839 3 SALESMAN 7499 ALLEN 7698 3 SALESMAN 7521 WARD 7698 3 SALESMAN 7654 MARTIN 7698 3 SALESMAN 7844 TURNER 7698 3 CLERK 7900 JAMES 7698 2 MANAGER 7782 CLARK 7839 3 CLERK 7934 MILLER 7782
level을 이용하여 들여쓰기를 해 보자.
col job format a20; SELECT LPAD(' ', 2*(LEVEL-1)) || job job, empno, ename, mgr FROM emp START WITH mgr is null CONNECT BY PRIOR empno = mgr;
JOB EMPNO ENAME MGR -------------------- ---------- ---------- ---------- PRESIDENT 7839 KING MANAGER 7566 JONES 7839 ANALYST 7902 FORD 7566 CLERK 7369 SMITH 7902 MANAGER 7698 BLAKE 7839 SALESMAN 7499 ALLEN 7698 SALESMAN 7521 WARD 7698 SALESMAN 7654 MARTIN 7698 SALESMAN 7844 TURNER 7698 CLERK 7900 JAMES 7698 MANAGER 7782 CLARK 7839 CLERK 7934 MILLER 7782
위 쿼리를 정렬하기 위해 ORDER BY empno DESC를 추가한다면 계층 관계가 깨진다. 계층 관계가 깨지지 않고 정렬을 하려면 ORDER SIBLINGS BY empno DESC를 사용해야 한다.
SELECT LPAD(' ', 2*(LEVEL-1)) || job job, empno, ename, mgr FROM emp START WITH mgr is null CONNECT BY PRIOR empno = mgr ORDER SIBLINGS BY empno DESC;
결과에서 강조된 부분으로 empno 내림차순으로 정렬되었음을 알 수 있다.
JOB EMPNO ENAME MGR -------------------- ---------- ---------- ---------- PRESIDENT 7839 KING MANAGER 7782 CLARK 7839 CLERK 7934 MILLER 7782 MANAGER 7698 BLAKE 7839 CLERK 7900 JAMES 7698 SALESMAN 7844 TURNER 7698 SALESMAN 7654 MARTIN 7698 SALESMAN 7521 WARD 7698 SALESMAN 7499 ALLEN 7698 MANAGER 7566 JONES 7839 ANALYST 7902 FORD 7566 CLERK 7369 SMITH 7902
다음 쿼리는 상위 10개의 레코드만 추출한다.
SELECT job, empno, ename,mgr FROM (SELECT ROWNUM R, A.* FROM (SELECT LPAD(' ', 2*(LEVEL-1)) || job job, empno, ename, mgr FROM emp START WITH mgr is null CONNECT BY PRIOR empno = mgr ORDER SIBLINGS BY empno DESC) A) WHERE R BETWEEN 1 AND 10;
JOB EMPNO ENAME MGR -------------------- ---------- ---------- ---------- PRESIDENT 7839 KING MANAGER 7782 CLARK 7839 CLERK 7934 MILLER 7782 MANAGER 7698 BLAKE 7839 CLERK 7900 JAMES 7698 SALESMAN 7844 TURNER 7698 SALESMAN 7654 MARTIN 7698 SALESMAN 7521 WARD 7698 SALESMAN 7499 ALLEN 7698 MANAGER 7566 JONES 7839
오라클 계층형 쿼리를 이용하여 계층형 게시판을 구현한다면, 부모 글의 고유번호를 저장할 parent 컬럼만 추가하면 된다.
오라클 계층형 게시판을 위한 쿼리 힌트
SELECT no, indent, parent, title, wdate FROM (SELECT ROWNUM R, A.* FROM (SELECT no, level as indent, parent, title, wdate FROM board START WITH parent = 0 CONNECT BY PRIOR no = parent ORDER SIBLINGS BY no DESC) A) WHERE R BETWEEN 1 AND 10
계층형 쿼리를 사용하는 계층형 게시판으로 수정: list.jsp
계층형 쿼리를 사용하는 계층형 게시판으로 수정하려면 list.jsp에서 아래와 같이 목록 데이터를 구하는 쿼리를 바꾼다.
if (keyword.equals("")) { sql = "SELECT no, indent, parent, title, wdate FROM " + "(SELECT ROWNUM R, A.* " + "FROM " + "(SELECT no, level as indent, parent, title, wdate FROM board " + "START WITH parent = 0 " + "CONNECT BY PRIOR no = parent " + "ORDER SIBLINGS BY no DESC) A) " + "WHERE R BETWEEN ? AND ?"; } else { sql = "SELECT no, indent, parent, title, wdate FROM " + "(SELECT ROWNUM R, A.* " + "FROM " + "(SELECT no, level as indent, parent, title, wdate FROM board " + "WHERE title LIKE '%" + keyword + "%' OR content LIKE '%" + keyword + "%' " + "START WITH parent = 0 " + "CONNECT BY PRIOR no = parent " + "ORDER SIBLINGS BY no DESC) A) " + "WHERE R BETWEEN ? AND ?"; }
list.jsp에서 다음 부분을 수정한다.
if(indent != 1) { out.println("⌙"); }
여기까지 작업한 후 테스트해도 게시판은 정상적으로 동작한다. 하지만 성능을 좋게 하기 위해 해야 할 일이 남아있다.
계층형 쿼리를 사용하는 계층형 게시판으로 수정: board 테이블 수정
ALTER TABLE board DROP COLUMN family / ALTER TABLE board DROP COLUMN depth / ALTER TABLE board DROP COLUMN indent /
계층형 쿼리를 사용하는 계층형 게시판으로 수정: 글쓰기
부모가 없는 글은 parent 컬럼 값을 0으로, 부모가 있는 글은 parent 컬럼에 부모 글의 no 컬럼의 값이 입력되도록 한다.
BoardWriter.java에서 새 게시글을 등록하는 쿼리를 아래와 같이 수정한다.
String sql = "INSERT INTO board (no, title, content, wdate, parent) " + "VALUES (board_no_seq.nextval, ?, ?, sysdate, 0)";
계층형 쿼리를 사용하는 계층형 게시판으로 수정: 답변쓰기
/board/reply_form.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <%@ page import="java.sql.*" %> <%@ page import="net.java_school.util.*" %> <%@ page import="net.java_school.db.dbpool.*" %> <jsp:useBean id="dbmgr" scope="application" class="net.java_school.db.dbpool.OracleConnectionManager" /> <!DOCTYPE html> <%! static final String LINE_SEPARATOR = System.getProperty("line.separator"); %> <% request.setCharacterEncoding("UTF-8"); Log log = new Log(); int parent = 0; String title = null; String content = null; Date wdate = null; int no = Integer.parseInt(request.getParameter("no")); String curPage = request.getParameter("curPage"); String keyword = request.getParameter("keyword"); Connection con = null; PreparedStatement stmt = null; ResultSet rs = null; String sql = "SELECT no, title, content, wdate " + "FROM board " + "WHERE no = ?"; try { con = dbmgr.getConnection(); stmt = con.prepareStatement(sql); stmt.setInt(1, no); rs = stmt.executeQuery(); rs.next(); no = rs.getInt("no"); title = rs.getString("title"); content = rs.getString("content"); //부모글의 각 행마다 >를 추가한다. content = content.replaceAll(LINE_SEPARATOR, LINE_SEPARATOR + ">"); content = LINE_SEPARATOR + LINE_SEPARATOR +">" + content; wdate = rs.getDate("wdate"); } catch (SQLException e) { log.debug("Error Source : board/modify_form.jsp : SQLException"); log.debug("SQLState : " + e.getSQLState()); log.debug("Message : " + e.getMessage()); log.debug("Oracle Error Code : " + e.getErrorCode()); log.debug("sql : " + sql ); } finally { if (rs != null) { try { rs.close(); } catch (SQLException e) {} } if (stmt != null) { try { stmt.close(); } catch (SQLException e) {} } if (con != null) { try { con.close(); } catch (SQLException e) {} } } %> <html> <head> <meta charset="UTF-8" /> <title>답변쓰기</title> </head> <body> <h1>답변쓰기</h1> <form action="../servlet/BoardReplier" method="post"> <input type="hidden" name="no" value="<%=no %>" /> <input type="hidden" name="curPage" value="<%=curPage %>" /> <input type="hidden" name="keyword" value="<%=keyword %>" /> 제목: <input type="text" name="title" size="45" value="<%=title %>" /><br /> <textarea name="content" rows="10" cols="60"><%=content %></textarea><br /> <input type="submit" value="전송" /> <input type="reset" value="취소" /><br /> </form> <a href="view.jsp?no=<%=no %>&curPage=<%=curPage %>&keyword=<%=keyword %>">상세보기</a> </body> </html>
답변쓰기 페이지의 textarea는 부모 글의 내용이 디폴트가 되도록 한다. 이때 부모 글과 답변 글이 구별되도록 부모 글의 각 행마다 >를 추가한다. 전송 버튼을 클릭하면 no, title, content 파라미터가 BoardReplier 서블릿에 전달된다.
BoardReplier.java
package net.java_school.board; import java.io.*; import javax.servlet.*; import javax.servlet.http.*; import java.sql.*; import net.java_school.db.dbpool.*; import net.java_school.util.*; public class BoardReplier extends HttpServlet { OracleConnectionManager dbmgr = null; //답변쓰기용 쿼리 String sql = "INSERT INTO board " + "(no, parent, title, content, wdate) " + "VALUES (board_no_seq.nextval, ?, ?, ?, sysdate)"; @Override public void init() throws ServletException { ServletContext sc = getServletContext(); dbmgr = (OracleConnectionManager)sc.getAttribute("dbmgr"); } @Override public void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); Log log = new Log(); // 파라미터를 받는다. int parent = Integer.parseInt(req.getParameter("no")); String title = req.getParameter("title"); String content = req.getParameter("content"); String curPage = req.getParameter("curPage"); String keyword = req.getParameter("keyword"); Connection con = null; PreparedStatement stmt = null; try { con = dbmgr.getConnection(); stmt = con.prepareStatement(sql); stmt.setInt(1, parent); stmt.setString(2, title); stmt.setString(3, content); stmt.executeUpdate(); } catch (SQLException e) { log.debug("Error Source:BoardReplier.java : SQLException"); log.debug("SQLState : " + e.getSQLState()); log.debug("Message : " + e.getMessage()); log.debug("Oracle Error Code : " + e.getErrorCode()); log.debug("sql : " + sql); } finally { if (stmt != null) { try { stmt.close(); } catch (SQLException e) { e.printStackTrace(); } } if (con != null) { dbmgr.freeConnection(con); } log.close(); String path = req.getContextPath(); keyword = java.net.URLEncoder.encode(keyword,"UTF-8"); resp.sendRedirect(path + "/board/list.jsp?curPage=" + curPage + "&keyword=" + keyword); } } }
계층형 쿼리를 사용하는 계층형 게시판으로 수정: 삭제
대부분의 게시판이 답변 글이 있으면 삭제하지 못하도록 하고 있으니 그렇게 구현한다.
BoardDeleter 서블릿 - 답변 글이 있으면 삭제할 수 없도록
package net.java_school.board; import java.io.*; import javax.servlet.*; import javax.servlet.http.*; import java.sql.*; import net.java_school.db.dbpool.*; import net.java_school.util.*; public class BoardDeleter extends HttpServlet { private OracleConnectionManager dbmgr; private static final String SQL1 = "SELECT count(*) FROM board WHERE parent = ?"; private static final String SQL2 = "DELETE FROM board WHERE no = ?"; @Override public void init() throws ServletException { ServletContext sc = getServletContext(); dbmgr = (OracleConnectionManager) sc.getAttribute("dbmgr"); } @Override public void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { doPost(req, resp); } @Override public void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); Log log = new Log(); int no = Integer.parseInt(req.getParameter("no")); String curPage = req.getParameter("curPage"); String keyword = req.getParameter("keyword"); Connection con = dbmgr.getConnection(); PreparedStatement stmt1 = null; PreparedStatement stmt2 = null; ResultSet rs = null; boolean check = false;//true면 삭제 try { stmt1 = con.prepareStatement(SQL1); stmt1.setInt(1, no); rs = stmt1.executeQuery(); rs.next(); int num = rs.getInt(1); if (num == 0) { check = true; } if (check == true) { stmt2 = con.prepareStatement(SQL2); stmt2.setInt(1, no); stmt2.executeUpdate(); } } catch (SQLException e) { log.debug("Error Source : BoardDeleter.java : SQLException"); log.debug("SQLState : " + e.getSQLState()); log.debug("Message : " + e.getMessage()); log.debug("Oracle Error Code : " + e.getErrorCode()); log.debug("sql1 : " + SQL1); log.debug("sql2 : " + SQL2); } finally { if (rs != null) { try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } } if (stmt1 != null) { try { stmt1.close(); } catch (SQLException e) { e.printStackTrace(); } } if (stmt2 != null) { try { stmt2.close(); } catch (SQLException e) { e.printStackTrace(); } } if (con != null) { dbmgr.freeConnection(con); } log.close(); String path = req.getContextPath(); keyword = java.net.URLEncoder.encode(keyword, "UTF-8"); resp.sendRedirect(path + "/board/list.jsp?curPage=" + curPage + "&keyword=" + keyword); } } }