english

스프링 MVC 게시판

이 글은 메이븐으로 스프링 MVC 개발에서 실습했던 예제에 게시판을 추가하는 과정을 다룬다. 데이터베이스 스키마와 화면 디자인은 JSP Porject의 최종 결과물(모델 2 게시판)을 사용할 것이다.

준비

모델 2 게시판 실습을 하지 않았다면, 모델 2 게시판 소스를 다음 명령으로 얻을 수 있다. --시스템에 깃Git이 설치되어 있어야 한다--

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

데이터베이스 스키마 파일은 아래 링크에서 얻을 수 있다.
https://java-school.net/jsp-pjt/database-design

정적 파일 복사

도큐먼트 베이스의 resources 서브 디렉터리에 모든 정적 파일을 둘 것이다.
C:/www/spring-bbs/src/main/webapp에 서브 디렉터리 resources 생성한다.
모델 2게시판의 C:/www/model2/WebContent에서 css/와 images/ 디렉터리를 복사하여, 방금 생성한 resources에 붙여넣는다.

JSP 복사

WEB-INF 안에 views 이름으로 디렉터리를 생성하고, 이 디렉터리에 뷰View 파일을 둘 것이다.
C:/www/spring-bbs/src/main/webapp/WEB-INF/views/ 디렉터리를 만들고, 모델 2 게시판의 C:/www/model2/WebContent에서 index.jsp(홈페이지)와 error.jsp(에러 페이지) 그리고 JSP 파일 디렉터리인 bbs/, inc/, java/, users/를 복사하여, 생성한 views 디렉터리에 붙여넣는다.
JSP 파일을 WEB-INF/ 아래에 두는 이유는 웹 브라우저로 JSP에 바로 접근하지 못 하게 하기 위해서다.
스프링 MVC는 모든 요청을 디스패처 서블릿을 거쳐 컨트롤러에 전달되도록 하는 것을 권장한다.
JSP를 바로 요청할 수 있으면서 어떤 요청은 디스패처 서블릿을 거치도록 프로그래밍하는 것은, 프로그램을 복잡하게 하여 유지 보수에 좋지 않다.

자바 소스 복사

src/main/java 디렉터리는 메이븐 프로젝트의 기본 디렉터리로 자바 소스를 두는 곳이다.
모델 2 게시판의 C:/www/model2/src 디렉터리에서 모든 자바 소스 디렉터리를 복사하여, C:/www/spring-bbs/src/main/java 디렉터리에 붙여넣는다.
자바 소스 중 action 클래스와 컨트롤러 역할을 수행하는 서블릿은 더이상 사용하지 않으므로 삭제한다.

web.xml

'메이븐으로 스프링 MVC 개발' 실습의 web.xml과 비교해서 추가된 부분을 강조했다.

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee
                      https://jakarta.ee/xml/ns/jakartaee/web-app_6_1.xsd"
  version="6.1">

<display-name>Java Web Programming Samples</display-name>

