#14 : 템플릿 상속
01. 표준 HTML 구조
- 표준 HTML 구조의 예
<!doctype html>
<html lang="ko">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Bootstrap CSS -->
<link rel="stylesheet" type="text/css" th:href="@{/bootstrap.min.css}">
<!-- sbb CSS -->
<link rel="stylesheet" type="text/css" th:href="@{/style.css}">
<title>Hello, sbb!</title>
</head>
<body>
(... 생략 ...)
</body>
</html>
위처럼 html, head, body 엘리먼트가 CSS 파일 head 엘리먼트 안에 링크되어야 하며, meta, title 엘리먼트 등이 포함되어야 한다.
02. 템플릿 상속
- 앞에서 작성한 템플릿을 표준 구조에 맞추어 수정하게 되면, body 엘리먼트의 바깥 부분은 모두 같은 내용으로 중복된다. 또한 css파일이 새롭게 추가될 경우 모든 파일을 수정해주어야 하기 때문에 템플릿 상속 기능을 활용하여 불편함을 해소할 수 있다.
- layout.html 생성
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<!--required meta tags-->
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" type="text/css" th:href="@{/bootstrap.min.css}">
<link rel="stylesheet" type="text/css" th:href="@{style.css}">
<title>Hello, sbb!</title>
</head>
<body>
<!--기본 템플릿 안에 삽입될 내용 start-->
<th:block layout:fragment="content"></th:block>
<!--기본 템플릿 안에 삽입될 내용 end-->
</body>
</html>
* th:block layout:fragment="content" : layout을 상속받은 파일에서 구현할 부분, 해당 영역만 작성하면 레이아웃에 작성한 내용이 자동으로 적용된다.
- question_list.html 수정
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<table class="table">
<thead class="table-dark">
<tr>
<th>번호</th>
<th>제목</th>
<th>작성일시</th>
</tr>
</thead>
<tbody>
<tr th:each="question, loop : ${qList}">
<td th:text="${loop.count}"></td>
<td>
<a th:href="@{|/question/detail/${question.id}|}"
th:text="${question.subject}"></a>
</td>
<td th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></td>
</tr>
</tbody>
</table>
</div>
</html>
* layout:decorate="~{layout}" : 템플릿의 레이아웃으로 사용할 템플릿을 지정한다.
* layout:fragment="content" : 부모 템플릿의 content 영역에 현재의 항목이 들어간다고 명시
- question_detail.html 수정
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<!--질문-->
<h1 class="border-bottom py-2" th:text="${question.subject}"></h1>
<div class="card my-3">
<div class="card-body">
<div class="card-text" style="white-space: pre-line;" th:text="${question.content}"></div>
<div class="d-flex justify-content-end">
<div class="badge bg-light text-dark p-2 text-start">
<div th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
</div>
</div>
</div>
<!--답변의 갯수 표시-->
<h5 class="border-bottom my-3 py-2"
th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|"></h5>
<!--답변 반복 시작-->
<div class="card my-3" th:each="answer : ${question.answerList}">
<div class="card-body">
<div class="card-text" style="white-space:pre-line;" th:text="${answer.content}"></div>
<div class="d-flex justify-content-end">
<div class="badge bg-light text-dark p-2 text-start">
<div th:text="${#temporals.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
</div>
</div>
</div>
<!--답변 반복 끝-->
<!--답변 작성-->
<form class="my-3" th:action="@{|/answer/create/${question.id}|}" method="post">
<textarea class="form-control" name="content" id="content" rows="15"></textarea>
<input class="btn btn-primary my-2" type="submit" value="답변등록">
</form>
</div>
</html>
03. style.css
- 부트스트랩의 사용으로 css는 필요가 없어졌으므로 내용을 비워준다.
#15 : 질문 등록과 폼
01. 질문 등록
- 질문 등록하기 버튼 question_list.html에 추가
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<table class="table">
...생략
</table>
<a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a>
</div>
</html>
a 엘리먼트도 btn 클래스를 추가하면 버튼으로 보인다.

