질문 목록 URL 매핑하기
// /question/QuestionController.java
package com.mysite.sbb.question;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class QuestionController {
@GetMapping("/question/list")
@ResponseBody
public String list() {
return "question list";
}
}

템플릿 설정하기
보통 브라우저에 응답하는 문자열은 자바 코드에서 직접 만들지 않고 템플릿 방식을 많이 사용한다. 템플릿(template)은 자바 코드를 삽입할 수 있는 HTML 형식의 파일을 말한다.
이러한 템플릿을 사용하기 위해 스프링 부트에서는 템플릿 엔진을 지원한다. 템플릿 엔진에는 Thymeleaf, Mustache, Groovy, Freemarker, Velocity 등이 있는데, 이 책에서는 스프링 진영에서 추천하는 타임리프(Thymleaf) 템플릿 엔진을 사용한다.
타임리프를 사용하려면 build.gradle 파일을 수정하여 타임리프를 설치해야 한다.
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
}
템플릿 사용하기
<h2>Hello Template</h2>
package com.mysite.sbb.question;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class QuestionController {
@GetMapping("/question/list")
public String list() {
// 문자열 "question_list"가 아니라 파일명을 의미
return "question_list";
}
}
템플릿을 사용하기 때문에 기존에 사용하던 @ResponseBody 애너테이션은 필요 없으므로 삭제한다. 그리고 list 메서드에서 question_list.html 템플릿 파일 이름인 ‘question_list’를 리턴한다.

데이터를 템플릿에 전달하기
질문 목록이 담긴 데이터를 조회하여 이를 템플릿을 통해 화면에 전달하려면 QuestionRepository를 사용해야 한다. QuestionRepository로 조회한 질문 목록 데이터는 Model 클래스를 사용하여 템플릿에 전달할 수 있다.
// QuestionController.java
package com.mysite.sbb.question;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import java.util.List;
@RequiredArgsConstructor
@Controller
public class QuestionController {
private final QuestionRepository questionRepository;
@GetMapping("/question/list")
public String list(Model model) { // 매개변수로 Model을 지정하면 객체가 자동 생성됨
List<Question> questionList = this.questionRepository.findAll();
model.addAttribute("questionList", questionList);
return "question_list";
}
}
@RequiredArgsConstructor 애너테이션의 생성자 방식으로 questionRepository 객체를 주입했다. @RequiredArgsConstructor는 롬복이 제공하는 애너테이션으로, final이 붙은 속성을 포함하는 생성자를 자동으로 만들어 주는 역할을 한다. 따라서 스프링 부트가 내부적으로 QuestionController를 생성할 때 롬복으로 만들어진 생성자에 의해 questionRepository 객체가 자동으로 주입된다.
그리고 QuestionRepository의 findAll 메서드를 사용하여 질문 목록 데이터인 questionList를 생성하고 Model 객체에 ‘questionList’라는 이름으로 저장했다. 여기서 Model 객체는 자바 클래스와 템플릿 간의 연결 고리 역할을 한다. Model 객체에 값을 담아두면 템플릿에서 그 값을 사용할 수 있다. Model 객체는 따로 생성할 필요 없이 컨트롤러의 메서드에 매개변수로 지정하기만 하면 스프링 부트가 자동으로 Model 객체를 생성한다.
데이터를 화면에 출력하기
<table>
<thead>
<tr>
<th>제목</th>
<th>작성일시</th>
</tr>
</thead>
<tbody>
<tr th:each="question : ${questionList}">
<td th:text="${question.subject}"></td>
<td th:text="${question.createDate}"></td>
</tr>
</tbody>
</table>
여기서 th:는 타임리프에서 사용하는 속성임을 나타낸다.
<tr th:each="question : ${questionList}">
QuestionController의 list 메서드에서 조회한 질문 목록 데이터를 ‘questionList’라는 이름으로 Model 객체에 저장했다. 타임리프는 Model 객체에 저장한 questionList를 ${questionList}로 읽을 수 있다. 위의 코드는 questionList에 저장된 데이터를 하나씩 꺼내 question 변수에 대입한 후 questionList의 개수만큼 반복하며 <tr> … </tr> 문장을 출력하라는 의미이다.
다음 코드는 question 객체의 subject를 <td> 태그로 출력하고 question 객체의 createDate를 출력한다.
<td th:text="${question.subject}"></td>
<td th:text="${question.createDate}"></td>

