메이븐으로 스프링 MVC 개발

아래 글은 워크스페이스를 C:\www라고 가정합니다.

아키타입 생성

원형이란 사전적 의미를 가진 아키타입은, 자바에선 프로젝트 프로토타입을 뜻한다.

C:\ Command Prompt
C:\www>mvn archetype:generate -Dfilter=maven-archetype-webapp

Choose archetype:
1: remote -> com.haoxuer.maven.archetype:maven-archetype-webapp
2: remote -> com.lodsve:lodsve-maven-archetype-webapp
3: remote -> org.apache.maven.archetypes:maven-archetype-webapp
4: remote -> org.bytesizebook.com.guide.boot:maven-archetype-webapp
Choose a number or apply filter: : 3
Choose org.apache.maven.archetypes:maven-archetype-webapp version: 
1: 1.0-alpha-1
2: 1.0-alpha-2
3: 1.0-alpha-3
4: 1.0-alpha-4
5: 1.0
6: 1.3
7: 1.4
Choose a number: 7: ↵ 
Define value for property 'groupId': net.java_school
Define value for property 'artifactId': spring-bbs
Define value for property 'version' 1.0-SNAPSHOT: : ↵ 
Define value for property 'package' net.java_school: : ↵
Confirm properties configuration:
groupId: net.java_school
artifactId: spring-bbs
version: 1.0-SNAPSHOT
package: net.java_school
 Y: : ↵

빌드가 완료되면 C:\www에 spring-bbs라는 폴더가 생긴다. C:\www\spring-bbs가 프로젝트 루트 디렉터리이다.

Spring MVC 테스트

pom.xml 수정

pom.xml
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">

  <modelVersion>4.0.0</modelVersion>
  <groupId>net.java_school</groupId>
  <artifactId>spring-bbs</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <packaging>war</packaging>

  <name>spring-bbs Maven Webapp</name>
  <url>http://localhhost:8080</url>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
    <spring.version>5.3.33</spring.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.11</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-context</artifactId>
      <version>${spring.version}</version>
    </dependency>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-webmvc</artifactId>
      <version>${spring.version}</version>
    </dependency>
    <!-- Servlet JSP -->
    <!-- https://mvnrepository.com/artifact/javax.servlet/javax.servlet-api -->
    <dependency>
      <groupId>javax.servlet</groupId>
      <artifactId>javax.servlet-api</artifactId>
      <version>4.0.1</version>
      <scope>provided</scope>
    </dependency>
    <!-- https://mvnrepository.com/artifact/javax.servlet.jsp/javax.servlet.jsp-api -->
    <dependency>
      <groupId>javax.servlet.jsp</groupId>
      <artifactId>javax.servlet.jsp-api</artifactId>
      <version>2.3.3</version>
      <scope>provided</scope>
    </dependency>
    <!-- https://mvnrepository.com/artifact/javax.servlet/jstl -->
    <dependency>
      <groupId>javax.servlet</groupId>
      <artifactId>jstl</artifactId>
      <version>1.2</version>
    </dependency>    
  </dependencies>

  <build>
    <finalName>spring-bbs</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>
      </plugins>
    </pluginManagement>
  </build>
</project>

web.xml 수정

