개발/JAVA

[자바웹개발워크북] 스프링 MVC 구현 - 환경설정, CRUD

독코더 2023. 1. 17. 20:23
반응형

/*

 *

 * 스프링 프레임워크와 스프링MVC를 결합해서 이전에 웹MVC로 개발했던 Todo예제를 개발하며 전체적인 구조를 이해합니다.

 */

 

1. 프로젝트의 구현목표와 준비

■ 검색과 필터링을 적용할 수 있는 화면을 구성하고 MyBatis의 동적쿼리를 이용해서 상황에 맞는 Todo를 검색합니다.

■ 새로운 Todo를 등록할때 문자열, boolean, LoacalDate를 자동으로 처리하도록 합니다.

■ 목록에서 조회화면으로 이동할 때 모든 검색, 필터링, 페이징조건을 유지하도록 구성합니다.

■ 조회화면에서는 모든조건을 유지한채로 수정/삭제 화면으로 이동하도록 구성합니다.

■ 삭제시에는 다시 목록화면으로 이동합니다.

■ 수정시에는 다시 조회화면으로 이동하지만 검색, 필터링, 페이징조건은 초기화합니다.

■ 프로젝트 구성은 3티어의 구성으로 다음과 같습니다.

Web Service Persistence
JSP파일들 TodoController TodoService / TodoServiceImpl TodoMapper
인터페이스
MyBatis
JSP파일들
JSP파일들

새로운 프로젝트를 생성하고, 프로젝트의 이름은 springMVC로, group은 org.zerock로 설정해줍니다.

톰캣 editConfiguration을 통해 deployment를 exploeded로, 경로를 '/'로 변경해주고,

server에서 update action과 frame deactivation을 'update classes and resources'로 변경합니다.

마지막으로 vm-options를 '-Dfile.encoding=UTF-8'로 바꿔줍니다.

build.gradle에 다음과 같은 라이브러리들을 확인합니다.

// Spring 관련
implementation group: 'org.springframework', name: 'spring-core', version: '5.3.19'

implementation group: 'org.springframework', name: 'spring-context', version: '5.3.19'

testImplementation group: 'org.springframework', name: 'spring-test', version: '5.3.19'

implementation group: 'org.springframework', name: 'spring-webmvc', version: '5.3.19'

implementation group: 'org.springframework', name: 'spring-jdbc', version: '5.3.19'

implementation group: 'org.springframework', name: 'spring-tx', version: '5.3.19'

// MyBatis, MariaDB, HikariCP 관련
implementation 'org.mariadb.jdbc:mariadb-java-client:3.0.4'

implementation group: 'com.zaxxer', name: 'HikariCP', version: '5.0.1'

implementation group: 'org.mybatis', name: 'mybatis', version: '3.5.9'

implementation group: 'org.mybatis', name: 'mybatis-spring', version: '2.0.7'

// Lombok 관련
compileOnly group: 'org.projectlombok', name: 'lombok', version: '1.18.24'

annotationProcessor group: 'org.projectlombok', name: 'lombok', version: '1.18.24'

testCompileOnly group: 'org.projectlombok', name: 'lombok', version: '1.18.24'

testAnnotationProcessor group: 'org.projectlombok', name: 'lombok', version: '1.18.24'

// Log4j2 관련
implementation group: 'org.apache.logging.log4j', name: 'log4j-core', version: '2.17.2'

implementation group: 'org.apache.logging.log4j', name: 'log4j-api', version: '2.17.2'

testImplementation group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: '2.17.2'

//JSTL 관련
implementation group: 'jstl', name: 'jstl', version: '1.2'

// 실습을 위해 새롭게 추가된 라이브러리들
// DTO와 VO의 변환을 위한 ModelMapper
implementation group: 'org.modelmapper', name: 'modelmapper', version: '3.0.0'

// DTO의 검증을 위한 validate관련 라이브러리
implementation group: 'org.hibernate', name: 'hibernate-validator', version: '6.2.1.Final'

라이브러리를 추가한 후 resource폴더에 log4j2.xml을 추가하고 아래 코드를 넣습니다.

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

<configuration status="INFO">

    <Appenders>
        <!-- 콘솔 -->
        <Console name="console" target="SYSTEM_OUT">
            <PatternLayout charset="UTF-8" pattern="%d{hh:mm:ss} %5p [%c] %m%n"/>
        </Console>
    </Appenders>

    <loggers>
        <logger name="org.springframework" level="INFO" additivity="false">
            <appender-ref ref="console"/>
        </logger>

        <logger name="org.zerock" level="INFO" additivity="false">
            <appender-ref ref="console"/>
        </logger>

        <root level="INFO" additivity="false">
            <AppenderRef ref="console"/>
        </root>
    </loggers>
</configuration>

프로젝트의 폴더와 패키지구조는 아래와 같이 개발전에 조금 정리를 한 상태에서 시작하겠습니다.(이전 프로젝트 참고)

프로젝트의 WEB-INF폴더에서 [New > XML Configuration File > Spring Config]

파일의 이름은 'root-context.xml'으로 지정합니다. 해당 파일을 열면 오른쪽 상단에 [Configure application context]라는 설정메뉴가 보이는데 이는 현재 프로젝트를 인텔리제이에서 스프링 프레임워크로 인식하고 필요한 기능들을 지원하기 위한 설정입니다.