자주 사용하는 타임리프(Timeleaf)의 3가지 속성
타임리프의 여러 속성 중 다음 3가지 속성을 자주 사용한다.
if 문, else if 문과 같은 분기문은 다음과 같이 사용한다.
th:if="${question != null}"
이 경우 question 객체가 null이 아닌 경우에만 이 속성을 포함한 요소가 표시된다.
th:each 반복문 속성은 자바의 for each 문과 유사하다.
th:each="question : ${questionList}"
반복문 속성은 다음과 같이 사용할 수도 있다.
th:each="question, loop : ${questionList}"
여기서 추가한 loop 객체를 이용하여 루프 내에서 다음과 같이 사용할 수 있다.
th:text=(속성)은 해당 요소의 텍스트값을 출력한다.
th:text="${question.subject}"
텍스트는 th:text 속성 대신에 다음처럼 대괄호를 사용하여 값을 직접 출력할 수 있다.
<tr th:each="question : ${questionList}">
<td>[[${question.subject}]]</td>
<td>[[${question.createDate}]]</td>
</tr>
서버의 URL을 요청할 때 도메인명 뒤에 아무런 주소도 덧붙이지 않는 URL을 루트 URL이라고 한다. 예를 들어 구글의 루트 URL은 google.com이다. 그리고 루트 URL을 요청했을 때 보여지는 페이지를 메인 페이지라고 한다. 웹 브라우저에서 http://localhost:8080/question/list 대신 루트 URL인 http://localhost:8080로 접속해도 질문 목록 화면을 출력하도록 해보자.
// MainController.java 수정
package com.mysite.sbb;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class MainController {
@GetMapping("/sbb")
@ResponseBody
public String index() {
return "안녕하세요 sbb에 오신것을 환영합니다.";
}
@GetMapping("/")
public String root() {
return "redirect:/question/list";
}
}
이와 같이 root 메서드를 추가하고 / URL을 매핑했다. 리턴 문자열 ’redirect:/question/list’는 /question/list URL로 페이지를 리다이렉트하라는 명령어이다. 여기서 리다이렉트란 클라이언트가 요청하면 새로운 URL로 전송하는 것을 의미한다.
대부분의 규모 있는 스프링 부트(Spring Boot) 프로젝트는 컨트롤러에서 리포지터리를 직접 호출하지 않고 중간에 서비스를 두어 데이터를 처리한다.
서비스(service)는 간단히 말해 스프링에서 데이터 처리를 위해 작성하는 클래스이다. 그동안 서비스 없이도 웹 프로그램을 동작시키는 데 문제가 없었는데 왜 굳이 서비스를 사용해야 할까?
복잡한 코드를 모듈화할 수 있다
예를 들어 A라는 컨트롤러가 어떤 기능을 수행하기 위해 C라는 리포지터리의 메서드 a, b, c를 순서대로 실행해야 한다고 가정해 보자. 그리고 B라는 컨트롤러도 A 컨트롤러와 동일한 기능을 수행해야 한다면 A, B 컨트롤러가 C 리포지터리의 메서드 a, b, c를 호출해 사용하는 중복된 코드를 가지게 된다. 이런 경우 C 리포지터리의 a, b, c 메서드를 호출하는 기능을 서비스로 만들고 컨트롤러에서 이 서비스를 호출하여 사용할 수 있다. 즉, 서비스를 사용하면 이와 같은 모듈화가 가능하다.
엔티티 객체를 DTO 객체로 변환할 수 있다.
앞에서 작성한 Question, Answer 클래스는 모두 엔티티 클래스이다. 엔티티 클래스는 데이터베이스와 직접 맞닿아 있는 클래스이므로 컨트롤러 또는 타임리프와 같은 템플릿 엔진에 전달해 사용하는 것은 좋지 않다. 왜냐하면 엔티티 객체에는 민감한 데이터가 포함될 수 있는데, 타임리프에서 엔티티 객체를 직접 사용하면 민감한 데이터가 노출될 위험이 있기 때문이다.
이러한 이유로 Question, Answer 같은 엔티티 클래스는 컨트롤러에서 사용하지 않도록 설계하는 것이 좋다. 그래서 Question, Answer를 대신해 사용할 DTO(Data Transfer Object) 클래스가 필요하다. 그리고 Question, Answer 등의 엔티티 객체를 DTO 객체로 변환하는 작업도 필요하다. 엔티티 객체를 DTO 객체로 변환하는 일에도 서비스가 필요하다. 서비스는 컨트롤러와 리포지터리의 중간에서 엔티티 객체와 DTO 객체를 서로 변환하여 양방향에 전달하는 역할을 한다.
// QuestionService.java
package com.mysite.sbb.question;
import java.util.List;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class QuestionService {
private final QuestionRepository questionRepository;
public List<Question> getList() {
return this.questionRepository.findAll();
}
}
생성한 클래스를 서비스로 만들기 위해서는 이와 같이 클래스명 위에 @Service 애너테이션을 붙이면 된다.
// QuestionController.java 수정
@RequiredArgsConstructor
@Controller
public class QuestionController {
private final QuestionService questionService;
@GetMapping("/question/list")
public String list(Model model) {
List<Question> questionList = this.questionService.getList();
model.addAttribute("questionList", questionList);
return "question_list";
}
}

브라우저에 접속하면 리포지터리를 사용했을 때와 동일한 화면을 볼 수 있다.
질문 목록에서 질문의 제목을 클릭하면 해당 질문과 관련된 상세 내용이 담긴 페이지로 넘어가게 기능을 추가해보자.
질문 목록에 링크 추가하기
<!-- question_list.html 수정 -->
<table>
<thead>
<tr>
<th>제목</th>
<th>작성일시</th>
</tr>
</thead>
<tbody>
<tr th:each="question, index : ${questionList}">
<td>
<a th:href="@{|/question/detail/${question.id}|}"
th:text="${question.subject}"></a>
</td>
<td th:text="${question.createDate}"></td>
</tr>
</tbody>
</table>
<td> 태그를 통해 질문 목록의 제목을 텍스트로 출력하던 것에서 질문의 상세 내용이 담긴 웹 페이지로 이동할 수 있는 링크로 변경했다.
제목에 상세 페이지 URL을 연결하기 위해 타임리프의 th:href 속성을 사용한다. 이때 URL은 반드시 @{와 } 문자 사이에 입력해야 한다.
타임리프에서는 /question/detail/과 같은 문자열과 ${question.id}와 같은 자바 객체의 값을 더할 때는 반드시 다음처럼 |로 좌우를 감싸 주어야 한다.
<a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
상세 페이지 컨트롤러 만들기
@RequiredArgsConstructor
@Controller
public class QuestionController {
...
@GetMapping(value = "/question/detail/{id}")
public String detail(Model model, @PathVariable("id") Integer id) {
return "question_detail";
}
}
요청한 URL인 http://localhost:8080/question/detail/2의 숫자 2처럼 변하는 id값을 얻을 때에는 @PathVariable 애너테이션을 사용한다. 이때 @GetMapping(value = "/question/detail/{id}")에서 사용한 id와 @PathVariable("id")의 매개변수 이름이 이와 같이 동일해야 한다.
<h1>제목</h1>
<div>내용</div>

