IT·테크/개발

Spring MVC 게시판 만들기 — 프로젝트 구조부터 글 등록과 목록 조회까지

레루루 2026. 8. 1. 17:18
반응형

이 글은 2022년 7월 기준 기록입니다.

 

게시판은 웹 애플리케이션의 기본 동작을 한 바퀴 도는 예제입니다. 요청을 받고, DB에 쓰고, 다시 읽어 화면에 뿌리는 흐름이 전부 들어 있습니다. 여기서는 Spring Legacy MVC 프로젝트를 만들어 글 등록과 목록 조회까지 이어 붙입니다.

1. 프로젝트 생성

STS에서 [New] - [Spring Legacy Project]를 선택합니다.

  1. [Project name]에 mvc_part07_board 입력
  2. [Templates] 목록에서 [Spring MVC Project] 선택
  3. [Next] 클릭 후, top-level package로 net.koreate.board 입력
  4. [Finish]

Spring MVC Project 템플릿은 선택 시 원격에서 템플릿 내용을 내려받으므로 네트워크 연결이 필요합니다.

2. pom.xml 설정

생성된 프로젝트의 pom.xml은 오래된 버전을 기준으로 하고 있어 손봐야 합니다. 자바와 Spring 버전을 올리고, JUnit·servlet-api·jsp-api를 최신으로 바꾸고, 이번 프로젝트에 필요한 spring-test·lombok·MySQL 드라이버·spring-jdbc·MyBatis를 새로 추가합니다.

 

<!-- 수정됨 -->은 기존 내용을 바꾼 자리, <!-- 추가됨 -->은 새로 넣은 자리입니다.

<?xml version="1.0" encoding="UTF-8"?>
<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 https://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>net.koreate</groupId>
    <artifactId>board</artifactId>
    <name>mvc_part07_board</name>
    <packaging>war</packaging>
    <version>1.0.0-BUILD-SNAPSHOT</version>
    <properties>
    <!-- 수정됨 -->
        <java-version>1.8</java-version>
        <org.springframework-version>5.3.21</org.springframework-version>
    <!-- ~~~~~~ -->
        <org.aspectj-version>1.6.10</org.aspectj-version>
        <org.slf4j-version>1.6.6</org.slf4j-version>
    </properties>
    <dependencies>
        <!-- Spring -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
            <version>${org.springframework-version}</version>
            <exclusions>
                <!-- Exclude Commons Logging in favor of SLF4j -->
                <exclusion>
                    <groupId>commons-logging</groupId>
                    <artifactId>commons-logging</artifactId>
                 </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-webmvc</artifactId>
            <version>${org.springframework-version}</version>
        </dependency>

        <!-- AspectJ -->
        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjrt</artifactId>
            <version>${org.aspectj-version}</version>
        </dependency>    

        <!-- Logging -->
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>${org.slf4j-version}</version>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>jcl-over-slf4j</artifactId>
            <version>${org.slf4j-version}</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-log4j12</artifactId>
            <version>${org.slf4j-version}</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>log4j</groupId>
            <artifactId>log4j</artifactId>
            <version>1.2.15</version>
            <exclusions>
                <exclusion>
                    <groupId>javax.mail</groupId>
                    <artifactId>mail</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>javax.jms</groupId>
                    <artifactId>jms</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>com.sun.jdmk</groupId>
                    <artifactId>jmxtools</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>com.sun.jmx</groupId>
                    <artifactId>jmxri</artifactId>
                </exclusion>
            </exclusions>
            <scope>runtime</scope>
        </dependency>

        <!-- @Inject -->
        <dependency>
            <groupId>javax.inject</groupId>
            <artifactId>javax.inject</artifactId>
            <version>1</version>
        </dependency>

        <!-- Servlet -->
<!--         수정됨 -->
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
            <version>4.0.1</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>javax.servlet.jsp</groupId>
            <artifactId>javax.servlet.jsp-api</artifactId>
            <version>2.3.3</version>
            <scope>provided</scope>
        </dependency>
<!--         ~~~~~~ -->
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>jstl</artifactId>
            <version>1.2</version>
        </dependency>

        <!-- Test -->
<!--         수정됨 -->
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.13.2</version>
            <scope>test</scope>
        </dependency>   
<!--         ~~~~~~ -->

<!--         추가됨 -->
        <!-- https://mvnrepository.com/artifact/org.springframework/spring-test -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-test</artifactId>
            <version>${org.springframework-version}</version>
            <scope>test</scope>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.24</version>
            <scope>provided</scope>
        </dependency>
        <!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.29</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.springframework/spring-jdbc -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-jdbc</artifactId>
            <version>${org.springframework-version}</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.mybatis/mybatis -->
        <dependency>
            <groupId>org.mybatis</groupId>
            <artifactId>mybatis</artifactId>
            <version>3.5.10</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.mybatis/mybatis-spring -->
        <dependency>
            <groupId>org.mybatis</groupId>
            <artifactId>mybatis-spring</artifactId>
            <version>2.0.7</version>
        </dependency>
