1.  Thymeleaf

스프링과 마찬가지로 스프링 부트도 다양한 뷰 view 관련 기술을 적용할 수 있음. 스프링은 대부분 JSP를 위주로 개발하는 경우가 많지만 스프링 부트는 Thymeleaf라는 템플릿 엔진을 주로 이용.

Thymeleaf는 '템플릿'이기 때문에 JSP 처럼 직접 데이터를 생성하지 않고, 만들어진 결과에 데이터를 맞춰서 보여주는 방식으로 구현.
JSP와 마찬가지로 서버에서 동작하기는 하지만 Thymleaf는 HTML을 기반으로 화면을 구성하기 때문에 HTML에 조금 더 가까운 방식으로 작성


2.   Thymeleaf 기초 문법

Thymeleaf는 JSP를 대신하는 목적으로 작성된 라이브러리이므로, JSP에서 필요한 기능들을 Thymeleaf로 구성


1) 인텔리제이 설정

앞에서 작성한 hello.html을 열기. Thymeleaf를 이용하기 위해서 가장 중요한 설정은 네임스페이스 xmlns에 Thymeleaf를 지정. 네임스페이스를 지정하면 'th:'와 같은 Thymeleaf의 모든 기능을 사용할 수 있음.

작성된 hello.html은 다음과 같이 'th:'로 시작하는 기능을 사용할 수 있지만 Model에 담긴 데이터를 사용할 때는 '해당 변수를 찾을 수 없다'는 방식으로 에러가 날 수 있음.


  
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1 th:text="${msg}"></h1>
</body>
</html>


  📍  만일 에러가 발생하는 경우에는 인텔리제이의 설정을 조금 변경해서 에러 없는 화면을 보는 것이 더 좋기 때문에 Setting 메뉴에서 Thymeleaf를 검색하고 Unresolved references .. 를 체크 해제 해 주도록 함

    ➡️  설정을 변경하고 기존에 열려있는 hello.html 에디터를 종료한 후에 다시 에디터로 보면 변수에 대한 검사를 하지 않는 것을 확인


2)  Thymleaf 출력

Model로 전달된 데이터를 출력하기 위해서 HTML 태그 내에 'th:'로 시작하는 속성을 이용하거나 inlining을 이용
SampleController에 ex1()을 추가해서 '/ex/ex1'이라는 경로를 호출할 때 동작하도록 구성


  
@GetMapping("/ex/ex1")
public void ex1(Model model) {
List<String> list = Arrays.asList("AAA", "BBB", "CCC", "DDD");
model.addAttribute("list", list);
}

 

ex1()의 결과 화면은 templates 내에 ex 디렉토리를 생성하고 ex1.html을 추가

  
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<!-- inlining 사용 -->
<h4>[[${list}]]</h4>
<hr>
<!-- 'th:' 속성 사용 -->
<h4 th:text="${list}"></h4>
</body>
</html>


 

3)  Thymeleaf 주석 처리

Thymeleaf가 작성하는 단계에서는 단순해 보이지만 디버깅할 때는 상황이 다름. 에러가 발생하게 되면 에러의 원인을 찾아내기 힘들다.
에러가 난 부분을 찾기 위해서는 주석 처리를 해야할 때는 '<!--/* ... */-->'를 이용하는 것이 좋음. 주석은 Thymeleaf가 파싱 처리할 때 삭제되어 처리되기 때문에 1) 잘못된 문법 에 대한 체크도 건너 뛸 수 있고, 삭제된 상태에서 처리되므로 2)브라우저에서는 아예 해당 부분은 결과 자체가 없음

 

hello.html 수정

 


 

4)  th:with를 이용한 변수 선언

Thymeleaf를 이용하는 과정에서 임시로 변수를 선언해야 하는 상황에서는 'th:with'를 이용해서 간단히 처리 가능
'th:with'로 만드는 변수를 '변수명 = 값'의 형태로 ', '를 이용해서 여러 개를 선언 할 수도 있음


  
<div th:with="num1 = ${10}, num2 = ${20}">
<h4 th:text="${num1 + num2}"></h4>
</div>

 


3.  반복문과 제어문 처리

화면 구성에서 가장 많이 사용되는 반복문과 제어문 처리.
SampleController의 ex1()에서는 Model을 이용해 'List<String>'을 담고 ex1.html을 이용해서 출력하도록 구성.


  
@GetMapping("/ex/ex1")
public void ex1(Model model) {
List<String> list = Arrays.asList("AAA", "BBB", "CCC", "DDD");
model.addAttribute("list", list);
}

 

반복문 처리는 크게 2가지 방법을 이용


    -  반복이 필요한 태그에 'th:each'를 적용하는 방법
    -  <th:block>이라는 별도의 태그를 이용하는 방법


    💡  'th:each' 속성을 이용할 때는 기존의 HTML을 그대로 둔 상태에서 반복 처리를 할 수 있다는 장점이 있지만 JSTL 과는 조금 이질적인 형태이고, <th:block>을 이용할 때는 추가로 태그가 들어가는 단점이 있음.

 

ex1.html의 내용을 변경

  
<ul>
<li th:each="str: ${list}" th:text="${str}"></li>
</ul>
<ul>
<th:block th:each="str: ${list}">
<li>[[${str}]]</li>
</th:block>
</ul>


 

1)  반복문의 status 변수

Thymeleaf는 th:each를 처리할 때 현재 반복문의 내부 상태에 변수를 추가해서 사용할 수 있음.
일명 status 변수라고 하는데 index / count / size / first / last / odd / even 등을 이용해서 자주 사용하는 값들을 출력할 수 있음


  
<ul>
<li th:each="str, status: ${list}">
[[${status.index}]] -- [[${str}]]
</li>
</ul>

 

  ✓  status 변수명은 사용자가 지정할 수 있고, index는 0부터 시작하는 번호를 의미. count는 1부터 시작


 

2)  th:if / th:unless / th:switch

Thymeleaf는 제어문의 형태로 th:if / th:unless / th:switch를 이용할 수 있음

th:if / th:unless는 별도의 속성으로 사용할 수 있으므로 if ~ else 와는 다르게 사용
예를 들어 반복문의 홀수 / 짝수를 구분해서 처리하고 싶다면 다음과 작성


  
<ul>
<li th:each="str, status: ${list}">
<span th:if="${status.odd}">ODD -- [[${str}]]</span>
<span th:unless="${status.odd}">EVEN -- [[${str}]]</span>
</li>
</ul>


 

📍  ?를 이용하면 앞선 방식보다는 좀 더 편하게 이항 혹은 삼항 처리가 가능
      예를 들어 반복 중에 홀수 번째만 무언가를 보여주고 싶다면 다음과 같이 ? 뒤에 하나만 표현식을 사용할 수 있음


  
<ul>
<li th:each="str, status: ${list}">
<span th:text="${status.odd} ? 'ODD ---' + ${str}"></span>
</li>
</ul>


 

📍  ?를 삼항연산자 그대로 사용할 수도 있음


  
<ul>
<li th:each="str, status: ${list}">
<span th:text="${status.odd} ? 'ODD ---' + ${str} : 'EVEN ---' + ${str}"></span>
</li>
</ul>


 

📍 th:switch는 th:case와 같이 사용해서 Switch 문을 처리할 때 사용할 수 있음


  
<ul>
<li th:each="str, status: ${list}">
<th:block th:switch="${status.index % 3}">
<span th:case="0">0</span>
<span th:case="1">1</span>
<span th:case="2">2</span>
</th:block>
</li>
</ul>


4.  Thymeleaf 링크 처리

  -  Thymeleaf는 '@'로 링크를 작성하기만 하면 됨


  
<a th:href="@{/hello}">Go to /hello </a>

 

 

1) 링크의 쿼리 스트링 처리

 

링크를 'key=value'의 형태로 필요한 파라미터를 처리해야 할 때 상당히 편리.

쿼리 스트링은 '()'를 이용해서 파라미터의 이름과 값을 지정.


  
<a th:href="@{/hello(name="AAA", age=16)}">Go to /hello </a>


 

📍  GET 방식으로 처리되는 링크에서 한글이나 공백 문자는 항상 주의해야 하는데 Thymeleaf를 이용하면 이에 대한 URL 인코딩 처리가 자동으로 이루어짐


  
<a th:href="@{/hello(name="한글처리", age=16)}">Go to /hello</a>


 

📍  만일 링크를 만드는 값이 배열과 같이 여러 개일 때는 자동으로 같은 이름의 파라미터를 처리


  
<a th:href="@{/hello(types=${{'AAA', 'BBB', 'CCC'}}, age=16}">Go to /hello</a>


5.  Thymeleaf의 특별한 기능들