상세 페이지에 서비스 사용하기
@RequiredArgsConstructor
@Service
public class QuestionService {
private final QuestionRepository questionRepository;
public List<Question> getList() {
return this.questionRepository.findAll();
}
public Question getQuestion(Integer id) {
Optional<Question> question = this.questionRepository.findById(id);
if (question.isPresent()) {
return question.get();
} else {
throw new DataNotFoundException("question not found");
}
}
}
id값으로 질문 데이터를 조회하기 위해 getQuestion 메서드를 추가했다. 리포지터리로 얻은 Question 객체는 Optional 객체이므로 if~else 문을 통해 isPresent 메서드로 해당 데이터가 존재하는지 검사하는 과정이 필요하다. 만약 id값에 해당하는 질문 데이터가 없을 경우에는 예외 클래스인 DataNotFoundException이 실행되도록 했다.
package com.mysite.sbb;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(value = HttpStatus.NOT_FOUND, reason = "entity not found")
public class DataNotFoundException extends RuntimeException {
private static final long serialVersionUID = 1L;
public DataNotFoundException(String message) {
super(message);
}
}
DataNotFoundException은 데이터베이스에서 특정 엔티티 또는 데이터를 찾을 수 없을 때 발생시키는 예외 클래스로 만들었다. 이 예외가 발생하면 스프링 부트는 설정된 HTTP 상태 코드(HttpStatus.NOT_FOUND)와 이유("entity not found")를 포함한 응답을 생성하여 클라이언트에게 반환하게 된다.
RuntimeException 클래스를 상속하는 것은 사용자 정의 예외 클래스를 정의하는 방법 중 하나이다. RuntimeException은 실행 시 발생하는 예외라는 의미이다.
@RequiredArgsConstructor
@Controller
public class QuestionController {
private final QuestionService questionService;
@GetMapping("/question/list")
public String list(Model model) {
List<Question> questionList = this.questionService.getList();
model.addAttribute("questionList", questionList);
return "question_list";
}
@GetMapping(value = "/question/detail/{id}")
public String detail(Model model, @PathVariable("id") Integer id) {
Question question = this.questionService.getQuestion(id);
model.addAttribute("question", question);
return "question_detail";
}
}
상세 페이지 출력하기
<h1 th:text="${question.subject}"></h1>
<div th:text="${question.content}"></div>


현재 QuestionController.java에는 다음 2개의 URL이 매핑되어 있다.
URL의 프리픽스가 모두 /question으로 시작한다는 것을 알 수 있다. 프리픽스(prefix)란 URL의 접두사 또는 시작 부분을 가리키는 말로, QuestionController에 속하는 URL 매핑은 항상 /question 프리픽스로 시작하도록 설정할 수 있다. QuestionController 클래스명 위에 다음과 같이 @RequestMapping("/question") 애너테이션을 추가하고, 메서드 단위에서는 /question을 생략하고 그 뒷부분만을 적으면 된다.
// QuestionController.java 수정
@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
public class QuestionController {
private final QuestionService questionService;
@GetMapping("/list")
public String list(Model model) {
List<Question> questionList = this.questionService.getList();
model.addAttribute("questionList", questionList);
return "question_list";
}
@GetMapping(value = "/detail/{id}")
public String detail(Model model, @PathVariable("id") Integer id) {
Question question = this.questionService.getQuestion(id);
model.addAttribute("question", question);
return "question_detail";
}
}
list 메서드의 URL 매핑은 /list이지만 @RequestMapping 애너테이션에서 이미 /question URL을 매핑했기 때문에 /question + /list가 되어 최종 URL 매핑은 /question/list가 된다. 그러므로 이와 같이 수정하면 기존과 완전히 동일하게 URL 매핑이 이루어진다. 다만, 앞으로 QuestionController.java에서 URL을 매핑할 때 반드시 /question으로 시작한다.
텍스트 창과 등록 버튼 만들기
<h1 th:text="${question.subject}"></h1>
<div th:text="${question.content}"></div>
<form th:action="@{|/answer/create/${question.id}|}" method="post">
<textarea name="content" id="content" rows="15"></textarea>
<input type="submit" value="답변등록">
</form>
[답변 등록] 버튼을 누르면 전송되는 form의 action은 타임리프의 th:action 속성으로 생성한다. 이제 텍스트 창에 답변을 작성하고, 답변 등록 버튼을 클릭하면 /answer/create/2와 같은 URL이 post 방식으로 호출될 것이다.