[Create new application context...] 항목을 선택하고 [root-context.xml]을 선택합니다.

root-context.xml의 내용은 다음과 같습니다.

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

    <bean id="hikariConfig" class="com.zaxxer.hikari.HikariConfig">
        <property name="driverClassName" value="org.mariadb.jdbc.Driver"></property>
        <property name="jdbcUrl" value="jdbc:mariadb://localhost:3306/webdb"></property>
        <property name="username" value="webuser"></property>
        <property name="password" value="webuser"></property>
        <property name="dataSourceProperties">
            <props>
                <prop key="cachePrepStmts">true</prop>
                <prop key="prepStmrCacheSize">250</prop>
                <prop key="prepStmrCacheSqlLimit">2048</prop>
            </props>
        </property>
    </bean>

    <bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource" destroy-method="close">
        <constructor-arg ref="hikariConfig" />
    </bean>

    <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
        <property name="dataSource" ref="dataSource" />
        <property name="mapperLocations" value="classpath:/mappers/**/*.xml"></property>
    </bean>

    <mybatis:scan base-package="org.zerock.springMVC.mapper"></mybatis:scan>

</beans>

프로젝트의 WEB-INF폴더에 servlet-context.xml파일을 추가하고 다음과 같이 입력합니다.

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

    <mvc:annotation-driven conversion-service="conversionService" />

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

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

    <bean id="conversionService" class="org.springframework.format.support.FormattingConversionServiceFactoryBean">
        <property name="formatters">
            <set>
                <bean class="org.zerock.springMVC.controller.formatter.LocalDateFormatter"/>
            </set>
        </property>
    </bean>

    <context:component-scan base-package="org.zerock.springMVC.controller"/>

</beans>

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">
    
    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/root-context.xml</param-value>
    </context-param>
    
    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>
    
    <servlet>
        <servlet-name>appServlet</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>/WEB-INF/servlet-context.xml</param-value>
        </init-param>

        <init-param>
            <param-name>throwExceptionIfNoHandlerFound</param-name>
            <param-value>true</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>

마지막으로 프로젝트를 실행시켜서 서버가 정상으로 로딩되는지 확인합니다.

스프링은 하나의 설정이라도 문제가 생기면 프로젝트가 실행되지 않습니다.

 

테이블 수정

이전 예제에서는 webdb에 tbl_todo테이블을 이용해서 개발이 진행됐습니다.

현재 프로젝트의 'Database'설정을 이용해서 webdb와 연결합니다.

drop table tbl_todo;

create table tbl_todo (
    tno int auto_increment primary key ,
    title varchar(100) not null ,
    dueDate date not null ,
    writer varchar(50) not null ,
    finished tinyint default 0
);

ModelMapper설정과 @Configuration

프로젝트 개발에는 DTO를 VO로 변환하거나 VO를 DTO로 변환해야하는 작업이 빈번하므로,

이를 처리하기위해 ModelMapper를 스프링의 빈으로 등록해서 처리합니다.

ModelMapper를 설정하기 위해서 config패키지를 생성하고 ModelMapperConfig클래스를 추가합니다.

ModelMapperConfig는 기존 MapperUtil클래스를 스프링으로 변경한 버전으로 @Configuration을 이용합니다.

@Configuration은 해당클래스가 스프링 빈에 대한 설정을 하는 클래스임을 명시합니다.

@Configuration
public class ModelMapperConfig {
    @Bean
    public ModelMapper getMapper() {
        ModelMapper modelMapper = new ModelMapper();
        modelMapper.getConfiguration()
                .setFieldMatchingEnabled(true)
                .setFieldAccessLevel(org.modelmapper.config.Configuration.AccessLevel.PRIVATE)
                .setMatchingStrategy(MatchingStrategies.STRICT);
        
        return modelMapper;
    }
}

ModelMapperConfig를 스프링빈으로 인식하도록 root-context.xml에 config패키지를 component-scan을 이용해서 추가합니다.

<context:component-scan base-package="org.zerock.springMVC.config"/>

ModelMapperConfig클래스내에는 getMapper()라는 메소드가 ModelMapper를 반환하도록 설계되었습니다.

중요한점은 getMapper()선언부에 있는 @Bean어노테이션입니다.

@Bean은 해당 메소드의 실행결과로 반환된 객체를 스프링의 빈으로 등록시키는 역할을 합니다.

스프링3이후로 XML설정 외에 java설정을 이용하는 경우가 늘고있는데,

@Configuration과 @Bean이 바로 java설정에서 가장 많이 사용되는 설정입니다.

 

화면디자인 - 부트스트랩 적용

JSP파일을 작성하기전, 프로젝트의 시작단계에서 화면 디자인을 결정해 두는것이 좋습니다.

화면디자인 없이 개발을 진행할 때는 나중에 코드를 다시 입혀야 하는 작업을 할 수도 있기 때문입니다.

화면디자인을 위해서 html파일을 작성하거나 현재 프로젝트내에 jsp파일을 추가해서 디자인을 확인할 수 있습니다.

여기선 webapp의 resource폴더에 test.html을 작성해서 부트스트랩을 적용하는 페이지를 작성해보겠습니다.

https://getbootstrap.com/docs/5.1/getting-started/introduction/에서 'StarterTemplete'이 제공됩니다.

test.html의 내용을 StarterTemplete내용으로 변경합니다.