- URL 매핑
컨트롤러에 create에 해당하는 매핑을 추가한다.
package com.mysite.sbb.controller;
import com.mysite.sbb.entity.Question;
import com.mysite.sbb.service.QuestionService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import java.util.List;
@Controller
@RequestMapping("/question")
@RequiredArgsConstructor
public class QuestionController {
private final QuestionService questionService;
...생략...
@GetMapping("/create")
public String questionCreate(){
return "question_form";
}
}
- question_form.html 생성
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<div layout:fragment="content" class="container">
<h5 class="my-3">질문등록</h5>
<form th:action="@{/question/create}" method="post">
<div class="mb-3">
<label for="subject" class="form-label">제목</label>
<input type="text" name="subject" id="subject" class="form-control">
</div>
<div class="mb-3">
<label for="content" class="form-label">제목</label>
<input type="text" name="content" id="content" class="form-control">
</div>
<input type="submit" value="저장하기" class="btn btn-primary my-2">
</form>
</div>
</html>
- post 매핑을 처리하는 메서드 QuestionController.java에 추가
package com.mysite.sbb.controller;
import com.mysite.sbb.entity.Question;
import com.mysite.sbb.service.QuestionService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Controller
@RequestMapping("/question")
@RequiredArgsConstructor
public class QuestionController {
private final QuestionService questionService;
...생략...
@GetMapping("/create")
public String questionCreate(){
return "question_form";
}
@PostMapping("/create")
public String questionCreate(@RequestParam String subject,
@RequestParam String content){
//todo : 질문저장
return "redirect:/question/list";
}
}
* 매개변수가 다른 경우에는 같은 메서드명을 사용할 수 있다.
* 질문이 저장되면 질문목록 페이지로 이동되도록 설정
- QuestionService.java 수정
package com.mysite.sbb.service;
import com.mysite.sbb.entity.Question;
import com.mysite.sbb.repository.QuestionRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import com.mysite.sbb.DataNotFoundException;
@Service
@RequiredArgsConstructor
public class QuestionService {
private final QuestionRepository questionRepository;
...생략...
public void create(String subject, String content){
Question q = new Question();
q.setSubject(subject);
q.setContent(content);
q.setCreateDate(LocalDateTime.now());
this.questionRepository.save(q);
}
}
- QuestionController.java에 적용
package com.mysite.sbb.controller;
import com.mysite.sbb.entity.Question;
import com.mysite.sbb.service.QuestionService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Controller
@RequestMapping("/question")
@RequiredArgsConstructor
public class QuestionController {
private final QuestionService questionService;
...생략...
@GetMapping("/create")
public String questionCreate(){
return "question_form";
}
@PostMapping("/create")
public String questionCreate(@RequestParam String subject,
@RequestParam String content){
this.questionService.create(subject,content);
return "redirect:/question/list";
}
}
02. 폼
- Spring Boot Validation( 유효성 검사 ) 라이브러리 build.gradle 추가
implementation 'org.springframework.boot:spring-boot-starter-validation'
아래 어노테이션들로 유효성 검사 가능
* @Size : 문자의 길이 제한
* @NotNull : Null 불가능
* @NotEmpty : Null 혹은 빈 문자열("") 불가능
* @Past : 과거 날짜만 가능
* @Future : 미래 날짜만 가능
* @FutureOrPresent : 미래 혹은 오늘 날짜만 가능
* @Max : 최대값
* @Min : 최소값
* @Pattern : 정규식으로 검증
- 폼 클래스 QuestionForm.java 생성, 화면에서 전달되는 입력값을 검증, 바인딩하기 위해 필요
package com.mysite.sbb.form;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class QuestionForm {
@NotEmpty(message = "제목은 필수항목입니다.")
@Size(max=200)
private String subject;
@NotEmpty(message = "내용은 필수항목입니다.")
private String content;
}
- QuestionForm.java를 QuestionController.java에 적용
package com.mysite.sbb.controller;
import com.mysite.sbb.entity.Question;
import com.mysite.sbb.form.QuestionForm;
import com.mysite.sbb.service.QuestionService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.bind.BindResult;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Controller
@RequestMapping("/question")
@RequiredArgsConstructor
public class QuestionController {
private final QuestionService questionService;
...생략...
@GetMapping("/create")
public String questionCreate(){
return "question_form";
}
@PostMapping("/create")
public String questionCreate(@Valid QuestionForm questionForm,
BindingResult result){
if(result.hasErrors())
return "question_form";
this.questionService.create(questionForm.getSubject(),questionForm.getContent());
return "redirect:/question/list";
}
}
* 매개변수를 QuestionForm 객체로 변경, @Valid 어노테이션 적용
* BindingResult 매개변수는 유효성 검사가 수행된 결과를 반환하는 객체
* result.hasErrors() : 유효성검사에 오류가 있을 시 true 반환, 조건문을 활용해 다시 폼으로 돌아가도록 설정
- 검증 오류 메세지를 출력하는 엘리먼트 question_form.html에 추가
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<div layout:fragment="content" class="container">
<h5 class="my-3">질문등록</h5>
<form th:action="@{/question/create}" th:object="${questionForm}" method="post">
<div class="alert alert-danger" role="alert" th:if="${#fields.hasAnyErrors()}">
<div th:each="err : ${#fields.allErrors()}" th:text="${err}"/>
</div>
<div class="mb-3">
<label for="subject" class="form-label">제목</label>
<input type="text" name="subject" id="subject" class="form-control">
</div>
<div class="mb-3">
<label for="content" class="form-label">제목</label>
<input type="text" name="content" id="content" class="form-control">
</div>
<input type="submit" value="저장하기" class="btn btn-primary my-2">
</form>
</div>
</html>
* field.hasAnyErrors : true이면 검증 실패
* field.allErrors : 검증에 실패한 오류 메세지 출력
* alert alert-davger : 오류를 붉은색으로 표시
* 위와 같이 오류를 표시하기 위해서는 타임리프의 th:object 속성이 반드시 필요하다. 폼의 속성들이 해당 클래스의 속성들로 구성되는 것을 명시해준다.
여기까지 진행하고 테스트를 실행하면 오류가 발생한다. Get 매핑으로 요청한 메서드도 th:object에 의해 QuestionForm 객체가 필요하기 때문이다.
- QuestionController.java 수정
package com.mysite.sbb.controller;
import com.mysite.sbb.entity.Question;
import com.mysite.sbb.form.QuestionForm;
import com.mysite.sbb.service.QuestionService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.bind.BindResult;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Controller
@RequestMapping("/question")
@RequiredArgsConstructor
public class QuestionController {
private final QuestionService questionService;
...생략...
@GetMapping("/create")
public String questionCreate(QuestionForm questionForm){
return "question_form";
}
@PostMapping("/create")
public String questionCreate(@Valid QuestionForm questionForm,
BindingResult result){
if(result.hasErrors())
return "question_form";
this.questionService.create(questionForm.getSubject(),questionForm.getContent());
return "redirect:/question/list";
}
}
get 매핑에도 매개변수로 QuestionForm 객체를 추가하면 폼으로 해당 객체가 전달된다.