web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
                      http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
  version="4.0">

  <display-name>Spring BBS</display-name>
        
  <filter>
    <filter-name>encodingFilter</filter-name>
    <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
    <init-param>
      <param-name>encoding</param-name>
      <param-value>UTF-8</param-value>
    </init-param>
    <init-param>
      <param-name>forceEncoding</param-name>
      <param-value>true</param-value>
    </init-param>
  </filter>
        
  <filter-mapping>
    <filter-name>encodingFilter</filter-name>
    <url-pattern>/*</url-pattern>
  </filter-mapping>
    
  <servlet>
    <servlet-name>spring-bbs</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <init-param>
      <param-name>contextConfigLocation</param-name>
      <param-value>/WEB-INF/spring/mvc.xml</param-value>
    </init-param>
    <load-on-startup>1</load-on-startup>
  </servlet>
    
  <servlet-mapping>
    <servlet-name>spring-bbs</servlet-name>
    <url-pattern>/</url-pattern>
  </servlet-mapping>
  
  <session-config>
    <session-timeout>-1</session-timeout>
  </session-config>

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

</web-app>

모든 요청에 대해 setCharacterEncoding("UTF-8");를 호출하는 필터가 작동하도록 설정했다. 비영어권 웹 사이트에선 이 필터가 필요하다. 이 필터는 다른 필터보다 먼저 작동하도록 선언한다.

web.xml에서 DispatcherServlet의 설정 파일을 /WEB-INF/spring/mvc.xml이라 정했다. /WEB-INF/spring/ 폴더에 mvc.xml 파일을 아래 내용으로 생성한다.

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

<mvc:annotation-driven />

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

</beans>

페이지 디자인

홈페이지로 사용할 페이지를 생성한다.

src/main/webapp/WEB-INF/views/index.jsp

index.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
         pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>Spring MVC Test</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="Keywords" content="Spring MVC Test" />
<meta name="Description" content="This is web app for Spring MVC Test" />
<style>
@CHARSET "UTF-8";
@import url(http://fonts.googleapis.com/earlyaccess/notosanskr.css);

html, body {
  margin: 0;
  padding: 0;
  background-color: #FFF;
  font-family: "Noto Sans KR", "Liberation Sans", Helvetica, "돋움", dotum, sans-serif;
}
#wordcard {
  margin: 7px auto;
  padding: 1em;
  border: 3px solid grey;
  width: 600px;
  text-align: center;
}
</style>
</head>
<body>

<div id="wordcard">
<h1>Vocabulary</h1>
<p>
어휘
</p>
</div>

</body>
</html>

영어 단어와 그 의미를 보여주는 화면이다.

화면이 나오면, 데이터베이스 설계를 시작으로, 자바 빈즈, DAO, 서비스, 컨트롤러 순으로 구현하는 게 일반적이지만, 대부분을 생략하고 컨트롤러와 뷰만으로 동작하는 예제를 만들어 보자.

컨트롤러
src/main/java/net/java_school/english/HomeController.java

src/main/java 폴더는 메이븐 기본 폴더다. 없으면 생성한다.

package net.java_school.english;

import org.springframework.stereotype.Controller;

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

@Controller
public class HomeController {

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

@Controller, @GetMapping은 스프링 어노테이션로, 스프링에 정보를 전달하기 위해 사용된다.
클래스 선언부 위에 위치한 @Controller는 해당 자바 빈이 컨트롤러임을 스프링에게 알린다. 컨트롤러의 메소드 위에 위치하는 @GetMapping("/")은 이 메소드가 GET 방식의 "/" 요청--ROOT 애플리케이션이라면 http://localhost:8080/--을 처리함을 나타낸다.

컨트롤러를 통해 홈페이지("/")가 서비스되도록 하려면 홈페이지 경로로 매핑된 다른 서블릿이 없어야 한다.
하지만 아키타입은 src/main/webapp 디렉터리에 index.jsp 파일을 가지고 있다. 그냥 두면 홈페이지 요청을 이 파일이 응답하게 된다.
src/main/webapp/index.jsp 파일을 삭제한다.

루트 애플리케이션의 홈페이지 요청 시나리오
사용자가 홈페이지("/")를 요청한다.
서블릿 컨테이너는 요청을 DispatcherServlet에 전달한다.
DispatcherServlet은 컨트롤러의 홈페이지 경로로 매핑된 메소드에 요청을 전달한다.
메소드는 홈페이지에 보일 데이터를 요청에 세팅하고--이 부분은 첫 번째 테스트에선 생략하고, 두 번째 테스트에서 다룬다--, "index" 문자열을 반환한다.
DispatcherServlet은 뷰 리졸버가 해석한 /WEB-INF/views/index.jsp 페이지의 서비스 메소드를 실행해 사용자에게 응답한다.

컨트롤러 등록
스프링 설정 파일에 다음을 추가한다.

<bean id="homeController" class="net.java_school.english.HomeController" />

ROOT 애플리케이션 변경
톰캣을 중지하고 ROOT.xml 파일을 아래처럼 작성한 후 {Tomcat}\Catalina\localhost 디렉터리 복사한다. --{Tomcat}은 톰캣이 설치된 디렉터리를 의미한다--

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

WEB-INF 바로 위 디렉터리가 도큐먼트베이스다.
docBase 값으로 메이븐 프로젝트 루트 디렉터리(C:/www/spring-bbs)를 추가하지 않도록 주의한다.

빌드와 배치
메이븐 루트 디렉터리에서 다음을 실행한다.
mvn compile war:inplace
war:inplace 옵션은 src/main/WEB-INF/classes에 바이트코드를, src/main/WEB-INF/lib에 의존 라이브러리를 생성시킨다.

테스트
톰캣을 시작한다.
http://localhost:8080을 요청한다.
Spring MVC 테스트 화면

컨트롤러가 어떻게 동작하는지 확인했다.
다음은 컨트롤러가 뷰에 전달할 데이터를 어떻게 세팅하는 지를 실습한다.
예제를 간단하게 만들기 위해 데이터베이스 연동은 하지 않는다.

자바 빈즈
src/main/java/net/java_school/english/WordCard.java

WordCard.java
package net.java_school.english;

public class WordCard {
  private String word;
  private String definitions;

  public WordCard() {}

  public WordCard(String word, String definitions) {
    this.word = word;
    this.definitions = definitions;
  }

  public String getWord() {
    return word;
  }

  public void setWord(String word) {
    this.word = word;
  }

  public String getDefinitions() {
    return definitions;
  }

  public void setDefinitions(String definitions) {
    this.definitions = definitions;
  }
}

DAO
src/main/java/net/java_school/english/WordCardDao.java

WordCardDao.java
package net.java_school.english;

public class WordCardDao {

  private final String[][] words = {
    {"illegible","읽기 어려운"}, 
    {"vulnerable","취약한"}, 
    {"abate","감소시키다"}, 
    {"clandestine","은밀한"}, 
    {"sojourn","잠시 머무름, 체류"}, 
    {"defiance","도전, 저항, 반항"}, 
    {"infraction","위반"}
  }; 
	
  public WordCard selectOne() {
    int no = (int)(Math.random() * 7) + 1;
    WordCard wordCard = new WordCard();
    wordCard.setWord(words[no - 1][0]);
    wordCard.setDefinitions(words[no - 1][1]);
    return wordCard;
  }
}
<bean id="wordCardDao" class="net.java_school.english.WordCardDao" />

서비스
src/main/java/net/java_school/english/WordCardService.java

WordCardService.java
package net.java_school.english;

public class WordCardService {
  private WordCardDao wordCardDao;

  public WordCardService(WordCardDao wordCardDao) {
    this.wordCardDao = wordCardDao;
  }

  public WordCard getWordCard() {
    return wordCardDao.selectOne();
  }
}
<bean id="wordCardService" class="net.java_school.english.WordCardService">
  <constructor-arg ref="wordCardDao" />
</bean>

컨트롤러 수정

package net.java_school.english;

import org.springframework.ui.Model;

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

public class HomeController {

  private WordCardService wordCardService;

  public HomeController(WordCardService wordCardService) {
    this.wordCardService = wordCardService;
  }

  @GetMapping("/")
  public String index(Model model) {
    WordCard wordCard = wordCardService.getWordCard();
    model.addAttribute("wordCard", wordCard);
    return "index";
  }
}
<bean id="homeController" class="net.java_school.english.HomeController">
  <constructor-arg ref="wordCardService" />
</bean>

뷰 수정
src/main/webapp/WEB-INF/views/index.jsp

body 엘리먼트 내용을 아래처럼 수정한다.

<div id="wordcard">
<h1>${wordCard.word }</h1>
<p>
${wordCard.definitions }
</p>
</div>

테스트
mvn compile war:inplace
http://localhost:8080
테스트 화면2

MyBatis-Spring 연동 모듈

아직 데이터베이스를 다루지 않았다.
모델 2 게시판은 JDBC를 그대로 사용했다. JDBC를 그대로 사용하는 코드는 반복되는 코드가 많아 생산성이 떨어진다.
퍼시스턴스 프레임워크--내부 소스에서 JDBC 사용--를 사용하면 사용자가 작성해야 할 코드의 양을 줄여준다.

MyBatis는 SQL 매핑 퍼시스턴스 프레임워크이다.
MyBatis-Spring는 스프링에서 마이바티스를 편리하게 사용하기 위한 연동 모듈이다.
spring-bbs 프로젝트에 MyBatis-Spring을 사용할 것이다.
MyBatis-Spring를 파악하기 위한 간단한 예제를 준비했다.

https://mybatis.org/spring/ko/getting-started.html
아래 내용을 공식 사이트의 시작하기와 순서를 같게 했다.

JAVA 계정에 접속해 다음 테이블과 시퀀스를 생성한다.

create table photo (
no number,
content varchar2(4000) not null,
constraint pk_photo primary key(no),
constraint uq_photo unique(content)
);

create sequence seq_photo
increment by 1
start with 1;

mybatis-spring과 함께 스프링 JDBC, 오라클 JDBC 드라이버, 아파치 DBCP 그리고 MyBatis 라이브러리를 함께 추가한다.

pom.xml
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-jdbc</artifactId>
  <version>${spring.version}</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.oracle.database.jdbc/ojdbc6 -->
<dependency>
  <groupId>com.oracle.database.jdbc</groupId>
  <artifactId>ojdbc6</artifactId>
  <version>11.2.0.4</version>
</dependency>
<!-- https://mvnrepository.com/artifact/commons-dbcp/commons-dbcp -->
<dependency>
  <groupId>commons-dbcp</groupId>
  <artifactId>commons-dbcp</artifactId>
  <version>1.4</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.mybatis/mybatis -->
<dependency>
  <groupId>org.mybatis</groupId>
  <artifactId>mybatis</artifactId>
  <version>3.5.11</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.mybatis/mybatis-spring -->
<dependency>
  <groupId>org.mybatis</groupId>
  <artifactId>mybatis-spring</artifactId>
  <version>2.1.0</version>
</dependency>

commons-dbcp는 아파치에서 제공하는 데이터베이스 커넥션 풀이다.
ojdbc6.jar는 JDBC 4 드라이버이므로 DBCP 1.4 버전을 추가했다.
https://dlcdn.apache.org//commons/dbcp/

스프링 설정 파일에 데이터소스와 SqlSessionFactoryBean을 추가한다.

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

  <mvc:annotation-driven />

  <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="wordCardDao" class="net.java_school.english.WordCardDao" />

  <bean id="wordCardService" class="net.java_school.english.WordCardService">
    <constructor-arg ref="wordCardDao" />
  </bean>

  <bean id="homeController" class="net.java_school.english.HomeController">
    <constructor-arg ref="wordCardService" />
  </bean>

  <bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" 
      destroy-method="close">
    <property name="driverClassName" value="oracle.jdbc.driver.OracleDriver" />
    <property name="url" value="jdbc:oracle:thin:@localhost:1521:XE" />
    <property name="username" value="java" />
    <property name="password" value="school" />
    <property name="maxActive" value="100" />
    <property name="maxWait" value="1000" />
    <property name="poolPreparedStatements" value="true" />
    <property name="defaultAutoCommit" value="true" />
    <property name="validationQuery" value=" SELECT 1 FROM DUAL" />
  </bean>

  <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
    <property name="dataSource" ref="dataSource" />
  </bean>
</beans>

commons-dbcp2를 사용한다면, maxActive와 maxWait 파라미터는 maxTotal과 maxWaitMillis로 바꿔야 한다.

자바 빈즈
src/main/java/net/java_school/photostudio/Photo.java

Photo.java
package net.java_school.photostudio;

public class Photo {
  private int no;
  private String content;

  public Photo() {}

  public Photo(int no, String content) {
    this.no = no;
    this.content = content;
  }

  public int getNo() {
    return no;
  }

  public void setNo(int no) {
    this.no = no;
  }

  public String getContent() {
    return content;
  }

  public void setContent(String content) {
    this.content = content;
  }
}

매퍼 인터페이스
매퍼 인터페이스를 다른 스프링 컴포넌트와 구별되는 패키지로 만드는 게 좋다.

src/main/java/net/java_school/mybatis/PhotoMapper.java

PhotoMapper.java
package net.java_school.mybatis;

import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Insert;

public interface PhotoMapper {
  @Insert("INSERT INTO photo VALUES (seq_photo.nextval, #{content})")
  public void insert(@Param("content") String content);
}

매퍼 인터페이스를 MapperFactoryBean을 사용해 스프링에 추가한다.

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

서비스
서비스 인터페이스와 서비스 구현 클래스를 작성한다.

src/main/java/net/java_school/photostudio/PhotoService.java

PhotoService.java
package net.java_school.photostudio;

public interface PhotoService {
  public void add(String content);
}

src/main/java/net/java_school/photostudio/PhotoServiceImpl.java

PhotoServiceImpl.java
package net.java_school.photostudio;

import net.java_school.mybatis.PhotoMapper;

import org.springframework.stereotype.Service;

@Service
public class PhotoServiceImpl implements PhotoService {

  private PhotoMapper photoMapper;

  public PhotoServiceImpl(PhotoMapper photoMapper) {
    this.photoMapper = photoMapper;
  }

  @Override
  public void add(String content) {
    photoMapper.insert(content);
  }
}

서비스를 스프링에 추가한다.

<bean id="photoService" class="net.java_school.photostudio.PhotoServiceImpl">
  <constructor-arg ref="photoMapper" />
</bean>

컨트롤러
클래스 선언 위에 @RequestMapping("photo") 어노테이션을 두어, ContextPath 다음에 photo가 붙는 요청을 담당하는 컨트롤러를 작성한다.

src/main/java/net/java_school/photostudio/PhotoController.java

PhotoController.java
package net.java_school.photostudio;

import org.springframework.stereotype.Controller;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
@RequestMapping("photo")
public class PhotoController {

  private PhotoService photoService;

  public PhotoController(PhotoService photoService) {
    this.photoService = photoService;
  }
  
  @GetMapping
  public String index() {
    return "photo/index";
  }

  @PostMapping
  public String add(String content) {
    photoService.add(content);	
    return "redirect:/photo/?page=1";
  }
}

컨트롤러를 스프링에 추가한다.

<bean id="photoController" class="net.java_school.photostudio.PhotoController">
  <constructor-arg ref="photoService" />
</bean>


src/main/webapp/WEB-INF/views/photo/index.jsp

index.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
         pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>mybatis-spring Test</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="Keywords" content="MyBatis Spring Test" />
<meta name="Description" content="This is web app for mybatis-spring Test" />
<style>
html, body {
  margin: 0;
  padding: 0;
  background-color: #FFF;
  font-family: "Liberation Sans", Helvetica, sans-serif;
}
</style>
</head>
<body>
  <form id="addForm" method="post">
    <input type="text" name="content" />
    <input id="submit" type="submit" value="Send" /> 
  </form>
</body>
</html>

테스트

mvn compile war:inplace
http://localhost:8080/photo
웹 상의 이미지 링크를 텍스트필드에 붙여 넣고 전송
SQL*PLUS로 인서트가 되었는지 확인

로깅
아래 내용은 https://mybatis.org/mybatis-3/ko/logging.html 사이트를 참조했다.

아파치 commons-logging과 log4j 2 라이브러리를 의존성에 추가한다.

<!-- Logging -->                
<!-- https://mvnrepository.com/artifact/commons-logging/commons-logging -->
<dependency>
  <groupId>commons-logging</groupId>
  <artifactId>commons-logging</artifactId>
  <version>1.2</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api -->
<dependency>
  <groupId>org.apache.logging.log4j</groupId>
  <artifactId>log4j-api</artifactId>
  <version>2.19.0</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.19.0</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.19.0</version>
</dependency>

log4j2.xml라는 이름으로 log4j 2 설정 파일을 src/main/resources/ 디렉터리에 생성한다.
src/main/resources 디렉터리는 메이븐 기본 디렉터리이니 없으면 생성한다.

log4j2.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE configuration>
<Configuration>
  <Appenders>
    <File name="A1" fileName="A1.log" append="false">
      <PatternLayout pattern="%t %-5p %c{2} - %m%n" />
    </File>
    <Console name="STDOUT" target="SYSTEM_OUT">
      <PatternLayout pattern="%d %-5p [%t] %C{2} (%F:%L) - %m%n" />
    </Console>
  </Appenders>
  <Loggers>
    <Logger name="net.java_school" level="DEBUG">
      <AppenderRef ref="A1" />
    </Logger>
    <Root level="INFO">
      <AppenderRef ref="STDOUT" />
    </Root>
  </Loggers>
</Configuration>

mvn compile war:inplace 실행해 컴파일하고 로그 메시지를 확인한다.

http-nio-8080-exec-14 DEBUG PhotoMapper.insert - ==>  Preparing: INSERT INTO photo VALUES (seq_photo.nextval, ?
http-nio-8080-exec-14 DEBUG PhotoMapper.insert - ==> Parameters: https://cdn.pixabay.com/house_720.jpg(String)

insert 구문에 오른쪽 괄호가 빠져있다.
괄호를 추가하고 다시 컴파일한다.
로그 메시지를 확인한다.

http-nio-8080-exec-26 DEBUG PhotoMapper.insert - ==>  Preparing: INSERT INTO photo VALUES (seq_photo.nextval, ?)
http-nio-8080-exec-26 DEBUG PhotoMapper.insert - ==> Parameters: https://cdn.pixabay.com/house_720.jpg(String)
http-nio-8080-exec-26 DEBUG PhotoMapper.insert - <==    Updates: 1

XML 매퍼
여러 줄로 구성된 복잡한 쿼리는 XML 파일로 따로 분리하면 유지 보수에 좋다.
이 파일이 XML 매퍼 파일이다.

지금까지 마이바티스 설정 파일을 만들지 않았다.
XML 매퍼 파일과 매퍼 인터페이스를 같은 클래스패스에 둔다면, 마이바티스 설정 파일이 필요 없다.
참조: https://mybatis.org/spring/ko/mappers.html#register

PhotoMapper 인터페이스와 같은 클래스패스에 파일이 위치하도록, src/main/resources/net/java_school/mybatis 디렉터리에, PhotoMapper.xml 매퍼 파일을 생성한다.

src/main/resources/net/java_school/mybatis/PhotoMapper.xml

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

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

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

  <insert id="insert">
    INSERT INTO photo VALUES (seq_photo.nextval, #{content})
  </insert>
	
</mapper>

PhotoMapper 인터페이스에서 쿼리를 제거한다.

PhotoMapper.java
package net.java_school.mybatis;

import org.apache.ibatis.annotations.Param;

public interface PhotoMapper {
  public void insert(@Param("content") String content);
}

저장된 이미지 보이기
저장된 이미지를 4개씩 묶어 뷰에 보여주려 한다.
그룹별로 레코드를 가져오는 여러 줄로 구성된 쿼리를 사용해야 한다.

index.jsp 파일의 body 엘리먼트 내용을 아래처럼 수정한다.

index.jsp
<div id="photos">
  <img width="640" alt="p_1" src="https://cdn.pixabay.com/photo/2022/10/09/02/16/haunted-house-7508035_960_720.jpg" />
  <img width="640" alt="p_2" src="https://cdn.pixabay.com/photo/2022/10/09/02/16/haunted-house-7508035_960_720.jpg" />
  <img width="640" alt="p_3" src="https://cdn.pixabay.com/photo/2022/10/09/02/16/haunted-house-7508035_960_720.jpg" />
  <img width="640" alt="p_4" src="https://cdn.pixabay.com/photo/2022/10/09/02/16/haunted-house-7508035_960_720.jpg" />
</div>

<div id="paging">
  <a href="?page=10" title="10">◁ back</a>
  <a href="?page=1" title="1">1</a>
  <a href="?page=10" title="10">...</a>
  <strong>11</strong>
  <a href="?page=12" title="12">12</a>
  <a href="?page=13" title="3">3</a>
  <a href="?page=14" title="4">4</a>
  <a href="?page=15" title="5">5</a>
  <a href="?page=16" title="6">6</a>
  <a href="?page=17" title="7">7</a>
  <a href="?page=18" title="8">8</a>
  <a href="?page=19" title="9">9</a>
  <a href="?page=20" title="10">10</a>
  <a href="?page=21" title="11">...</a>
  <a href="?page=407" title="407">407</a>
  <a href="?page=12" title="12">next▷ </a>

  <form id="addForm" method="post">
    <input type="text" name="content" width="500" />
    <input id="submit" type="submit" value="Send" /> 
  </form>

</div>

index.jsp 파일의 style 엘리먼트에 다음을 추가한다.

#paging {
  width: 640px;
  float: left;
  font-size: 1em;
}
a:link {
  color: #2C80D0;
  text-decoration: none;
}
a:visited {
  color: #2C80D0;
  text-decoration: none;
}
a:active {
  color: #2C80D0;
  text-decoration: none;
}
a:hover {
  color: #2C80D0;
  text-decoration: underline;
}

PhotoMapper 인터페이스에 총 레코드 수와 페이지에 보여줄 아이템을 가져오는 메소드를 추가한다.

PhotoMapper.java
package net.java_school.mybatis;

import net.java_school.photostudio.Photo;

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

import org.apache.ibatis.annotations.Param;

public interface PhotoMapper {
  
  public void insert(@Param("content") String content);

  public int selectCountOfPhotos();//총 레코드 수

  public List<Photo> selectPhotos(HashMap<String, String> hashmap);//뷰에 보여줄 아이템
}

매퍼 인터페이스에 추가한 메소드 이름을 id 값으로 갖는 엘리먼트를 XML 매퍼 파일에 추가한다.

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

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

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

  <insert id="insert">
    NSERT INTO photo VALUES (seq_photo.nextval, #{content})
  </insert>

  <select id="selectCountOfPhotos" resultType="int">
    SELECT count(*) FROM photo
  </select>

  <select id="selectPhotos" parameterType="hashmap" resultType="net.java_school.photostudio.Photo">
    SELECT no,content
    FROM (
      SELECT rownum R,A.*
      FROM (
        SELECT 
          no,content
        FROM 
          photo
        ORDER BY no DESC
      ) A
    )
    WHERE R BETWEEN #{start} AND #{end}
  </select>
	
</mapper>

마이바티스 설정 파일에서 typeAlias 요소를 사용하면 XML 매퍼 파일에서 resultType="net.java_school.photostudio.Photo"를 resultType="Photo"처럼 간단히 줄일 수 있다.

<-- XML 설정 파일에서 -->
<typeAlias type="net.java_school.photostudio.Photo" alias="Photo"/>

하지만 지금껏 생략했던 마이바티스 설정 파일을 추가해야 한다.
참고: https://mybatis.org/mybatis-3/ko/sqlmap-xml.html
자바 빈즈가 하나뿐이니, 마이바티스 설정 파일을 만들지 않고 진행한다.

서비스 수정
총 레코드 수와 페이지에 보일 레코드를 구하는 메소드를 서비스에 추가한다.

PhotoService.java
package net.java_school.photostudio;

import java.util.List;

public interface PhotoService {

  public void add(String content);

  public int getTotalRecordCount();	
  
  public List<Photo> getPhotos(Integer startRecord, Integer endRecord);
}
PhotoServiceImpl.java
package net.java_school.photostudio;

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

import net.java_school.mybatis.PhotoMapper;

public class PhotoServiceImpl implements PhotoService {

  private PhotoMapper photoMapper;

  public PhotoServiceImpl(PhotoMapper photoMapper) {
    this.photoMapper = photoMapper;
  }

  @Override
  public void add(String content) {
    photoMapper.insert(content);
  }

  @Override
  public int getTotalRecordCount() {
	return photoMapper.selectCountOfPhotos();
  }

  @Override
  public List<Photo> getPhotos(Integer startRecord, Integer endRecord) {
    HashMap<String, String> hashmap = new HashMap<String, String>();
    hashmap.put("start", startRecord.toString());
    hashmap.put("end", endRecord.toString());
    
    return photoMapper.selectPhotos(hashmap);
  }
}

컨트롤러 수정
컨트롤러에 총 레코드 수와 페이지에 보일 레코드를 구하는 메소드를 추가한다.
페이지 분할 기능에 필요한 숫자를 구하는 기능을 분리하여 메소드에 구현했다.

PhotoController.java
package net.java_school.photostudio;

import org.springframework.stereotype.Controller;

import org.springframework.ui.Model;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

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

@Controller
@RequestMapping("/photo")
public class PhotoController {

  private PhotoService photoService;

  public PhotoController(PhotoService photoService) {
    this.photoService = photoService;
  }

  @PostMapping
  public String add(String content) {
    photoService.add(content);	
    return "redirect:/photo/?page=1";
  }

  private Map<String, Integer> getNumbersForPaging(int totalRecordCount, int page, int recordsPerPage, int pagesPerBlock) {

    Map<String, Integer> map = new HashMap<String, Integer>();

    int totalPageCount = totalRecordCount / recordsPerPage;
    if (totalRecordCount % recordsPerPage != 0) totalPageCount++;

    int totalBlockCount = totalPageCount / pagesPerBlock;
    if (totalPageCount % pagesPerBlock != 0) totalBlockCount++;

    int block = page / pagesPerBlock;
    if (page % pagesPerBlock != 0) block++;

    int firstPage = (block - 1) * pagesPerBlock + 1;
    int lastPage = block * pagesPerBlock;

    int prevBlock = 0;//previus block's last page
    if (block > 1) prevBlock = firstPage - 1;

    int nextBlock = 0;//next blcok's first page
    if (block < totalBlockCount) nextBlock = lastPage + 1;
    if (block >= totalBlockCount) lastPage = totalPageCount;
		
    int listItemNo = totalRecordCount - (page - 1) * recordsPerPage;
    int startRecord = (page - 1) * recordsPerPage + 1;
    int endRecord = page * recordsPerPage;

    map.put("finalPage", totalPageCount);
    map.put("firstPage", firstPage);
    map.put("lastPage", lastPage);
    map.put("prevBlock", prevBlock);
    map.put("nextBlock", nextBlock);
    map.put("startRecord", startRecord);
    map.put("endRecord", endRecord);

    return map;
  }

  @GetMapping
  public String index(Integer page, Model model) {
    if (page == null) return "redirect:/photo/?page=1";

    int recordsPerPage = 4;
    int pagesPerBlock = 10;

    int totalRecordCount = photoService.getTotalRecordCount();

    Map<String, Integer> map = getNumbersForPaging(totalRecordCount, page, recordsPerPage, pagesPerBlock);
    Integer startRecord = map.get("startRecord");
    Integer endRecord = map.get("endRecord");

    List<Photo> photos = photoService.getPhotos(startRecord, endRecord);

    Integer prevBlock = map.get("prevBlock");
    Integer nextBlock = map.get("nextBlock");
    Integer firstPage = map.get("firstPage");
    Integer lastPage = map.get("lastPage");
    Integer finalPage = map.get("finalPage");

    model.addAttribute("photos", photos);
    model.addAttribute("prevBlock", prevBlock);
    model.addAttribute("nextBlock", nextBlock);
    model.addAttribute("firstPage", firstPage);
    model.addAttribute("lastPage", lastPage);
    model.addAttribute("finalPage", finalPage);

    return "photo/index";
  }
}

뷰 수정
JSTL을 사용하기 위해선 index.jsp 파일에 다음을 추가한다.

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

JSTL 코드를 사용하여 전달된 데이터와 페이지 분할 기능에 필요한 숫자를 뷰에 세팅한다.

index.jsp
<div id="photos">
  <c:forEach var="photo" items="${photos }" varStatus="status">
  <img width="640" alt="p_${photo.no }" src="${photo.content }" />
  </c:forEach>
</div>

<div id="paging">
  <c:if test="${param.page > 1 }">
  <a href="?page=${param.page - 1 }" title="${param.page - 1}">◁ Back</a>
  </c:if>

  <c:if test="${prevBlock > 0}">
  <a href="?page=1" title="1">1</a>
  <a href="?page=${prevBlock }" title="${prevBlock }">...</a>
  </c:if>

  <c:forEach var="i" begin="${firstPage }" end="${lastPage }" varStatus="status">
  <c:choose>
    <c:when test="${param.page == i}">
    <strong>${i }</strong>
    </c:when>
    <c:otherwise>
    <a href="?page=${i }" title="${i }">${i }</a>
    </c:otherwise>
  </c:choose>
  </c:forEach>

  <c:if test="${nextBlock > 0 }">
  <a href="?page=${nextBlock }" title="${nextBlock }">...</a>
  <a href="?page=${finalPage }" title="${finalPage }">${finalPage }</a>
  </c:if>

  <c:if test="${param.page < finalPage }">
  <a href="?page=${param.page + 1 }" title="${param.page + 1 }">Next▷ </a>
  </c:if>

  <form id="addForm" method="post">
    <input type="text" name="content" width="500" />
    <input id="submit" type="submit" value="Send" /> 
  </form>

</div>

스프링 자동 스캔

스프링의 자동 스캔 기능을 사용하면 설정 파일 내용을 줄일 수 있다.
컨트롤러와 서비스는 <context:component-scan ... />로 스캔할 수 있다.
매퍼 인스턴스는 마이바티스가 생성하는 것이기에 위 설정으로 스캔할 수 없다.
<mybatis:scan ... />로 매퍼 인스턴스를 스캔한다.

mvc.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans" 
    xmlns:mvc="http://www.springframework.org/schema/mvc"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:context="http://www.springframework.org/schema/context"
    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/mvc
    http://www.springframework.org/schema/mvc/spring-mvc.xsd
    http://www.springframework.org/schema/context
    http://www.springframework.org/schema/context/spring-context.xsd
    http://mybatis.org/schema/mybatis-spring
    http://mybatis.org/schema/mybatis-spring.xsd">

  <mvc:annotation-driven />

  <context:component-scan base-package="net.java_school.english,
      net.java_school.photostudio" />

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

  <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="dataSource" class="org.apache.commons.dbcp.BasicDataSource" 
      destroy-method="close">
    <property name="driverClassName" value="oracle.jdbc.driver.OracleDriver" />
    <property name="url" value="jdbc:oracle:thin:@localhost:1521:XE" />
    <property name="username" value="java" />
    <property name="password" value="school" />
    <property name="maxActive" value="100" />
    <property name="maxWait" value="1000" />
    <property name="poolPreparedStatements" value="true" />
    <property name="defaultAutoCommit" value="true" />
    <property name="validationQuery" value=" SELECT 1 FROM DUAL" />
  </bean>

  <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
    <property name="dataSource" ref="dataSource" />
  </bean>
</beans>

<context:component-scan />를 사용하면 @Autowired 어노테이션도 함께 사용할 수 있다.

PhotoController.java
import org.springframework.beans.factory.annotation.Autowired;

@Controller
public class PhotoController {

  @Autowired
  private PhotoService photoService;

  /* 생성자 제거
  public PhotoController(PhotoService photoService) {
    this.photoService = photoService;
  }
  */

  //..omit..
}
PhotoServiceImpl.java
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;