답변 컨트롤러 만들기
package com.mysite.sbb.answer;
import com.mysite.sbb.question.Question;
import com.mysite.sbb.question.QuestionService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
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;
@PostMapping("/create/{id}")
public String createAnswer(Model model, @PathVariable("id") Integer id,
@RequestParam(value="content") String content) {
Question question = this.questionService.getQuestion(id);
// TODO: 답변을 저장한다.
return String.format("redirect:/question/detail/%s", id);
}
}
/answer/create/{id}와 같은 URL 요청 시 createAnswer 메서드가 호출되도록 @PostMapping으로 매핑했다. @PostMapping 애너테이션은 @GetMapping과 동일하게 URL 매핑을 담당하는 역할을 하지만, POST 요청을 처리하는 경우에 사용한다.
그리고 질문 컨트롤러의 detail 메서드와 달리 createAnswer 메서드의 매개변수에는 @RequestParam(value="content") String content가 추가되었다. 이는 question_detail.html에서 답변으로 입력한 content을 얻으려고 추가한 것이다. 템플릿의 답변 내용에 해당하는 <textarea>의 name 속성명이 content이므로 여기서도 변수명을 content로 사용한다. /create/{id}에서 {id}는 질문 엔티티의 id이므로 이 id값으로 질문을 조회하고 값이 없을 경우에는 404 오류가 발생할 것이다.
답변 서비스 만들기
package com.mysite.sbb.answer;
import com.mysite.sbb.question.Question;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@RequiredArgsConstructor
@Service
public class AnswerService {
private final AnswerRepository answerRepository;
public void create(Question question, String content) {
Answer answer = new Answer();
answer.setContent(content);
answer.setCreateDate(LocalDateTime.now());
answer.setQuestion(question);
this.answerRepository.save(answer);
}
}
@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,
@RequestParam(value="content") String content) {
Question question = this.questionService.getQuestion(id);
this.answerService.create(question, content);
return String.format("redirect:/question/detail/%s", id);
}
}

상세 페이지에 답변 표시하기
<h1 th:text="${question.subject}"></h1>
<div th:text="${question.content}"></div>
<h5 th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|"></h5>
<div>
<ul>
<li th:each="answer : ${question.answerList}" th:text="${answer.content}"></li>
</ul>
</div>
<form th:action="@{|/answer/create/${question.id}|}" method="post">
<textarea name="content" id="content" rows="15"></textarea>
<input type="submit" value="답변등록">
</form>
#lists.size(question.answerList)는 답변 개수를 의미한다. 따라서 '1개의 답변이 있습니다.'와 같은 문장이 화면에 표시될 것이다. #lists.size(객체)는 타임리프에서 제공하는 기능으로, 해당 객체의 길이를 반환한다.
<div> 태그로 답변 리스트에 관한 내용을 묶었다. 그리고 <ul> 태그를 사용하여 질문에 연결된 답변을 모두 표시했다.

웹 개발에서는 색상이나 크기 등의 디자인을 적용할 때 스타일시트(style sheet), CSS를 사용한다.
스태틱 디렉터리와 스타일시트 이해하기
스타일시트 파일, 즉 CSS 파일은 HTML 파일과 달리 스태틱(static) 디렉터리에 저장해야 한다.
textarea {
width:100%;
}
input[type=submit] {
margin-top:10px;
}
style.css 파일에 질문 상세 화면의 디자인 요소들을 작성했다. 답변 등록 시 사용하는 텍스트 창의 넓이를 100%로 하고 [답변 등록] 버튼 상단에 마진을 10픽셀로 설정했다.
템플릿에 스타일 적용하기
<link rel="stylesheet" type="text/css" th:href="@{/style.css}">
<h1 th:text="${question.subject}"></h1>
<div th:text="${question.content}"></div>
<h5 th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|"></h5>
<div>
<ul>
<li th:each="answer : ${question.answerList}" th:text="${answer.content}"></li>
</ul>
</div>
<form th:action="@{|/answer/create/${question.id}|}" method="post">
<textarea name="content" id="content" rows="15"></textarea>
<input type="submit" value="답변등록">
</form>
question_detail.html 파일 상단에 style.css를 사용할 수 있는 링크를 추가하여 스타일시트 파일을 상세 페이지 템플릿에 적용했다.

부트스트랩(Bootstrap)은 트위터(Twitter)를 개발하면서 만들어졌으며 현재 지속적으로 관리되고 있는 오픈소스 프로젝트로, 웹 디자이너의 도움 없이도 개발자 혼자서 상당히 괜찮은 수준의 웹 페이지를 만들 수 있게 도와주는 프레임워크이다.
부트스트랩 적용하기
<link rel="stylesheet" type="text/css" th:href="@{/bootstrap.min.css}">
<div 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 : ${questionList}">
<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>
테이블 항목으로 '번호'를 추가했다. 번호는 loop.count를 사용하여 표시한다. loop.count는 questionList의 항목을 th:each로 반복할 때 현재의 순서를 나타낸다. 그리고 날짜를 보기 좋게 출력하기 위해 타임리프의 #temporals.format 기능을 사용했다. #temporals.format은 #temporals.format(날짜 객체, 날짜 포맷)와 같이 사용하는데, 날짜 객체를 날짜 포맷에 맞게 변환한다.
가장 윗줄에 bootstrap.min.css를 사용할 수 있도록 링크를 추가했다. 그리고 위에서 사용한 class="container my-3", class="table", class="table-dark 등은 bootstrap.min.css에 이미 정의되어 있는 클래스들로 간격을 조정하고 테이블에 스타일을 지정하는 용도로 사용했다.