<!--         ~~~~~~ -->

    </dependencies>
    <build>
        <plugins>
            <plugin>
                <artifactId>maven-eclipse-plugin</artifactId>
                <version>2.9</version>
                <configuration>
                    <additionalProjectnatures>
                        <projectnature>org.springframework.ide.eclipse.core.springnature</projectnature>
                    </additionalProjectnatures>
                    <additionalBuildcommands>
                        <buildcommand>org.springframework.ide.eclipse.core.springbuilder</buildcommand>
                    </additionalBuildcommands>
                    <downloadSources>true</downloadSources>
                    <downloadJavadocs>true</downloadJavadocs>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>2.5.1</version>
                <configuration>
<!--                 수정됨 -->
                    <source>${java-version}</source>
                    <target>${java-version}</target>
<!--                 ~~~~~~ -->
                    <compilerArgument>-Xlint:all</compilerArgument>
                    <showWarnings>true</showWarnings>
                    <showDeprecation>true</showDeprecation>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <version>1.2.1</version>
                <configuration>
                    <mainClass>org.test.int1.Main</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

 

작성 후에는 프로젝트를 우클릭해 [Maven] - [Update Project…]로 변경 내용을 반영합니다.

3. 설정 파일은 webapp이 아니라 resources에 둔다

템플릿이 만들어 준 상태에서는 Spring 설정 XML들이 src/main/webapp/WEB-INF/spring 안에 흩어져 있습니다. 이것을 src/main/resources 아래로 옮겨 설정 파일을 한곳에 모읍니다.

  1. src/main/resources 위치에 context 폴더를 추가하고, 그 밑에 mvc, root 폴더를 추가합니다
  2. 기존 src/main/webapp/WEB-INF/spring 안에 있던 root-context.xmlservlet-context.xml을 각각 위 위치로 이동합니다
  3. WEB-INFspring 폴더는 더 이상 필요 없으므로 삭제합니다

결과 구조는 다음과 같습니다.

mvc_part07_board
├─ src/main/java
│   └─ net.koreate.board
├─ src/main/resources
│   ├─ context
│   │   ├─ mvc
│   │   │   └─ servlet-context.xml
│   │   └─ root
│   │       └─ root-context.xml
│   ├─ META-INF
│   └─ log4j.xml
└─ src/main/webapp/WEB-INF
    ├─ classes
    ├─ views
    └─ web.xml

 

파일을 옮겼으니 web.xml이 새 경로를 알아야 합니다. classpath:src/main/resources를 의미하므로, 이를 이용해 두 곳의 contextConfigLocation을 수정합니다.

<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://java.sun.com/xml/ns/javaee https://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">

    <!-- The definition of the Root Spring Container shared by all Servlets and Filters -->
    <context-param>
        <param-name>contextConfigLocation</param-name>
<!--         수정됨 -->
        <param-value>classpath:/context/root/root-context.xml</param-value>
<!--         ~~~~~~ -->
    </context-param>

    <!-- Creates the Spring Container shared by all Servlets and Filters -->
    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>

    <!-- Processes application requests -->
    <servlet>
        <servlet-name>appServlet</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
<!--             수정됨 -->
            <param-value>classpath:/context/mvc/servlet-context.xml</param-value>
<!--             ~~~~~~ -->
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>

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

</web-app>

 

변경 후 프로젝트를 실행해 잘 뜨는지 반드시 확인합니다. 구조를 바꾼 직후는 경로 하나만 틀려도 서버가 올라오지 않는 구간이라, 코드를 더 쌓기 전에 확인하고 넘어가는 편이 낫습니다.

JSTL 오류가 나는 경우

이 시점에서 에러가 날 수 있습니다. 톰캣에 jstl을 직접 넣어 놓은 환경이라면 해당하지 않습니다.

 

원인은 서버 설정에서 [Serve modules without publishing]을 체크해 두었기 때문입니다. 이 설정에서는 jsp 파일에서 직접 가져오는 라이브러리의 경우 프로젝트에 포함되어 있어야 합니다. pom.xml에 의존성을 적어 두었더라도 .m2에만 있으면 jsp가 찾지 못합니다.

 

해결은 다음과 같습니다.

  1. 사용자 폴더의 .m2 아래 repository/javax/servlet/jstl/1.2 경로에서 jstl-1.2.jar를 찾습니다
  2. src/main/webapp/WEB-INF 아래에 lib 폴더를 추가합니다
  3. 그 폴더에 jstl-1.2.jar를 넣습니다

4. 컴포넌트 스캔 범위를 계층별로 나눈다

net.koreate.board 아래에 용도별 패키지를 만듭니다. controller, dao, service, util, vo 다섯 개입니다.

net.koreate.board
├─ controller
│   └─ HomeController.java
├─ dao
├─ service
├─ util
└─ vo

 

여기서 servlet-context.xmlcomponent-scan 범위를 한 번 좁힙니다. 템플릿이 만들어 준 설정은 net.koreate.board 전체를 스캔하는데, 이를 net.koreate.board.controller로 바꿉니다.