- 오류 발생 시 입력한 내용 유지하기
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<div layout:fragment="content" class="container">
<h5 class="my-3">질문등록</h5>
<form th:action="@{/question/create}" th:object="${questionForm}" method="post">
<div class="alert alert-danger" role="alert" th:if="${#fields.hasAnyErrors()}">
<div th:each="err : ${#fields.allErrors()}" th:text="${err}"/>
</div>
<div class="mb-3">
<label for="subject" class="form-label">제목</label>
<input type="text" th:field="*{subject}" name="subject" id="subject" class="form-control">
</div>
<div class="mb-3">
<label for="content" class="form-label">제목</label>
<input type="text" th:field="*{content}" name="content" id="content" class="form-control">
</div>
<input type="submit" value="저장하기" class="btn btn-primary my-2">
</form>
</div>
</html>
name을 th:field 속성으로 변경하여 name과 value 속성을 모두 대체하도록 한다.
03. 답변등록
- 답변 등록 폼 클래스 AnswerForm.java 생성
package com.mysite.sbb.form;
import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class AnswerForm {
@NotEmpty(message = "내용은 필수항목입니다.")
private String content;
}
- AnswerController.java 수정
package com.mysite.sbb.controller;
import com.mysite.sbb.entity.Question;
import com.mysite.sbb.form.AnswerForm;
import com.mysite.sbb.service.AnswerService;
import com.mysite.sbb.service.QuestionService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@RequestMapping("/answer")
@RequiredArgsConstructor
@Controller
public class AnswerController {
private final QuestionService questionService;
private final AnswerService answerService;
@PostMapping("/create/{id}")
public String createAnswer(
Model model,
@PathVariable("id") Integer id,
@Valid AnswerForm answerForm,
BindingResult result){
Question question = this.questionService.getQuestion(id);
if(result.hasErrors()){
model.addAttribute("question",question);
return "question_detail";
}
this.answerService.create(question,answerForm.getContent());
return String.format("redirect:/question/detail/%s",id);
}
}
* 검증에 실패할 경우 question_detail로 넘어가는데, 이때 question 객체가 필요하기 때문에 model 객체에 저장 후 넘겨준다.
- question_detail.html 수정
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
...생략...
<!--답변 작성-->
<form class="my-3" th:action="@{|/answer/create/${question.id}|}" th:object="${answerForm}" method="post">
<div class="alert alert-danger" role="alert" th:text="${#fields.hasAnyErrors()}">
<div th:each="err : ${#fields.allErrors()}" th:text="${err}"/>
</div>
<textarea class="form-control" th:field="*{content}" id="content" rows="15"></textarea>
<input class="btn btn-primary my-2" type="submit" value="답변등록">
</form>
</div>
</html>
* 유효성검사를 위해 th:object 속성 추가
* AnswerForm을 사용하기 때문에 질문 컨트롤러 메서드도 수정 필요
- QuestionController.java 수정
package com.mysite.sbb.controller;
import com.mysite.sbb.entity.Question;
import com.mysite.sbb.form.AnswerForm;
import com.mysite.sbb.form.QuestionForm;
import com.mysite.sbb.service.QuestionService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.bind.BindResult;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Controller
@RequestMapping("/question")
@RequiredArgsConstructor
public class QuestionController {
private final QuestionService questionService;
@GetMapping("/list")
public String list(Model model) {
List<Question> qList = this.questionService.getList();
model.addAttribute("qList",qList);
return "question_list";
}
@GetMapping("/detail/{id}")
public String detail(Model model, @PathVariable("id") Integer id,
AnswerForm answerForm) {
Question question = this.questionService.getQuestion(id);
model.addAttribute("question",question);
return "question_detail";
}
...생략...
}
내용없이 답변을 등록하려고 하면 오류 발생