<!doctype html>
<html lang="en">
<head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <!-- Bootstrap CSS -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">

    <title>Hello, world!</title>
</head>
<body>
<div class="container-fluid">
    <div class="row">
        <h1>Header</h1>
    </div>
    <div class="row content">
        <h1>Contents</h1>
    </div>
    <div class="row footer">
        <h1>Footer</h1>
    </div>
</div>
</body>
</html>

Card 컴포넌트 적용하기

부트스트랩에는 화면을 쉽게 제작할 수 있는 여러종류의 컴포넌트를 제공합니다.

이중 Card컴포넌트를 적용해서 현재화면에서 Content라는 영역을 변경해보도록 합니다.

부트스트랩 사이트의 Components메뉴에서는 [Card > Header and Footer]부분의 코드를 찾습니다.

test.html에 <div class="row content">부분에 <div class="col">를 추가하고, Card컴포넌트의 코드를 복사해서 추가합니다.

 

Navbar 컴포넌트 적용하기

화면상단에는 간단한 메뉴를 보여줄 수 있는 Nav혹은 Navbar컴포넌트를 적용하도록 합니다.

공식문서의 Navbar의 예제를 선택해서 'Header'라고 출력되는 부분에 적용합니다.

Navbar가 적용되면 반응형으로 동작하기 때문에 브라우저의 크기에 따라서 메뉴들이 보이거나 사라지게 됩니다.

 

Footer 처리하기

맨아래 <div class="row">에는 간단한 <footer>를 적용합니다.

해당 <div>를 맨 아래쪽으로 고정하기위해서 'fixed-bottom'을 적용합니다.

내용이 많은 경우 Footer영역으로인해 가려질 수 있는 부분이 있으므로 'z-index'값은 음수로 처리해서 가려질 수 있도록 구성합니다.

<div class="row footer">
    <!--<h1>Footer</h1>-->
    <div class="row fixed-bottom" style="z-index: -100">
        <footer class="py-1 my-1">
            <p class="text-center text-muted">Footer</p>
        </footer>
    </div>
</div>

완성된 test.html의 모습

 

MyBatis와 스프링을 이용한 영속처리

프로젝트개발은 데이터베이스 처리부터 시작해 보도록 하겠습니다.

MyBatis와 스프링을 연동하기 때문에 기존의 JDBC에 비해서 적은 양의 코드만으로 개발이 가능합니다.

MyBatis를 이용하는 개발 단계는 다음과 같습니다.

1) VO선언 2) Mapper인터페이스 개발 3) XML개발 4) 테스트코드 개발

프로젝트에 domain패키지를 선언하고 TodoVO클래스를 추가합니다.

@Getter
@ToString
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class TodoVO {
    private long tno;

    private String title;

    private LocalDate dueDate;

    private boolean finished;

    private String writer; // 새로 추가됨
}

 

TodoMapper와 인터페이스 XML

TodoVO는 매퍼인터페이스의 파라미터나 리턴타입이 되기에 먼저 정의하고, mapper패키지에 TodoMapper인터페이스를 정의합니다.

public interface TodoMapper {
    
    String getTime();
}

resources/mappers폴더에 TodoMapper.xml을 선언하고 getTime()에 해당하는 내용을 작성합니다.

XML을 작성할때 namespace값은 인터페이스의 이름, 메소드의 이름은 <select>태그의 id와 반드시 일치시킵니다.

<?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="org.zerock.springMVC.mapper.TodoMapper">
    <select id="getTime" resultType="string">
        select now()
    </select>
</mapper>

테스트코드로 정상적으로 MyBatis의 세팅이 완료되었는지 확인합니다.

SQL의 실행로그를 좀 더 자세히 보고싶다면, mapper패키지 로그는 TRACE레벨로 기록하도록 log4j2.xml에 다음 코드를 추가합니다.

<logger name="org.zerock.springMVC.mapper" level="TRACE" additivity="false">
    <appender-ref ref="console"/>
</logger>

좀더 디테일한 SQL이 출력되는것을 확인할 수 있습니다.


2. Todo 등록 기능 개발

가장 먼저 등록작업을 처리해봅니다. 등록의 경우 TodoMapper > TodoService > TodoController > JSP순으로 처리합니다.

TodoMapper에는 TodoVO를 파라미터로 입력받는 insert()를 추가합니다.

void insert(TodoVO vo);

mappers폴더에 TodoMapper.xml에 insert를 다음과 같이 구현합니다.

