스프링 MVC 게시판

메이븐으로 스프링 MVC 개발에 이어서, JSP Porject의 게시판 최종 결과물인 모델 2 게시판에 스프링 MVC를 적용하는 실습을 할 것이다. 모델 2 게시판은 다음을 실행하면 얻을 수 있다. (시스템에 git이 설치되어 있어야 한다)

C:\ Command Prompt
C:\www> git clone https://github.com/kimjonghoon/model2

데이터베이스 디자인

참조: http://www.java-school.net/jsp-pjt/database-design

CSS 파일과 이미지 복사

모델 2게시판의 C:/www/model2/WebContent에서 css/와 images/ 폴더를 복사하여, 도큐먼트 베이스인 C:/www/spring-bbs/src/main/webapp 디렉터리에 붙여넣는다.

JSP 파일 복사

C:/www/spring-bbs/src/main/webapp/WEB-INF/views/ 폴더를 만들고, 모델 2 게시판의 C:/www/model2/WebContent에서 index.jsp(홈페이지)와 error.jsp(에러 페이지) 그리고 JSP 파일 폴더인 bbs/, inc/, java/, users/를 복사하여 붙여넣는다.

JSP를 WEB-INF/ 아래에 두는 이유는 웹 브라우저로 JSP에 바로 접근하지 못 하게 하기 위해서다. 스프링 MVC는 모든 요청을 디스패처 서블릿을 거쳐 컨트롤러에 전달되도록 하는 것을 권장한다. JSP를 바로 요청할 수 있으면서 어떤 요청은 디스패처 서블릿을 거치도록 프로그래밍하는 것은 프로그램을 복잡하게 하고 유지 보수에도 좋지 않다.

자바 소스 복사

C:/www/spring-bbs/src/main/java에 모델 2 게시판의 C:/www/model2/src/net/java_school에서 모든 자바 소스 폴더를 복사하여 붙여넣는다. 만약 C:/www/spring-bbs/src/main/java 폴더가 없다면 메이븐 프로젝트의 기본 디렉터리이니 만든다.

복사한 자바 소스 중 action 클래스와 컨트롤러 역할을 수행하는 서블릿은 더이상 사용하지 않으므로 삭제해도 된다.

JSP 수정

스프링은 디스패처 서블릿이 모든 요청을 담당하도록 설정할 것을 권장한다. web.xml의 다음 부분이 디스패처 서블릿이 모든 요청을 담당토록 하는 설정이다.

<servlet-mapping>
  <servlet-name>spring-bbs</servlet-name>
  <url-pattern>/</url-pattern>
</servlet-mapping>

JSP에서 요청 문자열 변경

모델 2 게시판에서는 컨트롤러는 .do로 끝나는 요청만을 담당했다. 따라서 모델 2 게시판의 뷰에서 .do로 끝나는 모든 요청 문자열을 수정해야 한다. 예를 들어, header.jsp안의 ../users/login.do를 ../users/login으로 수정하는 식이다. 이렇게 모든 JSP에서 .do로 끝나는 요청 문자열에서 .do를 제거한다.

목록과 상세보기에서 날짜 포맷 변경

list.jsp와 view.jsp에서의 날짜 포맷을 다르게 할 것이다. 두 파일에 다음 태그 라이브러리를 추가한다.

<%@ taglib uri="http://java.sun.com/jsp/jstl/fmt" prefix="fmt" %>

list.jsp에서 날짜를 출력하는 부분을 찾아 다음과 같이 수정한다.

<fmt:formatDate pattern="yyyy.MM.dd" value="${article.regdate }" />

view.jsp에서 날짜를 출력하는 부분을 찾아 다음과 같이 수정한다.

<fmt:formatDate pattern="yyyy.MM.dd HH:mm:ss" value="${regdate }" />
<fmt:formatDate pattern="yyyy.MM.dd" value="${article.regdate }" />

첨부 파일을 내려받는 JSP 추가

모델 2 게시판은 상세보기(view.jsp)에서 첨부 파일을 단순히 링크 거는 것으로 구현했다. 스프링 MVC 게시판에서는 /WEB-INF/views/inc/download.jsp를 이용하여 첨부 파일을 내려받는 것으로 변경할 것이다. 이 경우 웹 브라우저가 접근하지 못하는 경로에 첨부 파일이 있어도 상관없다.

/WEB-INF/views/inc/download.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>    
    
<%@ page import="java.io.File" %>
<%@ page import="java.net.URLEncoder" %>
<%@ page import="java.io.OutputStream" %>
<%@ page import="java.io.FileInputStream" %>
<%@ page import="java.io.IOException" %>
<%@ page import="org.springframework.util.FileCopyUtils" %>
<%@ page import="net.java_school.commons.WebContants" %>
<%
//request.setCharacterEncoding("UTF-8");//이 작업은 필터가 한다.
String filename = request.getParameter("filename");

File file = new File(WebContants.UPLOAD_PATH + filename);

String filetype = filename.substring(filename.indexOf(".") + 1, filename.length());

if (filetype.trim().equalsIgnoreCase("txt")) {
  response.setContentType("text/plain");
} else {
  response.setContentType("application/octet-stream");
}

response.setContentLength((int) file.length());

boolean ie = request.getHeader("User-Agent").indexOf("MSIE") != -1;
if (ie) {
  filename = URLEncoder.encode(filename, "UTF-8").replaceAll("\\+", " ");
} else {
  filename = new String(filename.getBytes("UTF-8"), "8859_1");
}

response.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");

OutputStream outputStream = response.getOutputStream();
FileInputStream fis = null;

try {
  fis = new FileInputStream(file);
  FileCopyUtils.copy(fis, outputStream);
} finally {
  if (fis != null) {
    try {
      fis.close();
    } catch (IOException e) {}
  }
}

out.flush();
%>

위에서 WebContants.UPLOAD_PATH는 첨부 파일이 있는 디렉터리를 상수화 한 것이다. WebContants.java에 첨부 파일이 저장되는 디렉터리의 전체 경로 UPLOAD_PATH를 추가한다.

모델 2에서는 웹 브라우저로 접근할 수 있는 디렉터리였으나 여기서는 접근할 수 없는 디렉터리를 선택했다. 따라서 단순히 링크로 파일을 내려받을 수 없다. view.jsp의 첨부 파일 다운로드 코드가 변경되어야 한다는 점을 기억하자.

WebContants.java
package net.java_school.commons;

public class WebContants {
  //Session key
  public final static String USER_KEY = "user";
  //Error Message
  public final static String NOT_LOGIN = "Not Login";
  public final static String AUTHENTICATION_FAILED = "Authentication Failed";
  //Line Separator
  public final static String LINE_SEPARATOR = System.getProperty("line.separator");
  //Upload Path
  public final static String UPLOAD_PATH = "C:/www/spring-bbs/upload/";
}

로그

commons-logging.properties
org.apache.commons.logging.Log = org.apache.commons.logging.impl.Log4JLogger
log4j2.xml
<?xml version="1.0" encoding="UTF-8"?>
<Configuration>
  <Appenders>
    <File name="A1" fileName="A1.log" append="true">
      <PatternLayout pattern="%t %-5p %c{2} - %m%n" />
    </File>
    <Console name="STDOUT" target="SYSTEM_OUT">
      <PatternLayout pattern="%d %-5p [%t] %C{2} (%F:%L) - %m%n" />
    </Console>
  </Appenders>
  <Loggers>
    <Logger name="net.java_school" level="debug">
      <AppenderRef ref="A1" />
    </Logger>
    <Root level="debug">
      <AppenderRef ref="STDOUT" />
    </Root>
  </Loggers>
</Configuration>

위 두 개의 파일을 C:/www/spring-bbs/src/main/resources에 생성한다.
C:/www/spring-bbs/src/main/resources 폴더가 없다면 만들고, 컨텍스트 메뉴에서 Maven - Update Project... 를 실행하여 동기화한 후 작업한다.

회원

데이터베이스 관련 코드는 MyBatis-Spring 라이브러리를 사용하도록 수정할 것이다. MyBatis-Spring 공식 문서에는 스프링과 마이바티스의 연동에 관한 여러 가지 방법을 소개하고 있다. 여기서는 DAO 패턴을 이용하지 않는 방법을 선택했다.

UserService.java를 인터페이스로 변경한다.

UserService.java
package net.java_school.user;

public interface UserService {
    
  //회원 가입
  public void addUser(User user);

  //로그인
  public User login(String email, String passwd);

  //내 정보 수정
  public int editAccount(User user);

  //비밀번호 변경
  public int changePasswd(String currentPasswd, String newPasswd, String email);

  //탈퇴
  public void bye(User user);

  //회원 찾기
  public User getUser(String email);
    
}

net.java_school.mybatis 패키지에 UserMapper.java를 작성한다. (모델 2 게시판의 UserDao와 일관성을 위해 메소드 이름은 될 수 있으면 그대로 유지했다)

UserMapper.java
package net.java_school.mybatis;

import org.apache.ibatis.annotations.Param;

import net.java_school.user.User;

public interface UserMapper {
    
  public void insert(User user);

  public User login(
    @Param("email") String email, 
    @Param("passwd") String passwd);

  public int update(User user);

  public int updatePasswd(
    @Param("currentPasswd") String currentPasswd, 
    @Param("newPasswd") String newPasswd, 
    @Param("email") String email);

  public int delete(User user);

  public User selectOne(String email);
    
}

비밀번호 변경을 위한 메소드 명은 UserDao에서의 update가 아닌 updatePasswd로 변경했다. UserMapper.xml에 UserMapper.java의 메소드 이름으로 id를 설정해야 하는데, update라고 그대로 쓰면 내 정보 수정의 update와 이름이 같아져서 id가 중복되기 때문이다. (UserMapper.xml은 곧 다루는데, 마이바티스 설정 파일로 매퍼라 부른다)

net.java_school.user 패키지에 UserServiceImpl.java를 만든다.

UserServiceImpl.java
package net.java_school.user;

import net.java_school.mybatis.UserMapper;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class UserServiceImpl implements UserService {
    
  @Autowired
  private UserMapper userMapper;
    
  public void addUser(User user) {
    userMapper.insert(user);
  }

  public User login(String email, String passwd) {
    return userMapper.login(email, passwd);
  }

  public int editAccount(User user) {
    return userMapper.update(user);
  }

  public int changePasswd(String currentPasswd, String newPasswd, String email) {
    return userMapper.updatePasswd(currentPasswd, newPasswd, email);
  }

  public void bye(User user) {
    userMapper.delete(user);
  }

  public User getUser(String email) {
    return userMapper.selectOne(email);
  }
    
}

src/main/resources에 net.java_school.mybatis 패키지를 만들고, 이 패키지에 마이바티스 관련 설정 파일인 Congifuration.xml을 만든다. 이 파일은 타입의 별칭과 매퍼 파일의 위치를 설정한다. 메이븐 프로젝트에서 클래스 패스에 생성되어야 할 설정 파일은 반드시 src/main/resources에 생성해야 한다. 설정 파일을 소스 디렉터리에 만들어도 클래스 패스에 복사되지 않기 때문이다.

Configuration.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE configuration 
    PUBLIC "-//mybatis.org//DTD Config 3.0//EN" 
    "http://mybatis.org/dtd/mybatis-3-config.dtd">

<configuration> 

  <settings>
    <setting name="logImpl" value="LOG4J2"/>
  </settings>
        
  <typeAliases>
    <typeAlias type="net.java_school.board.AttachFile" alias="AttachFile" />
    <typeAlias type="net.java_school.board.Comment" alias="Comment" />
    <typeAlias type="net.java_school.board.Board" alias="Board" />
    <typeAlias type="net.java_school.board.Article" alias="Article" />
    <typeAlias type="net.java_school.user.User" alias="User" />
  </typeAliases>

  <mappers>
    <mapper resource="net/java_school/mybatis/BoardMapper.xml" />
    <mapper resource="net/java_school/mybatis/UserMapper.xml" />
  </mappers>

</configuration>

Configuration.xml와 같은 위치에 UserMapper.xml 파일을 만든다. UserMapper.java의 메소드 이름과 id 값이 일치해야 한다.