<link rel="stylesheet" type="text/css" th:href="@{/bootstrap.min.css}">
<div class="container my-3">
<!-- 질문 -->
<h2 class="border-bottom py-2" th:text="${question.subject}"></h2>
<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 th:action="@{|/answer/create/${question.id}|}" method="post" class="my-3">
<textarea name="content" id="content" rows="10" class="form-control"></textarea>
<input type="submit" value="답변등록" class="btn btn-primary my-2">
</form>
</div>
질문이나 답변은 각각 하나의 덩어리이므로 부트스트랩의 card 컴포넌트를 사용했다. 부트스트랩의 card 컴포넌트는 어떤 내용을 그룹화하여 보여 줄 때 사용한다.
card 컴포넌트를 비롯하여 질문 상세 템플릿에서 부트스트랩 클래스를 많이 사용했다.
| 부트스트랩 클래스 | 설명 |
|---|---|
| card, card-body, card-text | card 컴포넌트를 적용하는 클래스들이다. |
| badge | badge 컴포넌트를 적용하는 클래스이다. |
| form-control | 텍스트 창에 form 컴포넌트를 적용하는 클래스이다. |
| border-bottom | 아래 방향 테두리 선을 만드는 클래스이다. |
| my-3 | 상하 마진값으로 3을 지정하는 클래스이다. |
| py-2 | 상하 패딩값으로 2를 지정하는 클래스이다. |
| p-2 | 상하좌우 패딩값으로 2를 지정하는 클래스이다. |
| d-flex justify-content-end | HTML 요소를 오른쪽으로 정렬하는 클래스이다. |
| bg-light | 연회색으로 배경을 지정하는 클래스이다. |
| text-dark | 글자색을 검은색으로 지정하는 클래스이다. |
| text-start | 글자를 왼쪽으로 정렬하는 클래스이다. |
| btn btn-primary | 버튼 컴포넌트를 적용하는 클래스이다. |
그리고 질문과 답변 덩어리를 살펴보면 style="white-space: pre-line;"과 같은 스타일을 지정해 주었다. style="white-space: pre-line;"은 CSS 스타일 속성으로, 사용자가 입력한 대로 줄 바꿈이 적용되도록 만들어 준다.

어떤 웹 브라우저를 사용하더라도 웹 페이지가 동일하게 보이고 정상적으로 작동하게 하려면 반드시 웹 표준을 지키는 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 문서의 구조는 위 구조처럼 html, head, body 요소가 있어야 하며, CSS 파일은 <head> 태그 안에 링크되어야 한다. 또한 <head> 태그 안에는 meta, title 요소 등이 포함되어야 한다.
앞에서 작성한 템플릿 파일들을 모두 표준 HTML 구조로 변경하면 body 요소를 제외한 바깥 부분은 모두 같은 내용으로 중복된다.
그렇게 되면 CSS 파일 이름이 변경되거나 새로운 CSS 파일을 추가할 때마다 모든 템플릿 파일을 일일이 수정해야 한다. 타임리프는 이런 중복의 불편함을 해소하기 위해 템플릿 상속 기능을 제공한다. 템플릿 상속은 기본 틀이 되는 템플릿을 먼저 작성하고 다른 템플릿에서 그 템플릿을 상속해 사용하는 방법이다.
layout.html로 기본 틀 만들기
템플릿을 상속하려면 각 템플릿 파일에서 반복되는 내용을 담아 기본 틀이 되는 템플릿을 만들어야 한다.
<!doctype html>
<html lang="ko" 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">
<!-- 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>
<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->
</body>
</html>
layout.html은 모든 템플릿이 상속해야 하는 템플릿으로, 표준 HTML 문서 구조로 정리된 기본 틀이 된다. body 요소 안의 <th:block layout:fragment="content"></th:block>은 layout.html을 상속한 템플릿에서 개별적으로 구현해야 하는 영역이 된다. 즉, layout.html 템플릿을 상속하면 <th:block layout:fragment="content"></th:block> 영역만 수정해도 표준 HTML 문서로 작성된다.
question_list.html에 템플릿 상속하기
<html layout:decorate="~{layout}" xmlns:layout="http://www.w3.org/1999/xhtml">
<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 : ${questionList}">
<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.html 템플릿을 상속하려고 <html layout:decorate="~{layout}">을 사용했다. 타임리프의 layout:decorate 속성은 템플릿의 레이아웃으로 사용할 템플릿을 설정한다. 속성값인 ~{layout}이 layout.html 파일을 의미한다.
부모 템플릿인 layout.html에는 다음과 같은 내용이 있었다.
<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->
부모 템플릿에 작성된 이 부분을 자식 템플릿의 내용으로 적용될 수 있도록 다음과 같이 사용했다.
<div layout:fragment="content" class="container my-3">
...
</div>
이렇게 하면 부모 템플릿의 th:block 요소의 내용이 자식 템플릿의 div 요소의 내용으로 교체된다.
question_detail.html에 템플릿 상속하기
<html layout:decorate="~{layout}" xmlns:layout="http://www.w3.org/1999/xhtml">
<div layout:fragment="content" class="container my-3">
...
</div>
</html>