<insert id="insert">
    insert into tbl_todo (title, dueDate, writer) values (#{title}, #{dueDate}, #{writer})
</insert>

MyBatis를 이용하면 "?"대신에 '#{title}'과 같이 파라미터를 처리합니다.

'#{title}'부분은 PreparedStatement로 다시변경되서 '?'로 처리되고, 주어진 객체의 getTitle()을 호출한 결과를 적용하게 됩니다.

더보기

등록 테스트 하기

@Test
public void testInsert() {
    TodoVO todoVO = TodoVO.builder()
            .title("스프링테스트")
            .dueDate(LocalDate.of(2023,01,14))
            .writer("user00")
            .build();

    todoMapper.insert(todoVO);
}

 

TodoService와 TodoServiceImpl클래스

TodoMapper와 TodoController사이에는 서비스계층을 설계해서 적용하도록 합니다.

TodoService인터페이스를 먼저 추가하고, 이를 구현한 TodoServiceImpl을 스프링빈으로 처리합니다.

public interface TodoService {
    
    void register(TodoDTO todoDTO);
}

TodoService인터페이스에 추가한 register()는 여러개의 파라미터 대신에 TodoDTO로 묶어서 전달 받도록 구성합니다.

TodoService 인터페이스를 구현하는 TodoServiceImpl에는 의존성 주입을 이용해서 데이터베이스 처리를 하는 TodoMapper와

DTO, VO의 변환을 처리하는 ModelMapper를 주입합니다.

@Service
@Log4j2
@RequiredArgsConstructor
public class TodoServiceImpl implements TodoService {

    private final TodoMapper todoMapper;

    private final ModelMapper modelMapper;

    @Override
    public void register(TodoDTO todoDTO) {
        log.info(modelMapper);

        TodoVO todoVO = modelMapper.map(todoDTO, TodoVO.class);

        log.info(todoVO);

        todoMapper.insert(todoVO);
        
    }
}

TodoServiceImpl은 의존성 주입이 사용되는 방식을 눈여겨봐야합니다.

의존성 주입이 필요한 객체의 타입을 final로 고정하고 @RequiredArgsConstructor를 이용해서 생성자를 생성하는 방식을 사용합니다.

register()에서는 주입된 ModelMapper를 이용해서 TodoDTO를 TodoVO로 변환하고 이를 TodoMapper를 통해 insert했습니다.

service패키지는 root-context.xml에서 component-scan패키지로 추가해줍니다.

<mybatis:scan base-package="org.zerock.springMVC.mapper"></mybatis:scan>

<context:component-scan base-package="org.zerock.springMVC.config"/>
<context:component-scan base-package="org.zerock.springMVC.service"/>
더보기

TodoService테스트

예제는 서비스 계층에서 DTO를 VO로 변환하는 작업을 처리하기 때문에 가능하면 테스트를 진행해서 문제가 없는지 확인하는게 좋습니다.

test폴더내에 service패키지를 생성하고 TodoServiceTests클래스를 추가합니다.

@Log4j2
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "file:src/main/webapp/WEB-INF/root-context.xml")
public class TodoServiceTests {
    
    @Autowired
    private TodoService todoService;
    
    @Test
    public void testRegister() {
        TodoDTO todoDTO = TodoDTO.builder()
                .title("TEST...")
                .dueDate(LocalDate.now())
                .writer("user1")
                .build();
        
        todoService.register(todoDTO);
    }
}

테스트를 통해 데이터베이스에도 정상적으로 등록되었는지 확인합니다.

 

TodoController의 GET/POST처리

서비스계층까지 문제없이 동작하는것을 확인했다면 스프링MVC를 처리하도록 합니다.

우선 입력할 수 있는 화면이 필요합니다. controller패키지의 TodoController를 확인합니다.

TodoController에 GET방식으로 '/todo/register'가 실행가능한지 확인합니다.

@Controller
@RequestMapping(value = "/todo")
@Log4j2
public class TodoController {

    @RequestMapping(value = "/list")
    public void list() {
        log.info("todo list...");
    }
    
    @GetMapping("/register")
    public void registerGET() {
        log.info("todo register...");
    }

}

/WEB-INF/views/todo폴더에 register.jsp를 생성합니다.

register.jsp는 test.html을 복사해서 구성하겠습니다. 상단에 JSP관련 설정을 추가해야야 하는 점을 주의해야 합니다.

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>

register.jsp에 class속성이 'card-body'로 지정된 부분의 코드를 다음과 같이 수정합니다.

<div class="card-body">
    <form action="/todo/register" method="post">
        <div class="input-group mb-3">
            <span class="input-group-text">Title</span>
            <input type="text" name="title" class="form-control" placeholder="Title">
        </div>

        <div class="input-group mb-3">
            <span class="input-group-text">DueDate</span>
            <input type="date" name="dueDate" class="form-control" placeholder="DueDate">
        </div>

        <div class="input-group mb-3">
            <span class="input-group-text">Writer</span>
            <input type="text" name="writer" class="form-control" placeholder="Writer">
        </div>

        <div class="my-4">
            <div class="float-end">
                <button type="submit" class="btn btn-primary">Submit</button>
                <button type="result" class="btn btn-secondary">Reset</button>
            </div>
        </div>
    </form>
</div>

프로젝트를 실행한 후 '/todo/register'를 호출하면 아래와 같은 화면이 나옵니다.

 

POST방식의 처리

register.jsp의 <form action="/todo/register" method="post">태그에 의해서 [Submit]버튼틀 클릭하면 POST방식으로

title, dueDate, writer를 전송하게됩니다. TodoController에서 TodoDTO로 바로 전달된 파라미터값들을 수집합니다.

POST방식으로 처리한 후에는 '/todo/list'로 이동하도록 'redirect:/todo/list'로 문자열을 반환할 수 있게 처리합니다.

@PostMapping("/register")
public String registerPOST(TodoDTO todoDTO, RedirectAttributes redirectAttributes) {
    
    log.info("POST todo register...");
    
    log.info(todoDTO);
    
    return "redirect:/todo/list";
}

실행해보면 한글이 깨지고, 적당한 데이터를 전달하지 않았을때 문제가 발생할 수 있다는 문제점이 있지만,