<?xml version="1.0" encoding="UTF-8"?>
<beans:beans xmlns="http://www.springframework.org/schema/mvc"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:beans="http://www.springframework.org/schema/beans"
    xmlns:context="http://www.springframework.org/schema/context"
    xsi:schemaLocation="http://www.springframework.org/schema/mvc https://www.springframework.org/schema/mvc/spring-mvc.xsd
        http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd">

    <!-- DispatcherServlet Context: defines this servlet's request-processing infrastructure -->

    <!-- Enables the Spring MVC @Controller programming model -->
    <annotation-driven />

    <!-- Handles HTTP GET requests for /resources/** by efficiently serving up static resources in the ${webappRoot}/resources directory -->
    <resources mapping="/resources/**" location="/resources/" />

    <!-- Resolves views selected for rendering by @Controllers to .jsp resources in the /WEB-INF/views directory -->
    <beans:bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
        <beans:property name="prefix" value="/WEB-INF/views/" />
        <beans:property name="suffix" value=".jsp" />
    </beans:bean>

<!-- <context:component-scan base-package="net.koreate.board" /> -->
<!-- 위 내용을 아래와 같이 수정 -->
    <context:component-scan base-package="net.koreate.board.controller" />

</beans:beans>

 

InternalResourceViewResolver의 prefix·suffix 설정도 함께 봐 둘 만합니다. 컨트롤러가 "home"을 반환하면 /WEB-INF/views/home.jsp를 찾는 규칙이 여기서 정해집니다.

5. 파일이 놓이는 자리를 정한다

코드를 쓰기 전에 어디에 무엇을 둘지 정합니다.

  • .jsp 파일의 기본 경로는 src/main/webapp/WEB-INF/views이고, 게시판 관련 jsp 파일은 board 폴더 안에 생성합니다.
  • .java 파일의 기본 경로는 net.koreate.board이고, 앞서 만든 controller·dao·service·util·vo 패키지에 용도에 따라 구분해 생성합니다.

6. 첫 요청 하나를 끝까지 통과시킨다

먼저 기존 home.jsp를 지우고 새로 만듭니다. 링크 하나만 있는 최소 형태입니다.

<!-- 새로운 home.jsp -->
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
    <a href="board/register">글쓰기</a>
</body>
</html>

 

이 링크를 받을 컨트롤러를 만듭니다.

package net.koreate.board.controller;

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

// 컴포넌트 선언
@Controller
// /board/로 시작하는 모든 요청에 대응하는 BoardController
@RequestMapping("/board/*")
public class BoardController {

//    /board/register 경로에 대한 GET요청 처리 메소드
//    void 형식 리턴으로 요청 경로와 동일한 값 리턴(/board/register) => WEB-INF/views/board/register.jsp 필요
//    @RequestMapping(value = "/register", method = RequestMethod.GET) 아래와 같이 간략하게 작성 가능(동일)
    @GetMapping("/register")
    public void register() throws Exception {
        System.out.println("게시글 작성 페이지 요청");
    }

}

 

여기서 눈여겨볼 것은 반환 타입이 void라는 점입니다. 반환값이 없으면 요청 경로와 동일한 값을 뷰 이름으로 사용하므로, /board/register 요청은 WEB-INF/views/board/register.jsp를 찾게 됩니다. 그래서 그 자리에 빈 jsp를 만들어 둡니다.

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>

</body>
</html>

 

또한 @RequestMapping(value = "/register", method = RequestMethod.GET)@GetMapping("/register")로 짧게 쓸 수 있고, 두 표현은 동일합니다.

7. 테이블과 VO는 같은 모양이어야 한다

워크벤치에서 게시판 테이블을 만듭니다.

CREATE TABLE tbl_board (
    bno INT PRIMARY KEY AUTO_INCREMENT,
    title VARCHAR(200) NOT NULL,
    content TEXT,
    writer VARCHAR(20) NOT NULL,
    regdate TIMESTAMP NOT NULL DEFAULT NOW(),
    viewcnt INT DEFAULT 0
)

 

글 번호는 AUTO_INCREMENT로 DB가 채우고, 작성일은 DEFAULT NOW(), 조회수는 DEFAULT 0으로 시작합니다. 즉 애플리케이션이 넘겨야 하는 값은 제목·내용·작성자 셋뿐입니다.

 

이 테이블 한 행에 대응하는 VO를 만듭니다. lombok의 @Data 하나로 getter·setter·toString 등이 모두 생성됩니다.

package net.koreate.board.vo;

import java.util.Date;

import lombok.Data;

// lombok 활용
@Data
public class BoardVO {
    private int bno;
    private String title;
    private String content;
    private String writer;
    private Date regdate;
    private int viewcnt;
}

8. DB 접속 정보는 properties 파일로 뺀다

root-context.xml에 DataSource와 MyBatis 관련 bean을 등록합니다. 이때 접속 정보를 XML에 직접 쓰지 않고 별도의 properties 파일에서 읽어옵니다.

 

XML을 편집하기 전에 필요한 네임스페이스를 먼저 켜 둡니다. XML 에디터 하단의 [Namespaces] 탭에서 context, jdbc, mvc, 그리고 mybatis-spring을 체크합니다.

<?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:jdbc="http://www.springframework.org/schema/jdbc"
    xsi:schemaLocation="http://www.springframework.org/schema/jdbc http://www.springframework.org/schema/jdbc/spring-jdbc-4.3.xsd
        http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc-4.3.xsd
        http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.3.xsd">

    <!-- Root Context: defines shared resources visible to all other web components -->
<!--     src/main/resources/prop/db.properties 파일 생성 후 db 접속 정보 등록 -->
<!--     해당 내용 읽어오기 위해 아래 내용 작성 -->
    <context:property-placeholder location="classpath:/prop/*.properties"/>

<!--     properties 파일 내용 사용하여 db 접속 설정 추가 => ${}로 key 이름 지정하여 값 읽어옴 -->
    <bean id="ds" class="org.springframework.jdbc.datasource.DriverManagerDataSource">
        <property name="driverClassName" value="${jdbc.driver}" />
        <property name="url" value="${jdbc.url}" />
        <property name="username" value="${jdbc.username}" />
        <property name="password" value="${jdbc.password}" />
    </bean>

    <bean id="factory" class="org.mybatis.spring.SqlSessionFactoryBean">
        <property name="dataSource" ref="ds"/>
        <property name="typeAliasesPackage" value="net.koreate.board.vo" />
        <property name="mapperLocations" value="classpath:/mybatis/sql/*.xml" />
    </bean>

    <bean id="sqlSession" class="org.mybatis.spring.SqlSessionTemplate">
        <constructor-arg ref="factory" />
    </bean>
</beans>

 

SqlSessionFactoryBean의 두 속성이 중요합니다. typeAliasesPackage를 지정하면 그 패키지의 클래스들이 별칭으로 등록되어 매퍼에서 boardVO처럼 짧게 쓸 수 있고, mapperLocations는 매퍼 XML을 어디서 찾을지 알려 줍니다.

 

접속 정보는 다음 파일에 둡니다.

jdbc.driver=com.mysql.cj.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/spring_data
jdbc.username=spring
jdbc.password=1234

9. 연결은 코드를 더 쌓기 전에 확인한다

DB 연결이 실제로 되는지를 JUnit 테스트로 확인합니다. 화면을 만들어 눌러 보는 것보다 훨씬 빠르게 결과를 알 수 있습니다.

package net.koreate.board;

import java.sql.Connection;
import java.sql.SQLException;

import javax.inject.Inject;
import javax.sql.DataSource;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = { "classpath:/context/root/root-context.xml" })
public class DataSourceTest {

    @Inject
    DataSource ds;

    @Test
    public void dataSource() {
        Connection conn = null;

        try {
            conn = ds.getConnection();
            System.out.println("연결 완료 : " + conn);
        } catch (SQLException e) {
            System.out.println("연결 실패");
            e.printStackTrace();
        } finally {
            try {
                if (conn != null)
                    conn.close();
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }
    }

}

 

다음으로 매퍼 XML을 만듭니다. root-context.xmlmapperLocations가 가리키는 위치인 src/main/resources/mybatis/sql/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.koreate.board.dao.BoardDAO">

</mapper>

 

매퍼가 생겼으니 SqlSession까지 만들어지는지 테스트에 항목을 추가합니다.

    @Inject
    SqlSession sqlSession;

    @Test
    public void sqlSessionTest() {
        Connection conn = sqlSession.getConnection();
        System.out.println("sqlSession conn : " + conn);
    }

10. 계층은 인터페이스로 먼저 고정한다

구현을 시작하기 전에 각 계층이 무엇을 할지 인터페이스로 선언합니다. 게시판이 결국 어떤 기능을 갖게 될지가 여기서 한눈에 드러납니다.

페이징에 필요한 값을 담는 Criteria

요청 페이지 번호와 한 페이지에 보여줄 게시글 수를 담는 클래스입니다. 값 검증이 필요하므로 setter를 lombok에 맡기지 않고 직접 만듭니다.

package net.koreate.board.util;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.ToString;

@Getter
@AllArgsConstructor
@ToString
public class Criteria {

    private int page; // 요청 페이지 번호
    private int perPageNum; // 한 페이지에 보여줄 게시글 수

    // 기본 생성자에서 값 초기화를 위해 lombok으로 만들지 않고 직접 생성
    public Criteria() {
        this(1, 10);
    }

    // 값 검증 후 입력을 위해 setter 직접 생성
    public void setPage(int page) {
        if (page <= 0) {
            this.page = 1;
            return;
        }
        this.page = page;
    }

    public void setPerPageNum(int perPageNum) {
        if (perPageNum <= 0 || perPageNum > 100) {
            this.perPageNum = 10;
            return;
        }
        this.perPageNum = perPageNum;
    }

    // 게시물 검색 시에 limit 활용
    // limit startRow, perPageNum
    public int getStartRow() {
        return (this.page - 1) * perPageNum;
    }

}

 

사용자가 page=0이나 perPageNum=99999 같은 값을 쿼리 파라미터로 밀어 넣어도 setter에서 기본값으로 되돌립니다. getStartRow()는 SQL의 LIMIT 시작 번호를 계산합니다.

페이징 버튼 상태를 계산하는 PageMaker

Criteria와 전체 게시물 수만 있으면 화면에 필요한 나머지 값은 전부 계산으로 나옵니다. 그 계산을 담당하는 클래스입니다.

package net.koreate.board.util;

import lombok.Getter;
import lombok.ToString;

@Getter
@ToString
public class PageMaker {

    // set 될 수 있는 필드
    private Criteria cri; // 요청 페이지와 한 페이지 당 게시글 수 정보를 가진 Criteria
    private int displayPageNum; // 한 페이징 블록의 페이지 수
    private int totalCount; // 전체 게시물 수

    // 내부 계산 필드
    private int startPage; // 페이징 블록의 첫 번째 페이지 번호
    private int endPage; // 페이징 블록의 마지막 페이지 번호
    private int maxPage; // 전체 게시물에 대한 마지막 페이지 번호

    private boolean first; // 첫 페이지로 갈 수 있는지 여부
    private boolean last; // 마지막 페이지로 갈 수 있는지 여부
    private boolean prev; // 이전 페이징 블록으로 갈 수 있는지 여부
    private boolean next; // 다음 페이징 블록으로 갈 수 있는지 여부

    public PageMaker() {
        this(new Criteria(), 0);
    }

    public PageMaker(Criteria cri, int totalCount) {
        this.displayPageNum = 5;
        this.cri = cri;
        this.totalCount = totalCount;
        calcPaging();
    }

    private void calcPaging() {
        endPage = (int) (Math.ceil(cri.getPage() / (double) displayPageNum) * displayPageNum);
        startPage = (endPage - displayPageNum) + 1;
        maxPage = (int) Math.ceil(totalCount / (double) cri.getPerPageNum());

        if (endPage > maxPage) {
            endPage = maxPage;
        }

        first = (cri.getPage() != 1) ? true : false;
        last = (cri.getPage() != maxPage) ? true : false;
        prev = (startPage != 1) ? true : false;
        next = (endPage != maxPage) ? true : false;
    }

    public void setDisplayPageNum(int displayPageNum) {
        this.displayPageNum = displayPageNum;
        calcPaging();
    }

    public void setTotalCount(int totalCount) {
        this.totalCount = totalCount;
        calcPaging();
    }

    public void setCri(Criteria cri) {
        this.cri = cri;
        calcPaging();
    }

}

 

세 개의 setter가 모두 마지막에 calcPaging()을 호출하는 점이 이 클래스의 핵심입니다. 입력값 중 무엇이 바뀌든 파생 값이 즉시 다시 계산되므로, 바깥에서 계산 시점을 신경 쓸 필요가 없습니다.

서비스 계층 인터페이스

컨트롤러가 호출하게 될 기능 목록입니다.

package net.koreate.board.service;

import java.util.List;

import javax.servlet.http.HttpSession;

import net.koreate.board.util.Criteria;
import net.koreate.board.util.PageMaker;
import net.koreate.board.vo.BoardVO;

public interface BoardService {

    /**
     * @return 성공 유무에 따른 메시지 전달
     */
    String regist(BoardVO board) throws Exception;

    /**
     * @return 게시글 전체 목록
     */
    List<BoardVO> listAll() throws Exception;

    /**
     * @return 페이징 처리된 게시글 목록
     */
    List<BoardVO> listCriteria(Criteria cri) throws Exception;

    /**
     * @param 수정에 필요한 게시글 정보
     */
    void modify(BoardVO board) throws Exception;

    /**
     * @param 삭제에 필요한 게시글 번호
     */
    void remove(int bno) throws Exception;

    /**
     * @param 페이징 처리 계산을 위한 Criteria 객체
     * @return 페이징 처리 정보를 담은 PageMaker
     */
    PageMaker getPageMaker(Criteria cri) throws Exception;

    /**
     * 조회수 증가 처리를 위한 매개변수
     * 
     * @param bno
     * @param session
     */
    void updateCnt(int bno, HttpSession session) throws Exception;

    /**
     * @param 게시물 상세 보기 정보를 위한 게시글 번호 bno
     * @return 상세 보기에 해당하는 게시글 하나의 정보
     */
    BoardVO read(int bno) throws Exception;

}