<context-param>
	<param-name>contextConfigLocation</param-name>
	<param-value>/WEB-INF/spring/*.xml</param-value>
</context-param>

<listener>
	<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>

<servlet>
	<servlet-name>dispatcher</servlet-name>
	<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
	<init-param>
		<param-name>contextConfigLocation</param-name>
		<param-value></param-value>
	</init-param>
	<load-on-startup>1</load-on-startup>
	<multipart-config>
		<max-file-size>1048576</max-file-size>
		<max-request-size>1048576</max-request-size>
		<file-size-threshold>524288</file-size-threshold>
	</multipart-config>
</servlet>

<servlet-mapping>
    <servlet-name>dispatcher</servlet-name>
    <url-pattern>/</url-pattern>
</servlet-mapping>

<error-page>
	<error-code>403</error-code>
	<location>/WEB-INF/views/403.jsp</location>
</error-page>

<error-page>
	<error-code>404</error-code>
	<location>/WEB-INF/views/404.jsp</location>
</error-page>

<error-page>
	<error-code>500</error-code>
	<location>/WEB-INF/views/500.jsp</location>
</error-page>

<error-page>
	<exception-type>java.lang.Throwable</exception-type>
	<location>/WEB-INF/views/error.jsp</location>
</error-page>

</web-app>

<multipart-config>는 파일 업로드 설정이다.
<max-request-size>의 1048576(1024 * 1024)은 업로드하는 파일의 최대 크기를 1M로 제한하는 설정이다.

<error-page>는 웹 애플리케이션에서 발생한 에러를 애플리케이션 자체에서 잡지 못하고 톰캣과 같은 WAS에 까지 전달되었을 때 보여지는 페이지를 설정한다.

JSP에서 요청 문자열 변경

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

스프링 설정 파일

web.xml에서 스프링 설정 파일의 위치를 지정했다. WEB-INF/spring/ 디렉터리에 mvc.xml 이란 이름으로 스프링 설정 파일을 생성한다. --이전 예제를 진행했다면 이 위치에 같은 이름으로 파일이 있다. 파일을 열고 아래를 참조해 수정한다--

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: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">

<mvc:resources mapping="/resources/**" location="/resources/" />

<mvc:annotation-driven />

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

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

<bean id="internalResourceViewResolver" 
		class="org.springframework.web.servlet.view.InternalResourceViewResolver">
	<property name="viewClass" value="org.springframework.web.servlet.view.JstlView" />
	<property name="prefix" value="/WEB-INF/views/" />
	<property name="suffix" value=".jsp" />
</bean>

<bean id="multipartResolver" 
	class="org.springframework.web.multipart.support.StandardServletMultipartResolver" />

<bean id="dataSource" 
		class="org.apache.commons.dbcp2.BasicDataSource" destroy-method="close">
	<property name="driverClassName" value="oracle.jdbc.driver.OracleDriver" />
	<property name="url" value="jdbc:oracle:thin:@localhost:1521:XE" />
	<property name="username" value="java" />
	<property name="password" value="school" />
	<property name="maxTotal" value="100" />
	<property name="maxWaitMillis" 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:mybatis-config.xml" />
</bean>

</beans>

pom.xml

pom.xml 파일에 추가할 것은 log4j 의존성과 jetty-maven-plugin 플러그인이다.
jetty-maven-plugin 플러그인으로 제티 서버를 실행시킬 수 있다.
서버 실행 명령은 mvn clean jetty:run이다. 명령 실행 전에 톰캣을 중지해야 한다.
플러그인 제티를 실행하면 터미널에 웹 애플리케이션 실행 로그가 출력되기에 에러 검출에 유리하다.

pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" 
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">

<modelVersion>4.0.0</modelVersion>
<groupId>net.java_school</groupId>
<artifactId>jwpsamples</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>war</packaging>
<name>Java Web Programming Samples</name>
<url>http://localhost:8080</url>

<properties>
	<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
	<maven.compiler.source>25</maven.compiler.source>
	<maven.compiler.target>25</maven.compiler.target>
</properties>

<dependencyManagement>
	<dependencies>
		<!-- https://mvnrepository.com/artifact/org.springframework/spring-framework-bom -->
		<dependency>
			<groupId>org.springframework</groupId>
			<artifactId>spring-framework-bom</artifactId>
			<version>6.2.19</version>
			<type>pom</type>
			<scope>import</scope>
		</dependency>
	</dependencies>		
</dependencyManagement>

<dependencies>
	<dependency>
		<groupId>junit</groupId>
		<artifactId>junit</artifactId>
		<version>4.13.2</version>
		<scope>test</scope>
	</dependency>
	<dependency>
		<groupId>org.springframework</groupId>
		<artifactId>spring-context</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework</groupId>
		<artifactId>spring-webmvc</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework</groupId>
		<artifactId>spring-jdbc</artifactId>
	</dependency>
	<!-- https://mvnrepository.com/artifact/com.oracle.database.jdbc/ojdbc11 -->
	<dependency>
		<groupId>com.oracle.database.jdbc</groupId>
		<artifactId>ojdbc11</artifactId>
		<version>23.26.3.0.0</version>
	</dependency>
	<!-- https://mvnrepository.com/artifact/org.mybatis/mybatis-spring -->
	<dependency>
		<groupId>org.mybatis</groupId>
		<artifactId>mybatis-spring</artifactId>
		<version>4.1.0</version>
	</dependency>
	<!-- https://mvnrepository.com/artifact/org.mybatis/mybatis -->
	<dependency>
		<groupId>org.mybatis</groupId>
		<artifactId>mybatis</artifactId>
		<version>3.6.0</version>
	</dependency>
	<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-dbcp2 -->
	<dependency>
		<groupId>org.apache.commons</groupId>
		<artifactId>commons-dbcp2</artifactId>
		<version>2.14.0</version>
	</dependency>
	<!-- Logging -->
	<!-- https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api -->
	<dependency>
		<groupId>org.apache.logging.log4j</groupId>
		<artifactId>log4j-api</artifactId>
		<version>2.26.1</version>
	</dependency>
	<!-- https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core -->
	<dependency>
		<groupId>org.apache.logging.log4j</groupId>
		<artifactId>log4j-core</artifactId>
		<version>2.26.1</version>
	</dependency>
	<!-- https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-jcl -->
	<dependency>
		<groupId>org.apache.logging.log4j</groupId>
		<artifactId>log4j-jcl</artifactId>
		<version>2.26.1</version>
	</dependency>
	<!-- https://mvnrepository.com/artifact/jakarta.servlet/jakarta.servlet-api -->
	<dependency>
		<groupId>jakarta.servlet</groupId>
		<artifactId>jakarta.servlet-api</artifactId>
		<version>6.1.0</version>
		<scope>provided</scope>
	</dependency>
	<!-- https://mvnrepository.com/artifact/jakarta.servlet.jsp.jstl/jakarta.servlet.jsp.jstl-api -->
	<dependency>
		<groupId>jakarta.servlet.jsp.jstl</groupId>
		<artifactId>jakarta.servlet.jsp.jstl-api</artifactId>
		<version>3.0.2</version>
	</dependency>
	<!-- https://mvnrepository.com/artifact/org.glassfish.web/jakarta.servlet.jsp.jstl -->
	<dependency>
		<groupId>org.glassfish.web</groupId>
		<artifactId>jakarta.servlet.jsp.jstl</artifactId>
		<version>3.0.1</version>
	</dependency>
</dependencies>

<build>
	<finalName>ROOT</finalName>
	<pluginManagement>
		<plugins>
			<plugin>
				<artifactId>maven-clean-plugin</artifactId>
				<version>3.1.0</version>
				<configuration>
					<filesets>
						<fileset>
							<directory>src/main/webapp/WEB-INF/classes</directory>
						</fileset>
						<fileset>
							<directory>src/main/webapp/WEB-INF/lib</directory>
						</fileset>
					</filesets>
				</configuration>
			</plugin>
			<plugin>
				<artifactId>maven-resources-plugin</artifactId>
				<version>3.0.2</version>
			</plugin>
			<plugin>
				<artifactId>maven-compiler-plugin</artifactId>
				<version>3.8.0</version>
			</plugin>
			<plugin>
				<artifactId>maven-surefire-plugin</artifactId>
				<version>2.22.1</version>
			</plugin>
			<plugin>
				<artifactId>maven-war-plugin</artifactId>
				<version>3.2.2</version>
			</plugin>
			<plugin>
				<artifactId>maven-install-plugin</artifactId>
				<version>2.5.2</version>
			</plugin>
			<plugin>
				<artifactId>maven-deploy-plugin</artifactId>
				<version>2.8.2</version>
			</plugin>
			<!-- https://mvnrepository.com/artifact/org.eclipse.jetty/jetty-maven-plugin -->
			<plugin>
				<groupId>org.eclipse.jetty</groupId>
				<artifactId>jetty-maven-plugin</artifactId>
				<version>11.0.26</version>
			</plugin>
		</plugins>
	</pluginManagement>
</build>
</project>

로깅

모델 2 프로젝트로부터 commons-logging.properties와 log4j2.xml 파일을 C:/www/spring-bbs/src/main/resources에 복사한다. (C:/www/spring-bbs/src/main/resources 폴더가 없다면 생성) 컨텍스트 메뉴에서 Maven - Update Project... 를 실행하여 동기화한다.

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

회원

데이터베이스 관련 코드는 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.mapper 패키지에 UserMapper.java를 작성한다. (모델 2 게시판의 UserDao와 일관성을 위해 메소드 이름은 될 수 있으면 그대로 유지했다)

UserMapper.java
package net.java_school.mapper;

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);
}

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

UserServiceImpl.java
package net.java_school.user;

import net.java_school.mapper.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);
  }
}

마이바티스 설정 파일인 mybatis-config.xml을 src/main/resources 디렉터리에 생성한다. 메이븐 프로젝트에서 클래스 패스에 생성되어야 할 설정 파일은 반드시 src/main/resources 아래에 생성해야 한다. 설정 파일을 소스 디렉터리에 만들면 클래스 패스에 복사되지 않는다.

mybatis-config.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> 

  <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>

</configuration>

UserMapper 인터페이스와 같은 클래스 패스를 가지도록, UserMapper.xml 파일을 src/main/resources/net/java_school/mapper 디렉터리에 생성한다. UserMapper.xml 파일 내용에서 id 값을 UserMapper.java의 메소드 이름과 일치시킨다.

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.mapper.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>

"/users"를 포함하는 모든 요청을 처리하는 UsersController 클래스를 net.java_school.controller 패키지에 생성한다.

UsersController.java
package net.java_school.controller;

import java.net.URLEncoder;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.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.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

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

  @GetMapping("signUp")
  public String signUp() {
    return "users/signUp";
  }

  @PostMapping("signUp")
  public String signUp(User user) {
    userService.addUser(user);
    return "redirect:/users/welcome";
  }

  @GetMapping("welcome")
  public String welcome() {
    return "users/welcome";
  }

  @GetMapping("login")
  public String login() {
    return "users/login";
  }
    
  @PostMapping("login")
  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:/";
    }
  }
        
  @GetMapping("editAccount")
  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";
  }
    
  @PostMapping("editAccount")
  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";
  }
    
  @GetMapping("changePasswd")
  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";
  }
    
  @PostMapping("changePasswd")
  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";
  }
    
  @GetMapping("changePasswd_confirm")
  public String changePasswd_confirm() {
    return "users/changePasswd_confirm";
  }
    
  @GetMapping("bye")
  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";
  }

  @PostMapping("bye")
  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";
  }
  
  @GetMapping("bye_confirm")
  public String bye_confirm() {
    return "users/bye_confirm";    
  } 
    
  @GetMapping("logout")
  public String logout(HttpSession session) {
    session.removeAttribute(WebContants.USER_KEY);
    return "redirect:/";
  }
}

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

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

HomeController.java
package net.java_school.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class HomeController {

    @GetMapping("/")
    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.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

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

    @GetMapping
    public String index() {
        return "java/index";
    }
    
    @GetMapping("jdk-install")
    public String basic() {
        return "java/jdk-install";
    }
}

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

JavascriptController.java
package net.java_school.controller;

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

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

    @GetMapping
    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 mapping="/resources/**" location="/resources/" />
        
  <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.mapper" />

  <bean id="dataSource" class="org.apache.commons.dbcp2.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="maxTotal" value="100"/>
    <property name="maxWaitMillis" 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:mybatis-config.xml" />
  </bean>

  <bean id="multipartResolver" 
      class="org.springframework.web.multipart.support.StandardServletMultipartResolver" />
    
  <!-- ViewResolver -->
  <bean id="internalResourceViewResolver" 
      class="org.springframework.web.servlet.view.InternalResourceViewResolver">
    <property name="viewClass" value="org.springframework.web.servlet.view.JstlView" />
    <property name="prefix" value="/WEB-INF/views/" />
    <property name="suffix" value=".jsp" />
  </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: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.mapper.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 엘리먼트로 설정했다.

게시판

mybatis-config.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.mapper.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="search != null and search != ''">
                    AND (title LIKE '%${search}%' OR DBMS_LOB.INSTR(content, #{search}) > 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="search != null and search != ''">
            AND (title LIKE '%${search}%' OR DBMS_LOB.INSTR(content, #{search}) > 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="search != null and search != ''">
                    AND (title LIKE '%${search}%' OR DBMS_LOB.INSTR(content, #{search}) > 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="search != null and search != ''">
                    AND (title LIKE '%${search}%' OR DBMS_LOB.INSTR(content, #{search}) > 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.mapper;

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 search, Integer startRecord, Integer endRecord);

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

  //글쓰기
  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 search);

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

  //첨부 파일 리스트 
  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.mapper.BoardMapper;

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

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

    return boardMapper.selectListOfArticles(hashmap);
  }
    
  //총 레코드 
  @Override 
  public int getTotalRecord(String boardCd, String search) {
    HashMap<String,String> hashmap = new HashMap<String,String>();
    hashmap.put("boardCd", boardCd);
    hashmap.put("search", search);
    
    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 search) {
    HashMap<String, String> hashmap = new HashMap<String, String>();
    Integer no = articleNo;
    hashmap.put("articleNo", no.toString());
    hashmap.put("boardCd", boardCd);
    hashmap.put("search", search);

    return boardMapper.selectNextOne(hashmap);
  }

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

    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
@Controller
@RequestMapping("/bbs")
public class BbsController extends Paginator {

  @Autowired
  private BoardService boardService;

  @GetMapping("/list")
  public String list(@RequestParam(name="boardCd") String boardCd, 
      @RequestParam(name="page") Integer page, 
      @RequestParam(name="search") String search,
      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, search);

    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("search", search);
    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";
  }

  @GetMapping("/write")
  public String writeForm(@RequestParam(name="boardCd") 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";
  }

  @PostMapping("write")
  public String write(Article article, 
      @RequestParam(name="attachFile") MultipartFile attachFile,
      HttpSession session) throws Exception {

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

    article.setEmail(user.getEmail());
    boardService.addArticle(article);

    if (!attachFile.isEmpty()) {
      AttachFile file = new AttachFile();
      file.setFilename(attachFile.getOriginalFilename());
      file.setFiletype(attachFile.getContentType());
      file.setFilesize(attachFile.getSize());
      file.setArticleNo(article.getArticleNo());
      file.setEmail(user.getEmail());
      
      boardService.addAttachFile(file);
      
      //임시 저장 디렉터리에 있는 파일을 UPLOAD_PATH로 이동
      File dir = new File(WebContants.UPLOAD_PATH + user.getEmail());
      if (!dir.exists()) dir.mkdirs();
      Path path = Paths.get(WebContants.UPLOAD_PATH + user.getEmail());
      try (InputStream inputStream = attachFile.getInputStream()) {
        Files.copy(inputStream, path.resolve(attachFile.getOriginalFilename()), StandardCopyOption.REPLACE_EXISTING);
      }
    }
    return "redirect:/bbs/list?page=1&boardCd=" + article.getBoardCd();
  }

  @GetMapping("/view")
  public String view(@RequestParam(name="articleNo") Integer articleNo, 
      @RequestParam(name="boardCd") String boardCd, 
      @RequestParam(name="page") Integer page,
      @RequestParam(name="search") String search,
      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, search);
    Article prevArticle = boardService.getPrevArticle(articleNo, boardCd, search);
    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, search);

    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("search", search);
    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";
  }

  @PostMapping("/addComment")
  public String addComment(Integer articleNo, 
      String boardCd, 
      Integer page, 
      String search,
      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);

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

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

  @PostMapping("/updateComment")
  public String updateComment(Integer commentNo, 
      Integer articleNo, 
      String boardCd, 
      Integer page, 
      String search, 
      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);

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

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

  @PostMapping("/deleteComment")
  public String deleteComment(Integer commentNo, 
      Integer articleNo, 
      String boardCd, 
      Integer page, 
      String search,
      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);

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

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

  @GetMapping("/modify")
  public String modifyForm(@RequestParam(name="articleNo") Integer articleNo, 
      @RequetParam(name="boardCd") 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";
  }

  @PostMapping("/modify")
  public String modify(Article article, 
      @RequestParam(name="attachFile") attachFile,
      @RequestParam(name="page") Integer page,
      @RequestParam(name="search") String search,
      HttpSession session) throws Exception {

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

    //로그인 사용자가 글 작성자인지 검사
    int articleNo = article.getArticleNo();
    String ownerEmail = boardService.getArticle(articleNo).getEmail();
    if (!ownerEmail.equals(user.getEmail())) {
      throw new AuthenticationException(WebContants.AUTHENTICATION_FAILED);
    }

    //게시글 수정
    boardService.modifyArticle(article);
    
    if (!attachFile.isEmpty()) {
      AttachFile file = new AttachFile();
      file.setFilename(attachFile.getOriginalFilename());
      file.setFiletype(attachFile.getContentType());
      file.setFilesize(attachFile.getSize());
      file.setArticleNo(article.getArticleNo());
      file.setEmail(user.getEmail());
      
      boardService.addAttachFile(file);
      
      //임시 저장 디렉터리에 있는 파일을 UPLOAD_PATH로 이동
      File dir = new File(WebContants.UPLOAD_PATH + user.getEmail());
      if (!dir.exists()) dir.mkdirs();
      Path path = Paths.get(WebContants.UPLOAD_PATH + user.getEmail());
      try (InputStream inputStream = attachFile.getInputStream()) {
        Files.copy(inputStream, path.resolve(attachFile.getOriginalFilename()), StandardCopyOption.REPLACE_EXISTING);
      }
    }

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

  @PostMapping("/deleteAttachFile")
  public String deleteAttachFile(Integer attachFileNo, 
      Integer articleNo, 
      String boardCd, 
      Integer page, 
      String search,
      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);

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

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

  @PostMapping("/del")
  public String del(Integer articleNo, 
      String boardCd, 
      Integer page, 
      String search,
      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);

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

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

BbsController 클래스에 적용된 어노테이션

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

목록 요청

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

@GetMapping("list")

클래스 레벨로 @RequestMapping("bbs")를 적용했기에, 위 메소드는 HTTP 프로토콜의 METHOD가 GET 방식의 /bbs/list 요청에 매핑된다.

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

@RequestParam(name="page") Integer page

컨트롤러 메소드의 아규먼트를 파라미터의 이름과 같게 하면 쉽게 파라미터값을 받을 수 있다.
파라미터 중에서 boardCd와 page는 필수적으로 전달되도록 구현해야 한다.
search는 사용자가 검색할 때만 전달된다.
전달되는 파라미터값을 전부 받았음에도 아규먼트 리스트에서 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, search);

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("search", search);
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 요청에 매핑된다.

@GetMapping("write")
public String writeForm(@RequestParam(name="boardCd") String boardCd, HttpServletRequest req, HttpSession session...)

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

글쓰기 처리 요청

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

@PostMapping("write")
public String write(Article article, HttpSession session, 
    @RequestParam(name="attachFile") MultipartFile attachFile) throws Exception {

메소드는. 먼저 로그인 여부를 판단한다.
로그인되어 있지 않다면 AuthenticationException 예외를 발생하고, mvc.xml에 설정된 예외 리졸브에 의해서 /WEB-INF/views/403-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()가 호출되어 게시글 고유번호가 세팅된다.

article.setBoardCd(article.getBoardCd());
article.setTitle(article.getTitle());
article.setContent(article.getContent());
article.setEmail(user.getEmail());
boardService.addArticle(article);

첨부 파일이 있다면, 파일 정보를 테이블에 삽입한다.

if (!attachFile.isEmpty()) {
	//파일 정보 삽입
	AttachFile file = new AttachFile();
	file.setFilename(attachFile.getOriginalFilename());
	file.setFiletype(attachFile.getContentType());
	file.setFilesize(attachFile.getSize());
	file.setArticleNo(article.getArticleNo());
	file.setEmail(user.getEmail());
	boardService.addAttachFile(file);

서버에 전달된 파일을 우리가 원하는 업로드 디렉터리로 옮긴다.
컨트롤러 메소드의 파라미터 중 MultipartFile 타입은 시스템에 전달된 파일에 접근할 수 있다.

if (!attachFile.isEmpty()) {
	//파일 정보 삽입
	
	//..생략..
	
	File dir = new File(WebContants.UPLOAD_PATH + user.getEmail());
	if (!dir.exists()) dir.mkdirs();
	Path path = Paths.get(WebContants.UPLOAD_PATH + user.getEmail());
	
	try (InputStream inputStream = attachFile.getInputStream()) {
		Files.copy(InputStream, path.resolve(attachFile.getOriginalFilename()), StandardCopyOption.REPLACE_EXISTING);
	}
}

글쓰기를 서버스의 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, search이다.
메소드의 아규먼트로 이들 파라미터의 값을 전달받을 변수를 선언했다.
포워드로 이동하지만 view.jsp에 필요한 데이터를 생산하기 위해 전달되는 파라미터 모두 필요하기 때문이다.
그럼에도 HttpServletRequest 타입의 아규먼트 변수가 있는 것은 로그인 체크를 통과 못 했을 때 요청 URL 정보를 로그인 페이지에 전달하기 위해서다.
HttpSession 타입의 아규먼트는 로그인 체크를 하려면 세션의 저장된 값에 접근해야 하기 때문에 필요하다.

@GetMapping("/view")
public String view(@RequestParam(name="articleNo") Integer articleNo, 
        @RequestParam(name="boardCd") String boardCd, 
        @RequestParam(name="page") Integer page,
        @RequestParam(name="search") String search,
        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, search);
Article prevArticle = boardService.getPrevArticle(articleNo, boardCd, search);
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 요청에 매핑되는 메소드다.

@PostMapping("/addComment")
public String addComment(@RequestParam(name="articleNo") Integer articleNo, 
        @RequestParam(name="boardCd") String boardCd, 
        @RequestParam(name="page") Integer page, 
        @RequestParam(name="search") String search,
        @RequestParam(name="memo") 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);

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

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

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

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

댓글 수정 요청

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

@PostMapping("/updateComment")
public String updateComment(
        @RequestParam(name="commentNo") Integer commentNo, 
        @RequestParam(name="articleNo") Integer articleNo, 
        @RequestParam(name="boardCd") String boardCd, 
        @RequestParam(name="page") Integer page, 
        @RequestParam(name="search") String search, 
        @RequestParam(name="memo") 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);

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

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

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

댓글 삭제 요청

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

@PostMapping("/deleteComment")
public String deleteComment(
        @RequestParam(name="commentNo") Integer commentNo, 
        @RequestParam(name="articleNo") Integer articleNo, 
        @RequestParam(name="boardCd") String boardCd, 
        @RequestParam(name="page") Integer page, 
        @RequestParam(name="search") String search,
        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);

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

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

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

첨부 파일 다운로드

상세보기 페이지에서 첨부 파일 링크를 클릭하면 다음 폼에 파일 번호와 파일 이름이 전달되어 GET /data/fileno=101 요청을 서버에 전달하도록 구현하고, /data로 시작하는 모든 요청에 매핑하도록 첨부 파일 다운로드를 위한 전담 컨트롤러를 생성한다.

DownloadController.java
package net.java_school.controller;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.file.Path;
import java.nio.file.Paths;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;

import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

import net.java_school.commons.WebContants;
import net.java_school.board.BoardService;
import net.java_school.board.AttachFile;
import net.java_school.exception.AuthenticationException;
import net.java_school.user.User;
import org.springframework.beans.factory.annotation.Autowired;

@Controller
public class DownloadController {
	
	@Autowired
	private BoardService boardService;

	@GetMapping("data")
	public ResponseEntity<InputStreamResource> download(
			@RequestParam(name="fileno") Integer fileno, 
			HttpServletRequest req, HttpSession session) throws IOException {
		
		//로그인 체크
		User user = (User) session.getAttribute(WebContants.USER_KEY);
		if (user == null) {
		    throw new AuthenticationException(WebContants.NOT_LOGIN);
		}
					
		AttachFile attachFile = boardService.getAttachFile(fileno);
		String owner = attachFile.getEmail();
		String filename = attachFile.getFilename();

		//검증 코드 시작
		Path ownerPath = Paths.get(WebContants.UPLOAD_PATH + owner).toAbsolutePath().normalize();
		Path filePath = ownerPath.resolve(filename);
		Path normalizedFilePath = filePath.normalize();
		if (!normalizedFilePath.startsWith(ownerPath)) {
			throw new SecurityException("Path Traversal (" + filename + ")");
		}
		File file = normalizedFilePath.toFile();
		if (!file.exists() || !file.isFile()) {
			throw new FileNotFoundException("The file does not exist!");
		}
		//검증 코드 끝
		
		InputStreamResource resource = new InputStreamResource(new FileInputStream(file));

		boolean ie = req.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");
		}

		return ResponseEntity.ok()
				.header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=\"" + filename + "\"")
				.contentType(MediaType.APPLICATION_OCTET_STREAM).contentLength(file.length())
				.body(resource);
	}
}

다운로드 컨트롤러는 응답 헤더에 Content-Dispostion 헤더를 attachment로 설정하여 브라우저가 파일 저창 창을 띄우도록 한다. Content-Type 설정도 중요한데 브라우저가 파일을 무조건 다운로드하게 하려면 범용 바이너리 타입인 application/octet-stream을 사용하는 것이 안전하다. Content-Length를 제공하여 대용량 파일을 다운로드할 때, 다운로드 창에 남은 시간과 진행률(%)가 표시되게 할 수 있다. 응답 헤더를 설정한 다음, 파일을 소스로 하는 입력스트림을 응답이 목적지인 출력스트림에 둔다.

검증 코드는 ../로 시작하는 파일 이름으로 서버의 중요 파일을 다운로드하려는 시도를 방어하기 위한 코드다.

#form-group에 다음을 추가한다.

<c:url var="dataUrl" value="/data" />
<form id="download" action="${dataUrl}" method="get">
	<input type="hidden" name="fileno"/>
</form>

view.jsp의 다운로드 링크를 아래처럼 수정한다.

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

글 수정 폼

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

@GetMapping("/modify")
public String modifyForm(
        @RequestParam(name="articleNo") Integer articleNo, 
        @RequestParam(name="boardCd") String boardCd,
        HttpSession session, Model model) {

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

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

Article article = boardSe
rvice.getArticle(articleNo);

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

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

String boardName = boardService.getBoard(boardCd).getBoardNm_ko();
        
model.addAttribute("boardName", boardName);
model.addAttribute("article", article);

return "bbs/modify";

글 수정 처리 요청

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

@PostMapping("/modify")
public String modify(Article article,
		@RequestParam(name="page") Integer page,
		@RequestParam(name="search") String search,
		HttpSession session,
		@RequestParam(name="attachFile") MultipartFile attachFile) throws Exception {

로그인 여부에 이어 글 소유자인지도 함께 검사한다.
검사를 통과하지 못하면 사용자 정의 인증 예외를 던진다.

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

Article modifyTargetArticle = boardService.getArticle(article.getArticleNo());

if (!modifyTargetArticle.getEmail().equals(user.getEmail())) {
  throw new AuthenticationException(WebContants.AUTHENTICATION_FAILED);
}

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

article.setEmail(user.getEmail());
boardService.modifyArticle(article);

첨부 파일이 있다면, 파일 정보를 테이블에 삽입한다.

if (!attachFile.isEmpty()) {
	AttachFile file = new AttachFile();
	file.setFilename(file.getOriginalFilename());
	file.setFiletype(file.getContentType());
	file.setFilesize(file.getSize());
	file.setArticleNo(article.getArticleNo());
	boardService.addAttachFile(file);
	
	//TODO 임시 디렉터리에 저장된 파일을 원하는 곳으로 이동
}

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

if (!attachFile.isEmpty()) {

	//..생략..
		
	//임시 디렉터리에 저장된 파일을 원하는 곳으로 이동
	File dir = new File(WebContants.UPLOAD_PATH + user.getEmail());
	if (!dir.exists()) dir.mkdirs();
	Path path = Paths.get(WebContants.UPLOAD_PATH + user.getEmail());
	try (InputStream inputStream = attachFile.getInputStream()) {
		Files.copy(
				inputStream, 
				path.resolve(attachFile.getOriginalFilename()), 
				StandardCopyOption.REPLACE_EXISTING
		);
	}
}

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

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

return "redirect:/bbs/view?articleNo=" + article.getArticleNo() 
    + "&boardCd=" + article.getBoardCd() 
    + "&page=" + page 
    + "&search=" + search;

첨부 파일 삭제 처리 요청

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

@PostMapping("/deleteAttachFile")
public String deleteAttachFile(
        Integer attachFileNo, 
        Integer articleNo, 
        String boardCd, 
        Integer page, 
        String search,
        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);

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

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

게시글 삭제 처리 요청

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

@PostMapping("/del")
public String del(
        Integer articleNo, 
        String boardCd, 
        Integer page, 
        String search,
        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);

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

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

테스트

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

 
mvn clean compile war:inplace

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

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

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

톰캣을 재실행한 다음
http://localhost:8080/bbs/list?boarCd=chat&page=1을 방문하여 테스트한다.
JSP 프로젝트나 모델 2에서 테스트했던 데이터가 있다면 보일 것이다.
데이터가 있다면 한글 검색을 테스트한다.

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.attachFileNo }')">${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. 조회 수 증가 역시 애이잭스를 이용하여 구현한다.
참고