1)  인라인 처리

Thymeleaf는 여러 편리한 점이 있지만 상황에 따라 동일한 데이터를 다르게 출력해 주는 인라인 기능은 자바 스크립트를 사용할 때 편리한 기능.

 

SampleController에 다양한 종류의 데이터를 Model에 담아서 전달하는 메서드를 추가

 

  • 추가되는 코드는 내부 클래스인 SampleDTO와 ex2()
  • SampleDTO를 정의할 때는 반드시 getter들을 만들어 줌

  
class SampleDTO {
private String p1, p2, p3;
public String getP1() {
return p1;
}
public String getP2() {
return p2;
}
public String getP3() {
return p3;
}
}

 


  
@GetMapping("ex/ex2")
public void ex2(Model model) {
log.info("ex/ex2..........");
List<String> strList = IntStream.range(1,10)
.mapToObj(i -> "Data"+i)
.collect(Collectors.toList());
model.addAttribute("list", strList);
Map<String, String> map = new HashMap<>();
map.put("A", "AAAA");
map.put("B", "BBBB");
model.addAttribute("map", map);
SampleDTO sampleDTO = new SampleDTO();
sampleDTO.p1 = "Value -- p1";
sampleDTO.p2 = "Value -- p2";
sampleDTO.p3 = "Value -- p3";
model.addAttribute("dto", sampleDTO);
}

 

화면 구성을 위해 ex2.html을 추가

  
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div th:text="${list}"></div>
<div th:text="${map}"></div>
<div th:text="${dto}"></div>
<script th:inline="javascript">
const list = [[${list}]]
const map = [[${map}]]
const dto = [[${dto}]]
console.log(list)
console.log(map)
console.log(dto)
</script>
</body>
</html>

 

📍 HTML 코드를 이용하거나 자바 스크립트 코드를 이용할 때 같은 객체를 사용. 다만 차이점은 <script th:inline="javascript">가 지정된 점

📍 프로젝트를 실행해서 만들어진 결과를 보면 HTML은 기존처럼 출력되고, <script> 부분은 자바 스크립트에 맞는 문법으로 만들어진 것을 확인

 


2)  Thymeleaf의 레이아웃 기능

Thymeleaf의 <th:block>을 이용하면 레이아웃을 만들고 특정한 페이지에서는 필요한 부분만을 작성하는 방식으로 개발이 가능

 

레이아웃 기능을 위해서 별도의 라이브러리가 필요하므로 build.gradle에 추가

  
// 레이아웃 기능
implementation group: 'nz.net.ultraq.thymeleaf', name: 'thymeleaf-layout-dialect', version: '3.1.0'

 

templates에 layout 폴더를 생성하고 레이아웃을 위한 layout1.html을 작성

  
<!DOCTYPE html>
<html lang="en"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Layout page</title>
</head>
<body>
<div>
<h3>Sample Layout Header</h3>
</div>
<div layout:fragment="content">
<p>Page content goes here</p>
</div>
<div>
<h3>Sample Layout Footer</h3>
</div>
<th:block layout:fragment="script">
</th:block>
</body>
</html>

 

📍 코드 위쪽에는 http://www.ultraq.net.nz/thymeleaf/layout을 이용해서 Thymeleaf의 Layout을 적용하기 위한 네임스페이스를 지정
📍 코드 중간에는 layout:fragment 속성을 이용해서 해당 영역은 나중에 다른 파일에서 해당 부분만을 개발할 수 있음
     ➡️  layout1.html에는 content와 script 부분을 fragment로 지정

 

SampleController에 레이아웃 예제를 위한 ex3()을 추가

  
@GetMapping("/ex/ex3")
public void ex3(Model model) {
model.addAttribute("arr", new String[] {"AAA", "BBB", "CCC"});
}

 

templates의 ex폴더에 ex3.html을 생성. 가장 중요한 부분은 <html>에서 사용된 레이아웃 관련 설정

  
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/layout1.html}">
<div layout:fragment="content">
<h1>ex3.html</h1>
</div>

 

fragment에 content 부분만 작성한 것을 확인

 

 

layout1.html에는 content와 script 영역을 따로 구성했으므로 이를 이용해서 자바스크립트를 처리하고 싶다면 별도의 영역을 지정하고 fragment를 지정

  
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/layout1.html}">
<div layout:fragment="content">
<h1>ex3.html</h1>
</div>
<script layout:fragment="script" th:inline="javascript">
const arr = [[${arr}]]
</script>

 


1.  스프링 부트란 ?

🐰  스프링 부트는 엄밀하게 말하면 '스프링 프레임워크 개발 도구'. 즉, 엔터프라이즈급 에플리케이션을 개발 하기 위해서 필요한 기능을 제공하는 개발 도구
🐰  스프링의 중요한 특징자동 설정 Auto Configuration (라이브러리만으로 설정을 인식하려는 특성) 을 내세울 수 있음
     예를 들어 스프링 부트는 데이터베이스와 관련된 모듈을 추가하면 자동으로 데이터베이스 관련 설정을 찾아서 실행
      ✓  스프링만을 이용하는 경우와 비교해보면 추가한 모듈 설정이 전혀 필요하지 않고, 설정 자체도 단순
🐰  다른 특징으로는 '내장 톰캣'과 단독 실행 가능한 도구라는 점. 스프링 부트는 별도의 서버 설정 없이도 개발이 가능하고, 실행도 가능

 

 

1) 기존 개발과 차이점


✓ 설정과 관련해서는 직접 필요한 라이브러리를 기존 build.gradle 파일에 추가하는 설정이 상당히 단순하기도 하지만 자동으로 처리

✓ 특히 톰캣이 내장된 상태로 프로젝트가 생성되기 때문에 WAS의 추가 설정이 필요하지 않다는 점도 편리
✓ 빈 설정은 XML을 대신해서 자바 설정을 이용하는 것으로 약간의 변경이 있음

스프링 MVC에서는 JSP를 이용할 수 있지만 기본 설정이 아니라서 라이브러리를 추가해야 되고, 스프링 부트는 Thymeleaf라는 템플릿 엔진을 활용하는 경우가 많음
스프링 부트에서도 MyBatis를 이용할 수 있지만, JPA를 이용하면 객체지향으로 구성된 객체들을 데이터베이스에 반영할 수 있는데 이를 자동으로 처리할 수 있으므로 별도의 SQL의 개발 없이도 개발이 가능

 