입력한 데이터를 수집하고 'todo/list'로 정상 이동하는것을 확인할 수 있습니다.

 

한글처리를 위한 필터 설정

브라우저에서 한글을 입력하면 문제가 발생합니다.

이 경우에는 브라우저에서 서버로 데이터를 전송할때 한글이 깨지는지 서버에서 데이터를 수집할때 깨지는지를 알아야합니다.

브라우저의 개발자도구 > Network에서 'todo/register'항목을 이용하면 알 수 있습니다.(페이로드에서 확인가능)

브라우저에서 보내는데 문제가 없다면 서버에서 다음과 같이 한글에 문제가 있는것을 확인할 수 있습니다.

서버의 한글처리에 대한 설정은 스프링MVC에서 제공하는 필터로 쉽게 처리할 수 있습니다.

web.xml에 필터에 대한 설정을 추가합니다(<web-app>태그가 끝나기전에 추가).

<filter>
    <filter-name>encoding</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>
</filter>
<filter-mapping>
    <filter-name>encoding</filter-name>
    <servlet-name>appServlet</servlet-name>
</filter-mapping>

web.xml의 설정은 서버를 재시작해야 반영되므로 재시작 후 한글처리를 확인합니다.

잘 넘어옵니다

 

@Valid를 이용한 서버사이드 검증

과거의 웹개발에는 자바스크립트를 이용해서 브라우저에서만 유효성검사를 진행하는 방식을 많이 사용했지만,

모바일 같이 다양한 환경에서 서버를 이용하는 현재엔 프론트쪽에서의 검증과 더불어 서버에서도 입력되는 값들을 검증하고 있습니다.

이러한 검증작업은 컨트롤러에서 진행하는데 스프링MVC의 경우 @Valid와 BindingResult를 이용해서 간단히 처리할 수 있습니다.

스프링MVC에서 검증을 처리하려면 hibernate-validate라이브러리가 필요합니다만 주의해야 할 점이 있습니다.

7버전부터는 jakarta패키지를 쓰는 문제로 제한이 생겨서 예제는 6.2.1.Final을 이용했습니다.

// DTO의 검증을 위한 validate관련 라이브러리
implementation group: 'org.hibernate', name: 'hibernate-validator', version: '6.2.1.Final'

hibernate-validator 대표 어노테이션

@NotNull Null 불가
@Null Null만 입력 가능
@NotEmpty Null, 빈 문자열 불가
@NotBlank Null, 빈 문자열, 스페이스만 있는 문자열 불가
@Size(min=,max=) 문자열, 배열 등의 크기가 만족하는가?
@Pattern(regex=) 정규식을 만족하는가?
@Max(num) 지정 값 이하인가?
@Min(num) 지정 값 이상인가?
@Future 현재 보다 미래인가?
@Past 현재 보다 과거인가?
@Positive 양수만 가능
@PositiveOrZero 양수와 0만 가능
@Negative 음수만 가능
@NegativeOrZero 음수와 0만 가능

 

TodoDTO 검증하기

TodoDTO에 간단한 어노테이션을 적용해서 다음과 같이 수정합니다.

@ToString
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TodoDTO {

    private long tno;

    @NotEmpty
    private String title;

    @Future
    private LocalDate dueDate;

    private boolean finished;

    @NotEmpty
    private String writer;
}

TodoController에서는 POST방식으로 처리할때 이를 반영하도록 BindingResult와 @Valid를 적용합니다.

@PostMapping("/register")
public String registerPOST(@Valid TodoDTO todoDTO, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

    log.info("POST todo register...");
    
    if (bindingResult.hasErrors()) {
        log.info("has error...");
        redirectAttributes.addFlashAttribute("errors", bindingResult.getAllErrors());
        return "redirect:/todo/register";
    }

    log.info(todoDTO);

    return "redirect:/todo/list";
}

TodoDTO에 @Valid적용, BindingResult타입을 파라미터로 추가했습니다.

hasErrors()를 이용해서 검증에 문제가 있다면 다시 입력화면으로 리다이렉트되도록 처리했습니다.

이때 처리과정에서 잘못된 결과는 addFlashAttribute()를 이용해서 전달되도록 처리했습니다.

 

JSP에서 검증에러 메시지 확인하기

register.jsp에는 검증된 결과를 확인하기 위해서 JSP상단에 태그 라이브러리를 추가합니다.

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>

화면에서 <form>태그가 끝난 후에 <script>태그를 추가합니다.

</form>
<script>
    const serverValidResult = {}
    
    <c:forEach items="${errors}" var="error">
    
    serverValidResult['${error.getField()}'] = '${error.defaultMessage}'
    
    </c:forEach>
    
    console.log(serverValidResult)
</script>

값이 없는 상태에서 Submit하게될 경우,

자바스크립트를 이용해서 오류객체를 생성하면 나중에 화면에서 자유롭게 처리할 수 있다는 장점이 있습니다.

 

입력값의 검증까지 끝났다면 최종적으로 TodoService를 주입하고 연동하도록 구성합니다.

TodoController의 클래스 선언부에서 TodoService를 주입합니다.

@Controller
@RequestMapping(value = "/todo")
@Log4j2
@RequiredArgsConstructor
public class TodoController {
    
    private final TodoService todoService;
    