<html layout:decorate="~{layout}" xmlns:layout="http://www.w3.org/1999/xhtml">
<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> ... </a> 요소를 추가하여 부트스트랩의 btn btn-primary 클래스를 적용하면 다음과 같이 화면에 버튼 형태로 보인다.

URL 매핑하기
// QuestionController.java 수정
@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
public class QuestionController {
private final QuestionService questionService;
...
@GetMapping("/create")
public String questionCreate() {
return "question_form";
}
}
"[질문 등록하기]" 버튼을 통한 /question/create 요청은 GET 요청에 해당하므로 @GetMapping 애너테이션을 사용했다. questionCreate 메서드는 question_form 템플릿을 출력한다.
템플릿 만들기
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container">
<h5 class="my-3 border-bottom pb-2">질문등록</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>
<textarea name="content" id="content" class="form-control" rows="10"></textarea>
</div>
<input type="submit" value="저장하기" class="btn btn-primary my-2">
</form>
</div>
</html>
제목과 내용을 입력하여 질문을 등록할 수 있는 템플릿을 작성했다.
템플릿에는 제목과 내용을 입력할 수 있는 텍스트 창을 추가했다. 제목은 일반적인 input 텍스트 창을 사용하고 내용은 글자 수에 제한이 없는 textarea 창을 사용했다. 그리고 입력한 내용을 /question/create URL로 post 방식을 이용해 전송할 수 있도록 form과 버튼을 추가했다.

위 화면에서 질문과 내용을 입력하고 [저장하기] 버튼을 누르면 405 오류가 발생한다. 405 오류는 ‘Method Not Allowed’라는 의미로, /question/create URL을 POST 방식으로는 처리할 수 없음을 나타낸다.

@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
public class QuestionController {
...
@GetMapping("/create")
public String questionCreate() {
return "question_form";
}
@PostMapping("/create")
public String questionCreate(@RequestParam(value="subject") String subject,
@RequestParam(value="content") String content) {
// TODO: 질문을 저장한다.
return "redirect:/question/list"; // 질문 저장 후 질문목록으로 이동
}
}
POST 방식으로 요청한 /question/create URL을 처리하도록 @PostMapping 애너테이션을 지정한 questionCreate 메서드를 추가했다. 메서드명은 @GetMapping에서 사용한 questionCreate 메서드명과 동일하게 사용할 수 있다(오버로딩).
questionCreate 메서드는 화면에서 입력한 제목(subject)과 내용(content)을 매개변수로 받는다. 이때 question_form.html에서 입력 항목으로 사용한 subject, content의 이름과 RequestParam의 value 값이 동일해야 한다. 그래야 입력 항목의 값을 제대로 얻을 수 있다.
서비스 수정하기
@RequiredArgsConstructor
@Service
public class QuestionService {
...
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);
}
}
제목(subject)과 내용(content)을 입력받아 이를 질문으로 저장하는 create 메서드를 만들었다.
public class QuestionController {
...
@PostMapping("/create")
public String questionCreate(@RequestParam(value="subject") String subject,
@RequestParam(value="content") String content) {
this.questionService.create(subject, content);
return "redirect:/question/list";
}
}
TODO 주석문 대신 QuestionService의 create 메서드를 호출하여 질문 데이터(subject, content)를 저장하는 코드를 작성했다.
이렇게 수정하고 질문을 작성하고 저장하면 잘 동작하는 것을 확인할 수 있다. 질문 등록 화면에서 다음과 같이 질문과 내용을 입력한 후에 [저장하기] 버튼을 클릭하면 질문이 저장되고 질문 목록 화면으로 이동하는 것을 확인할 수 있다.