스프링 부트의 프로젝트 생성 방식


  👾  스프링 부트를 위한 프로젝트의 생성 방법은 크게 2가지
      -  Spring Initializr를 이용한 자동 생성 ( 대부분 많이 사용. 프로젝트의 기본 템플릿 구조를 만들어 주기 때문 )

            ➡️  웹 사이트(https://start.spring.io/) 에서 프로젝트를 생성하거나, 이클립스나 인텔리제이, VS Code 등에서도 Spring Initializr를 지원하기 때문에 호환성 면에서도 유리
      -  Maven이나 Gradle을 이용한 직접 생성

  👾  스프링 부트는 스프링을 쉽게 사용하기 위한 도구이므로 프로젝트를 생성하고 필요한 라이브러리들을 추가하는 형태의 개발도 가능


 

2.  프로젝트의 실행

🐰  스프링 부트의 프로젝트는 이미 서버를 내장한 상태에서 만들어지기 때문에 스프링만을 이용할 때와 달리 별 의 WAS (Web Application Server) 설정이 필요하지 않고 main() 메서드의 실행을 통해서 프로젝트를 실행

 

main()을 실행하면 자동으로 내장된 톰캣이 실행되는 것을 로그를 통해서 확인

  ✓  실행 결과는 에러가 발생
  ✓  스프링 부트가 자동 설정을 통해서 인식한 Spring Data JPA를 실행했을 때 DB와 관련된 설정을 찾을 수 없어서 발생한 에러

  📍  에러가 발생하긴 했지만, 아무런 설정이 없는 상태인데 자동으로 데이터베이스 관련 설정을 이용을 함
        ➡️  이와 같이 라이브러리만으로 설정을 인식하려는 특성을 '자동 설정 auto configuration'이라고 함

  📍  스프링 부트 설정은 프로젝트 생성 시에 만들어진 application.properties 파일을 이용하거나 application.yml (YAML 이라고 함) 파일을 이용할 수 있음
        ➡️  만일 파일 설정을 피하고 싶으면 @Configuration이 있는 클래스 파일을 만들어서 필요한 설정을 추가할 수 있음

  📍  대부분의 스프링을 지원하는 개발 도구 IDE에서는 application.properties 파일에 들어갈수 있는 내용을 쉽게 완성해 주는 기능을 제공

 

application.properties 파일에 데이터베이스 설정을 추가

  
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/sample
spring.datasource.username="아이디"
spring.datasource.password="비밀번호"

 

  💡  만일8080 port가 다른 프로젝트에서 실행되고 있다면 포트번호를 application.properties에서 server.port를 지정해서 변경할 수 있음


  
server.port=8082

 

서블릿이나 스프링처럼 프로젝트를 실행하면 브라우저가 자동으로 실행되지 않음. 부트의 경우 서버를 내장하고 있어서 브라우저가 자동 실행이 안되어서, 직접 브라우저를 열어서 주소창에 주소를 입력해야 함


 

3.  편의성을 높이는 몇 가지 설정

DataSource를 이용하는 것만으로도 스프링 부트가 편리하다는 사실을 알수 있지만, 추가적인 설정 몇 가지만 더 한다면 개발 시간을 더욱 단축할 수 있음

자동 리로딩 설정

 

  • 웹 개발 시에 코드를 수정하고 다시 deploy를 하는 과정을 자동으로 설정. Edit Configuration 메뉴를 실행
  • Bulid and run 메뉴에 있는 Modify options를 선택
  • On 'Update' action / On frame deactivation 의 옵션값을 Update classes and resources 로 지정

 

 

Lombok을 테스트 환경에서도 사용하기


스프링 부트는 체크박스를 선택하는 것만으로 Lombok 라이브러리를 추가하지만 테스트 환경에서는 설정이 빠져 있음
build.gradle 파일 내 dependencies 항목에 test 관련 설정을 조정


  
// lombok을 테스트 환경에서도 사용.
testCompileOnly('org.projectlombok:lombok')
testAnnotationProcessor('org.projectlombok:lombok')



로그 레벨의 설정


스프링 부트는 기본적으로 Log4j2가 추가되어 있기 때문에 라이브러리를 추가하지 않아도 됨.
applocation.properties 파일을 이용해서 간단하게 로그 설정을 추가할 수 있음.


  
logging.level.org.springframework=info
logging.level.kr.nomadlab=debug

 

 

인텔리제이의 DataSource 설정


인텔리제이 ultimate의 경우 JPA 관련 플러그인이 이미 설치되어 있기 때문에 DataSource를 설정해두면 나중에 엔티티 클래스의 생성이나 기타 클래스의 생성과 설정 시에 도움이 됨

 

 

테스트 환경과 의존성 주입 테스트


스프링에는 'spring-test-xxx' 라이브러리를 추가해야 하고 JUnit 등도 직접 추가 해야만 하지만, 스프링 부트는 프로젝트 생성할 때 이미 테스트 관련 설정이 완료되고 테스트 코드가 하나 생성되어 있음

 

  ✓  테스트 코드의 실행을 점검하기 위해서 DataSourceTest를 작성해서 HikariCP의 테스트와 Lombok을 확인

  ✓  DataSource는 application.properties에 설정된 DataSource 관련 설정을 통해서 생성된 빈 Bean이고, 이에 대한 별도의 설정 없이 스프링에서 바로 사용이 가능


  
package com.example.springboot;
import lombok.Cleanup;
import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
@SpringBootTest
@Log4j2
public class DataSourceTest {
@Autowired
private DataSource dataSource;
@Test
public void connection() throws SQLException {
@Cleanup Connection connection = dataSource.getConnection();
log.info(connection);
Assertions.assertNotNull(connection);
}
}

 

 

Spring Data JPA을 위한 설정


DataSource 설정까지 모든 테스트가 완료되었다면 Spring Data JPA를 이용할 때 필요한 설정을 추가
application.properties에 다음과 같은 내용을 추가


  
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.show-sql=true

 

  ✓  spring.jpa.hibernate.ddl-auto 속성은 프로젝트 실행 시 DDL 문을 처리 할 것인지를 명시
  ✓  DDL : Data Definition Language  ➡️  테이블이나 관계의 구조를 생성하는데 사용하며 CREATE, ALTER, DROP, TRUNCATE 문 등이 있음

  ✓  spring.jpa.properties.hibernate.format_sql 속성은 실제로 실행되는 SQL을 포맷팅해서 알아보기 쉽게 출력
  ✓  spring.jpa.show-sql은 JPA가 실행하는 SQL을 같이 출력

 

속성값 의미
none DDL을 하지 않음
create-drop 실행할 때 DDL을 실행하고 종료시에 만들어진 테이블 등을 모두 삭제
create 실행할 때마다 새롭게 테이블을 생성
update 기존과 다르게 변경돤 부분이 있을 때는 새로 생성
validate 변경된 부분만 알려주고 종료

 

📍  update 속성 값의 경우 테이블이 없을 때는 자동으로 생성하고 변경이 필요할 때는 alter table이 실행. 테이블뿐만 아니라 인덱스나 외래키 등도 자동으로 처리

 


 

4.  스프링 부트에서 웹 개발

🐰  스프링 부트를 이용해서 웹을 개발하는 일은 컨트롤러나 화면을 개발하는 것은 유사하지만, web.xml이나 server-context.xml과 같은 웹 관련 설정 파일들이 없기 때문에 이를 대신하는 클래스를 작성해 준다는 점이 다름
   ✓  xml을 통한 설정은 오류가 발생했을 때 찾기 힘듬. 마크업 언어라서 문법을 체크하는데 한계가 있음. 인텔리제이같은 통합 개발 환경에서도 자동완성을 사용하기 힘듬

 

1) 컨트롤러와 Thymeleaf 만들기

프로젝트에 우선 controller라는 패키지를 생성하고 SampleController 클래스를 생성
SampleController 자체의 개발은 기존의 스프링 MVC를 그대로 이용


  
@Controller
@Log4j2
public class SampleController {
@GetMapping("/hello")
public void hello(Model model) {
log.info("hello...");
model.addAttribute("msg", "Hello World");
}
}

 

  📍 화면은 Thymeleaf를 이용하는데 위치를 주의해서 작성. 프로젝트 생성 시에 만들어져 있는 resources/templates 폴더에 hello.html을 작성
  📍 중요한 부분은 Thymeleaf의 네임스페이스 namespace를 추가


  
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1 th:text="${msg}"></h1>
</body>
</html>

 

 

 

Thymeleaf는 JSP와 동일하게 서버에서 결과물을 생성해서 보내는 방식이지만
좀더 HTML에 가깝게 작성할 수 있고 다양한 기능을 가지고 있음

 

 

 

 


 

2)  JSON 데이터 만들기

스프링을 사용할 때는 jackon-databind 라는 별도의 라이브러리를 출력한 후에 개발할 수 있지만, 스프링 부트는 'web' 항목을 추가할 때 자동으로 포함되므로 별도의 설정 없이 바로 개발할 수 있음

 

controller 패키지에 SampleJSONController라는 클래스를 작성

  
import lombok.extern.log4j.Log4j2;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@Log4j2
@RestController
public class SampleJSONController {
@GetMapping("/helloArr")
public String[] helloArr() {
log.info("HelloArr...");
return new String[] {"AAA", "BBB", "CCC"};
}
}

 

브라우저에 'helloArr' 경로를 호출하면 배열이 그대로 출력. 중요한 점은 서버에서 해당 데이터는 Content_Type을 'application/json' 방식 으로 전송

 


8.  수정 - 제목, 내용만

  • DAO에 수정하는 updateOne() 메서드 작성
  • 테스트 코드 작성 후 테스트
  • Service 작업
  • 컨트롤러에 modify 추가
  • modify.jsp 작성

  