    ...

register()에서 TodoService의 기능을 호출하도록 합니다.

@PostMapping("/register")
public String registerPOST(@Valid TodoDTO todoDTO, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

    log.info("POST todo register...");

    if (bindingResult.hasErrors()) {
        log.info("has error...");
        redirectAttributes.addFlashAttribute("errors", bindingResult.getAllErrors());
        return "redirect:/todo/register";
    }

    log.info(todoDTO);

    todoService.register(todoDTO);

    return "redirect:/todo/list";
}

모든 기능의 개발이 완료되었다면 등록 후에 'todo/list'로 이동하게 됩니다.

아직 'todo/list'의 개발은 완료되지 않았으니 데이터베이스를 이용해서 최종확인합니다.


3. Todo 목록 기능 개발

목록의 경우 나중에 페이징처리나 검색기능이 필요하지만 우선은 목록 데이터를 출력하는 수준으로만 작성하겠습니다.

 

TodoMapper의 개발

TodoMapper인터페이스에는 가장 최근에 등록된 TodoVO가 우선적으로 나올 수 있도록 selectAll()를 추가합니다.

List<TodoVO> selectAll();
<select id="selectAll" resultType="org.zerock.springMVC.dto.TodoDTO">
    select * from tbl_todo order by tno desc
</select>
@Test
public void testSelectAll() {
    log.info(todoMapper.selectAll());
}

 

TodoService/TodoServiceImpl의 개발

서비스 계층의 개발은 특별한 파라미터가 없는 경우 TodoMapper를 호출하는것이 전부입니다.

다만 TodoMapper가 반환하는 데이터의 타입이 List<TodoVO>이기 때문에 이를 List<TodoDTO>로 변화하는 작업이 필요합니다.

TodoService인터페이스에 getAll()기능을 추가합니다.

public interface TodoService {

    void register(TodoDTO todoDTO);
    