@Service
public class PhotoServiceImpl implements PhotoService {

  @Autowired
  private PhotoMapper photoMapper;

  /* 생성자 제거
  public PhotoServiceImpl(PhotoMapper photoMapper) {
    this.photoMapper = photoMapper;
  }
  */

  //..omit..
}

자동 스캔 적용할 때는 스프링 컴포넌트는 클래스 선언 어노테이션을 생략해선 안 된다.
홈페이지 요청에 동작하는 WordCardDao, WordCardService, HomeController를 수정한다.

src/main/java/net/java_school/english/WordCardDao.java

WordCardDao.java
import org.springframework.stereotype.Repository;

@Repository
public class WordCardDao {

  //..omit..
}

src/main/java/net/java_school/english/WordCardService.java

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

@Service
public class WordCardService {

  @Autowired
  private WordCardDao wordCardDao;

  //..omit..
}

src/main/java/net/java_school/english/HomeController.java

HomeController.java
import org.springframework.beans.factory.annotation.Autowired;

@Controller
public class HomeController {

  @Autowired
  private WordCardService wordCardService;
  
  //..omit..
}

이클립스 작업환경 구축

이클립스를 시작하고 워크스페이스를 C:\www로 선택한다. Project Explorer 뷰에서 마우스 오른쪽 버튼을 사용하여 컨텍스트 메뉴를 보이게 한다. Import를 사용하여 spring-bbs 프로젝트를 이클립스로 불러온다.
컨텍스트 메뉴에서 Import
이클립스에서 메이븐 프로젝트 Import
진행하면서 pom.xml 파일이 바뀌면 이클립스 설정과 동기화를 해주어야 한다.
pom과 이클립스 설정 동기화

참고