public interface BoardMapper {
void insert(BoardVO boardVO); // DB 저장하는 메소드
List<BoardVO> selectAll();
BoardVO selectOne(Integer no);
void updateHit(Integer no);
void updateOne(BoardVO boardVO);
}

  
<update id="updateOne">
UPDATE tbl_board SET title = #{title}, content= #{content} WHERE no = #{no}
</update>

  
@Test
public void updateOne() {
Integer no = 8;
BoardVO boardVO = BoardVO.builder()
.no(no)
.title("수정")
.content("수정된 내용...")
.build();
boardMapper.updateOne(boardVO);
log.info(boardMapper.selectOne(no));
}


  
public interface BoardService {
void register(BoardDTO boardDTO);
List<BoardDTO> getAll();
BoardDTO getOne(Integer no);
void updateOne(BoardDTO boardDTO);
}

  
@Override
public void updateOne(BoardDTO boardDTO) {
log.info("update...");
BoardVO boardVO = modelMapper.map(boardDTO, BoardVO.class);
log.info(boardVO);
boardMapper.updateOne(boardVO);
}

  
@PostMapping("/modify")
public String modify(@Valid BoardDTO boardDTO,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
if (bindingResult.hasErrors()) {
// 유효성 검사 결과 에러가 있으면 수정 페이지로 돌아감
log.info("has error");
redirectAttributes.addFlashAttribute("errors", bindingResult.getAllErrors());
redirectAttributes.addAttribute("no", boardDTO.getNo());
return "redirect:/board/modify";
}
boardService.updateOne(boardDTO);
return "redirect:/board/read?no=" + boardDTO.getNo();
}

  
<div class="card-body">
<form action="/board/modify" method="post">
<input type="hidden" name="no" value="${dto.no}">
<br>
<div class="input-group">
<span class="input-group-text">Title</span>
<input type="text" name="title" class="form-control" value="${dto.title}">
</div><br>
<div class="input-group">
<span class="input-group-text">Content</span>
<textarea name="content" class="form-control">${dto.content}</textarea>
</div><br>
<div class="input-group">
<span class="input-group-text">Writer</span>
<input type="text" name="writer" class="form-control" value="${dto.writer}" readonly>
</div><br>
<div class="input-group">
<span class="input-group-text">Date</span>
<input type="date" name="dueDate" class="form-control" value="${dto.addDate}" readonly>
</div><br>
<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>
<script>
const serverValidResult = {};
<c:forEach items="${errors}" var="error">
serverValidResult['${error.getField()}'] = '${error.defaultMessage}';
</c:forEach>
console.log(serverValidResult);
</script>
<script>
const frmModify = document.querySelector('form');
document.querySelector('.btn-danger').addEventListener('click', function () {
frmModify.action = '/board/remove';
frmModify.method = 'post';
frmModify.submit();
});
document.querySelector('.btn-primary').addEventListener('click', function () {
frmModify.action = '/board/modify';
frmModify.method = 'post';
frmModify.submit();
});
document.querySelector('.btn-secondary').addEventListener('click', function (e) {
self.location = `/board/list?${boardDTO.link}`;
});
</script>
</div>

 


 

9.  삭제

  • DAO에 삭제하는 deleteOne() 메서드 작성
  • 테스트 코드 작성 후 테스트
  • Service 작업
  • 컨트롤러에 remove 추가

  
public interface BoardMapper {
void insert(BoardVO boardVO); // DB 저장하는 메소드
List<BoardVO> selectAll();
BoardVO selectOne(Integer no);
void updateHit(Integer no);
void updateOne(BoardVO boardVO);
void deleteOne(Integer no);
}

  
<delete id="deleteOne">
DELETE FROM tbl_board WHERE no = #{no}
</delete>

  
@Test
public void deleteOne() {
Integer no = 8;
BoardVO boardVO = boardMapper.selectOne(no);
log.info(boardVO);
boardMapper.deleteOne(no); // 삭제
boardVO = boardMapper.selectOne(no);
log.info(boardVO);
}


  
public interface BoardService {
void register(BoardDTO boardDTO);
List<BoardDTO> getAll();
BoardDTO getOne(Integer no);
void updateOne(BoardDTO boardDTO);
void deleteOne(Integer no);
}

  
@Override
public void deleteOne(Integer no) {
boardMapper.deleteOne(no);
}

  
@PostMapping("/remove")
public String remove(Integer no, RedirectAttributes redirectAttributes) {
log.info("-----remove----");
log.info("no: " + no);
boardService.deleteOne(no);
return "redirect:/board/list";
}

 

 


 

10.  비밀번호 확인

  • PasswdVO 작성
  • BoardMapper 인터페이스 작성
  • BoardMapperxml 작성
  • BoardMapperTest 클래스 작성
  • PasswdDTO 작성
  • BoardService 작성
  • BoardServiceImpl 작성

  
@Getter
@ToString
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class PasswdVO {
private int no;
private String passwd;
}

  
public interface BoardMapper {
...
BoardVO selectOneByPasswd(PasswdVO passwdVO);
}

  
<select id="selectOneByPasswd" resultType="com.example.spring_ex_01_2404.domain.BoardVO">
SELECT * FROM tbl_board WHERE no = #{no} AND passwd = SHA2(#{passwd}, 256)
</select>

  
@Test
public void selectOneByPasswd() {
PasswdVO passwdVO = PasswdVO.builder()
.no(12)
.passwd("1234").build();
BoardVO boardVO = boardMapper.selectOneByPasswd(passwdVO);
log.info(boardVO);
}


  
@ToString
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class PasswdDTO {
private Integer no;
private String passwd;
}

  
public interface BoardService {
...
boolean isCurrentPasswd(PasswdDTO passwdDTO);
}

  
@Override
public boolean isCurrentPasswd(PasswdDTO passwdDTO) {
PasswdVO passwdVO = modelMapper.map(passwdDTO, PasswdVO.class);
return boardMapper.selectOneByPasswd(passwdVO) != null;
}

  
@Test
public void isCurrentPasswd() {
PasswdDTO passwdDTO = PasswdDTO.builder()
.no(12)
.passwd("1234").build();
log.info(boardService.isCurrentPasswd(passwdDTO));
}

 

JSP 코드 - 수정 or 삭제에 따라 버튼 내용 및 문구 타이틀 변경 추가

  
<div class="card-body">
<h3>${reason == "incorrect" ? "틀린 비밀번호 입니다." : "비밀번호를 입력해주세요!"}</h3>
<form action="/board/passwd" method="post">
<input type="hidden" name="no" value="${no}">
<input type="hidden" name="mode" value="${mode}">
<div class="input-group">
<span class="input-group-text">Password</span>
<input type="password" name="passwd" placeholder="비밀번호">
</div><br>
<div class="my-4">
<div class="float-end">
<button type="submit" class="btn btn-danger">${mode == "remove" ? "삭제" : "수정"}</button>
</div>
</div>
</form>
</div>

 

Controller - 수정, 삭제 하기 전 비밀번호 확인 코드

  
@GetMapping("/modify")
public String modify(Integer no, Model model,
HttpServletRequest request,
RedirectAttributes redirectAttributes) {
// 1. 세션에 저장된 비밀번호를 불러옴.
HttpSession session = request.getSession();
String passwd = (String) session.getAttribute("passwd");
// 2. 비밀번호가 없으면 비밀번호 입력 페이지로 리다이렉트
if (passwd == null || passwd.isEmpty()) {
redirectAttributes.addAttribute("no", no);
redirectAttributes.addAttribute("mode", "modify");
return "redirect:/board/passwd";
}
// 3. 비밀번호가 틀리면 비밀번호 입력 페이지로 리다이렉트
if (!boardService.isCurrentPasswd(PasswdDTO.builder().no(no).passwd(passwd).build())) {
session.removeAttribute("passwd");
redirectAttributes.addAttribute("no", no);
redirectAttributes.addAttribute("mode", "modify");
redirectAttributes.addFlashAttribute("reason", "incorrect");
return "redirect:/board/passwd";
}
BoardDTO boardDTO = boardService.getOne(no);
model.addAttribute("dto", boardDTO);
return "/board/modify";
}
@RequestMapping("/remove")
public String remove(Integer no, HttpServletRequest request, RedirectAttributes redirectAttributes) {
// 1. 세션에 저장된 비밀번호를 불러옴.
HttpSession session = request.getSession();
String passwd = (String) session.getAttribute("passwd");
// 2. 비밀번호가 없으면 비밀번호 입력 페이지로 리다이렉트
if (passwd == null || passwd.isEmpty()) {
redirectAttributes.addAttribute("no", no);
redirectAttributes.addAttribute("mode", "remove");
return "redirect:/board/passwd";
}
// 3. 비밀번호가 틀리면 비밀번호 입력 페이지로 리다이렉트
if (!boardService.isCurrentPasswd(PasswdDTO.builder().no(no).passwd(passwd).build())) {
session.removeAttribute("passwd");
redirectAttributes.addAttribute("no", no);
redirectAttributes.addAttribute("mode", "remove");
redirectAttributes.addFlashAttribute("reason", "incorrect");
return "redirect:/board/passwd";
}
// 4. 게시물 삭제
log.info("-----remove----");
boardService.deleteOne(no);
// 5. 삭제 후 비밀번호 삭제
session.removeAttribute("passwd");
return "redirect:/board/list";
}
// 비밀번호 입력 페이지
@GetMapping("/passwd")
public void passwd(Integer no, String mode, Model model) {
model.addAttribute("no", no);
model.addAttribute("mode", mode);
}
@PostMapping("/passwd")
public String passwdPost(Integer no, String mode, String passwd, HttpServletRequest request,
RedirectAttributes redirectAttributes) {
log.info("...passwdPost()");
log.info(mode);
// 1. 전달받은 비밀번호를 세션에 저장
HttpSession session = request.getSession();
session.setAttribute("passwd", passwd);
// 2. 해당 처리 페이지로 리다이렉트
redirectAttributes.addAttribute("no", no);
return "redirect:/board/" + mode;
}

 

 

 

 

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]

 


 