    List<TodoDTO> getAll();
}
@Override
public List<TodoDTO> getAll() {
     List<TodoDTO> dtoList = todoMapper.selectAll().stream()
             .map(vo -> modelMapper.map(vo, TodoDTO.class))
             .collect(Collectors.toList());
     
     return dtoList;
}

List<TodoVO>를 List<TodoDTO>로 변환하는 작업은 java8부터 지원하는 stream을 이용해서 각 TodoVO는 map()을 통해서

TodoDTO로 바꾸고 collect()를 이용해서 List<TodoDTO>로 묶어줍니다.

 

TodoController의 처리

TodoController의 list()기능에서 TodoService를 처리하고 Model에 데이터를 담아서 JSP로 전달해야 합니다.

@RequestMapping(value = "/list")
public void list(Model model) {

    log.info("todo list...");
    
    model.addAttribute("dtoList", todoService.getAll());
    
}

Model에는 'dtoList'라는 이름으로 목록 데이터를 담았기 때문에 JSP에서는 JSTL을 이용해서 목록을 출력합니다.

(화면디자인은 부트스트랩의 tables 항목 https://getbootstrap.com/docs/5.1/content/tables/를 참고해서 작성합니다.)

list.jsp는 test.html의 코드를 복사해서 이용하고 'card-body'부분만 다음과 같이 구현합니다.

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<div class="card-body">
    <table class="table">
        <thead>
        <tr>
            <th scope="col">Tno</th>
            <th scope="col">Title</th>
            <th scope="col">Writer</th>
            <th scope="col">DueDate</th>
            <th scope="col">finished</th>
        </tr>
        </thead>
        <tbody>
        <c:forEach items="${dtoList}" var="dto">
            <tr>
                <th scope="row"><c:out value="${dto.tno}"/></th>
                <td><c:out value="${dto.title}"/></td>
                <td><c:out value="${dto.writer}"/></td>
                <td><c:out value="${dto.dueDate}"/></td>
                <td><c:out value="${dto.finished}"/></td>
            </tr>
        </c:forEach>
        </tbody>
    </table>
</div>


4. Todo 조회 기능 개발

목록화면에서 제목(Title)을 눌렀을때 'todo/read?tno=xx'와 같이 TodoController를 호출하도록 개발하겠습니다.

 

TodoMapper 조회 기능 개발

TodoMapper의 개발은 selectOne()이라는 메소드를 추가합니다.

파라미터는 Long타입으로 tno를 받도록 설계하고, TodoVO 객체를 반환하도록 구성합니다.

TodoVO selectOne(long tno);
<select id="selectOne" resultType="org.zerock.springMVC.domain.TodoVO">
    select * from tbl_todo where tno = #{tno}
</select>
@Test
public void testSelectOne() {
    log.info(todoMapper.selectOne(1L));
}

 

TodoService/TodoServiceImpl의 개발

TodoService와 TodoServiceImpl에 getOne()이라는 메소드를 구현합니다.

TodoDTO getOne(Long tno);
@Override
public TodoDTO getOne(Long tno) {

    TodoDTO todoDTO = modelMapper.map(todoMapper.selectOne(tno), TodoDTO.class);

    return todoDTO;
}

 

TodoController의 개발

TodoController의 GET방식으로 동작하는 read()기능을 개발합니다.

@GetMapping("/read")
public void readGet(long tno, Model model) {
    
    TodoDTO todoDTO = todoService.getOne(tno);
    
    log.info(todoDTO);
    
    model.addAttribute("dto", todoDTO);
    
}

화면상의 'card-body'부분에 dto라는 이름으로 전달된 TodoDTO를 출력합니다.

<div class="card-body">
    <div class="input-group mb-3">
        <span class="input-group-text">TNO</span>
        <input type="text" name="tno" class="form-control"
               value='<c:out value="${dto.tno}"></c:out>' readonly>
    </div>
    <div class="input-group mb-3">
        <span class="input-group-text">Title</span>
        <input type="text" name="title" class="form-control"
               value='<c:out value="${dto.title}"></c:out>' readonly>
    </div>
    <div class="input-group mb-3">
        <span class="input-group-text">DueDate</span>
        <input type="date" name="dueDate" class="form-control"
               value='<c:out value="${dto.dueDate}"></c:out>' readonly>
    </div>
    <div class="input-group mb-3">
        <span class="input-group-text">WRITER</span>
        <input type="text" name="writer" class="form-control"
               value='<c:out value="${dto.writer}"></c:out>' readonly>
    </div>
    <div class="form-check">
        <label class="form-check-label">
            Finished &nbsp;
        </label>
        <input type="checkbox" name="finished" class="form-check-input"
               ${dto.finished?"checked":""} disabled>
    </div>
    
    <div class="my-4">
        <div class="float-end">
            <button type="button" class="btn btn-primary">Modify</button>
            <button type="button" class="btn btn-secondary">List</button>
        </div>
    </div>
</div>

 

수정/삭제를 위한 링크처리

조회화면에는 수정/삭제를 위해서 [Modify]버튼을 누르면 GET방식의 수정/삭제 선택이 가능한 화면으로 이동하게 구현합니다.

이를 위해서 자바스크립트를 이용해서 이벤트를 처리해두도록 합니다.

<div class="my-4">
    <div class="float-end">
        <button type="button" class="btn btn-primary">Modify</button>
        <button type="button" class="btn btn-secondary">List</button>
    </div>
</div>

<script>
    document.querySelector(".btn-primary").addEventListener("click", function (e){
        self.location = "todo/modify?tno=" + ${dto.tno};
    }, false)
    
    document.querySelector(".btn-secondary").addEventListener("click", function (e){
        self.location = "todo/list";
    }, false)
</script>

 

list.jsp의 링크처리

list.jsp에서는 각 TodoDTO의 제목에 '/todo/read?tno=xx'와 같이 이동 가능하도록 링크를 처리해 줍니다.

<tbody>
<c:forEach items="${dtoList}" var="dto">
    <tr>
        <th scope="row"><c:out value="${dto.tno}"/></th>
        <td><a href="/todo/read?tno=${dto.tno}" class="text-decoration-none"><c:out value="${dto.title}"/></a></td>
        <td><c:out value="${dto.writer}"/></td>
        <td><c:out value="${dto.dueDate}"/></td>
        <td><c:out value="${dto.finished}"/></td>
    </tr>
</c:forEach>
</tbody>

5. Todo 삭제 기능 개발

수정과 삭제는 GET방식으로 조회한 후에 POST방식으로 처리합니다. 사실상 GET방식의 내용은 조회화면과 같지만,

스프링MVC에는 여러개의 경로를 배열과 같은 표기법을 이용해서 하나의 @GetMapping으로 처리할 수 있기 때문에

read()기능을 수정해서 수정과 삭제에도 같은 메소드를 이용하도록 처리해줍니다.

TodoController의 read()를 다음과 같이 수정해서 'todo/modify?tno=xx'의 경로를 처리하도록 수정해줍니다.

@GetMapping({"/read", "/modify"})
public void readGet(long tno, Model model) {

    TodoDTO todoDTO = todoService.getOne(tno);

    log.info(todoDTO);

    model.addAttribute("dto", todoDTO);

}

/WEB-INF/views/todo폴더에는 read.jsp를 그대로 복사해서 modify.jsp를 구성합니다.

modify.jsp는에서는 수정과 삭제작업이 POST방식으로 처리될 예정이므로 <form>태그를 다음과 같이 수정하겠습니다.

<div class="card-body">
    <form action="/todo/modify" method="post">
    <div class="input-group mb-3">
        <span class="input-group-text">TNO</span>
        <input type="text" name="tno" class="form-control"
               value='<c:out value="${dto.tno}"></c:out>' readonly>
    </div>
    <div class="input-group mb-3">
        <span class="input-group-text">Title</span>
        <input type="text" name="title" class="form-control"
               value='<c:out value="${dto.title}"></c:out>'>
    </div>
    <div class="input-group mb-3">
        <span class="input-group-text">DueDate</span>
        <input type="date" name="dueDate" class="form-control"
               value='<c:out value="${dto.dueDate}"></c:out>'>
    </div>
    <div class="input-group mb-3">
        <span class="input-group-text">WRITER</span>
        <input type="text" name="writer" class="form-control"
               value='<c:out value="${dto.writer}"></c:out>' readonly>
    </div>
    <div class="form-check">
        <label class="form-check-label">
            Finished &nbsp;
        </label>
        <input type="checkbox" name="finished" class="form-check-input"
        ${dto.finished?"checked":""}>
    </div>

    <div class="my-4">
        <div class="float-end">
            <button type="button" class="btn btn-danger">Remove</button>
            <button type="button" class="btn btn-primary">Modify</button>
            <button type="button" class="btn btn-secondary">List</button>
        </div>
    </div>
    </form>
</div>

 

Remove버튼의 처리

Remove버튼의 클릭은 자바스크립트를 이용해서 <form>태그의 action을 조정하는 방식으로 구현하겠습니다.

/form>
</div>
<script>
    const formObj = document.querySelector("form")
    
    document.querySelector(".btn-danger").addEventListener("click", function (e) {
        e.preventDefault()
        e.stopPropagation()
        
        formObj.action = "/todo/remove"
        formObj.method = "post"
        
        formObj.submit()
    }, false)
</script>

TodoController에는 POST방식으로 remove()메소드를 설계합니다.

@PostMapping("/remove")
public String remove(long tno, RedirectAttributes redirectAttributes) {
    
    log.info("----------------remove---------------");
    log.info("tno : " + tno);
    
    return "redircect:/todo/list";
}

 

TodoMapper와 TodoService 처리

TodoMapper에는 delete()메소드를 추가하고 TodoMapper.xml에는 SQL을 추가합니다.

void delete(long tno);
<delete id="delete">
    delete from tbl_todo where tno = #{tno}
</delete>

TodoService/TodoServiceImpl에는 remove()메소드를 작성합니다.

void remove(long tno);
@Override
public void remove(long tno) {
    todoMapper.delete(tno);
}

TodoController에서 remove()

@PostMapping("/remove")
public String remove(long tno, RedirectAttributes redirectAttributes) {

    log.info("----------------remove---------------");
    log.info("tno : " + tno);
    
    todoService.remove(tno);

    return "redircect:/todo/list";
}

6. Todo 수정 기능 개발

Todo의 수정기능은 수정이 가능한 항목들만 변경되어야 하므로 SQL이 조금 복잡해집니다.

우선 TodoMapper에는 update()메소드를 추가하고 TodoMapper.xml에는 SQL을 추가합니다.

void update(TodoVO vo);
<update id="update">
    update tbl_todo set title = #{title}, dueDate = #{dueDate}, finished = #{finished} where tno = #{tno} 
</update>

TodoService/TodoServiceImpl에는 modify()메소드를 작성합니다.

void modify(TodoDTO todoDTO);
@Override
public void modify(TodoDTO todoDTO) {

    TodoVO todoVO = modelMapper.map(todoDTO, TodoVO.class);

    todoMapper.update(todoVO);
}

checkbox를 위한 Formatter

수정작업에서는 화면에서 체크박스를 이용해서 완료여부(finished)를 처리하게 됩니다.

문제는 브라우저가 체크박스가 클릭된 상태일때 전송되는 값은 'on'이라는 값을 전달한다는 것입니다.

TodoDTO로 데이터를 수집할때 문자열 'on'을 boolean타입으로 처리해줄 CheckboxFormatter를 추가해야 합니다.

public class CheckboxFormatter implements Formatter<Boolean> {
    
    @Override
    public Boolean parse(String text, Locale locale) throws ParseException {
        if (text == null) return false;
        
        return text.equals("on");
    }

    @Override
    public String print(Boolean object, Locale locale) {
        return object.toString();
    }
}

추가한 CheckboxFormatter는 servlet-context.xml에 등록해줍니다.

<bean id="conversionService" class="org.springframework.format.support.FormattingConversionServiceFactoryBean">
    <property name="formatters">
        <set>
            <bean class="org.zerock.springMVC.controller.formatter.LocalDateFormatter"/>
            <bean class="org.zerock.springMVC.controller.formatter.CheckboxFormatter"/>
        </set>
    </property>
</bean>

TodoController의 modify()

@PostMapping("/modify")
public String modify(@Valid TodoDTO todoDTO, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    
    if (bindingResult.hasErrors()) {
        log.info("has errors......");
        redirectAttributes.addFlashAttribute("errors", bindingResult.getAllErrors());
        redirectAttributes.addAttribute("tno", todoDTO.getTno());
        return "redirect:/todo/modify";
    }
    
    todoService.modify(todoDTO);
    
    return "redirect:/todo/list";
}

modify.jsp에는 검증된 정보를 처리하는 코드를 추가합니다.

</form>
</div>
<script>
    const serverValidResult = {}

    <c:forEach items="${errors}" var="error">
    serverValidResult['${error.getField()}'] = '${error.defaultMessage}'
    </c:forEach>

    console.log(serverValidResult)
</script>

실제 Modify버튼 이벤트를 처리하는 내용을 이전에 만든 <scripti>에 추가합니다.

<script>
    const formObj = document.querySelector("form")

    document.querySelector(".btn-danger").addEventListener("click", function (e) {
        e.preventDefault()
        e.stopPropagation()

        formObj.action = "/todo/remove"
        formObj.method = "post"

        formObj.submit()
    }, false)
    
    document.querySelector(".btn-primary").addEventListener("click", function (e) {
        e.preventDefault()
        e.stopPropagation()
        
        formObj.action = "/todo/modify"
        formObj.method = "post"
        
        formObj.submit()
    }, false)
    
</script>

마지막으로 'List'버튼의 클릭 이벤트를 처리합니다.

document.querySelector(".btn-secondary").addEventListener("click", function (e) {
    e.preventDefault()
    e.stopPropagation()
    
    selr.location = "/todo/list";
}, false)

 

반응형