현재는 질문을 등록할 때 비어 있는 값으로도 등록할 수 있다. 아무것도 입력하지 않은 상태에서 질문이 등록될 수 없도록 하려면 여러 방법이 있지만 폼 클래스를 사용하여 입력값을 체크하는 방법을 사용하겠다.
폼(form) 클래스 또한 컨트롤러, 서비스와 같이 웹 프로그램을 개발하는 주요 구성 요소 중 하나로, 웹 프로그램에서 사용자가 입력한 데이터를 검증하는 데 사용한다.
Spring Boot Validation 라이브러리 설치하기
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-validation'
}
Spring Boot Validation 라이브러리를 설치하면 다음과 같은 애너테이션을 사용하여 사용자가 입력한 값을 검증할 수 있다.
| 항목 | 설명 |
|---|---|
| @Size | 문자 길이를 제한한다. |
| @NotNull | Null을 허용하지 않는다. |
| @NotEmpty | Null 또는 빈 문자열("")을 허용하지 않는다. |
| @Past | 과거 날짜만 입력할 수 있다. |
| @Future | 미래 날짜만 입력할 수 있다. |
| @FutureOrPresent | 미래 또는 오늘 날짜만 입력할 수 있다. |
| @Max | 최댓값 이하의 값만 입력할 수 있도록 제한한다. |
| @Min | 최솟값 이상의 값만 입력할 수 있도록 제한한다. |
| @Pattern | 입력값을 정규식 패턴으로 검증한다. |
폼 클래스 만들기
// QuestionForm.java 생성
package com.mysite.sbb.question;
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;
}
subject 속성에는 @NotEmpty와 @Size 애너테이션이 적용되었다. @NotEmpty는 해당 값이 Null 또는 빈 문자열("")을 허용하지 않음을 의미한다. 그리고 여기에 사용한 message는 검증이 실패할 경우 화면에 표시할 오류 메시지이다. @Size(max=200)은 최대 길이가 200 바이트(byte)를 넘으면 안 된다는 의미로, 이와 같이 설정하면 길이가 200 바이트보다 큰 제목이 입력되면 오류가 발생한다. content 속성 역시 @NotEmpty 애너테이션을 적용하여 빈 값을 허용하지 않도록 했다.
폼 클래스는 입력값 검증할 때뿐만 아니라 입력 항목을 바인딩할 때도 사용한다. 즉, question_form.html 템플릿의 입력 항목인 subject와 content가 폼 클래스의 subject, content 속성과 바인딩된다. 여기서 바인딩이란 템플릿의 항목과 form 클래스의 속성이 매핑되는 과정을 말한다.
컨트롤러에 전송하기
// QuestionController 수정
public class QuestionController {
...
@GetMapping("/create")
public String questionCreate() {
return "question_form";
}
@PostMapping("/create")
public String questionCreate(@Valid QuestionForm questionForm, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "question_form";
}
this.questionService.create(questionForm.getSubject(), questionForm.getContent());
return "redirect:/question/list";
}
}
questionCreate 메서드의 매개변수를 subject, content 대신 QuestionForm 객체로 변경했다. subject, content 항목을 지닌 폼이 전송되면 QuestionForm의 subject, content 속성이 자동으로 바인딩된다. 이렇게 이름이 동일하면 함께 연결되어 묶이는 것이 바로 폼의 바인딩 기능이다.
QuestionForm 매개변수 앞에 @Valid 애너테이션을 적용했다. @Valid 애너테이션을 적용하면 QuestionForm의 @NotEmpty, @Size 등으로 설정한 검증 기능이 동작한다. 그리고 이어지는 BindingResult 매개변수는 @Valid 애너테이션으로 검증이 수행된 결과를 의미하는 객체이다.
BindingResult 매개변수는 항상 @Valid 매개변수 바로 뒤에 위치해야 한다. 만약 두 매개변수의 위치가 정확하지 않다면 @Valid만 적용되어 입력값 검증 실패 시 400 오류가 발생한다.
questionCreate 메서드는 bindResult.hasErrors()를 호출하여 오류가 있는 경우에는 다시 제목과 내용을 작성하는 화면으로 돌아가도록 했고, 오류가 없을 경우에만 질문이 등록되도록 만들었다.
아무런 값도 입력하지 않고 [저장하기] 버튼을 클릭하면 QuestionForm의 @NotEmpty에 의해 Validation이 실패하여 다시 질문 등록 화면에 머물러 있는다. 하지만 QuestionForm에 설정한 '제목은 필수 항목입니다.'와 같은 오류 메시지는 보이지 않는다.
템플릿 수정하기
<html layout:decorate="~{layout}" xmlns:layout="http://www.w3.org/1999/xhtml">
<div layout:fragment="content" class="container">
<h5 class="my-3 border-bottom pb-2">질문등록</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>
<textarea name="content" id="content" class="form-control" rows="10"></textarea>
</div>
<input type="submit" value="저장하기" class="btn btn-primary my-2">
</form>
</div>
</html>
검증에 실패할 경우 오류 메시지를 출력할 수 있도록 수정했다. #fields.hasAnyErrors()가 true라면 QuestionForm 검증이 실패한 것이다. QuestionForm 검증이 실패한 이유는 #fields.allErrors()로 확인할 수 있다. #fields.allErrors()에는 오류의 내용이 담겨 있다.
그리고 부트스트랩의 alert alert-danger 클래스를 사용하여 오류 메시지가 붉은 색으로 표시되도록 했다. 이렇게 오류를 표시하려면 타임리프의 th:object 속성이 반드시 필요한데, th:object는 <form>의 입력 항목들이 QuestionForm과 연결된다는 점을 타임리프에 알려주는 역할을 한다.
템플릿의 form 태그에 th:object 속성을 추가했으므로 QuestionController의 GetMapping으로 매핑한 메서드도 다음과 같이 변경해야 오류가 발생하지 않는다. 왜냐하면 question_form.html은 [질문 등록하기] 버튼을 통해 GET 방식으로 URL이 요청되더라도 th:object에 의해 QuestionForm 객체가 필요하기 때문이다.
public class QuestionController {
...
@GetMapping("/create")
public String questionCreate(QuestionForm questionForm) {
return "question_form";
}
...
}
@GetMapping으로 매핑한 questionCreate 메서드에 매개변수로 QuestionForm 객체를 추가했다. 이렇게 하면 이제 GET 방식에서도 question_form 템플릿에 QuestionForm 객체가 전달된다.
QuestionForm과 같이 매개변수로 바인딩한 객체는 Model 객체로 전달하지 않아도 템플릿에서 사용할 수 있다.