💡  DTO : 컨트롤러에서 서비스로, 혹은 서비스에서 컨트롤러로 데이터를 주고 받을 때 사용
       VO : 서비스는 DAO와 데이터 주고 받을 때는 VO를 사용하기 때문에 서비스에서 DTO ↔ VO 작업이 필요
  • DTO : 컨트롤러 ↔ 서비스
  • VO : 서비스 ↔ DAO

1.  콘솔창에서 데이터베이스 작업 


  
CREATE TABLE `tbl_board` (
`no` int auto_increment primary key,
`title` varchar(100) not null, // 제목
`content` text not null , // 내용
`writer` varchar(50) not null , // 작성자
`passwd` varchar(100) not null , // 비밀번호
`addDate` datetime, // 작성날짜
`hit` int default 0 // 조회수
);

 


 

2.  BoardVO 작성 

테이블에 저장할 데이터를 담거나, 테이블에서 들고온 데이터를 담는 용도

주로 DAO (데이터베이스 처리하는 파트) 에서 사용


  
@Getter
@ToString
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class BoardVO {
private Integer no;
private String title; // 제목
private String content; // 내용
private String writer; // 작성자
private String passwd; // 비밀번호
private LocalDate addDate; // 작성일
private Integer hit; // 조회수
}

 


3.  VO를 이용해서 데이터베이스에 저장하는 테스트 코드 작성

 작성 순서

 

  1)  BoardMapper 인터페이스 작성 - 객체 받아 데이터베이스에 저장, 수정, 삭제를 실행하는 메소드 작성

  2) BoardMapperxml 작성 - 데이터베이스 쿼리문 작성

  3)  BoardMapperTest 클래스 작성 - 실행 테스트

 

 


  
public interface BoardMapper {
void insert(BoardVO boardVO); // DB 저장하는 메소드
}

 


  
<mapper namespace="com.example.spring_ex_01_2404.mapper.BoardMapper">
<insert id="insert">
INSERT INTO tbl_board (title, content, writer, passwd, addDate)
VALUES (#{title}, #{content}, #{writer}, SHA2(#{passwd},256), now())
</insert>
</mapper>

 

  ✓  mysql로 비밀번호 암호화 할 경우 SHA2(입력값, 256) 함수 사용

  ✓ addDate는 글 작성 시간이 들어가니 now() 함수를 사용

  ✓ hit는 작성시에는 0이니 컬럼의 기본값을 사용


  
@Log4j2
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "file:src/main/webapp/WEB-INF/root-context.xml")
class BoardMapperTest {
@Autowired(required = false)
private BoardMapper boardMapper;
@Test
public void testInsert() {
BoardVO boardVO = BoardVO.builder() // 빌더를 이용해서 BoardVO 객체를 생성
.title("스프링 테스트")
.content("스프링 테스트 중...")
.writer("river")
.passwd("1234")
.addDate(now())
.build();
boardMapper.insert(boardVO);
}
}
02:34:06 DEBUG [com.example.spring_ex_01_2404.mapper.BoardMapper.insert] ==> Preparing: INSERT INTO tbl_board (title, content, writer, passwd, addDate) VALUES (?, ?, ?, SHA2(?,256), now())
02:34:06 DEBUG [com.example.spring_ex_01_2404.mapper.BoardMapper.insert] ==> Parameters: 스프링 테스트(String), 스프링 테스트 중...(String), river(String), 1234(String)
02:34:06 DEBUG [com.example.spring_ex_01_2404.mapper.BoardMapper.insert] <== Updates: 1

 


4.  Service 작업

BoardDTO → BoardService → BoardServiceImpl → 테스트 코드

  
@ToString
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class BoardDTO {
private Integer no;
private String title;
private String content;
private String writer;
private String passwd;
private LocalDate addDate;
private Integer hit;
}

 


  
public interface BoardService {
void register(BoardDTO boardDTO);
}

 


  
@Log4j2
@Service
@RequiredArgsConstructor // 생성자 객체 주입. private final로 선언된 참조변수에 객체를 저장하는 생성자 작성.
public class BoardServiceImpl implements BoardService{
private final BoardMapper boardMapper;
private final ModelMapper modelMapper;
@Override
public void register(BoardDTO boardDTO) {
log.info(boardDTO);
BoardVO boardVO = modelMapper.map(boardDTO, BoardVO.class);
log.info(boardVO);
boardMapper.insert(boardVO);
}
}

 


  
@Log4j2
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "file:src/main/webapp/WEB-INF/root-context.xml")
class BoardServiceImplTest {
@Autowired
private BoardService boardService;
@Test
public void testRegister() {
BoardDTO boardDTO = BoardDTO.builder()
.title("service test")
.content("service test...")
.writer("user")
.passwd("12345")
.addDate(LocalDate.now())
.build();
boardService.register(boardDTO);
}
}
03:23:33 INFO [com.example.spring_ex_01_2404.service.BoardServiceImpl] BoardDTO(no=null, title=service test, content=service test..., writer=user, passwd=12345, addDate=2024-05-01, hit=null)
03:23:33 INFO [com.example.spring_ex_01_2404.service.BoardServiceImpl] BoardVO(no=null, title=service test, content=service test..., writer=user, passwd=12345, addDate=2024-05-01, hit=null)
03:23:33 DEBUG [com.example.spring_ex_01_2404.mapper.BoardMapper.insert] ==> Preparing: INSERT INTO tbl_board (title, content, writer, passwd, addDate) VALUES (?, ?, ?, SHA2(?,256), now())
03:23:33 DEBUG [com.example.spring_ex_01_2404.mapper.BoardMapper.insert] ==> Parameters: service test(String), service test...(String), user(String), 12345(String)
03:23:33 DEBUG [com.example.spring_ex_01_2404.mapper.BoardMapper.insert] <== Updates: 1

 


 

5.  등록페이지 작성

  • /board/add로 접근할 경우 등록 페이지 출력
  • 등록페이지에서 입력 후 submit을 하면 데이터베이스에 글이 등록
작업순서 
  • BoardController 작성
  • get으로 /board/add에 접근할 경우 실행할 메서드 작성
  • /board/add로 정상적으로 접근되는지 확인
  • webapp에 add.jsp 코딩
  • controller에 post로 /board/add에 접근할 경우 실행할 메서드 작성
  • 테스트 후 입력한 데이터가 제대로 전달되는지 확인
  • addPost()를 수정해서 서비스로 연결
  • 데이터베이스에 저장 확인
  • addPost()를 수정해서 /board/list 이동 확인

  
@Log4j2
@Controller
@RequestMapping("/board")
@RequiredArgsConstructor
public class BoardController {
private final BoardService boardService;
@GetMapping("/add")
public void add() {
log.info("add board...");
}
@PostMapping("/add")
public String addPost(@Valid BoardDTO boardDTO, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
log.info("board addPost()...");
if (bindingResult.hasErrors()) {
log.info("has error...");
redirectAttributes.addFlashAttribute("errors", bindingResult.getAllErrors());
return "redirect:/board/add";
}
log.info(boardDTO);
boardService.register(boardDTO);
return "redirect:/board/list";
}
}

 


  
<form method="post">
<div class="input mb-3">
<span class="input-group-text">Title</span><br>
<input type="text" name="title" placeholder="Title"><br><br>
</div>
<div class="input mb-3">
<span class="input-group-text">Content</span><br>
<textarea name="content" cols="60" rows="18"></textarea><br><br>
</div>
<div class="input mb-3">
<span class="input-group-text">Writer</span><br>
<input type="text" name="writer" placeholder="Writer"><br><br>
</div>
<div class="input mb-3">
<span class="input-group-text">Password</span><br>
<input type="password" name="passwd" placeholder="비밀번호"><br><br>
</div>
<div class="float-end">
<button type="submit" name="submitBtn">Submit</button>
</div>
</form>

 

 


 

6.  목록페이지

  • DAO에 목록을 불러오는 selectAll() 메서드 작성
  • 테스트 코드 작성 후 테스트
  • Service 작업
  • 테스트 코드 작성 후 테스트
  • 컨트롤러에 /board/list 경로와 매핑을 한 list() 작성
  • list.jsp 작성

  