DAO 계층 인터페이스

DB 접근 단위입니다. 서비스가 여러 DAO 호출을 조합할 수 있도록, DAO 쪽은 더 잘게 나뉘어 있습니다.

package net.koreate.board.dao;

import java.util.List;

import net.koreate.board.util.Criteria;
import net.koreate.board.vo.BoardVO;

public interface BoardDAO {

    /**
     * @param 게시글 작성을 위한 게시글 정보 vo
     * @return 게시글 작성 후 적용된 행 개수
     */
    int create(BoardVO vo) throws Exception;

    /**
     * @param 게시글 상세 보기를 위한 게시글 번호 bno
     * @return 상세 보기에 해당하는 게시글 정보
     */
    BoardVO read(int bno) throws Exception;

    /**
     * @param 게시글 수정을 위한 게시글 정보 vo
     */
    void update(BoardVO vo) throws Exception;

    /**
     * @param 게시글 삭제를 위한 게시글 번호 bno
     */
    void delete(int bno) throws Exception;

    /**
     * @param 게시글 조회수 수정을 위한 게시글 번호 bno
     */
    void updateCnt(int bno) throws Exception;

    /**
     * @return 게시글 전체 리스트
     */
    List<BoardVO> listAll() throws Exception;

    /**
     * @return 게시글 전체 개수
     */
    int totalCount() throws Exception;

