english

메이븐으로 스프링 MVC 개발

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

아키타입 생성

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

 
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': jwpsamples
Define value for property 'version' 1.0-SNAPSHOT: : ↵ 
Define value for property 'package' net.java_school: : ↵
Confirm properties configuration:
groupId: net.java_school
artifactId: jwpsamples
version: 1.0-SNAPSHOT
package: net.java_school
 Y: : ↵

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

Spring MVC 테스트

스프링 서블릿 스택으로 애플리케이션을 만들려면, 기본적으로 spring-context, spring-webmvc, servlet-api, jstl-api, jstl 라이브러리가 필요하다.

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>jwpsamples</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <packaging>war</packaging>
  <name>Java Web Programming Samples</name>
  <url>http://localhhost: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.11</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>
    <!-- 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>

플러그인 자리에 jetty-maven-plugin을 추가한 이유는 플러그인 제티를 실행하면 좀 더 쉽게 에러를 검출할 수 있기 때문이다. 제티를 실행할 때는, 톰캣을 중지하고 mvn clean jetty:run 명령을 실행한다.

아키타입 생성할 때 만들어지는 web.xml 파일은 배포 대상 톰캣과 버전이 맞지 않다. 설치한 톰캣의 conf 디렉터리에서 web.xml 파일을 찾아 루트 엘리먼트(web-app)를 부분을 참조해 수정한다.

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

web.xml에서 스프링 설정 파일 위치를 /WEB-INF/spring/*.xml이라 설정했다. 와일드 카드를 사용했기에 이름은 어떤 이름도 괜찮다.

/WEB-INF/spring/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>

JSP를 사용하는 스프링 MVC 환경에서 필요한 기본적인 설정만 있다. <mvc:annotation-driven />은 스프링 MVC 기능을 활성화한다. JSP를 사용하려면 InternalResourceViewResolver 빈은 등록한다.

페이지 디자인

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 방식의 "/" 요청의 핸들러이다.

컨트롤러를 통해 홈페이지("/")가 서비스되도록 하려면 홈페이지 경로로 매핑된 다른 서블릿이 없어야 한다.
하지만 아키타입은 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}\conf\Catalina\localhost 디렉터리 복사한다. --{TOMCAT}은 톰캣이 설치된 디렉터리--

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

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

빌드와 배치
메이븐 루트 디렉터리에서 다음을 실행한다.
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는 스프링에서 마이바티스를 편리하게 사용하기 위한 연동 모듈이다.
jwpsamples 프로젝트에 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/ojdbc11 -->
<dependency>
  <groupId>com.oracle.database.jdbc</groupId>
  <artifactId>ojdbc11</artifactId>
  <version>23.26.3.0.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>
<!-- 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.mybatis/mybatis-spring -->
<dependency>
  <groupId>org.mybatis</groupId>
  <artifactId>mybatis-spring</artifactId>
  <version>4.1.0</version>
</dependency>

commons-dbcp는 아파치에서 제공하는 데이터베이스 커넥션 풀이다.
ojdbc6.jar는 JDBC 4 드라이버이므로 DBCP 1.4 버전을 추가하는 것이 맞다. https://dlcdn.apache.org//commons/dbcp/ 하지만, 설치된 오라클 11g XE에 최신 ojdbc11.jar와 DBCP2를 사용해 테스트하니 문제가 없었다. 그래서 위 의존성에는 ojdbc11과 DBCP2를 추가했다.

스프링 설정 파일에 데이터소스와 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.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" />
  </bean>
</beans>

commons-dbcp의 maxActive와 maxWait 파라미터는, commons-dbcp2에선 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/mapper/PhotoMapper.java

PhotoMapper.java
package net.java_school.mapper;

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.mapper.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.mapper.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 사이트를 참조했다.

log4j 2 라이브러리를 의존성에 추가한다.

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

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

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>

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 매퍼 파일이다.

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

src/main/resources/net/java_school/mapper/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.mapper.PhotoMapper">

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

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

PhotoMapper.java
package net.java_school.mapper;

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

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.mapper.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"처럼 간단히 줄일 수 있다.

<typeAlias type="net.java_school.photostudio.Photo" alias="Photo"/>

하지만 지금껏 생략했던 마이바티스 설정 파일을 추가해야 한다.
참고: https://mybatis.org/mybatis-3/ko/sqlmap-xml.html

이름으론 어떤 이름도 괜찮다. 여기선 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>
	<settings>
		<setting name="jdbcTypeForNull" value="NULL"/>
	</settings>
	<typeAliases>
		<typeAlias type="net.java_school.photostudio.Photo" alias="Photo"/>
	</typeAliases>
</configuration>

<setting name="jdbcTypeForNull" value="NULL"/> 설정은 null 값을 인서트할 때, 해당 데이터의 JDBC 타입을 어떻게 전달할 지를 결정한다. 마이바티스 기본값은 OTHER이기에 오라클과 같이 타입을 엄격히 따지는 데이터베이스에서는 기본값은 에러를 발생시킨다.

부적합한 열 유형입니다. 1111 (Error Code: Invalid column type)

value="NULL"은 마이바티스가 자바의 null 타입을 데이터베이스로 보낼 때 JDBC 표준 타입인 Types.NULL을 명시해 전달하게 한다. 오라클도 파싱 에러를 내지 않고, 데이터가 없는 null 상태임을 정상적으로 받아들여 빈 값을 채운다.

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

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.mapper.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 org.springframework.web.bind.annotation.RequestParam;

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(@RequestParam(name="page") 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 prefix="c" uri="jakarta.tags.core"%>

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.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="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" />
  </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를 사용하여 jwpsamples 프로젝트를 이클립스로 불러온다.
컨텍스트 메뉴에서 Import
이클립스에서 메이븐 프로젝트 Import
진행하면서 pom.xml 파일이 바뀌면 이클립스 설정과 동기화를 해주어야 한다.
pom과 이클립스 설정 동기화

참고