public interface BoardMapper {
void insert(BoardVO boardVO); // DB 저장하는 메소드
List<BoardVO> selectAll(); // DB에 저장되어 있는 리스트 목록
}

  
<select id="selectAll" resultType="com.example.spring_ex_01_2404.domain.BoardVO">
SELECT * FROM tbl_board ORDER BY no DESC
</select>

  
@Test
public void selectAll() {
List<BoardVO> boardVOList = boardMapper.selectAll();
for(BoardVO boardVO : boardVOList) {
log.info(boardVO);
}
}

  
public interface BoardService {
void register(BoardDTO boardDTO);
List<BoardDTO> getAll();
}

  
@Override
public List<BoardDTO> getAll() {
List<BoardVO> voList = boardMapper.selectAll(); // dao에서 데이터베이스에서 들고온 VO리스트를 리턴
List<BoardDTO> dtoList = new ArrayList<>();
for (BoardVO boardVO: voList) {
// 개별 VO를 DTO로 변환.
BoardDTO boardDTO = modelMapper.map(boardVO, BoardDTO.class);
dtoList.add(boardDTO); // DTO리스트에 저장.
}
return dtoList;
}

  
@Test
public void getAll() {
List<BoardDTO> boardDTOList = boardService.getAll();
for (BoardDTO boardDTO : boardDTOList) {
log.info(boardDTO);
}
}

  
@RequestMapping("/list")
public void list(Model model) {
log.info("todo list...");
model.addAttribute("dtoList", boardService.getAll());
}

  
<div class="card">
<div class="card-header">Board List</div>
<div class="card-body">
<table class="table">
<thead>
<tr>
<th scope="col">No</th>
<th scope="col">Title</th>
<th scope="col">Writer</th>
<th scope="col">Date</th>
</tr>
</thead>
<tbody>
<c:forEach var="dto" items="${dtoList}">
<tr>
<th scope="row">${dto.no}</th>
<td>${dto.title}</td>
<td>${dto.writer}</td>
<td>${dto.addDate}</td>
</tr>
</c:forEach>
</tbody>
</table>
</div>
</div>
</div>

 


7.  조회

리스트에서 클릭을 하면 해당 글을 보여주는 페이지 구현

  • DAO에 게시물을 불러오는 selectOne() 메서드 작성
  • 테스트 코드 작성  후 테스트
  • Service 작업
  • 컨트롤러에 /board/read 경로와 매핑을 한 read() 작성
  • read.jsp 작성
  • updatehit 추가 (조회수 증가)

  
public interface BoardMapper {
void insert(BoardVO boardVO); // DB 저장하는 메소드
List<BoardVO> selectAll();
BoardVO selectOne(Integer no); // 조회
void updateHit(Integer no); // 조회수 증가
}

  
<select id="selectOne" resultType="com.example.spring_ex_01_2404.domain.BoardVO">
SELECT * FROM tbl_board WHERE no = #{no}
</select>
<update id="updateHit">
UPDATE tbl_board SET hit = hit + 1 where no = #{no}
</update>

  
@Test
public void selectOne() {
BoardVO boardVO = boardMapper.selectOne(1);
log.info(boardVO);
}
11:46:50 DEBUG [com.example.spring_ex_01_2404.mapper.BoardMapper.selectOne] ==> Preparing: SELECT * FROM tbl_board WHERE no = ?
11:46:50 DEBUG [com.example.spring_ex_01_2404.mapper.BoardMapper.selectOne] ==> Parameters: 1(Integer)
11:46:50 TRACE [com.example.spring_ex_01_2404.mapper.BoardMapper.selectOne] <== Columns: no, title, content, writer, passwd, addDate, hit
11:46:50 TRACE [com.example.spring_ex_01_2404.mapper.BoardMapper.selectOne] <== Row: 1, 스프링 테스트, <<BLOB>>, river, 1234, 2024-05-01 00:00:00, 1
11:46:50 DEBUG [com.example.spring_ex_01_2404.mapper.BoardMapper.selectOne] <== Total: 1

 


  
public interface BoardService {
void register(BoardDTO boardDTO);
List<BoardDTO> getAll();
BoardDTO getOne(Integer no);
}

  
@Override
public BoardDTO getOne(Integer no) {
boardMapper.updateHit(no);
BoardVO boardVO = boardMapper.selectOne(no);
BoardDTO boardDTO = modelMapper.map(boardVO, BoardDTO.class);
return boardDTO;
}

 


  
@GetMapping("/read")
public void read(Integer no, Model model) {
BoardDTO boardDTO = boardService.getOne(no);
log.info(boardDTO);
model.addAttribute("dto", boardDTO);
}

 


  
<div class="card-body">
<div class="input-group">
<span class="input-group-text">No</span>
<input type="text" name="tno" class="form-control" value="${dto.no}" readonly>
</div> <br>
<div class="input-group">
<span class="input-group-text">Title</span>
<input type="text" name="title" class="form-control" value="${dto.title}" readonly>
</div><br>
<div class="input-group">
<span class="input-group-text">Content</span>
<textarea name="title" class="form-control" readonly>${dto.content}</textarea>
</div><br>
<div class="input-group">
<span class="input-group-text">Writer</span>
<input type="text" name="writer" class="form-control" value="${dto.writer}" readonly>
</div><br>
<div class="input-group">
<span class="input-group-text">Date</span>
<input type="date" name="dueDate" class="form-control" value="${dto.addDate}" readonly>
</div><br>
<div class="input-group">
<span class="input-group-text">Hit</span>
<input type="text" name="writer" class="form-control" value="${dto.hit}" readonly>
</div><br>
<div class="my-4">
<div class="float-end">
<button type="submit" class="btn btn-primary">Modify</button>
<button type="reset" class="btn btn-secondary">List</button>
</div>
</div>
<script>
document.querySelector('.btn-primary').addEventListener('click', function (e) {
self.location = `/board/modify?no=${dto.no}&${boardDTO.link}`;
}, false);
document.querySelector('.btn-secondary').addEventListener('click', function (e) {
self.location = "/board/list?${boardDTO.link}";
}, false);
</script>
</div>

 

 

 

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  검색 조건을 위한 화면 처리

검색 기능은 /WEB-INF/view/todo/list.jsp에서 이루어지므로 list.jsp에 검색 관련된 화면을 작성하기 위해서 <div class="card">를 하나 추가하고 검색에 필요한 내용들을 담을 수 있도록 구성.


  
<div class="row content">
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Search </h5>
<form action="/todo/list" method="get">
<input type="hidden" name="size" value="${pageRequestDTO.size}">
<div class="mb-3">
<input type="checkbox" name="finished">완료여부
</div>
<div class="mb-3">
<input type="checkbox" name="types" value="t">제목
<input type="checkbox" name="types" value="w">작성자
<input type="text" name="keyword" class="form-control">
</div>
<div class="input-group mb-3 dueDateDiv">
<input type="date" name="from" class="form-control">
<input type="date" name="to" class="form-control">
</div>
<div class="input-group mb-3">
<div class="float-end">
<button class="btn btn-primary" type="submit">Search</button>
<button class="btn btn-info clearBtn" type="reset">Clear</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>

 

 

  ✓  화면에는 <form> 태그를 이용해서 검색 조건을 전송할 수 있도록 구성
  ✓  검색을 하는 경우 무조건 페이지는 1페이지가 되므로 별도의 파라미터를 지정하지 않음
 

TodoController에서는 log.info()를 이용해서 파라미터가 정상적으로 수집되는지 확인

 

  ✓  브라우저의 주소창에는 모든 검색 조건이 GET 방식의 쿼리 스트링으로 만들어짐


 

1) 화면에 검색 조건 표시하기


검색이 처리되기는 하지만 PageRequestDTO의 정보를 EL로 처리하지 않았기 때문에 검색 후에는 검색 부분이 초기화되는 문제가 있음
  ➡️  다른 페이지로 이동을 하면 검색값이 유지가 안됨
작성된 <div>에 EL을 적용할 때 가장 문제가 되는 부분은 제목 title, 작성자 writer를 배열로 처리하고 있으므로 화면에서 처리 할 때 좀 더 편하게 사용하기 위하여 PageRequestDTO에 별도의 메서드를 구성하도록 함


  
public boolean checkType(String type) {
if(this.types == null || this.types.length == 0) {
return false;
}
return Arrays.asList(this.types).contains(type);
}

 

 

list.jsp 코드 수정

  
<form action="/todo/list" method="get">
<input type="hidden" name="size" value="${pageRequestDTO.size}">
<div class="mb-3">
<input type="checkbox" name="finished"${pageRequestDTO.finished ? " checked" : ""}>완료여부
</div>
<div class="mb-3">
<input type="checkbox" name="types" value="t"${pageRequestDTO.checkType("t") ? " checked" : ""}>제목
<input type="checkbox" name="types" value="w"${pageRequestDTO.checkType("w") ? " checked" : ""}>작성자
<input type="text" name="keyword" class="form-control" value="${pageRequestDTO.keyword}">
</div>
<div class="input-group mb-3 dueDateDiv">
<input type="date" name="from" class="form-control" value="${pageRequestDTO.from}">
<input type="date" name="to" class="form-control" value="${pageRequestDTO.to}">
</div>
<div class="input-group mb-3">
<div class="float-end">
<button class="btn btn-primary" type="submit">Search</button>
<button class="btn btn-info clearBtn" type="reset">Clear</button>
</div>
</div>
</form>


 