UserMapper.xml
<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE mapper
    PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="net.java_school.mybatis.UserMapper">
    
  <insert id="insert" parameterType="User">
    INSERT INTO member VALUES (#{email}, #{passwd}, #{name}, #{mobile})
  </insert>

  <select id="login" resultType="User">
    SELECT email, passwd, name, mobile FROM member 
    WHERE email = #{email} AND passwd = #{passwd}
  </select>

  <update id="update" parameterType="User">
    UPDATE member SET name = #{name}, mobile = #{mobile} 
    WHERE email = #{email} AND passwd = #{passwd}
  </update>

  <update id="updatePasswd">
    UPDATE member SET passwd = #{newPasswd} 
    WHERE passwd = #{currentPasswd} AND email = #{email}
  </update>

  <delete id="delete">
    DELETE FROM member 
    WHERE email = #{email}
  </delete>

  <select id="selectOne" parameterType="string" resultType="User">
    SELECT email, passwd, name, mobile 
    FROM member
    WHERE email = #{email}
  </select>
    
</mapper>

net.java_school.controller 패키지에 UsersController.java를 작성한다. 이 컨트롤러가 "/users"를 포함하는 모든 요청을 처리하도록 하려면, 클래스를 선언하는 곳 위에 @Controller 어노테이션을 사용하여 이 클래스가 컨트롤러 컴포넌트임을 표시해주고 @Controller 어노테이션 바로 아래 @RequestMapping("users") 어노테이션을 적용하여 "/users로 시작하는 모든 요청을 담당한다고 표시한다.

UsersController.java
package net.java_school.controller;

import java.net.URLEncoder;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

import net.java_school.commons.WebContants;
import net.java_school.exception.AuthenticationException;
import net.java_school.user.User;
import net.java_school.user.UserService;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

@Controller
@RequestMapping("users")
public class UsersController {
    
  @Autowired
  private UserService userService;

  @RequestMapping(value="signUp", method=RequestMethod.GET)
  public String signUp() {
    return "users/signUp";
  }

  @RequestMapping(value="signUp", method=RequestMethod.POST)
  public String signUp(User user) {
    userService.addUser(user);
    return "redirect:/users/welcome";
  }

  @RequestMapping(value="welcome", method=RequestMethod.GET)
  public String welcome() {
    return "users/welcome";
  }

  @RequestMapping(value="login", method=RequestMethod.GET)
  public String login() {
    return "users/login";
  }
    
  @RequestMapping(value="login", method=RequestMethod.POST)
  public String login(String email, String passwd, String url, HttpSession session) {
    User user = userService.login(email, passwd);
        
    if (user == null) {
      return "redirect:/users/login?url=" + url + "&msg=Login-Failed";
    } else {
      session.setAttribute(WebContants.USER_KEY, user);
      if (!url.equals("")) {
        return "redirect:" + url;
      }
      
      return "redirect:/";
    }
        
  }
        
  @RequestMapping(value="editAccount", method=RequestMethod.GET)
  public String editAccount(HttpServletRequest req, HttpSession session) throws Exception {
    User user = (User) session.getAttribute(WebContants.USER_KEY);

    if (user == null) {
      //로그인 후 다시 돌아오기 위해
      String url = req.getServletPath();
      String query = req.getQueryString();
      if (query != null) url += "?" + query;
      //로그인 페이지로 리다이렉트
      url = URLEncoder.encode(url, "UTF-8");
      
      return "redirect:/users/login?url=" + url;
    }

    return "users/editAccount";
  }
    
  @RequestMapping(value="editAccount", method=RequestMethod.POST)
  public String editAccount(User user, HttpSession session) {
    User loginUser = (User) session.getAttribute(WebContants.USER_KEY);
    if (loginUser == null) {
      throw new AuthenticationException(WebContants.NOT_LOGIN);
    }

    user.setEmail(loginUser.getEmail());
        
    int check = userService.editAccount(user);
    if (check < 1) {
      throw new AuthenticationException(WebContants.AUTHENTICATION_FAILED);
    }
    
    session.setAttribute(WebContants.USER_KEY, user);

    return "users/changePasswd";
        
  }
    
  @RequestMapping(value="changePasswd", method=RequestMethod.GET)
  public String changePasswd(HttpServletRequest req, HttpSession session) throws Exception {
    User user = (User) session.getAttribute(WebContants.USER_KEY);
        
    if (user == null) {
      //로그인 후 다시 돌아오기 위해
      String url = req.getServletPath();
      String query = req.getQueryString();
      if (query != null) url += "?" + query;
      //로그인 페이지로 리다이렉트
      url = URLEncoder.encode(url, "UTF-8");
      return "redirect:/users/login?url=" + url;     
    }
        
    return "users/changePasswd";
  }
    
  @RequestMapping(value="changePasswd", method=RequestMethod.POST)
  public String changePasswd(String currentPasswd, String newPasswd, HttpSession session) {
    String email = ((User)session.getAttribute(WebContants.USER_KEY)).getEmail();
        
    int check = userService.changePasswd(currentPasswd, newPasswd, email);
    if (check < 1) {
        throw new AuthenticationException(WebContants.AUTHENTICATION_FAILED);
    } 
    
    return "redirect:/users/changePasswd_confirm";
        
  }
    
  @RequestMapping(value="changePasswd_confirm", method=RequestMethod.GET)
  public String changePasswd_confirm() {
    return "users/changePasswd_confirm";
  }
    
  @RequestMapping(value="bye", method=RequestMethod.GET)
  public String bye(HttpServletRequest req, HttpSession session) throws Exception {
    User user = (User)session.getAttribute(WebContants.USER_KEY);
        
    if (user == null) {
      //로그인 후 다시 돌아오기 위해
      String url = req.getServletPath();
      String query = req.getQueryString();
      if (query != null) url += "?" + query;
      //로그인 페이지로 리다이렉트
      url = URLEncoder.encode(url, "UTF-8");
      
      return "redirect:/users/login?url=" + url;     
    }
        
    return "users/bye";
  }

  @RequestMapping(value="bye", method=RequestMethod.POST)
  public String bye(String email, String passwd, HttpSession session) {
    User user = (User)session.getAttribute(WebContants.USER_KEY);

    if (user == null || !user.getEmail().equals(email)) {
      throw new AuthenticationException(WebContants.AUTHENTICATION_FAILED);
    }
    
    user = userService.login(email, passwd);
    userService.bye(user);
    session.removeAttribute(WebContants.USER_KEY);
    
    return "redirect:/users/bye_confirm";
        
  }
  
  @RequestMapping(value="bye_confirm", method=RequestMethod.GET)
  public String bye_confirm() {
  
    return "users/bye_confirm";    
  } 
    
  @RequestMapping(value="logout", method=RequestMethod.GET)
  public String logout(HttpSession session) {
    session.removeAttribute(WebContants.USER_KEY);

    return "redirect:/";

  }

}

메소드 선언 위의 @RequestMapping(value="signUp", method=RequestMethod.GET)는 메소드가 GET 방식의 "/users/signUp" 요청을 처리한다는 것을 표시한다. ("/users/signUp"에서 "/users" 부분은 UsersController 클래스 이름 위의 @RequestMapping("users") 어노테이션 때문이다.) 메소드 선언 위의 @RequestMapping(value="/signUp", method=RequestMethod.POST)는 메소드가 POST 방식의 "/users/signUp" 요청을 처리한다는 것을 표시한다. 이처럼 요청 문자열이 같더라도 HTTP 메소드로 구분하여 요청을 각각 처리할 수 있다. POST 방식의 /users/signUp 요청 시 호출되는 메소드는 회원 가입을 처리한 후에는 return "redirect:/users/welcome";로 리다이렉트한다. 여기서 포워드를 쓴다면 사용자가 F5버튼 등으로 재로딩할때 회원 가입이 똑같은 정보로 다시 시도될 수 있다. 리다이렉트는 그런 일이 발생하지 않는다. 이점이 포워딩과 리다이렉트를 선택할 때 중요한 선택 기준이다. 리다이렉트를 선택할 때는 컨트롤러에 리다이렉트 요청에 대한 매핑을 추가해야 한다. @RequestMapping(value="welcome", method="RequestMethod.GET")

아래는 HomeController은 홈페이지 요청을 담당하는 컨트롤러이다.

HomeController.java
package net.java_school.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

@Controller
@RequestMapping("/")
public class HomeController {
    @RequestMapping(method=RequestMethod.GET)
    public String index() {
        return "index";
    }
}

JavaController은 "/java"가 포함된 모든 요청을 처리하는 컨트롤러이다.

JavaController.java
package net.java_school.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

@Controller
@RequestMapping("java")
public class JavaController {

    @RequestMapping(method=RequestMethod.GET)
    public String index() {
        return "java/index";
    }
    
    @RequestMapping(value="jdk-install", method=RequestMethod.GET)
    public String basic() {
        return "java/jdk-install";
    }

}

JavascriptController은 "/javascrit"가 포함된 모든 요청을 처리하는 컨트롤러이다.

JavascriptController.java
package net.java_school.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

@Controller
@RequestMapping("javascript")
public class JavascriptController {

    @RequestMapping(method=RequestMethod.GET)
    public String index() {
        return "javascript/index";
    }
    
}
mvc.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans" 
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:context="http://www.springframework.org/schema/context"
  xmlns:mvc="http://www.springframework.org/schema/mvc"
  xmlns:p="http://www.springframework.org/schema/p"
  xmlns:mybatis="http://mybatis.org/schema/mybatis-spring"
  xsi:schemaLocation="http://www.springframework.org/schema/beans 
    http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/context
    http://www.springframework.org/schema/context/spring-context.xsd
    http://www.springframework.org/schema/mvc
    http://www.springframework.org/schema/mvc/spring-mvc.xsd
    http://mybatis.org/schema/mybatis-spring 
    http://mybatis.org/schema/mybatis-spring.xsd">
            
  <!-- 스프링의 DispatcherServet에게 정적인 자원을 알려준다.  -->
  <mvc:resources location="/images/" mapping="/images/**" />
  <mvc:resources location="/css/" mapping="/css/**" />
        
  <mvc:annotation-driven />
    
  <context:component-scan
    base-package="net.java_school.controller,
      net.java_school.board, 
      net.java_school.user, " />

  <mybatis:scan base-package="net.java_school.mybatis" />

  <bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close">
    <property name="driverClassName" value="oracle.jdbc.driver.OracleDriver"/>
    <property name="url" value="jdbc:oracle:thin:@127.0.0.1:1521:XE"/>
    <property name="username" value="java"/>
    <property name="password" value="school"/>
    <property name="maxActive" value="100"/>
    <property name="maxWait" value="1000"/>
    <property name="poolPreparedStatements" value="true"/>
    <property name="defaultAutoCommit" value="true"/>
    <property name="validationQuery" value=" SELECT 1 FROM DUAL" />
  </bean>
    
  <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
    <property name="dataSource" ref="dataSource" />
    <property name="configLocation" value="classpath:net/java_school/mybatis/Configuration.xml" />
  </bean>

  <bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
    <property name="maxUploadSize" value="2097152" />
  </bean>
    
  <!-- ViewResolver -->
  <bean id="internalResourceViewResolver" 
      class="org.springframework.web.servlet.view.InternalResourceViewResolver">
    <property name="viewClass">
      <value>org.springframework.web.servlet.view.JstlView</value>
    </property>
    <property name="prefix">
      <value>/WEB-INF/views/</value>
    </property>
    <property name="suffix">
      <value>.jsp</value>
    </property>
  </bean>
  
  <bean class="org.springframework.web.servlet.handler.SimpleMappingExceptionResolver">
    <property name="defaultErrorView" value="error" />
    <property name="exceptionMappings">
      <props>
        <prop key="net.java_school.exception.AuthenticationException">
          403-error
        </prop>
      </props>
    </property>
  </bean>
</beans>

mvc.xml 설명

<mvc:resources location=".." />

이 설정은 디스패쳐 서블릿에 정적 자원의 위치를 알려준다.

<context:component-scan base-package=".." />

이 설정은 자동으로 빈을 스캔하여 빈 컨테이너에 등록한다. 스캔 되는 빈은 스프링 설정 파일에서 bean 엘리먼트를 사용하여 따로 등록하지 않아도 된다. 빈이 자동 스캔되려면 base-package 패키지에 속하면서, 동시에 클래스 선언 위에 컴포넌트임을 표시하는 어노테이션이 있어야 한다. 이때, 클래스 선언 위에 @Controller, @Repository 어노테이션이 있는 클래스는 어노테이션의 이름대로 취급된다. @Repository는 DAO 클래스에 사용한다. 클래스 선언 위에 @Component와 @Service 어노테이션은 클래스가 단지 자동 스캔 대상이라는 사실만을 표시하며, 그 외 특별하게 취급되는 사항은 없다.

<mvc:annotation-driven />

어노테이션 기반으로 동작하는 스프링 애플리케이션은 이 설정이 필요하다.

<mybatis:scan base-package=".." />

base-package 속성에 매퍼 인터페이스의 패키지를 지정하면, 마이바티스 스프링 연동 빈즈가 스캔된다. 이 설정을 이용하면 아래 설정을 생략할 수 있다.

<bean id="boardMapper" class="org.mybatis.spring.mapper.MapperFactoryBean">
  <property name="mapperInterface" value="net.java_school.mybatis.BoardMapper" />
  <property name="sqlSessionFactory" ref="sqlSessionFactory" />
</bean>
<bean id="dataSource" ..>

dataSource 정의는 스프링 JDBC를 사용하든지 마이바티스를 사용하든지 상관없이 언제나 필요하다.

<bean id="sqlSessionFactory" ..>

모든 MyBatis 애플리케이션은 SqlSessionFactory 인터테이스 타입의 인스턴스를 사용해야 한다. 마이바티스 스프링 연동모듈에서는 SqlSessionFactoryBean를 사용한다.

<bean id="multipartResolver" ..>

파일 업로드를 위한 설정이다. 파일을 업로드할 때 HttpServletRequest의 래퍼가 컨트롤러에 전달된다. 컨트롤러에서 업로드된 파일을 처리하는 메소드는 래퍼를 MultiPartHttpServletRequest 인터페이스로 캐스팅해야 서버로 전달된 멀티파트 파일을 다룰 수 있게 된다.

<!-- ViewResolver --> 부분은 컨트롤러가 반환하는 문자열로부터 뷰를 해석하는 방법이다.

<bean class="org.springframework.web.servlet.handler.SimpleMappingExceptionResolver">

스프링 MVC 예외 처리에 대한 설정이다. 스프링 MVC에는 여러 가지 예외 처리 방법이 있다. 이 중에서 SimpleMappingExceptionResolver를 이용하는 것이 가장 사용하기 쉽다. 설정대로라면 AuthenticationException 예외가 발생하면 선택되는 뷰는 403-error이다. 403-error 역시 뷰리졸브의 의해 /WEB-INF/views/403-error.jsp로 해석된다. 이 파일은 정확한 위치에 생성한다. 간단히 "접근거부"라고 보여주면 된다. 참고로, /403-error에 대한 요청은 컨트롤러에서 매핑하지 않아도 된다. 이 밖의 다른 예외가 발생하면 다음 설정이 에러를 처리한다.

<property name="defaultErrorView" value="error" />

/WEB-INF/views/error.jsp는 이전 예제의 error.jsp 파일이다. 이밖에 404와 500 HTTP 상태 코드에 대해서는 web.xml에서 error-page 엘리먼트로 설정했다.

게시판

Configuration.xml의 설정대로, BoardMapper.xml 파일을 UserMapper.xml와 같은 위치에 만든다.

BoardMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>

<!DOCTYPE mapper
    PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="net.java_school.mybatis.BoardMapper">

    <select id="selectListOfArticles" parameterType="hashmap" resultType="Article">
    SELECT articleno, title, regdate, hit, name, attachfileNum, commentNum 
    FROM (
        SELECT rownum r,a.* 
            FROM (
                SELECT 
                    a.articleno, a.title, a.regdate, a.hit, m.name,
                    COUNT(DISTINCT(f.attachfileno)) attachfileNum, 
                    COUNT(DISTINCT(c.commentno)) commentNum
                FROM 
                    article a, attachfile f, comments c, member m
                WHERE
                    a.articleno = f.articleno(+)
                    AND a.articleno = c.articleno(+)
                    AND a.email = m.email(+)
                    AND a.boardcd = #{boardCd}
                    <if test="searchWord != null and searchWord != ''">
                    AND (title LIKE '%${searchWord}%' OR DBMS_LOB.INSTR(content, #{searchWord}) > 0)
                    </if>
                GROUP BY a.articleno, title, a.regdate, hit, m.name
                ORDER BY articleno DESC
                ) a
        )
    WHERE r BETWEEN #{start} AND #{end}
    </select>

    <select id="selectCountOfArticles" parameterType="hashmap" resultType="int">
        SELECT count(*) FROM article WHERE boardcd = #{boardCd}
            <if test="searchWord != null and searchWord != ''">
            AND (title LIKE '%${searchWord}%' OR DBMS_LOB.INSTR(content, #{searchWord}) > 0)
            </if>
    </select>

    <insert id="insert" parameterType="Article" useGeneratedKeys="true">
        <selectKey keyProperty="articleNo" resultType="int" order="BEFORE">
            SELECT seq_article.nextval FROM dual
        </selectKey>
        INSERT INTO article (articleNo, boardCd, title, content, email, hit, regdate)
        VALUES
        (#{articleNo}, #{boardCd}, #{title}, #{content}, #{email}, 0, sysdate)
    </insert>

    <insert id="insertAttachFile" parameterType="AttachFile">
        INSERT INTO attachfile (attachfileno, filename, filetype, filesize, articleno, email)
        VALUES
        (seq_attachfile.nextval, #{filename}, #{filetype}, #{filesize}, #{articleNo}, #{email})
    </insert>
    
    <update id="update" parameterType="Article">
        UPDATE article 
        SET title = #{title}, content = #{content} 
        WHERE articleno = #{articleNo}
    </update>
    
    <delete id="delete" parameterType="int">
        DELETE FROM article WHERE articleno = #{articleNo}
    </delete>
    
    <update id="updateHitPlusOne" parameterType="int">
        UPDATE article SET hit = hit + 1 WHERE articleno = #{articleNo}
    </update>
    
    <select id="selectOne" parameterType="int" resultType="Article">
        SELECT 
            articleno, 
            title, 
            content, 
            a.email, 
            NVL(name, 'Anonymous') name, 
            hit, 
            regdate
        FROM article a, member m
        WHERE a.email = m.email(+) AND articleno = #{articleNo}
    </select>
    
    <select id="selectNextOne" parameterType="hashmap" resultType="Article">
        SELECT articleno, title
        FROM
            (SELECT rownum r,a.*
            FROM
                (SELECT articleno, title 
                FROM article 
                WHERE 
                    boardCd = #{boardCd} 
                    AND articleno > #{articleNo}
                <if test="searchWord != null and searchWord != ''">
                    AND (title LIKE '%${searchWord}%' OR DBMS_LOB.INSTR(content, #{searchWord}) > 0)
                </if>
                ORDER BY articleno) 
            a)
        WHERE r = 1
    </select>
    
    <select id="selectPrevOne" parameterType="hashmap" resultType="Article">
        SELECT articleno, title
        FROM
            (SELECT rownum r,a.*
            FROM
                (SELECT articleno, title 
                FROM article 
                WHERE 
                    boardCd = #{boardCd} 
                    AND articleno < #{articleNo}
                <if test="searchWord != null and searchWord != ''">
                    AND (title LIKE '%${searchWord}%' OR DBMS_LOB.INSTR(content, #{searchWord}) > 0)
                </if> 
                ORDER BY articleno DESC)
            a)
        WHERE r = 1
    </select>
    
    <select id="selectListOfAttachFiles" parameterType="int" resultType="AttachFile">
        SELECT 
            attachfileno, 
            filename, 
            filetype, 
            filesize, 
            articleno, 
            email
        FROM attachfile 
        WHERE articleno = #{articleNo} 
        ORDER BY attachfileno
    </select>
    
    <delete id="deleteFile" parameterType="int">
        DELETE FROM attachfile WHERE attachfileno = #{attachFileNo}
    </delete>
    
    <select id="selectOneBoard" parameterType="string" resultType="string">
        SELECT * FROM board WHERE boardcd = #{boardCd}
    </select>
    
    <insert id="insertComment" parameterType="Comment">
        INSERT INTO comments (commentno, articleno, email, memo, regdate)
        VALUES (seq_comments.nextval, #{articleNo}, #{email}, #{memo}, sysdate)
    </insert>
    
    <update id="updateComment" parameterType="Comment">
        UPDATE comments SET memo = #{memo} WHERE commentno = #{commentNo}
    </update>
    
    <delete id="deleteComment" parameterType="int">
        DELETE FROM comments WHERE commentno = #{commentNo}
    </delete>

    <select id="selectListOfComments" parameterType="int" resultType="Comment">
        SELECT 
            commentno, 
            articleno, 
            c.email, 
            NVL(name,'Anonymous') name, 
            memo, 
            regdate
        FROM comments c, member m
        WHERE 
            c.email = m.email(+)
            AND articleno = #{articleNo}
        ORDER BY commentno DESC
    </select>

    <select id="selectOneAttachFile" parameterType="int" resultType="AttachFile">
        SELECT 
            attachfileno, 
            filename, 
            filetype, 
            filesize, 
            articleno, 
            email
        FROM attachfile
        WHERE attachfileno = #{attachFileNo}
    </select>
    
    <select id="selectOneComment" parameterType="int" resultType="Comment">
        SELECT 
            commentno,
            articleno,
            email,
            memo,
            regdate 
        FROM comments 
        WHERE commentno = #{commentNo}
    </select>

</mapper>

MyBatis에서 인서트 후 고유번호가 반환되게 하는 방법

<insert id="insert" ..>부분이 새 글을 추가할 때 쓰인다.
모델 2 게시판의 BoardDao.insert() 메소드는
INSERT INTO article (articleno, boardcd, title, content, email, hit, regdate)
VALUES (SEQ_ARTICLE.nextval, ?, ?, ?, ?, 0, sysdate);

문이 성공한 후 첨부 파일이 있으면
INSERT INTO attachfile (attachfileno, filename, filetype, filesize, articleno, email)
VALUES (SEQ_ATTACHFILE.nextval, ?, ?, ?, SEQ_ARTICLE.currval, ?)
문을 이어서 실행하는 것으로 글쓰기를 구현했다.
이렇듯 JDBC를 쓰는 상황에서는 모든 것이 분명하다. 하지만 JDBC 위에서 동작하는 프레임워크를 사용하는 상황에서는 JDBC를 사용할 때 분명해 보이던 것에 대한 해법을 찾기가 쉽지 않다.

마이바티스에서 DML은 일반적으로 바뀐 행수가 반환한다. 인서트 문장이 실행된 후 고유번호가 반환되기를 원한다면 useGeneratedKeys="true" 속성이 있어야 하고, 오라클과 같이 자동 증가 칼럼이 없는 경우엔 selectKey 서브 엘리먼트를 사용해야 한다.

<insert id="insert" parameterType="Article" useGeneratedKeys="true">
    <selectKey keyProperty="articleNo" resultType="int" order="BEFORE">
        SELECT seq_article.nextval FROM dual
    </selectKey>
    INSERT INTO article (articleNo, boardCd, title, content, email, hit, regdate)
    VALUES
    (#{articleNo}, #{boardCd}, #{title}, #{content}, #{email}, 0, sysdate)
</insert>

이외에도 MyBatis와 연동하면서 바뀐 내용은 다음과 같다.
모델 2의 게시글 수정 메소드는
UPDATE article SET title = ?, content = ? WHERE articleno = ?
을 실행한 후에 첨부 파일이 있다면
INSERT INTO attachfile
(attachfileno, filename, filetype, filesize, articleno, email)
VALUES (SEQ_ATTACHFILE.nextval, ?, ?, ?, ?, ?)
을 연이어 실행하는 구조였으나 Spring MVC 프로젝트에서는 update()와 insertAttachFile() 메소드의 조합으로 이를 대신했다.

모델 2의 게시글 삭제 메소드는
DELETE FROM comments WHERE articleno = ?
DELETE FROM attachfile WHERE articleno = ?
DELETE FROM article WHERE articleno = ?을 하나의 트랜잭션으로 실행하는 것이었다.
Spring MVC 프로젝트에서는 간단한 구현을 위해
DELETE FROM article WHERE articleno = ?만 실행하는 것으로 구현한다.

BoardMapper.xml에 맞게 BoardMapper.java를 아래와 같이 만든다.

BoardMapper.java
package net.java_school.mybatis;

import java.util.HashMap;
import java.util.List;

import net.java_school.board.Article;
import net.java_school.board.AttachFile;
import net.java_school.board.Comment;

public interface BoardMapper {

  //목록
  public List<Article> selectListOfArticles(HashMap<String, String> hashmap);  

  //총 레코드
  public int selectCountOfArticles(HashMap<String, String> hashmap);

  //글쓰기
  public int insert(Article article);   

  //첨부 파일 추가
  public void insertAttachFile(AttachFile attachFile);

  //글수정
  public void update(Article article);  

  //글삭제
  public void delete(int articleNo);

  //조회 수 증가
  public void updateHitPlusOne(int articleNo);  

  //상세보기
  public Article selectOne(int articleNo);

  //다음 글
  public Article selectNextOne(HashMap<String, String> hashmap); 

  //이전 글 
  public Article selectPrevOne(HashMap<String, String> hashmap);

  //첨부 파일 리스트  
  public List<AttachFile> selectListOfAttachFiles(int articleNo);    

  //첨부 파일 삭제  
  public void deleteFile(int attachFileNo); 

  //게시판
  public String selectOneBoard(String boardCd);

  //댓글 쓰기 
  public void insertComment(Comment comment);   

  //댓글 수정 
  public void updateComment(Comment comment);

  //댓글 삭제
  public void deleteComment(int commentNo);

  //댓글 리스트
  public List<Comment> selectListOfComments(int articleNo);

  //첨부 파일 찾기
  public AttachFile selectOneAttachFile(int attachFileNo);

  //댓글 찾기
  public Comment selectOneComment(int commentNo);

} 

모델 2의 BoardService.java를 인터페이스로 바꾼다.

BoardService.java
package net.java_school.board;

import java.util.List;

public interface BoardService {

  //목록
  public List<Article> getArticleList(String boardCd, String searchWord, Integer startRecord, Integer endRecord);

  //총 레코드
  public int getTotalRecord(String boardCd, String searchWord);

  //글쓰기
  public int addArticle(Article article);

  //첨부 파일 추가 
  public void addAttachFile(AttachFile attachFile);

  //글 수정 
  public void modifyArticle(Article article);

  //글 삭제 
  public void removeArticle(int articleNo);

  //조회 수 증가 
  public void increaseHit(int articleNo);

  //상세보기 
  public Article getArticle(int articleNo);

  //다음 글 
  public Article getNextArticle(int articleNo, 
      String boardCd, String searchWord);

  //이전 글 
  public Article getPrevArticle(int articleNo, 
      String boardCd, String searchWord);

  //첨부 파일 리스트 
  public List<AttachFile> getAttachFileList(int articleNo);

  //첨부 파일 삭제 
  public void removeAttachFile(int attachFileNo);

  //게시판 
  public Board getBoard(String boardCd);

  //댓글 쓰기 
  public void addComment(Comment comment);

  //댓글 수정 
  public void modifyComment(Comment comment);

  //댓글 삭제 
  public void removeComment(int commentNo);

  //댓글 리스트 
  public List<Comment> getCommentList(int articleNo);

  //첨부 파일 찾기 
  public AttachFile getAttachFile(int attachFileNo);

  //댓글 찾기 
  public Comment getComment(int commentNo);
}

BoardService 인터페이스의 구현체 BoardServiceImpl.java를 net.java_school.board 패키지에 만든다.

BoardServiceImpl.java
package net.java_school.board;

import java.util.HashMap;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import net.java_school.mybatis.BoardMapper;

@Service
public class BoardServiceImpl implements BoardService {
  @Autowired
  private BoardMapper boardMapper;

  //목록 
  @Override
  public List<Article> getArticleList(String boardCd, String searchWord, Integer startRecord, Integer endRecord) {
    HashMap<String, String> hashmap = new HashMap<String, String>();
    hashmap.put("boardCd", boardCd);
    hashmap.put("searchWord", searchWord);
    hashmap.put("start", startRecord.toString());
    hashmap.put("end", endRecord.toString());

    return boardMapper.selectListOfArticles(hashmap);
  }
    
  //총 레코드 
  @Override 
  public int getTotalRecord(String boardCd, String searchWord) {
    HashMap<String,String> hashmap = new HashMap<String,String>();
    hashmap.put("boardCd", boardCd);
    hashmap.put("searchWord", searchWord);
    
    return boardMapper.selectCountOfArticles(hashmap);
  }
    
  //글쓰기 
  @Override
  public int addArticle(Article article) {
    return boardMapper.insert(article);
  }

  //첨부 파일 추가 
  @Override
  public void addAttachFile(AttachFile attachFile) {
    boardMapper.insertAttachFile(attachFile);
  }

  //글 수정 
  @Override
  public void modifyArticle(Article article) {
    boardMapper.update(article);
  }

  //글 삭제
  @Override
  public void removeArticle(int articleNo) {
    boardMapper.delete(articleNo);
  }

  //조회 수 증가 
  @Override
  public void increaseHit(int articleNo) {
    boardMapper.updateHitPlusOne(articleNo);
  }

  //상세보기 
  @Override
  public Article getArticle(int articleNo) {
    return boardMapper.selectOne(articleNo);
  }

  //다음 글 
  @Override
  public Article getNextArticle(int articleNo, String boardCd, String searchWord) {
    HashMap<String, String> hashmap = new HashMap<String, String>();
    Integer no = articleNo;
    hashmap.put("articleNo", no.toString());
    hashmap.put("boardCd", boardCd);
    hashmap.put("searchWord", searchWord);

    return boardMapper.selectNextOne(hashmap);
  }

  //이전 글 
  @Override
  public Article getPrevArticle(int articleNo, String boardCd, String searchWord) {
    HashMap<String, String> hashmap = new HashMap<String, String>();
    Integer no = articleNo;
    hashmap.put("articleNo", no.toString());
    hashmap.put("boardCd", boardCd);
    hashmap.put("searchWord", searchWord);

    return boardMapper.selectPrevOne(hashmap);
  }

  //첨부 파일 리스트 
  @Override
  public List<AttachFile> getAttachFileList(int articleNo) {
    return boardMapper.selectListOfAttachFiles(articleNo);
  }

  //첨부 파일 삭제 
  @Override
  public void removeAttachFile(int attachFileNo) {
    boardMapper.deleteFile(attachFileNo);
  }

  //게시판 
  @Override
  public Board getBoard(String boardCd) {
    return boardMapper.selectOneBoard(boardCd);
  }

  //댓글 쓰기 
  @Override
  public void addComment(Comment comment) {
    boardMapper.insertComment(comment);
  }

  //댓글 수정 
  @Override
  public void modifyComment(Comment comment) {
    boardMapper.updateComment(comment);
  }

  //댓글 삭제 
  @Override
  public void removeComment(int commentNo) {
    boardMapper.deleteComment(commentNo);
  }

  //댓글 리스트 
  @Override
  public List<Comment> getCommentList(int articleNo) {
    return boardMapper.selectListOfComments(articleNo);
  }

  //첨부 파일 찾기 
  @Override
  public AttachFile getAttachFile(int attachFileNo) {
    return boardMapper.selectOneAttachFile(attachFileNo);
  }

  //댓글 찾기 
  @Override
  public Comment getComment(int commentNo) {
    return boardMapper.selectOneComment(commentNo);
  }
}

게시판과 관련된 모든 요청을 담당하는 컨트롤러를 만들어야 한다. 그 전에 페이징 처리를 위한 클래스를 작성한다.

NumbersForPaging.java
package net.java_school.commons;

public class NumbersForPaging {
  private int totalPage;
  private int firstPage;
  private int lastPage;
  private int prevBlock;
  private int nextBlock;
  private int listItemNo;
  
  public int getTotalPage() {
    return totalPage;
  }
  public void setTotalPage(int totalPage) {
    this.totalPage = totalPage;
  }
  public int getFirstPage() {
    return firstPage;
  }
  public void setFirstPage(int firstPage) {
    this.firstPage = firstPage;
  }
  public int getLastPage() {
    return lastPage;
  }
  public void setLastPage(int lastPage) {
    this.lastPage = lastPage;
  }
  public int getPrevBlock() {
    return prevBlock;
  }
  public void setPrevBlock(int prevBlock) {
    this.prevBlock = prevBlock;
  }
  public int getNextBlock() {
    return nextBlock;
  }
  public void setNextBlock(int nextBlock) {
    this.nextBlock = nextBlock;
  }
  public int getListItemNo() {
    return listItemNo;
  }
  public void setListItemNo(int listItemNo) {
    this.listItemNo = listItemNo;
  }
  
}
Paginator.java
package net.java_school.commons;

public class Paginator {

  public NumbersForPaging getNumbersForPaging(int totalRecord, int page, int numPerPage, int pagePerBlock) {
    int totalPage = totalRecord / numPerPage;
    if (totalRecord % numPerPage != 0) totalPage++;
    int totalBlock = totalPage / pagePerBlock;
    if (totalPage % pagePerBlock != 0) totalBlock++;
    int block = page / pagePerBlock;
    if (page % pagePerBlock != 0) block++;
    int firstPage = (block - 1) * pagePerBlock + 1;
    int lastPage = block * pagePerBlock;
    int prevPage = 0;
    if (block > 1) {
      prevPage = firstPage - 1;
    }
    int nextPage = 0;
    if (block < totalBlock) {
      nextPage = lastPage + 1;
    }
    if (block >= totalBlock) {
      lastPage = totalPage;
    }
    int listItemNo = totalRecord - (page - 1) * numPerPage;
    
    NumbersForPaging numbers = new NumbersForPaging();
    
    numbers.setTotalPage(totalPage);
    numbers.setFirstPage(firstPage);
    numbers.setLastPage(lastPage);
    numbers.setPrevBlock(prevPage);
    numbers.setNextBlock(nextPage);
    numbers.setListItemNo(listItemNo);
    
    return numbers;
  }

}

BbsController.java 클래스를 net.java_school.controller 패키지에 만든다.

BbsController.java
package net.java_school.controller;

import java.io.File;
import java.net.URLEncoder;
import java.util.HashMap;
import java.util.Map;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

import net.java_school.board.Article;
import net.java_school.board.AttachFile;
import net.java_school.board.BoardService;
import net.java_school.board.Comment;
import net.java_school.commons.WebContants;
import net.java_school.exception.AuthenticationException;
import net.java_school.user.User;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;

@Controller
@RequestMapping("/bbs")
public class BbsController extends Paginator {

  @Autowired
  private BoardService boardService;

  @RequestMapping(value="/list", method=RequestMethod.GET)
  public String list(String boardCd, 
      Integer page, 
      String searchWord,
      HttpServletRequest req,
      HttpSession session,
      Model model) throws Exception {

    //로그인 체크
    User user = (User) session.getAttribute(WebContants.USER_KEY);
    if (user == null) {
      //로그인 후 되돌아갈 URL을 구한다.
      String url = req.getServletPath();
      String query = req.getQueryString();
      if (query != null) url += "?" + query;
      //로그인 페이지로 리다이렉트
      url = URLEncoder.encode(url, "UTF-8");
      return "redirect:/users/login?url=" + url;
    }

    int numPerPage = 10;
    int pagePerBlock = 10;

    int totalRecord = boardService.getTotalRecord(boardCd, searchWord);

    NumbersForPaging numbers = this.getNumbersForPaging(totalRecord, page, numPerPage, pagePerBlock);
    //oracle
    Integer startRecord = (page - 1) * numPerPage + 1;
    Integer endRecord = page * numPerPage;

    HashMap<String, String> map = new HashMap<String, String>();
    map.put("boardCd", boardCd);
    map.put("searchWord", searchWord);
    map.put("start", startRecord.toString());
    map.put("end", endRecord.toString());
    List<Article> list = boardService.getArticleList(map);
    String boardName = boardService.getBoard(boardCd).getBoardNm_ko();
    
    Integer listItemNo = numbers.getListItemNo();
    Integer prevPage = numbers.getPrevBlock();
    Integer nextPage = numbers.getNextBlock();
    Integer firstPage = numbers.getFirstPage();
    Integer lastPage = numbers.getLastPage();

    model.addAttribute("list", list);
    model.addAttribute("boardName", boardName);
    model.addAttribute("listItemNo", listItemNo);
    model.addAttribute("prevPage", prevPage);
    model.addAttribute("nextPage", nextPage);
    model.addAttribute("firstPage", firstPage);
    model.addAttribute("lastPage", lastPage);

    return "bbs/list";

  }

  @RequestMapping(value="/write", method=RequestMethod.GET)
  public String writeForm(String boardCd,
      HttpServletRequest req,
      HttpSession session,
      Model model) throws Exception {

    //로그인 체크
    User user = (User) session.getAttribute(WebContants.USER_KEY);
    if (user == null) {
      //로그인 후 되돌아갈 URL을 구한다.
      String url = req.getServletPath();
      String query = req.getQueryString();
      if (query != null) url += "?" + query;
      //로그인 페이지로 리다이렉트
      url = URLEncoder.encode(url, "UTF-8");
      return "redirect:/users/login?url=" + url;
    }

    //게시판 이름
    String boardName = boardService.getBoard(boardCd).getBoardNm_ko();
    model.addAttribute("boardName", boardName);

    return "bbs/write";
  }

  @RequestMapping(value="/write", method=RequestMethod.POST)
  public String write(MultipartHttpServletRequest mpRequest,
      HttpSession session) throws Exception {

    //로그인 체크
    User user = (User) session.getAttribute(WebContants.USER_KEY);
    if (user == null) {
      throw new AuthenticationException(WebContants.NOT_LOGIN);
    }

    String boardCd = mpRequest.getParameter("boardCd");
    String title = mpRequest.getParameter("title");
    String content = mpRequest.getParameter("content");

    Article article = new Article();
    article.setBoardCd(boardCd);
    article.setTitle(title);
    article.setContent(content);
    article.setEmail(user.getEmail());

    boardService.addArticle(article);

    //파일 업로드
    Iterator<String> it = mpRequest.getFileNames();
    List<MultipartFile> fileList = new ArrayList<MultipartFile>();
    while (it.hasNext()) {
      MultipartFile multiFile = mpRequest.getFile((String) it.next());
      if (multiFile.getSize() > 0) {
        String filename = multiFile.getOriginalFilename();
        multiFile.transferTo(new File(WebContants.UPLOAD_PATH + filename));
        fileList.add(multiFile);
      }
    }

    //파일데이터 삽입
    int size = fileList.size();
    for (int i = 0; i < size; i++) {
      MultipartFile mpFile = fileList.get(i);
      AttachFile attachFile = new AttachFile();
      String filename = mpFile.getOriginalFilename();
      attachFile.setFilename(filename);
      attachFile.setFiletype(mpFile.getContentType());
      attachFile.setFilesize(mpFile.getSize());
      attachFile.setArticleNo(article.getArticleNo());
      attachFile.setEmail(user.getEmail());
      boardService.addAttachFile(attachFile);
    }

    return "redirect:/bbs/list?page=1&boardCd=" + article.getBoardCd();
  }

  @RequestMapping(value="/view", method=RequestMethod.GET)
  public String view(Integer articleNo, 
      String boardCd, 
      Integer page,
      String searchWord,
      HttpServletRequest req,
      HttpSession session,
      Model model) throws Exception {

    //로그인 체크
    User user = (User) session.getAttribute(WebContants.USER_KEY);
    if (user == null) {
      //로그인 후 되돌아갈 URL을 구한다.
      String url = req.getServletPath();
      String query = req.getQueryString();
      if (query != null) url += "?" + query;
      //로그인 페이지로 리다이렉트
      url = URLEncoder.encode(url, "UTF-8");
      return "redirect:/users/login?url=" + url;
    }

    /*
        상세보기를 할 때마다 조회 수를 1 증가
        하단에 목록에서 조회 수를 제대로 보기 위해서는
        목록 레코드를 페치하기 전에 조회 수를 먼저 증가시켜야 한다.
        TODO : 사용자 IP와 시간을 고려해서 조회 수를 증가하도록
     */
    boardService.increaseHit(articleNo);

    Article article = boardService.getArticle(articleNo);//상세보기에서 볼 게시글
    List<AttachFile> attachFileList = boardService.getAttachFileList(articleNo);
    Article nextArticle = boardService.getNextArticle(articleNo, boardCd, searchWord);
    Article prevArticle = boardService.getPrevArticle(articleNo, boardCd, searchWord);
    List<Comment> commentList = boardService.getCommentList(articleNo);

    //상세보기에서 볼 게시글 관련 정보
    String title = article.getTitle();//제목
    String content = article.getContent();//내용
    content = content.replaceAll(WebContants.LINE_SEPARATOR, "<br />");
    int hit = article.getHit();//조회 수
    String name = article.getName();//작성자 이름
    String email = article.getEmail();//작성자 ID
    String regdate = article.getRegdateForView();//작성일

    model.addAttribute("title", title);
    model.addAttribute("content", content);
    model.addAttribute("hit", hit);
    model.addAttribute("name", name);
    model.addAttribute("email", email);
    model.addAttribute("regdate", regdate);
    model.addAttribute("attachFileList", attachFileList);
    model.addAttribute("nextArticle", nextArticle);
    model.addAttribute("prevArticle", prevArticle);
    model.addAttribute("commentList", commentList);

    //목록 관련
    int numPerPage = 10;//페이지당 레코드 수
    int pagePerBlock = 10;//블록당 페이지 링크 수

    int totalRecord = boardService.getTotalRecord(boardCd, searchWord);

    NumbersForPaging numbers = this.getNumbersForPaging(totalRecord, page, numPerPage, pagePerBlock);

    //oracle
    Integer startRecord = (page - 1) * numPerPage + 1;
    Integer endRecord = page * numPerPage;

    HashMap<String, String> map = new HashMap<String, String>();
    map.put("boardCd", boardCd);
    map.put("searchWord", searchWord);
    map.put("start", startRecord.toString());
    map.put("end", endRecord.toString());
    List<Article> list = boardService.getArticleList(map);
    String boardName = boardService.getBoard(boardCd).getBoardNm_ko();
    
    int listItemNo = numbers.getListItemNo();
    int prevPage = numbers.getPrevBlock();
    int nextPage = numbers.getNextBlock();
    int firstPage = numbers.getFirstPage();
    int lastPage = numbers.getLastPage();

    model.addAttribute("list", list);
    model.addAttribute("listItemNo", listItemNo);
    model.addAttribute("prevPage", prevPage);
    model.addAttribute("firstPage", firstPage);
    model.addAttribute("lastPage", lastPage);
    model.addAttribute("nextPage", nextPage);
    model.addAttribute("boardName", boardName);

    return "bbs/view";
  }

  @RequestMapping(value="/addComment", method=RequestMethod.POST)
  public String addComment(Integer articleNo, 
      String boardCd, 
      Integer page, 
      String searchWord,
      String memo,
      HttpSession session) throws Exception {

    //로그인 체크
    User user = (User) session.getAttribute(WebContants.USER_KEY);
    if (user == null) {
      throw new AuthenticationException(WebContants.NOT_LOGIN);
    }

    Comment comment = new Comment();
    comment.setArticleNo(articleNo);
    comment.setEmail(user.getEmail());
    comment.setMemo(memo);

    boardService.addComment(comment);

    searchWord = URLEncoder.encode(searchWord,"UTF-8");

    return "redirect:/bbs/view?articleNo=" + articleNo + 
        "&boardCd=" + boardCd + 
        "&page=" + page + 
        "&searchWord=" + searchWord;

  }

  @RequestMapping(value="/updateComment", method=RequestMethod.POST)
  public String updateComment(Integer commentNo, 
      Integer articleNo, 
      String boardCd, 
      Integer page, 
      String searchWord, 
      String memo,
      HttpSession session) throws Exception {

    User user = (User) session.getAttribute(WebContants.USER_KEY);

    Comment comment = boardService.getComment(commentNo);

    //로그인 사용자가 댓글 소유자인지  검사
    if (user == null || !user.getEmail().equals(comment.getEmail())) {
      throw new AuthenticationException(WebContants.AUTHENTICATION_FAILED);
    }

    //생성된 Comment 객체를 재사용한다.
    comment.setMemo(memo);
    boardService.modifyComment(comment);

    searchWord = URLEncoder.encode(searchWord, "UTF-8");

    return "redirect:/bbs/view?articleNo=" + articleNo + 
        "&boardCd=" + boardCd + 
        "&page=" + page + 
        "&searchWord=" + searchWord;

  }

  @RequestMapping(value="/deleteComment", method=RequestMethod.POST)
  public String deleteComment(Integer commentNo, 
      Integer articleNo, 
      String boardCd, 
      Integer page, 
      String searchWord,
      HttpSession session) throws Exception {

    User user = (User) session.getAttribute(WebContants.USER_KEY);

    Comment comment = boardService.getComment(commentNo);

    //로그인 사용자가 댓글의 소유자인지 검사
    if (user == null || !user.getEmail().equals(comment.getEmail())) {
      throw new AuthenticationException(WebContants.AUTHENTICATION_FAILED);
    }

    boardService.removeComment(commentNo);

    searchWord = URLEncoder.encode(searchWord,"UTF-8");

    return "redirect:/bbs/view?articleNo=" + articleNo + 
        "&boardCd=" + boardCd + 
        "&page=" + page + 
        "&searchWord=" + searchWord;

  }

  @RequestMapping(value="/modify", method=RequestMethod.GET)
  public String modifyForm(Integer articleNo, 
      String boardCd,
      HttpSession session,
      Model model) {

    User user = (User) session.getAttribute(WebContants.USER_KEY);

    Article article = boardService.getArticle(articleNo);

    //로그인 사용자가 글 작성자인지 검사
    if (user == null || !user.getEmail().equals(article.getEmail())) {
      throw new AuthenticationException(WebContants.AUTHENTICATION_FAILED);
    }

    //수정페이지에서의 보일 게시글 정보
    String title = article.getTitle();
    String content = article.getContent();
    String boardName = boardService.getBoard(boardCd).getBoardNm_ko();

    model.addAttribute("title", title);
    model.addAttribute("content", content);
    model.addAttribute("boardName", boardName);

    return "bbs/modify";
  }

  @RequestMapping(value="/modify", method=RequestMethod.POST)
  public String modify(MultipartHttpServletRequest mpRequest,
      HttpSession session) throws Exception {

    User user = (User) session.getAttribute(WebContants.USER_KEY);

    int articleNo = Integer.parseInt(mpRequest.getParameter("articleNo"));
    Article article = boardService.getArticle(articleNo);

    //로그인 사용자가 글 작성자인지 검사
    if (!article.getEmail().equals(user.getEmail())) {
      throw new AuthenticationException(WebContants.AUTHENTICATION_FAILED);
    }

    String boardCd = mpRequest.getParameter("boardCd");
    int page = Integer.parseInt(mpRequest.getParameter("page"));
    String searchWord = mpRequest.getParameter("searchWord");

    String title = mpRequest.getParameter("title");
    String content = mpRequest.getParameter("content");

    //게시글 수정
    article.setTitle(title);
    article.setContent(content);
    article.setBoardCd(boardCd);
    boardService.modifyArticle(article);

    //파일 업로드
    Iterator<String> it = mpRequest.getFileNames();
    List<MultipartFile> fileList = new ArrayList<MultipartFile>();
    while (it.hasNext()) {
      MultipartFile multiFile = mpRequest.getFile((String) it.next());
      if (multiFile.getSize() > 0) {
        String filename = multiFile.getOriginalFilename();
        multiFile.transferTo(new File(WebContants.UPLOAD_PATH + filename));
        fileList.add(multiFile);
      }
    }

    //파일데이터 삽입
    int size = fileList.size();
    for (int i = 0; i < size; i++) {
      MultipartFile mpFile = fileList.get(i);
      AttachFile attachFile = new AttachFile();
      String filename = mpFile.getOriginalFilename();
      attachFile.setFilename(filename);
      attachFile.setFiletype(mpFile.getContentType());
      attachFile.setFilesize(mpFile.getSize());
      attachFile.setArticleNo(articleNo);
      attachFile.setEmail(user.getEmail());
      boardService.addAttachFile(attachFile);
    }

    searchWord = URLEncoder.encode(searchWord,"UTF-8");
    return "redirect:/bbs/view?articleNo=" + articleNo 
        + "&boardCd=" + boardCd 
        + "&page=" + page 
        + "&searchWord=" + searchWord;

  }

  @RequestMapping(value="/download", method=RequestMethod.POST)
  public String download(String filename, HttpSession session, Model model) {
    //로그인 체크
    User user = (User) session.getAttribute(WebContants.USER_KEY);
    if (user == null) {
      throw new AuthenticationException(WebContants.NOT_LOGIN);
    }

    model.addAttribute("filename", filename);
    return "inc/download";

  }

  @RequestMapping(value="/deleteAttachFile", method=RequestMethod.POST)
  public String deleteAttachFile(Integer attachFileNo, 
      Integer articleNo, 
      String boardCd, 
      Integer page, 
      String searchWord,
      HttpSession session) throws Exception {

    User user = (User) session.getAttribute(WebContants.USER_KEY);
    AttachFile attachFile = boardService.getAttachFile(attachFileNo);

    //로그인 사용자가 첨부 파일 소유자인지 검사
    if (user == null || !user.getEmail().equals(attachFile.getEmail())) {
      throw new AuthenticationException(WebContants.AUTHENTICATION_FAILED);
    }

    boardService.removeAttachFile(attachFileNo);

    searchWord = URLEncoder.encode(searchWord,"UTF-8");

    return "redirect:/bbs/view?articleNo=" + articleNo + 
        "&boardCd=" + boardCd + 
        "&page=" + page + 
        "&searchWord=" + searchWord;

  }

  @RequestMapping(value="/del", method=RequestMethod.POST)
  public String del(Integer articleNo, 
      String boardCd, 
      Integer page, 
      String searchWord,
      HttpSession session) throws Exception {

    User user = (User) session.getAttribute(WebContants.USER_KEY);
    Article article = boardService.getArticle(articleNo);

    //로그인 사용자가 글 작성자인지 검사
    if (user == null || !user.getEmail().equals(article.getEmail())) {
      throw new AuthenticationException(WebContants.AUTHENTICATION_FAILED);
    }

    boardService.removeArticle(articleNo);

    searchWord = URLEncoder.encode(searchWord, "UTF-8");

    return "redirect:/bbs/list?boardCd=" + boardCd + 
        "&page=" + page + 
        "&searchWord=" + searchWord;

  }

}

BbsController.java에 적용된 어노테이션 설명

다음은 컨트롤러에 적용된 어노테이션을 정리한 것이다.

  • @Controller는 클래스가 컨트롤러 컴포넌트임을 표시한다.
  • 클래스 레벨의 @RequestMapping("/bbs")는 컨트롤러가 "/bbs"를 포함하는 모든 요청을 담당한다는 것을 표시한다.
  • 메소드 레벨의 @RequestMapping(value="/list", method={RequestMethod.GET, RequestMethod.POST})는
    메소드가 GET이나 POST 방식의 "/bbs/list" 요청에 매핑됨을 표시한다.
  • 멤버 변수에 @Autowired를 적용하면 변수의 접근자가 private이고 공개된 setter가 없어도 종속객체가 주입된다.

목록 요청

목록 요청 URL은 /bbs/list이다. 컨트롤러에서 이 요청을 메소드에 매핑하려면 메소드 레벨로 @RequestMapping을 사용한다.

@RequestMapping(value="list", method={RequestMethod.GET, RequestMethod.POST})

클래스 레벨로 @RequestMapping("bbs")를 적용했기에, 위 메소드는 HTTP 프로토콜의 METHOD가 GET이나 POST 방식의 /bbs/list 요청에 매핑된다. 목록이라면 GET 방식이 맞을 것이다. GET 방식의 요청만을 매핑하려면 다음과 같이 고친다.

@RequestMapping(value="/list", method=RequestMethod.GET)
public String list(String boardCd, Integer page, String searchWord,...) {..}

메소드 아규먼트 리스트 boardCd, page, searchWord에는 요청에 실려 오는 파라미터의 값이 할당된다.
만약 파라미터 이름이 curPage이고 이 파라미터의 값을 할당받아야 하는 아규먼트 이름이 page라면 다음과 같이 해결한다.

@RequestParam("curPage") String page

컨트롤러 메소드의 아규먼트를 파라미터의 이름과 같게 하면 쉽게 파라미터값을 받을 수 있다.
파라미터 중에서 boardCd와 page는 필수적으로 전달되도록 구현해야 한다.
searchWord는 사용자가 검색할 때만 전달된다.
전달되는 파라미터값을 전부 받았음에도 아규먼트 리스트에서 HttpServletRequest가 있는 이유는 요청 URL을 구하기 위해서다.
메소드 구현부에서 로그인 체크를 통과하지 못하면 로그인 페이지로 리다이렉트하면서 현재 요청 URL을 전달해야 하기 때문이다.
HttpSession 타입의 아규먼트는 세션에 접근하기 위해서다.

//로그인 체크
User user = (User) session.getAttribute(WebContants.USER_KEY);
if (user == null) {
    //로그인 후 되돌아갈 URL을 구한다.
    String url = req.getServletPath();
    String query = req.getQueryString();
    if (query != null) url += "?" + query;
    //로그인 페이지로 리다이렉트
    url = URLEncoder.encode(url, "UTF-8");
    return "redirect:/users/login?url=" + url;
}

로그인 체크를 통과했다면 뷰로 전달할 데이터를 생산하는 비즈니스 로직이 이어진다.

int numPerPage = 10;
int pagePerBlock = 10;

int totalRecord = boardService.getTotalRecord(boardCd, searchWord);

NumbersForPaging numbers = this.getNumbersForPaging(totalRecord, page, numPerPage, pagePerBlock);

Integer startRecord = (page - 1) * numPerPage + 1;
Integer endRecord = page * numPerPage;

목록을 구성하는 리스트, 게시판 이름, 목록 아이템의 번호, 이전 링크, 다음 링크, 첫 번째 페이지, 마지막 페이지 데이터를 생산한다.

HashMap<String, String> map = new HashMap<String, String>();
map.put("boardCd", boardCd);
map.put("searchWord", searchWord);
map.put("start", startRecord.toString());
map.put("end", endRecord.toString());
List<Article> list = boardService.getArticleList(map);
String boardName = boardService.getBoard(boardCd).getBoardNm_ko();

Integer listItemNo = numbers.getListItemNo();
Integer prevPage = numbers.getPrevBlock();
Integer nextPage = numbers.getNextBlock();
Integer firstPage = numbers.getFirstPage();
Integer lastPage = numbers.getLastPage();

글쓰기 폼 요청

writeForm() 메소드는 GET 방식의 /bbs/write 요청에 매핑된다.

@RequestMapping(value="write", method=RequestMethod.GET)
public String writeForm(String boardCd,HttpServletRequest req,HttpSession session...)

메소드 아규먼트로는 파라미터값을 받는 boardCd와 목록 메소드와 같은 이유로 req와 session이 있다.
boardCd는 게시판 이름을 만드는 데 필요하다.
page와 searchWord는 포워딩 되므로 아규먼트로 받을 특별한 이유가 없다.
게시판은 로그인 사용자만 이용할 수 있으므로 메소드는 먼저 로그인 체크로 시작하고, 로그인 체크가 통과하면 게시판 이름을 생성하고 write.jsp로 포워딩한다.

글쓰기 처리 요청

write() 메소드는 POST 방식의 /bbs/write 요청에 매핑된다.

@RequestMapping(value="write", method=RequestMethod.POST)
public String write(MultipartHttpServletRequest mpRequest, HttpSession session) throws Exception

MultipartHttpServletRequest 타입의 mpRequest 아규먼트는 시스템에 전달된 파일에 접근할 수 있다.

메소드는. 먼저 로그인 여부를 판단한다.
로그인되어 있지 않다면 AuthenticationException 예외를 발생시킨다.
mvc.xml에 설정된 예외 리졸브에 의해서 뷰는 /WEB-INF/views/error.jsp가 선택된다.

//로그인 체크
User user = (User) session.getAttribute(WebContants.USER_KEY);
if (user == null) {
    throw new AuthenticationException(WebContants.NOT_LOGIN);
}

로그인 체크를 통과하면 새 글을 테이블에 삽입한다.
BoardMapper.xml 파일에서 설명했듯이, boardService.addArticle(article);가 실행되면 아규먼트인 article이 가리키는 Article 객체의 setArticleNo()가 호출되어 게시글 고유번호가 세팅된다.

String boardCd = mpRequest.getParameter("boardCd");
String title = mpRequest.getParameter("title");
String content = mpRequest.getParameter("content");

Article article = new Article();
article.setBoardCd(boardCd);
article.setTitle(title);
article.setContent(content);
article.setEmail(user.getEmail());

boardService.addArticle(article);

테이블에 새 글 정보를 삽입한 후, 서버에 전달된 파일을 우리가 원하는 업로드 디렉터리로 옮긴다.
글쓰기 폼 페이지에서는 첨부 파일을 하나만 올릴 수 있지만 메소드는 여러 파일을 올릴 수 있도록 구현했다.

//파일 업로드
Iterator<String> it = mpRequest.getFileNames();
List<MultipartFile> fileList = new ArrayList<MultipartFile>();
while (it.hasNext()) {
    MultipartFile multiFile = mpRequest.getFile((String) it.next());
    if (multiFile.getSize() > 0) {
        String filename = multiFile.getOriginalFilename();
        multiFile.transferTo(new File(WebContants.UPLOAD_PATH + filename));
        fileList.add(multiFile);
    }
}

컨트롤러 메소드의 파라미터 중 MultipartHttpServletRequest 타입은 시스템에 전달된 파일에 접근할 수 있다. 위 코드는 전달된 파일을 다루는 방법을 보여준다.

스프링 MVC는 파일 업로드를 위해 아파치의 commons-fileupload를 지원한다. 개발자가 해야 할 일은 필요한 의존성을 추가하고, multipartResolver 빈을 설정하는 것이다.

파일 업로드를 위한 부분을 다시 살펴본다. pom.xml에 다음 의존 라이브러리 설정이 필요하다.

<!-- https://mvnrepository.com/artifact/commons-io/commons-io -->
<dependency>
  <groupId>commons-io</groupId>
  <artifactId>commons-io</artifactId>
  <version>2.8.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/commons-fileupload/commons-fileupload -->
<dependency>
  <groupId>commons-fileupload</groupId>
  <artifactId>commons-fileupload</artifactId>
  <version>1.4</version>
</dependency>

mvc.xml에 multipartResolver를 설정이 필요하다.

<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
  <property name="maxUploadSize" value="2097152" />
</bean>

파일을 업로드 디렉터리에 옮겼다면 첨부 파일 테이블에 파일 정보를 인서트 한다.
article.getArticleNo()로 테이블에 입력된 게시글의 고유번호를 얻을 수 있다.

//파일데이터 삽입
int size = fileList.size();
for (int i = 0; i < size; i++) {
    MultipartFile mpFile = fileList.get(i);
    AttachFile attachFile = new AttachFile();
    String filename = mpFile.getOriginalFilename();
    attachFile.setFilename(filename);
    attachFile.setFiletype(mpFile.getContentType());
    attachFile.setFilesize(mpFile.getSize());
    attachFile.setArticleNo(article.getArticleNo());
    attachFile.setEmail(user.getEmail());
    boardService.addAttachFile(attachFile);
}

글쓰기를 서버스의 addArticle(article)과 addAttachFile(attachFile) 2개의 메소드를 사용하여 구현하고 있다. (모델 2에서는 서비스의 addArticle(article, attachFile); 메소드 하나로 구현했었다.)

마지막으로 포워딩이 아닌 리다이렉트로 이동해야 한다. 포워딩하면 문제가 발생할 수 있는데, 사용자가 F5키로 웹 브라우저를 리로딩하면 똑같은 정보로 글쓰기 처리가 다시 수행될 수 있기 때문이다. 리다이렉트로 이동할 때는 게시판 코드와 함께 page=1도 함께 전달해야 한다.

return redirect:/bbs/list?page=1&boardCd=" + article.getBoardCd();

상세보기 요청

상세보기 요청(/bbs/view)에 매핑되는 메소드는 view()이다.
메소드에 전달되는 파라미터는 articleNo, boardCd, page, searchWord이다.
메소드의 아규먼트로 이들 파라미터의 값을 전달받을 변수를 선언했다.
포워드로 이동하지만 view.jsp에 필요한 데이터를 생산하기 위해 전달되는 파라미터 모두 필요하기 때문이다.
그럼에도 HttpServletRequest 타입의 아규먼트 변수가 있는 것은 로그인 체크를 통과 못 했을 때 요청 URL 정보를 로그인 페이지에 전달하기 위해서다.
HttpSession 타입의 아규먼트는 로그인 체크를 하려면 세션의 저장된 값에 접근해야 하기 때문에 필요하다.

@RequestMapping(value="/view", method=RequestMethod.GET)
public String view(Integer articleNo, 
        String boardCd, 
        Integer page,
        String searchWord,
        HttpServletRequest req,
        HttpSession session,
        Model model) throws Exception {

메소드는 먼저 로그인 체크를 한다.
로그인 체크를 통과하면 조회 수를 증가시킨 후에 상세보기 화면에 필요한 데이터를 생산한다.
조회 수 증가는 사용자가 F5로 재로딩하면 계속 증가하는데, 나중에 IP와 시간을 고려해서 증가하도록 후에 수정해야 할 것이다.
IP와 시간을 고려한 조회 수 증가는 이 책에서는 다루지 않는다.

/*
상세보기를 할 때마다 조회 수를 1 증가
하단에 목록에서 조회 수를 제대로 보기 위해서는
목록 레코드를 생산하기 전에 조회 수를 먼저 증가시켜야 한다.
TODO : 사용자 IP와 시간을 고려해서 조회 수를 증가하도록
*/
boardService.increaseHit(articleNo);

Article article = boardService.getArticle(articleNo);//상세보기에서 볼 게시글
List<AttachFile> attachFileList = boardService.getAttachFileList(articleNo);
Article nextArticle = boardService.getNextArticle(articleNo, boardCd, searchWord);
Article prevArticle = boardService.getPrevArticle(articleNo, boardCd, searchWord);
List<Comment> commentList = boardService.getCommentList(articleNo);
String boardName = boardService.getBoard(boardCd).getBoardNm_ko();

//상세보기에서 볼 게시글 관련 정보
String title = article.getTitle();//제목
String content = article.getContent();//내용
content = content.replaceAll(WebContants.LINE_SEPARATOR, "<br />");
int hit = article.getHit();//조회 수
String name = article.getName();//작성자 이름
String email = article.getEmail();//작성자 ID
String regdate = article.getRegdateForView();//작성일

model.addAttribute("title", title);
model.addAttribute("content", content);
model.addAttribute("hit", hit);
model.addAttribute("name", name);
model.addAttribute("email", email);
model.addAttribute("regdate", regdate);
model.addAttribute("attachFileList", attachFileList);
model.addAttribute("nextArticle", nextArticle);
model.addAttribute("prevArticle", prevArticle);
model.addAttribute("commentList", commentList);

상세보기 화면에도 목록이 있다. 이 부분을 위한 코드는 목록 메소드와 같다.

댓글 쓰기 처리 요청

addComment() 메소드는 POST방식의 /bbs/addComment 요청에 매핑되는 메소드다.

@RequestMapping(value="/addComment", method=RequestMethod.POST)
public String addComment(Integer articleNo, 
        String boardCd, 
        Integer page, 
        String searchWord,
        String memo,
        HttpSession session) throws Exception {

댓글 쓰기 처리 후 상세보기 뷰로 리다이렉트될 것이므로 파라미터값을 저장할 아규먼트가 필요하다.
로그인되어 있지 않다면 사용자 정의 인증 예외를 던진다.

//로그인 체크
User user = (User) session.getAttribute(WebContants.USER_KEY);
if (user == null) {
    throw new AuthenticationException(WebContants.NOT_LOGIN);
}

로그인 체크를 통과하면 파라미터값으로 댓글을 인서트 한다.

Comment comment = new Comment();
comment.setArticleNo(articleNo);
comment.setEmail(user.getEmail());
comment.setMemo(memo);

boardService.addComment(comment);

마지막으로 다시 상세보기 화면으로 리다이렉트해야 하는데,
이때 검색어 searchWord는 한글일 수 있으므로 인코딩 작업을 한다.

searchWord = URLEncoder.encode(searchWord,"UTF-8");

상세보기 뷰로 리다이렉트한다.

return "redirect:/bbs/view?articleNo=" + articleNo + 
    "&boardCd=" + boardCd + 
    "&page=" + page + 
    "&searchWord=" + searchWord;

댓글 수정 요청

updateComment() 메소드는 POST 방식의 댓글 수정 요청 /bbs/updateComment에 매핑된다.

@RequestMapping(value="/updateComment", method=RequestMethod.POST)
public String updateComment(
        Integer commentNo, 
        Integer articleNo, 
        String boardCd, 
        Integer page, 
        String searchWord, 
        String memo,
        HttpSession session) throws Exception {

구현은 먼저 로그인 여부와 글 소유자 여부를 동시에 검사한다.
검사를 통과하지 못하면 사용자 정의 인증 예외를 던진다.

User user = (User) session.getAttribute(WebContants.USER_KEY);

Comment comment = boardService.getComment(commentNo);

//로그인 사용자가 댓글 소유자인지  검사
if (user == null || !user.getEmail().equals(comment.getEmail())) {
    throw new AuthenticationException(WebContants.AUTHENTICATION_FAILED);
}

검사를 통과하면 파라미터로 댓글을 수정한다.
댓글 수정은 댓글 내용만을 수정할 수 있다.
즉, 관리자가 댓글을 수정하더라도 댓글의 소유자는 변경되지 않는 거로 구현했다.

//생성된 Comment 객체를 재사용한다.
comment.setMemo(memo);
boardService.modifyComment(comment);

마지막으로 상세보기 화면으로 리다이렉트한다.

searchWord = URLEncoder.encode(searchWord, "UTF-8");

return "redirect:/bbs/view?articleNo=" + articleNo + 
    "&boardCd=" + boardCd + 
    "&page=" + page + 
    "&searchWord=" + searchWord;

댓글 삭제 요청

deleteComment() 메소드는 POST방식의 /bbs/deleteComment 요청에 매핑되는 메소드다.

@RequestMapping(value="/deleteComment", method=RequestMethod.POST)
public String deleteComment(
        Integer commentNo, 
        Integer articleNo, 
        String boardCd, 
        Integer page, 
        String searchWord,
        HttpSession session) throws Exception {

구현은 먼저 로그인 여부와 글 소유자 여부를 동시에 검사한다.
검사를 통과하지 못하면 사용자 정의 인증 예외를 던진다.

User user = (User) session.getAttribute(WebContants.USER_KEY);

Comment comment = boardService.getComment(commentNo);

//로그인 사용자가 댓글의 소유자인지 검사
if (user == null || !user.getEmail().equals(comment.getEmail())) {
    throw new AuthenticationException(WebContants.AUTHENTICATION_FAILED);
}

검사를 통과하면 해당 댓글을 삭제한다.

boardService.removeComment(commentNo);

마지막으로 상세보기 화면으로 리다이렉트한다.

searchWord = URLEncoder.encode(searchWord,"UTF-8");

return "redirect:/bbs/view?articleNo=" + articleNo + 
    "&boardCd=" + boardCd + 
    "&page=" + page + 
    "&searchWord=" + searchWord;

글 수정 폼 요청

modifyForm() 메소드는 GET 방식의 게시글 수정 폼 요청 /bbs/modify에 매핑되는 메소드다.

@RequestMapping(value="/modify", method=RequestMethod.GET)
public String modifyForm(
        Integer articleNo, 
        String boardCd,
        HttpSession session,
        Model model) {

로그인 검사와 글 소유자 검사를 한다.
검사를 통과하지 못하면 사용자 정의 인증 예외를 던진다.

User user = (User) session.getAttribute(WebContants.USER_KEY);

Article article = boardService.getArticle(articleNo);

//로그인 사용자가 글 작성자인지 검사
if (user == null || !user.getEmail().equals(article.getEmail())) {
    throw new AuthenticationException(WebContants.AUTHENTICATION_FAILED);
}

검사를 통과하면 게시글 수정 폼 화면에 필요한 데이터를 생산한다.
마지막으로 게시글 수정 폼으로 포워드 한다.

//수정 페이지에서의 보일 게시글 정보
String title = article.getTitle();
String content = article.getContent();
String boardName = boardService.getBoard(boardCd).getBoardNm_ko();
        
model.addAttribute("title", title);
model.addAttribute("content", content);
model.addAttribute("boardName", boardName);

return "bbs/modify";

글 수정 처리 요청

modify() 메소드는 POST 방식의 글 수정 처리 요청 /bbs/modify에 매핑되는 메소드다.

@RequestMapping(value="/modify", method=RequestMethod.POST)
public String modify(
        MultipartHttpServletRequest mpRequest,
        HttpSession session) throws Exception {

글 소유자인지 검사한다.
검사를 통과하지 못하면 사용자 정의 인증 예외를 던진다.

User user = (User) session.getAttribute(WebContants.USER_KEY);

int articleNo = Integer.parseInt(mpRequest.getParameter("articleNo"));
Article article = boardService.getArticle(articleNo);

//로그인 사용자가 글 작성자인지 검사
if (!article.getEmail().equals(user.getEmail())) {
    throw new AuthenticationException(WebContants.AUTHENTICATION_FAILED);
}

전달된 파라미터 정보로 글을 수정한다.

String boardCd = mpRequest.getParameter("boardCd");
int page = Integer.parseInt(mpRequest.getParameter("page"));
String searchWord = mpRequest.getParameter("searchWord");

String title = mpRequest.getParameter("title");
String content = mpRequest.getParameter("content");

//게시글 수정
article.setTitle(title);
article.setContent(content);
article.setBoardCd(boardCd);//TODO 게시판 종류 변경
boardService.modifyArticle(article);

서버에 전달된 파일을 원하는 위치로 옮긴다.

//파일 업로드
Iterator<String> it = mpRequest.getFileNames();
List<MultipartFile> fileList = new ArrayList<MultipartFile>();
while (it.hasNext()) {
    MultipartFile multiFile = mpRequest.getFile((String) it.next());
    if (multiFile.getSize() > 0) {
        String filename = multiFile.getOriginalFilename();
        multiFile.transferTo(new File(WebContants.UPLOAD_PATH + filename));
        fileList.add(multiFile);
    }
}

첨부 파일 데이터를 인서트 한다.
글쓰기와 달리 게시글의 고유번호는 파라미터 articleNo이다.

//파일데이터 삽입
int size = fileList.size();
for (int i = 0; i < size; i++) {
    MultipartFile mpFile = fileList.get(i);
    AttachFile attachFile = new AttachFile();
    String filename = mpFile.getOriginalFilename();
    attachFile.setFilename(filename);
    attachFile.setFiletype(mpFile.getContentType());
    attachFile.setFilesize(mpFile.getSize());
    attachFile.setArticleNo(articleNo);
    attachFile.setEmail(user.getEmail());
    boardService.addAttachFile(attachFile);
}

마지막으로 상세보기 화면으로 리다이렉트한다.

searchWord = URLEncoder.encode(searchWord,"UTF-8");

return "redirect:/bbs/view?articleNo=" + articleNo 
    + "&boardCd=" + boardCd 
    + "&page=" + page 
    + "&searchWord=" + searchWord;

첨부 파일 다운로드 요청

download() 메소드는 POST 방식의 파일 다운로드 요청 /bbs/download에 매핑되는 메소드다.
사실 다운로드는 게시판이 아닌 다른 모듈에서도 이용될 수 있으므로 파일 다운로드만을 위한 컨트롤러를 만들어 처리할 수 있다.

@RequestMapping(value="/download", method=RequestMethod.POST)
    public String download(String filename, HttpSession session, Model model) {

구현은 먼저 로그인을 했는지 검사한다.
검사를 통과하지 못하면 사용자 정의 인증 예외를 던진다.

//로그인 체크
User user = (User) session.getAttribute(WebContants.USER_KEY);
if (user == null) {
    throw new AuthenticationException(WebContants.NOT_LOGIN);
}

파일명을 download.jsp에 전달한다.
우리의 프로그램에서는 JSP 페이지가 파일 다운로드를 실행한다.

model.addAttribute("filename", filename);
return "inc/download";

첨부 파일 삭제 처리 요청

deleteAttachFile() 메소드는 POST 방식의 첨부 파일 삭제 요청 /bbs/deleteAttachFile에 매핑되는 메소드다.

@RequestMapping(value="/deleteAttachFile", method=RequestMethod.POST)
public String deleteAttachFile(
        Integer attachFileNo, 
        Integer articleNo, 
        String boardCd, 
        Integer page, 
        String searchWord,
        HttpSession session) throws Exception {

첨부 파일의 소유자인지 검사하고 검사를 통과하지 못하면 사용자 정의 인증 예외를 던진다.

User user = (User) session.getAttribute(WebContants.USER_KEY);
AttachFile attachFile = boardService.getAttachFile(attachFileNo);

//로그인 사용자가 첨부 파일 소유자인지 검사
if (user == null || !user.getEmail().equals(attachFile.getEmail())) {
    throw new AuthenticationException(WebContants.AUTHENTICATION_FAILED);
}

검사를 통과하면 첨부 파일을 삭제하고 상세보기 화면으로 리다이렉트한다.

boardService.removeAttachFile(attachFileNo);

searchWord = URLEncoder.encode(searchWord,"UTF-8");

return "redirect:/bbs/view?articleNo=" + articleNo + 
    "&boardCd=" + boardCd + 
    "&page=" + page + 
    "&searchWord=" + searchWord;

게시글 삭제 처리 요청

del() 메소드는 POST 방식의 게시글 삭제 처리 요청 /bbs/del에 매핑되는 메소드다.

@RequestMapping(value="/del", method=RequestMethod.POST)
public String del(
        Integer articleNo, 
        String boardCd, 
        Integer page, 
        String searchWord,
        HttpSession session) throws Exception {

글 소유자 검사하고 검사를 통과하지 못하면 사용자 정의 인증 예외를 던진다.

User user = (User) session.getAttribute(WebContants.USER_KEY);
Article article = boardService.getArticle(articleNo);

//로그인 사용자가 글 작성자인지 검사
if (user == null || !user.getEmail().equals(article.getEmail())) {
    throw new AuthenticationException(WebContants.AUTHENTICATION_FAILED);
}

검사를 통과하면 게시글을 삭제하고 목록으로 리다이렉트한다.

boardService.removeArticle(articleNo);

searchWord = URLEncoder.encode(searchWord, "UTF-8");

return "redirect:/bbs/list?boardCd=" + boardCd + 
    "&page=" + page + 
    "&searchWord=" + searchWord;

view.jsp에서 파일 다운로드 부분 수정

모델 2와 달리 첨부 파일을 단순히 링크를 거는 것이 아니라 자바의 입출력 클래스를 이용해서 파일을 다운로드하는 것으로 변경했다.
view.jsp에 다음 자바스크립트 함수를 추가한다.

function download(filename) {
    var form = document.getElementById("downForm");
    form.filename.value = filename;
    form.submit();
}

하단 #form-group에 폼 엘리먼트를 추가한다.

<form id="downForm" action="download" method="post">
<p>
    <input type="hidden" name="filename" />
</p>
</form>

파일 다운로드 부분을 아래와 같이 변경한다.

<a href="javascript:download('${file.filename }')">${file.filename }</a>

테스트

Proejct Explorer 뷰에서 프로젝트를 선택한다.
메이븐 프로젝트의 루트 디렉터리에서 명령 프롬프트로 다음과 같이 실행한다.

C:\ Command Prompt
mvn clean compile war:inplace

compile war:inplace은 C:\www\spring-bbs\src\main\webapp\WEB-INF\lib 에 의존 라이브러리를 복사하고 C:\www\spring-bbs\src\main\webapp\WEB-INF\classes 에 자바 클래스를 컴파일한다.

기존 ROOT.xml 톰캣 컨텍스트 파일을 수정한다.
WEB-INF의 바로 위인 src/main/webapp가 DocumentBase이다.
메이븐 프로젝트 루트 디렉터리가 C:/www/spring-bbs라면 컨텍스트 파일을 아래와 같이 작성한다.

ROOT.xml
<?xml version="1.0" encoding="UTF-8"?>
<Context
    docBase="C:/www/spring-bbs/src/main/webapp"
    reloadable="true">
</Context>

톰캣을 재실행한 다음
http://localhost:8080/bbs/list?boarCd=chat&page=1을 방문하여 테스트한다.
JSP 프로젝트나 모델 2에서 테스트했던 데이터가 있다면 보일 것이다.
데이터가 있다면 한글 검색을 테스트한다.
한글 검색이 되지 않는다면 CATALINA_HOME/conf/server.xml 파일을 열고 다음 강조된 부분이 있는지 확인한다.

server.xml
<Connector port="8080" protocol="HTTP/1.1"
  connectionTimeout="20000"
  URIEncoding="UTF-8"
  redirectPort="8443" />

강조된 부분이 있어야 한글 검색이 된다.
다음은 테스트 실패 시 검사 항목을 정리한 것이다.

  1. /WEB-INF/classes에 바이트 코드가 있는가?
  2. /WEB-INF/lib에 라이브러리 파일이 있는가?
  3. CATALINA_HOME/lib에 ojdbc6.jar 파일이 있는가?
  4. JSP 파일의 EL이 해석되지 않는다면 web.xml이 DTD 버전이 2.4버전 이상인가?

Spring MVC 프로젝트에서의 사용자 인증 정리

스프링 시큐리티를 적용하기 전에 현재 인증 처리를 어떻게 구현했는지 다시 정리해 볼 필요가 있다.
다음은 게시판 컨트롤러의 메소드에 적용된 인증 코드이다.

목록보기, 상세보기, 글쓰기 폼

//로그인 체크
User user = (User) session.getAttribute(WebContants.USER_KEY);
if (user == null) {
  //로그인 후 되돌아갈 URL을 구한다.
  String url = req.getServletPath();
  String query = req.getQueryString();
  if (query != null) url += "?" + query;
  //로그인 페이지로 리다이렉트
  url = URLEncoder.encode(url, "UTF-8");
  return "redirect:/users/login?url=" + url;
}

글쓰기, 댓글 쓰기

//로그인 체크
User user = (User) session.getAttribute(WebContants.USER_KEY);
if (user == null) {
  throw new AuthenticationException(WebContants.NOT_LOGIN);
}

댓글 수정, 댓글 삭제

//로그인 사용자가 댓글 소유자인지  검사
if (user == null || !user.getEmail().equals(comment.getEmail())) {
  throw new AuthenticationException(WebContants.AUTHENTICATION_FAILED);
}

수정 폼, 수정하기, 글 삭제

//로그인 사용자가 글 작성자인지 검사
if (user == null || !user.getEmail().equals(article.getEmail())) {
  throw new AuthenticationException(WebContants.AUTHENTICATION_FAILED);
}

첨부 파일 삭제

//로그인 사용자가 첨부 파일 소유자인지 검사
if (user == null || !user.getEmail().equals(attachFile.getEmail())) {
  throw new AuthenticationException(WebContants.AUTHENTICATION_FAILED);
}

다음은 회원 컨트롤러에 적용된 사용자 인증 로직이다.
회원 컨트롤러의 대부분의 메소드는 인증 로직을 포함한다.

로그인

User user = userService.login(email, passwd);
if (user == null) {
  return "redirect:/users/login?url=" + url + "&msg=Login-Failed";
} else {
  session.setAttribute(WebContants.USER_KEY, user);
  if (url != null && !url.equals("")) {
  return "redirect:" + url;
}

return "redirect:/";

내 정보 수정 폼, 비밀번호 변경 폼, 탈퇴

User user = (User) session.getAttribute(WebContants.USER_KEY);
if (user == null) {
  //로그인 후 다시 돌아오기 위해
  String url = req.getServletPath();
  String query = req.getQueryString();
  if (query != null) url += "?" + query;
  //로그인 페이지로 리다이렉트
  url = URLEncoder.encode(url, "UTF-8");
  return "redirect/users/login?url=" + url;
}

내 정보 수정

User loginUser = (User) session.getAttribute(WebContants.USER_KEY);

if (loginUser == null) {
  throw new AuthenticationException(WebContants.NOT_LOGIN);
}

if (userService.login(loginUser.getEmail(), user.getPasswd()) == null) {
  throw new AuthenticationException(WebContants.AUTHENTICATION_FAILED);
}

비밀번호 변경

String email = ((User)session.getAttribute(WebContants.USER_KEY)).getEmail();

JSP에 사용자 인증 코드를 적용한 부분을 살펴보자.
이 또한 스프링 시큐리티를 적용하면 바뀐다.
상세보기 view.jsp에 있는 사용자 인증 코드가 핵심이다.
JSP는 인증이 적용된 부분을 선별적으로 랜더링할 것이다.

<!-- 중략 -->

<p id="file-list" style="text-align: right">
  <c:forEach var="file" items="${attachFileList }" varStatus="status">    
  <a href="javascript:download('${file.filename }')">${file.filename }</a>
  <c:if test="${user.email == file.email }">
  <a href="javascript:deleteAttachFile('${file.attachFileNo }')">x</a>
  </c:if>
  <br />
  </c:forEach>
</p>

<!-- 중략 -->

<c:if test="${user.email == comment.email }">    
<span class="modify-del">
  <a href="javascript:modifyCommentToggle('${comment.commentNo }')">수정</a>
  | <a href="javascript:deleteComment('${comment.commentNo }')">삭제</a>
</span>
</c:if>  

<!-- 중략 -->

<c:if test="${user.email == email }">
<div class="fl">
  <input type="button" value="수정" onclick="goModify()" />
  <input type="button" value="삭제" onclick="goDelete()"/>
</div>
</c:if>

다음 장에서 사용자 인증을 스프링 시큐리티를 사용하여 구현할 것이다.
스프링 MVC에서 사용한 인증 코드와 스프링 시큐리티를 적용한 코드를 비교하는 것을 잊지 않도록 한다.

테스트하면서 발생하는 에러는 log4j.xml에서 설정한 로그 파일이나 CATALINA_HOME/logs에 있는 로그 파일을 뒤져서 해결해야 한다.
로그 메시지 전부를 이해하는 사람은 없을 것이다. 로그 메시지 중에 자신이 작성한 클래스의 어느 라인에서 예외가 발생하고 있는지 파악하고 이를 기초로 추론하는 능력을 키워야 한다.

남겨진 과제

  1. 게시글로 유튜브 동영상 소스 코드를 올렸을 때, 동영상를 보면서 댓글은 작성하거나 수정하거나 삭제하면 다시 상세보기 /bbs/view를 요청하므로 동영상이 중지된다. 애이잭스를 이용하여 동영상을 중지시키지 않으면서 댓글을 등록, 수정, 삭제할 수 있게 한다.
  2. 조회 수 증가를 유명 포탈 사이트에서와 같이 구현한다.
참고