오류 처리하기
<html layout:decorate="~{layout}" xmlns:layout="http://www.w3.org/1999/xhtml">
<div layout:fragment="content" class="container">
<h5 class="my-3 border-bottom pb-2">질문등록</h5>
<!--/*@thymesVar id="questionForm" type="com.mysite.sbb.question.QuestionForm"*/-->
<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}" class="form-control">
</div>
<div class="mb-3">
<label for="content" class="form-label">내용</label>
<textarea th:field="*{content}" class="form-control" rows="10"></textarea>
</div>
<input type="submit" value="저장하기" class="btn btn-primary my-2">
</form>
</div>
</html>
name="subject", name="content" 대신 th:field 속성을 사용하도록 변경했다. 이렇게 하면 해당 태그의 id, name, value 속성이 모두 자동으로 생성되고 타임리프가 value 속성에 기존에 입력된 값을 채워 넣어 오류가 발생하더라도 기존에 입력한 값이 유지된다.

package com.mysite.sbb.answer;
import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class AnswerForm {
@NotEmpty(message = "내용은 필수항목입니다.")
private String content;
}
@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 bindingResult) {
Question question = this.questionService.getQuestion(id);
if (bindingResult.hasErrors()) {
model.addAttribute("question", question);
return "question_detail";
}
this.answerService.create(question, answerForm.getContent());
return String.format("redirect:/question/detail/%s", id);
}
}
AnswerController를 AnswerForm을 사용하도록 변경했다. QuestionForm을 사용했던 방법과 마찬가지로 @Valid와 BindingResult를 사용하여 검증을 진행한다. 검증에 실패할 경우에는 다시 답변을 등록할 수 있는 question_detail 템플릿을 출력하게 했다. 이때 question_detail 템플릿은 Question 객체가 필요하므로 model 객체에 Question 객체를 저장한 후에 question_detail 템플릿을 출력해야 한다.
<html layout:decorate="~{layout}" xmlns:layout="http://www.w3.org/1999/xhtml">
<div layout:fragment="content" class="container my-3">
...
<!-- 답변 작성 -->
<form th:action="@{|/answer/create/${question.id}|}" th:object="${answerForm}" method="post" class="my-3">
<div class="alert alert-danger" role="alert" th:if="${#fields.hasAnyErrors()}">
<div th:each="err : ${#fields.allErrors()}" th:text="${err}" />
</div>
<textarea th:field="*{content}" rows="10" class="form-control"></textarea>
<input type="submit" value="답변등록" class="btn btn-primary my-2">
</form>
</div>
</html>
답변 등록 form의 입력 항목과 AnswerForm을 타임리프에 연결하기 위해 th:object 속성을 추가했다. 그리고 검증이 실패할 경우 #fields.hasAnyErrors()와 #fields.allErrors()를 사용하여 오류 메시지를 표시하도록 했다. 그리고 답변 등록 기능의 content 항목도 th:field 속성을 사용하도록 변경했다.
public class QuestionController {
...
@GetMapping(value = "/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";
}
...
}


오류 메시지를 출력하는 HTML 코드는 질문 등록과 답변 등록 페이지에서 모두 반복해서 사용한다. 이렇게 반복적으로 사용하는 코드를 공통 템플릿으로 만들어 사용해 보자.
앞서 질문 등록과 답변 등록 기능을 만들 때 입력값이 없어 오류가 발생하면 다음과 같이 오류를 표시하도록 코드를 작성했다.
<div class="alert alert-danger" role="alert" th:if="${#fields.hasAnyErrors()}">
<div th:each="err : ${#fields.allErrors()}" th:text="${err}" />
</div>
앞으로 추가로 만들 템플릿에도 이와 같이 오류를 표시하는 부분이 필요할 것이다. 오류 메시지를 출력하는 부분을 공통 템플릿으로 만들어 필요한 곳에 삽입할 수 있도록 해보자.
오류 메시지 템플릿 만들기
<!-- form_error.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>
출력할 오류 메시지 부분에 th:fragment="formErrorsFragment" 속성을 추가했다. th:fragment="formErrorsFragment"는 다른 템플릿에서 이 div 태그의 영역을 사용할 수 있도록 이름을 설정한 것이다.
기존 템플릿에 적용하기
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container">
<h5 class="my-3 border-bottom pb-2">질문등록</h5>
<form th:action="@{/question/create}" th:object="${questionForm}" method="post">
<div th:replace="~{form_errors :: formErrorsFragment}"></div>
<div class="mb-3">
<label for="subject" class="form-label">제목</label>
<input type="text" th:field="*{subject}" class="form-control">
</div>
<div class="mb-3">
<label for="content" class="form-label">내용</label>
<textarea th:field="*{content}" class="form-control" rows="10"></textarea>
</div>
<input type="submit" value="저장하기" class="btn btn-primary my-2">
</form>
</div>
</html>
th:replace 속성을 사용하면 템플릿 내에 공통 템플릿을 삽입할 수 있다. <div th:replace="~{form_errors :: formErrorsFragment}"></div>는 th:replace 속성에 의해 div 요소의 내용을 form_errors 템플릿으로 대체하라는 의미이다.
<html layout:decorate="~{layout}" xmlns:layout="http://www.w3.org/1999/xhtml">
<div layout:fragment="content" class="container my-3">
...
<!-- 답변 작성 -->
<form th:action="@{|/answer/create/${question.id}|}" th:object="${answerForm}" method="post" class="my-3">
<div th:replace="~{form_errors :: formErrorsFragment}"></div>
<textarea th:field="*{content}" rows="10" class="form-control"></textarea>
<input type="submit" value="답변등록" class="btn btn-primary my-2">
</form>
</div>
</html>