2) 검색 조건 초기화 시키기


검색 영역에 Clear 버튼을 누르면 모든 검색 조건을 무효화 시키도록 '/todo/list'를 호출하도록 수정

 

화면의 버튼에 'clearBtn'이라는 class 속성을 추가

  
<div class="input-group mb-3">
<div class="float-end">
<button class="btn btn-primary" type="submit">Search</button>
<button class="btn btn-info clearBtn" type="reset">Clear</button>
</div>
</div>

  
<script>
document.querySelector('.clearBtn').addEventListener('click', function (e) {
self.location = '/todo/list';
});
</script>

 


 

3) 조회를 위한 링크 처리


검색 기능이 추가되면 문제 되는 것은 조회나 수정 화면에 있는 'List' 버튼. 기존과 달리 검색 조건들을 그대로 유지해야 하므로 상당히 복잡한 처리가 필요. 하지만 PageRequestDTO의 getLink()를 이용하면 상대적으로 간단히 처리 가능

 

getLink()를 통해서 생성되는 링크에서 검색 조건등을 반영해 주도록 수정

  
public String getLink() {
StringBuilder builder = new StringBuilder();
builder.append("page=" + this.page);
builder.append("&size=" + this.size);
if(this.finished){
builder.append("&finished=on");
}
if(this.types != null && this.types.length > 0){
for (int i = 0; i < this.types.length; i++) {
builder.append("&types=" + types[i]);
}
}
if(this.keyword != null){
try {
builder.append("&keyword=" + URLEncoder.encode(keyword, "UTF-8"));
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}
if (this.from != null) {
builder.append("&from=" + from.toString());
}
if (this.to != null) {
builder.append("&to=" + to.toString());
}
return builder.toString();
}

 

getLink()는 모든 검색 / 필터링 조건을 쿼리 스트링으로 구성해야 한다. 그렇지 않으면 화면에서 모든 링크를 수정해야 하기 때문에 더 복잡하게 되기 때문. 주의할 부분은 한글이 가능한 keyword 부분은 URLEncoder를 이용해서 링크로 처리할 수 있도록 처리해야 됨

 

getLink()가 수정되면 화면에서 링크가 반영되는것 확인
목록 -> 상세 -> 수정 페이지 이동시에 쿼리 스트링이 반영되는 것 확인


 

4)  페이지 이동 링크 처리


페이지 이동에도 검색 / 필터링 조건은 필요하므로 자바스크립트로 동작하는 부분을 수정해야 함
기존에는 자바 스크립트에서 직접 쿼리 스트링을 추가해서 구성했지만, 검색 / 필터링 부분에 name이 page인 부분만 추가해서 <form> 태그를 submit으로 처리해 주면 검색 / 필터링 조건을 유지하면서도 페이지 번호만 변경하는 것이 가능


  
document.querySelector(`.pagination`).addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation(); //이벤트가 상위 엘리먼트에 전달되지 않게 막아 준다
const target = e.target;
if(target.tagName !== 'A') {
return;
}
const num = target.getAttribute('data-num');
const frmPage = document.querySelector('form');
frmPage.innerHTML += `<input type="hidden" name="page" value="\${num}">`;
frmPage.submit();
//self.location = `/todo/list?page=\${num}`;
});

 

검색/ 필터링을 유지하면서 페이지 이동하는 것을 확인

 


 

5) 수정 화면에서의 링크 처리


수정 화면인 modify.jsp에는 Remove, Modify, List 버튼이 존재하고 각 버튼에 대한 클릭 이벤트 수정

 

A. List 버튼 처리


PageRequestDTO의 getLink()를 이용해서 처리


  
document.querySelector('.btn-secondary').addEventListener('click', function (e) {
self.location = `/todo/list?${pageRequestDTO.link}`;
});

 

B. Remove 버튼 처리


삭제된 후에 1페이지로 이동해도 문제가 되지 않지만 삭제 후에도 기존 페이지와 검색 / 필터링 정보를 유지하고 싶다면 PageRequestDTO를 이용할 수 있음


  
const frmModify = document.querySelector('form');
document.querySelector('.btn-danger').addEventListener('click', function () {
frmModify.action = '/todo/remove';
frmModify.method = 'post';
frmModify.submit();
});

 

TodoController에서는 remove() 메서드가 이미 PageRequestDTO를 파라미터로 받고 있기 때문에 삭제 처리를 하고나서 리다이렉트하는 경로에 getLink()의 결과를 반영하도록 수정


  
@PostMapping("/remove")
public String remove(Long tno, PageRequestDTO pageRequestDTO , RedirectAttributes redirectAttributes) {
log.info("-----remove----");
log.info("tno: " + tno);
todoService.remove(tno);
redirectAttributes.addAttribute("page", 1);
redirectAttributes.addAttribute("size", pageRequestDTO.getSize());
return "redirect:/todo/list" + pageRequestDTO.getLink();
}

 

 

C. Modify 버튼 처리


기존과 다른 처리가 필요한데, 검색 / 필터링 기능이 추가되면 Todo의 내용이 수정되면서 검색 / 필터링 조건에 맞지 않게 될 수 있기 때문.
  ➡️  예를 들어 검색 / 필터링에 날짜로 검색했는데 날짜를 수정하면서 검색 / 필터링 조건에 맞지 않아서 목록에 나오지 않을 수 있음

 

따라서 안전하게 하려면 검색 / 필터링의 경우 수정한 후에 조회 페이지로 이동하게 하고, 검색 / 필터링 조건은 없애는 것이 안전
검색 / 필터링 조건을 유지하지 않는다면 modify.jsp 에 선언된 <input type="hidden"> 태그의 내용은 필요하지 않으므로 삭제


  
<form action="/todo/modify" method="post">
<%-- <input type="hidden" name="page" value="${pageRequestDTO.page}">
<input type="hidden" name="size" value="${pageRequestDTO.size}"> --%>
<div class="input-group mb-3">
<span class="input-group-text">Tno</span>
<input type="text" name="tno" class="form-control" value="${dto.tno}" readonly>
</div>

 

TodoController에서는 '/todo/list'가 아닌 '/todo/read'로 이동하도록 수정

  
@PostMapping("/modify")
public String midify(PageRequestDTO pageRequestDTO,
@Valid TodoDTO todoDTO,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
// 유효성 검사를 통과하지 못했더라도 값을 전송
//redirectAttributes.addAttribute("page", pageRequestDTO.getPage());
//redirectAttributes.addAttribute("size", pageRequestDTO.getSize());
if (bindingResult.hasErrors()) {
log.info("has error");
redirectAttributes.addFlashAttribute("errors", bindingResult.getAllErrors());
redirectAttributes.addAttribute("tno", todoDTO.getTno());
return "redirect:/todo/modify";
}
redirectAttributes.addAttribute("tno", todoDTO.getTno());
return "redirect:/todo/read";
}

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  검색 / 필터링 조건의 정의

📍  대부분의 서비스에서는 검색 기능을 제공. 단순히 제목이나 내용 등을 검색하는 경우도 있고, 복잡한 검색 조건을 필터링 filtering 하는 경우도 있음
📍  검색 search는 'A 혹은 B 혹은 C'와 같이 찾고자 하는 경우 검색 조건들은 주로 OR 조건으로 연결되는 경우가 많음
        ➡️  예를 들어 '제목 or 내용 or 작성자'가 xxx인 경우와 같은 데이터가 있을수도 있고, 없을 수도 있음
📍  필터링 filtering'A인 동시에 B에도 해당'한다는 개념. 필터링은 주로 특정한 범위나 범주의 값으로 걸러내는 방식
        ➡️  예를 들어 '완료된 일 중에서 특정 날짜까지 끝난 Todo'는 'A & B'와 같이 AND라는 개념의 필터링이 적용

 

다음과 같은 검색과 필터링 조건을 구성하고 처리

 

  • 제목 title과 작성자 writer 는 키워드 keyword를 이용하는 검색 처리
  • 완료 여부를 필터링 처리
  • 특정한 기간을 지정 from, to 한 필터링 처리
  • 검색과 필터링에 필요한 데이터는 다음과 같이 구분
    * 제목, 작성자 검색에 사용하는 문자열 - keyword
    * 완료 여부에 사용되는 boolean 타입 - finished
    * 특정 기간 검색을 위한 LocalDate 변수 2개 - from, to

 