#16 : 공통 템플릿
01. 오류 메세지 공통 템플릿
- 공통으로 들어가는 오류 부분이 담긴 form_errors.html 생성
<div th:fragment="formErrorsFragment" class="alert alert-danger"
role="alert" th:if="${#fields.hasAnyErrors()}">
<div th:each="err : ${#fields.allErrors()}" th:text="${err}" />
</div>
출력할 오류메세지 부분에 프래그먼트 값을 추가한다.
02. 템플릿에 적용하기
- question_form.html, question_detail 수정
<!-- <div class="alert alert-danger" role="alert" th:if="${#fields.hasAnyErrors()}">-->
<!-- <div th:each="err : ${#fields.allErrors()}" th:text="${err}"/>-->
<!-- </div>-->
<div th:replace="~{form_errors :: formErrorsFragment}"></div>
* th:replace : 공통템플릿을 템플릿 내에 삽입 가능
* "~{form_errors :: formErrorsFragment}" : form_errors 파일 내의 formErrorsFragment의 이름을 가진 부분으로 교체하라는 뜻이다.
2장 학습 완료!🔥
❗ 헷갈리는 기호 구분하기
- ~{} : th:layout, repalce 등의 속성의 경로에 사용
- *{} : th:field 속성에 사용
- @{} : th:href 경로에 사용
'T-I-L > [책] 요약&정리' 카테고리의 다른 글
| [점프 투 스프링부트] 3장 SBB 서비스 개발(일련번호, 답변개수) - 2023. 08. 23. (0) | 2023.08.23 |
|---|---|
| [점프 투 스프링부트] 3장 SBB 서비스 개발(내비게이션바, 페이징) - 2023. 08. 22. (0) | 2023.08.22 |
| [점프 투 스프링부트] 2장 스프링부트의 기본 요소(답변등록,부트스트랩) - 2023. 08. 18 (0) | 2023.08.18 |
| [점프 투 스프링부트] 2장 스프링부트의 기본 요소(질문상세) - 2023. 08. 17 (0) | 2023.08.17 |
| [점프 투 스프링부트] 2장 스프링부트의 기본 요소(질문목록, 템플릿, 서비스) - 2023. 08. 16 (0) | 2023.08.16 |