    /**
     * @param 페이지 정보를 담고 있는 Criteria 객체 cri
     * @return 해당 페이지의 게시글 목록
     */
    List<BoardVO> listCriteria(Criteria cri) throws Exception;
}

11. 글쓰기 폼과 POST 처리

register.jsp<body>를 채웁니다.

<h1>REGISTER BOARD</h1>
<!--     form 태그에 action이 없으면 현재 요청 페이지 위치를 action으로 사용(여기서는 /board/register) -->
    <form method="POST">
        <div>
            <label> TITLE </label>
            <input type="text" name="title" />
        </div>
        <div>
            <label> CONTENT </label>
            <input type="text" name="content" />
        </div>
        <div>
            <label> WRITER </label>
            <input type="text" name="writer" />
        </div>
        <div>
            <input type="submit" value="글작성 완료" />
        </div>
    </form>

 

form 태그에 action이 없으면 현재 요청 페이지 위치를 action으로 사용합니다. 여기서는 /board/register이므로, 같은 경로에 POST 메서드만 추가하면 됩니다. inputname을 BoardVO의 필드명과 맞춰 두면 Spring이 알아서 객체로 묶어 줍니다.

 

컨트롤러에 POST 처리를 추가합니다.

package net.koreate.board.controller;

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;

import lombok.RequiredArgsConstructor;
import net.koreate.board.service.BoardService;
import net.koreate.board.vo.BoardVO;