2.  검색 / 필터링 조건의 결정

검색 기능을 개발할 때는 우선 검색 기능의 경우의 수를 구분하는 작업이 필요

  • 검색 / 필터링의 종류가 '완료 여부, 제목, 작성자, 기간'들의 조합으로 구성
  • 검색 종류를 types라고 지정해서 '제목(t), 작성자(w)'로 구분해서 검색의 실제 값은 검색 종류에 따라 키워드 keyword를 이용
  • 검색은 목록 기능에 사용하는 PageRequestDTO에 필요한 변수들을 추가해서 구성
PageRequestDTO에 types, keyword, finished, from, to 변수를 새롭게 추가

  
private String[] types; // 검색 경우의 수 1) title 2) writer 3) title, writer
private String keyword; // 검색어
private boolean finished;
private LocalDate from;
private LocalDate to;

 


3.  types에 따른 동적 쿼리

Mybatis에는 실행 시에 쿼리를 만들 수 있는 여러 태그들을 제공

 

1) foreach, if

 

🤓  <foreach>는 반복 처리를 위해 제공
🤓  <foreach>의 대상은 배열이나 List, Map, Set과 같은 컬렉션 계열이나 배열을 이용

 

실습을 위해 {"t", "w"}와 같은 types를 PageRequestDTO에 설정하고 테스트를 진행
기존의 TodoMapperTests 클래스에 새로운 메서드를 추가

  
@Test
public void testSelectSearch() {
PageRequestDTO pageRequestDTO = PageRequestDTO.builder()
.page(1)
.size(10)
.types(new String[]{"t", "w"})
.keyword("AAAA")
.build();
List<TodoVO> voList = todoMapper.selectList(pageRequestDTO);
voList.forEach(vo -> log.info(vo));
}

 

📍 TodoMapper의 selectList()는 PageRequestDTO를 파라미터로 받고 있기 때문에 변경 없이 바로 사용 가능하므로 TodoMapper.xml만 수정하면 됨

 

TodoMapper.xml에서는 <select id="selectList"> 태그에 MyBatis의 <foreach>를 적용

  
<select id="selectList" resultType="com.example.spring_ex_01_2404.domain.TodoVO">
select * from tbl_todo
<foreach collection="types" item="type">
#{type}
</foreach>
order by tno desc limit #{skip}, #{size}
</select>

 

  👾  현재 PageRequestDTO의 types는 {"t", "w"}이므로 테스트 코드를 실행하면 다음과 같은 코드가 만들어지는 것을 확인
        (쿼리문이 아직 완성된 상태가 아니라서 에러가 발생)

 

  👾  sql 부분만 보면 다음과 같이 sql 이 실행
        select * from tbl_todo ? ? order by tno desc limit ?, ?
        ✓  't'와 'w'가 전달되었기 때문에 'from tbl_todo' 뒤에 두 개의 "?"가 생성된 것을 확인.

 

<if> 적용

  
<select id="selectList" resultType="com.example.spring_ex_01_2404.domain.TodoVO">
select * from tbl_todo
<foreach collection="types" item="type" open="(" close=")" separator=" OR">
<if test="type == 't'.toString()">
title like concat('%', #{keyword}, '%')
</if>
<if test="type =='w'.toString()">
writer like concat('%', #{keyword}, '%')
</if>
</foreach>
order by tno desc limit #{skip}, #{size}
</select>

 

  👾  open과 close를 이용해서 '()'와 배열을 처리하면서 중간에는 OR을 추가해서 다음과 같은 쿼리가 생성
       select * from tbl_todo (title like concat('%', ?, '%') OR

           writer like concat('%', ?, '%'))

       order by tno desc limit ?, ?


2)  <where>


📍 쿼리에 where 키워드가 빠져있음. 이것은 만일에 types가 없는 경우에는 쿼리문에 where를 생성하지 않기 위함.
📍 <where>는 태그 안쪽에서 문자열이 생성되어야만 where 키워드를 추가.
📍 이를 이용해서 types가 null이 아닌 경우를 같이 적용하면 다음과 같이 작성.


  
<select id="selectList" resultType="com.example.spring_ex_01_2404.domain.TodoVO">
select * from tbl_todo
<where>
<if test="types != null and types.length > 0">
<foreach collection="types" item="type" open="(" close=")" separator=" OR">
<if test="type == 't'.toString()">
title like concat('%', #{keyword}, '%')
</if>
<if test="type =='w'.toString()">
writer like concat('%', #{keyword}, '%')
</if>
</foreach>
</if>
</where>
order by tno desc limit #{skip}, #{size}
</select>

 


3) <trim>과 완료 여부 / 만료일 필터링


완료 여부 (f)와 만료 기간 (d)에 대한 처리
  📍  완료 여부는 PageRequestDTO의 finished 변수 값이 true인 경우에만 'finished = 1'과 같은 문자열이 만들어지도록 구성
         ✓  주의점은 앞의 검색 조건(제목, 작성자)이 있는 경우에는 'and finished = 1'의 형태로 만들어져야 하고,
              그렇지 않은 경우에는 바로 'finished = 1'이 되어야 함
  📍  MyBatis의 <trim>이 이런 경우에 사용. <where>과 유사하게 동작하면서 필요한 문자열을 생성하거나 제거할 수 있음


  
<if test = "finished">
<trim prefix="and">
finished = 1
</trim>
</if>
</where>
order by tno desc limit #{skip}, #{size}
</select>

 

  👾  finished 값을 이용해서 SQL 문을 생성해 내는데 <trim>을 적용. prefix를 적용하게 되면 상황에 따라서 'and'가 추가.

 

테스트 코드 수정해서 확인

  
@Test
public void testSelectSearch() {
PageRequestDTO pageRequestDTO = PageRequestDTO.builder()
.page(1)
.size(10)
.types(new String[]{"t", "w"})
.keyword("스프링")
.finished(true)
.build();
List<TodoVO> voList = todoMapper.selectList(pageRequestDTO);
voList.forEach(vo -> log.info(vo));
}

 

같은 방식으로 만료일 dueDate 을 처리하면 다음과 같음

  
<if test="from != null and to != null">
<trim prefix="and">
dueDate between #{from} and #{to}
</trim>
</if>

 


4) <sql> 과 <include>


MyBatis의 동적 쿼리 적용은 단순히 목록 데이터를 가져오는 부분과 전체 개수를 가져오는 부분에도 적용되어야 함
  📍전체 개수를 가져오는 TodoMapper의 getCount()에 파라미터에 PageRequestDTO 타입을 지정한 이유 역시 동적 쿼리를 적용하기 위함
  📍 MyBatis에는 <sql> 태그를 이용해서 동일한 SQL 조각을 재사용할 수 있는 방법을 제공
        ➡️  동적 쿼리 부분을 <sql>로 분리하고 동일하게 동적 쿼리가 적용될 부분은 <include>를 이용해서 작성

 

TodoMapper.xml에서 <sql id='search'>로 동적 쿼리 부분을 분리

  
<sql id="search">
<where>
<if test="types != null and types.length > 0">
<foreach collection="types" item="type" open="(" close=")" separator=" OR">
<if test="type == 't'.toString()">
title like concat('%', #{keyword}, '%')
</if>
<if test="type =='w'.toString()">
writer like concat('%', #{keyword}, '%')
</if>
</foreach>
</if>
<if test = "finished">
<trim prefix="and">
finished = 1
</trim>
</if>
<if test="from != null and to != null">
<trim prefix="and">
dueDate between #{from} and #{to}
</trim>
</if>
</where>
</sql>

  
<select id="selectList" resultType="com.example.spring_ex_01_2404.domain.TodoVO">
select * from tbl_todo
<include refid="search"></include>
order by tno desc limit #{skip}, #{size}
</select>
<select id="getCount" resultType="int">
select COUNT(tno) FROM tbl_todo
<include refid="search"></include>
</select>

 

테스트 코드에서는 TodoMapper의 selectList()와 getCount()를 호출해서 결과를 확인

  
@Test
public void testSelectSearch() {
PageRequestDTO pageRequestDTO = PageRequestDTO.builder()
.page(1)
.size(10)
.types(new String[]{"t", "w"})
.keyword("스프링")
//.finished(true)
.from(LocalDate.parse("2022-04-28"))
.to(LocalDate.parse("2024-04-30"))
.build();
List<TodoVO> voList = todoMapper.selectList(pageRequestDTO);
voList.forEach(vo -> log.info(vo));
log.info(todoMapper.getCount(pageRequestDTO));
}

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]

+ Recent posts