// 컴포넌트 선언
@Controller
// /board/로 시작하는 모든 요청에 대응하는 BoardController
@RequestMapping("/board/*")
// final에 해당하는 모든 필드에 대한 생성자
@RequiredArgsConstructor
public class BoardController {

    private final BoardService bs;

//    /board/register 경로에 대한 GET요청 처리 메소드
//    void 형식 리턴으로 요청 경로와 동일한 값 리턴(/board/register) => WEB-INF/views/board/register.jsp 필요
//    @RequestMapping(value = "/register", method = RequestMethod.GET) 아래와 같이 간략하게 작성 가능(동일)
    @GetMapping("/register")
    public void register() throws Exception {
        System.out.println("게시글 작성 페이지 요청");
    }

//    POST 처리를 위한 메소드
    @PostMapping("/register")
    public String register(BoardVO board) throws Exception {
        System.out.println(board); // form 태그에서 제대로 값 넘어오는지 확인

        return "redirect:/";
    }

}

 

lombok의 @RequiredArgsConstructorfinal 필드에 대한 생성자를 만들어 주므로, BoardService가 생성자 주입으로 들어옵니다.

 

주입이 되려면 서비스와 DAO도 스캔 대상이어야 합니다. servlet-context.xml에 두 줄을 추가합니다.

    <context:component-scan base-package="net.koreate.board.service" />
    <context:component-scan base-package="net.koreate.board.dao" />

서비스 구현체를 만듭니다. 지금 필요한 것은 regist 하나이므로 나머지는 골격만 둡니다.

package net.koreate.board.service;

import java.util.List;

import javax.servlet.http.HttpSession;

import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;
import net.koreate.board.dao.BoardDAO;
import net.koreate.board.util.Criteria;
import net.koreate.board.util.PageMaker;
import net.koreate.board.vo.BoardVO;

@Service // 컴포넌트(서비스) 등록
@RequiredArgsConstructor
public class BoardServiceImpl implements BoardService {

    private final BoardDAO dao;

    @Override
    public String regist(BoardVO board) throws Exception {
        int result = dao.create(board);
        String message = (result != 0) ? "SUCCESS" : "FAILED";
        return message;
    }

    @Override
    public List<BoardVO> listAll() throws Exception {
        // TODO Auto-generated method stub
        return null;
    }

    @Override
    public List<BoardVO> listCriteria(Criteria cri) throws Exception {
        // TODO Auto-generated method stub
        return null;
    }

    @Override
    public void modify(BoardVO board) throws Exception {
        // TODO Auto-generated method stub

    }

    @Override
    public void remove(int bno) throws Exception {
        // TODO Auto-generated method stub

    }

    @Override
    public PageMaker getPageMaker(Criteria cri) throws Exception {
        // TODO Auto-generated method stub
        return null;
    }

    @Override
    public void updateCnt(int bno, HttpSession session) throws Exception {
        // TODO Auto-generated method stub

    }

    @Override
    public BoardVO read(int bno) throws Exception {
        // TODO Auto-generated method stub
        return null;
    }

}

 

create()가 반환하는 것은 영향받은 행의 개수이므로, 0이 아니면 성공으로 판단해 메시지를 만듭니다.

12. DAO 구현체는 MyBatis가 만들어 준다

여기서 이 구성의 편한 점이 드러납니다. BoardDAO의 구현 클래스를 작성할 필요가 없습니다.

 

먼저 매퍼에 INSERT 구문을 채웁니다.

<?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">
<!-- namespace를 통해 BoardDAO와 연결된 것을 알게 됨 -->
<mapper namespace="net.koreate.board.dao.BoardDAO">

<!--     BoardDAO의 메소드 이름과 일치하는 id를 찾아서 실행 -->
    <insert id="create" parameterType="boardVO">
        INSERT INTO tbl_board(title, content, writer) VALUES(#{title}, #{content}, #{writer})
    </insert>

</mapper>

 

연결 규칙은 두 가지입니다. namespace가 인터페이스의 전체 경로와 일치하고, 구문의 id가 그 인터페이스의 메서드 이름과 일치하면 됩니다. parameterType="boardVO"가 짧게 쓰이는 것은 앞서 typeAliasesPackage를 지정해 둔 덕분입니다.

 

BoardDAO의 구현 객체는 사실상 sqlSession을 호출하는 것 외에는 기능이 없으므로, MyBatis가 인터페이스 구조를 참조해서 자동으로 구현체를 만들 수 있습니다. 이를 등록하는 방법이 root-context.xml에서 세 단계로 진화합니다.

<!--     BoardDAO의 구현 객체는 사실상 sqlSession을 호출하는 것 외에는 기능이 없으므로 mybatis가 인터페이스 구조를 참조해서 -->
<!--     자동으로 구현체를 만들 수 있음 -->
<!--     boardMapper.xml에서 namespace를 통해 구현 대상이 되는 인터페이스를 지정 -->
<!--     현재 각 DAO마다 bean을 작성해야 하는 상황이므로 이를 편하게 해주는 스캐너를 이용 -->
<!--     <bean class="org.mybatis.spring.mapper.MapperFactoryBean"> -->
<!--         <property name="mapperInterface" value="net.koreate.board.dao.BoardDAO" /> -->
<!--         <property name="sqlSessionFactory" ref="factory"/> -->
<!--         <property name="sqlSessionTemplate" ref="sqlSession"/> -->
<!--     </bean> -->

<!--     스캐너 사용 -->
<!--     <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> -->
<!--         <property name="basePackage"  value="net.koreate.board.dao"/> -->
<!--     </bean> -->

<!--     mybatis에서 제공하는 스캔 기능     -->
    <mybatis-spring:scan base-package="net.koreate.board.dao" />

 

MapperFactoryBean은 DAO 하나마다 bean을 하나씩 써야 합니다. MapperScannerConfigurer는 패키지 단위로 훑어 그 수고를 덜어 주고, <mybatis-spring:scan>은 같은 일을 전용 태그로 처리합니다. 셋 다 결과는 같고 작성량만 다릅니다.

구현체가 없는데 정말 동작하는지 테스트로 확인합니다.

package net.koreate.board;

import javax.inject.Inject;

import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

import net.koreate.board.dao.BoardDAO;
import net.koreate.board.vo.BoardVO;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration("classpath:/context/root/root-context.xml")
public class FactoryTest {

    @Inject
    BoardDAO dao;

    @Test
    public void factoryBeanTest() {
        BoardVO vo = new BoardVO();
        vo.setTitle("테스트제목");
        vo.setContent("테스트내용");
        vo.setWriter("테스트작성자");
        try {
            int result = dao.create(vo);
            Assert.assertEquals(1, result); // 테스트를 위해 제공되는 Assert(1과 result가 일치하는지 판단해줌)
        } catch (Exception e) {
            e.printStackTrace();
        }

    }

}

 

Assert.assertEquals(1, result)로 INSERT가 정확히 한 행에 적용되었는지를 확인합니다.

13. redirect 이후에 메시지를 전달하는 법

이제 컨트롤러가 서비스를 호출하도록 POST 처리를 채웁니다. 문제는 결과 메시지를 어떻게 다음 화면까지 전달하느냐입니다. 첫 시도는 ModelHttpSession에 모두 넣어 보는 것입니다.

//    POST 처리를 위한 메소드
    @PostMapping("/register")
    public String register(BoardVO board, Model model, HttpSession session) throws Exception {
        System.out.println(board); // form 태그에서 제대로 값 넘어오는지 확인
        String result = bs.regist(board);

        model.addAttribute("msg", result);
        session.setAttribute("message", result);

        return "redirect:/";
    }

 

home.jsp에서 둘 다 찍어 보면 차이가 드러납니다.

<!-- 새로운 home.jsp -->
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
    <h3>model : ${msg}</h3>
    <h3>session : ${message}</h3>
    <a href="board/register">글쓰기</a>
</body>
</html>

 

redirect 시 request는 새로 생성되기 때문에 model을 바로 이용할 수 없습니다. model에 담은 값은 사라지고 session에 담은 값만 남습니다.

 

그래서 session에 넣은 값을 다시 model로 옮기고 session에서 지우는 작업을 HomeController가 떠맡게 됩니다.

package net.koreate.board.controller;

import javax.servlet.http.HttpSession;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

@Controller
public class HomeController {

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

    @RequestMapping(value = "/", method = RequestMethod.GET)
    public String home(HttpSession session, Model model) {
        String message = (String) session.getAttribute("message");
        if (message != null) {
            model.addAttribute("msg", message);
            session.removeAttribute("message");
        }
        return "home";
    }

}

동작은 하지만 목적지 컨트롤러마다 이 코드가 필요해집니다. 스프링에서는 이 기능을 하는 객체를 제공합니다. RedirectAttributes입니다.

따라서 HomeController는 원래대로 되돌립니다.

package net.koreate.board.controller;

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

@Controller
public class HomeController {

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

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

}

 

home.jsp도 링크만 남기고, 이번에 만들 목록 페이지로 가는 링크를 추가합니다.

<!-- 새로운 home.jsp -->
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
    <a href="board/register">글쓰기</a> <br />
    <a href="board/listAll">전체 게시글 목록</a>
</body>
</html>

14. 목록 조회로 한 바퀴를 닫는다

목록 화면을 만듭니다. JSTL의 <c:choose>로 게시물 유무를 나누고, 있으면 <c:forEach>로 행을 그립니다. 제목에는 상세 보기로 가는 링크를 걸어 둡니다.

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
    <%@ taglib uri="http://java.sun.com/jsp/jstl/core"  prefix="c"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h3>BOARD LIST ALL</h3>
<a href="register">NEW BOARD</a>
<table border=1>
    <tr>
        <th>BNO</th>
        <th>TITLE</th>
        <th>WRITER</th>
        <th>REGDATE</th>
        <th>VIEWCNT</th>
    </tr>
    <c:choose>
        <c:when test="${!empty list}">
            <c:forEach var="board" items="${list}">
                <tr>
                    <td>${board.bno}</td>
                    <td><a href="read?bno=${board.bno}">${board.title}</a></td>
                    <td>${board.writer}</td>
                    <td>${board.regdate}</td>
                    <td>${board.viewcnt}</td>
                </tr>                
            </c:forEach>
        </c:when>
        <c:otherwise>
            <tr>
                <td colspan="5">
                    <h3>게시물이 존재하지 않습니다.</h3>
                </td>
            </tr>
        </c:otherwise>
    </c:choose>
</table>
<c:if test="${!empty msg}">
    <script>
        alert('게시물 등록 : ${msg}');
    </script>
</c:if>
</body>
</html>

 

맨 아래의 <c:if>가 등록 결과 메시지를 받는 자리입니다. 컨트롤러에서 RedirectAttributes로 넘긴 값이 여기 ${msg}로 들어옵니다.

 

컨트롤러를 마무리합니다. 등록 후에는 목록으로 보내고, 결과 메시지는 addFlashAttribute로 실어 보냅니다.

package net.koreate.board.controller;

import java.util.List;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import lombok.RequiredArgsConstructor;
import net.koreate.board.service.BoardService;
import net.koreate.board.vo.BoardVO;

// 컴포넌트 선언
@Controller
// /board/로 시작하는 모든 요청에 대응하는 BoardController
@RequestMapping("/board/*")
// final에 해당하는 모든 필드에 대한 생성자
@RequiredArgsConstructor
public class BoardController {

    private final BoardService bs;

//    /board/register 경로에 대한 GET요청 처리 메소드
//    void 형식 리턴으로 요청 경로와 동일한 값 리턴(/board/register) => WEB-INF/views/board/register.jsp 필요
//    @RequestMapping(value = "/register", method = RequestMethod.GET) 아래와 같이 간략하게 작성 가능(동일)
    @GetMapping("/register")
    public void register() throws Exception {
        System.out.println("게시글 작성 페이지 요청");
    }

//    POST 처리를 위한 메소드
    @PostMapping("/register")
    public String register(BoardVO board, RedirectAttributes rttr) throws Exception {
        System.out.println(board); // form 태그에서 제대로 값 넘어오는지 확인
        String result = bs.regist(board);
//        세션에 넣은 후 모델 처리하는 과정을 자동으로 해주는 RedirectAttributes 객체
        rttr.addFlashAttribute("msg", result);
        return "redirect:/board/listAll";
    }

    @GetMapping("/listAll")
    public void listAll(Model model) throws Exception {
        List<BoardVO> list = bs.listAll();
        model.addAttribute("list", list);
    }

}

 

마지막으로 서비스의 listAll이 DAO를 호출하도록 채웁니다. 매퍼에 대응하는 listAll 구문이 있으면 구현체 없이 그대로 동작합니다.

@Override
    public List<BoardVO> listAll() throws Exception {
        return dao.listAll();
    }

 

글을 쓰면 DB에 들어가고, 목록으로 돌아와 방금 쓴 글이 보이고, 등록 성공 메시지가 알림으로 뜹니다. 게시판의 기본 흐름이 여기서 한 바퀴 닫힙니다.

반응형