2-12 답변 기능 만들기
1) 텍스트 창과 등록 버튼 만들기
<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>
th:action : [답변등록] 버튼을 누르면 전송되는 form 의 action은 타임리프 속성으로 생성
- 답변등록 버을 누르면 /answer/create/(question.id)와 같은 URL이 post 방식으로 호출
- post : 데이터를 저장하는 용도로 주로 사용

- 답변등록 버튼 클릭 후 화면(아직 매핑 전이므로 오류가 뜬다)

2) 답변 컨트롤러 만들기
package com.example.sbb.answer;
import com.example.sbb.question.Question;
import com.example.sbb.question.QuestionService;
import lombok.Getter;
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);
return String.format("redirect:/question/detail/%s",id);
}
}
@PostMapping : URL 매핑을 담당하는 역할을 하지만, POST 요청을 처리하는 경우에 사용
- /answer/create/{id}와 같은 URL 요청 시 createAnswer 메서드가 호출
@RequestParam(value ="content") String content : question_detail 에서 답변으로 입력한 내용(content)을 얻기 위한 구문
- name의 속성명이 content 이므로 변수명은 content로 사용 필수

create/{id} : {id}는 질문 엔티티의 id
3) 답변 서비스 만들기
package com.example.sbb.answer;
import com.example.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);
}
}
- create 메서드 : 답변을 생성하기 위한 메서드
package com.example.sbb.answer;
import com.example.sbb.question.Question;
import com.example.sbb.question.QuestionService;
import lombok.Getter;
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;
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);
}
}
- AnswerService의 create 메서드 호출하여 답변 저장
4) 상세 페이지에 답변 표시하기
<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(객체) : 객체의 길이를 반환
lists.size(question.answerList) : 답변의 개수

2-13 웹 페이지 디자인하기
- CSS : 색상이나 크기 등의 디자인을 적용할 때 사용하는 것
1) 스태틱 디렉터리와 스타일시트 이해하기
- src/main/resources/static : css 파일 등을 저장
textarea{
width : 100%;
}
input[type=submit]{
margin-top:10px;
}
2) 템플릿에 스타일 적용하기
<link rel="stylesheet" href="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>
<link> : HTML에서 외부 리소스를 연결할 때 사용하는 태그 (css 파일 연결)
rel="stylesheet" : 연결된 리소스가 스타일 시트임을 명시
href="text/css" : 연결할 리소스의 타입을 명시
- href는 연결될 파일의 경로나 URL을 지정하는 속성
th:href="@{/style.css}" : 이 부분은 Thymeleaf 템플릿 엔진의 문법. 서버 사이드에서 처리되며, 최종적으로 클라이언트에게 전달될 때는 해당 서버의 컨텍스트에 맞는 실제 경로로 변환. 이 경우 style.css 파일을 참조하도록 설정.

2-14 부트스트랩으로 화면 꾸미기
- 부트스트랩 : 트위터(Twitter)를 개발하면서 만들어졌으며 현재 지속적으로 관리되고 있는 오픈소스 프로젝트로, 웹 디자이너의 도움 없이도 개발자 혼자서 상당히 괜찮은 수준의 웹 페이지를 만들 수 있게 도와주는 프레임워크
1) 부트스트랩 설치하기
2) 부트스트랩 적용하기
question_list에 적용하기
// bootstrap.min.css 사용할 수 있도록 링크 추가
<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 : 테이블 항목으로 번호 추가
- questionList 항목을 반복할 때 현재 순서
#temporals.format(날짜 객체,날자 포맷) : 날자 객체를 날짜 포맷에 맞게 변환
class="container my-3" class="table" class="table-dark" 등 : 이미 정의되어있는 클래스들로 간격을 조정하고 테이블에 스타일을 지정하는 용도

question_detail에 적용하기
<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 : 어떤 내용을 그룹화하여 보여 줄 때 사용
style="white-space: pre-line;" : 사용자가 입력한 대로 줄 바꿈이 적용되도록 만들어준다
참고
| 부트스트랩 클래스 | 설명 |
|---|
| 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 | 버튼 컴포넌트를 적용하는 클래스이다. |
2-15 표준 HTML 구조로 변경하기
- 어떤 웹 브라우저를 사용하더라도 웹 페이지가 동일하게 보이고 정상적으로 작동하게 하려면 반드시 웹 표준을 지키는 HTML 문서로 작성
1) 표준 HTML 구조 살펴보기
- 표준 HTML 문서의 구조
- 요소 : html, head, body
- < head > : CSS 파일, meta, title 요소 등이 포함
2) 템플릿 상속하기
- 템플릿 상속 : 기본 틀이 되는 템플릿을 먼저 작성하고 다른 템플릿에서 그 템플릿을 상속해 사용하는 방법
layout.html 기본 틀 만들기
<!doctype html>
<html lang="ko">
<head>
<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>
<th:block layout:fragment="content"></th:block>
</body>
</html>
- layout.html : 모든 템플릿이 상속해야 하는 템플릿으로, 표준 HTML 문서 구조로 정리된 기본 틀
- body 요소 안
<th:block layout:fragment="content"></th:block> 은 상속한 템플릿에서 개별적으로 구현해야 하는 영역
question_list.html에 템플릿 상속
<html 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 : ${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>
<html layout:decorate="~{layout}"> : 템플릿을 상속하는 역할
layout:decorate : 템플릿의 레이아웃(부모 템플릿)으로 사용할 템플릿 설정
~{파일명} : 상속받을 파일 명
layout:fragment="content" : 부모 클래스에서 개별적으로 구현해야 한다고 한 부분을 자식 클래스에서 구현해주었다
- th:block 요소의 내용이 자식 템플릿의 div 요소의 내용으로 교체 (상단 layout 참고)
question_detail.html에 템플릿 상속
<html layout:decorate="~{layout}">
<div layout:fragment="content" 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>
</html>
2-16 질문 등록 기능 추가하기
1) 질문 등록 버튼과 화면 만들기
<html 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 : ${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>
<a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a>
</div>
</html>
<a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a> 추가하여 버튼 만들기

URL 매핑하기
package com.example.sbb.question;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@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";
}
@GetMapping("/create")
public String questionCreate(){
return "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과 버튼을 추가

- 저장하기를 누르면 오류가 뜬다 : POST 방식으로 create 처리 불가라는 뜻
post 요청 처리 가능하게 수정
package com.example.sbb.question;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@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";
}
@GetMapping("/create")
public String questionCreate(){
return "question_form";
}
@PostMapping("/create")
public String questionCreate(@RequestParam(value = "subject") String subject,
@RequestParam(value = "content") String content){
return "redirect:/question/list";
}
}
@PostMapping("/create") : POST 방식으로 요청한 URL을 처리하도록 매서드 추가
- 오버로딩 : 한 클래스에서 동일한 메서드명을 사용할 수 있는 것
- 매개변수 형태가 다른 GET, POST 에서 동일한 메서드명 사용
questionCreate() : 화면에서 입력한 제목과 내용을 매개변수로 받는다
- question_form에서 입력 항목으로 사용한 이름과 RequsetParam의 value 값은 동일해야한다
서비스 수정하기
package com.example.sbb.question;
import com.example.sbb.DataNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@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");
}
}
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);
}
}
- create() : 제목과 내용을 입력 받아 이를 질문으로 저장하는 메서드
package com.example.sbb.question;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@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";
}
@GetMapping("/create")
public String questionCreate(){
return "question_form";
}
@PostMapping("/create")
public String questionCreate(@RequestParam(value = "subject") String subject,
@RequestParam(value = "content") String content){
this.questionService.create(subject,content);
return "redirect:/question/list";
}
}
this.questionService.create(subject,content); : create 메서드 호출하여 질문 데이터 저장
2) 폼 활용하기
- 아무것도 입력하지 않은 상태에서 질문이 등록될 수 없도록 하기 위하여 폼 클래스 활용
- 폼 클래스 : 컨트롤러, 서비스와 같이 웹 프로그램을 개발하는 주요 구성 요소 중 하나로, 웹 프로그램에서 사용자가 입력한 데이터를 검증하는 데 사용
Spring Boot Validation 라이브러리 설치하기
implementation 'org.springframework.boot:spring-boot-starter-validation'
- Spring Boot Validation 라이브러리 기능
| 항목 | 설명 |
|---|
| @Size | 문자 길이를 제한한다. |
| @NotNull | Null을 허용하지 않는다. |
| @NotEmpty | Null 또는 빈 문자열("")을 허용하지 않는다. |
| @Past | 과거 날짜만 입력할 수 있다. |
| @Future | 미래 날짜만 입력할 수 있다. |
| @FutureOrPresent | 미래 또는 오늘 날짜만 입력할 수 있다. |
| @Max | 최댓값 이하의 값만 입력할 수 있도록 제한한다. |
| @Min | 최솟값 이상의 값만 입력할 수 있도록 제한한다. |
| @Pattern | 입력값을 정규식 패턴으로 검증한다. |
폼 클래스 만들기
package com.example.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;
}
@NotEmpty : 해당 값이 Null 또는 빈 문자열을 허용하지 않음을 의미
message = "제목은 필수항목입니다." : 검증이 실패할 경우 화면에 표시할 오류 메시지
@Size : 규격을 정의해준다
- 참고) 폼 클래스는 입력값 검증 뿐 아니라 입력 항목을 바인딩할 때도 사용한다
- 바인딩 : 템플릿의 항목과 form 클래스의 속성이 매핑되는 과정
컨트롤러에 전송하기
package com.example.sbb.question;
import jakarta.validation.Valid;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Value;
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;
@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";
}
@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 매개변수를 QuestionForm 객체로 변결
@Valid : 검증 기능(@NotEmpty,@Size 등)이 동작
BindingResult : @Valid 으로 검증이 수행된 결과를 의미
- 항상
@Valid 뒤 위치해야한다. 만약 위치가 정확하지 않다면 @Valid만 적용되어 입력값 검증 실패 시 400 오류가 발생
오류메시지가 뜨게 템플릿 수정하기
<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 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라면 검증이 실패
- 실패 이유는
#fields.allErrors()로 확인 가능
alert alert-danger : 오류 메시지가 붉은 색으로 표시
th:object : < form > 의 입력 하목들이 QuestionForm 과 연결되는 점을 타임리프에 알려줌
package com.example.sbb.question;
import jakarta.validation.Valid;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Value;
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;
@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";
}
@GetMapping("/create")
public String questionCreate(QuestionForm questionForm){
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";
}
}

오류 처리하기
- 제목 입력하고 내용 빈채로 저장하기 누르면 오류 메시지가 나타나고 이미 입력한 제목도 사라진다
<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 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>
th:field 속성 : name="subject", name="content" 대신 사용
- 해당 태그의 id,name,value 속성이 모두 자동 생성
- 타임리프가 value 속성에 기존에 입력된 값을 채워 넣어 오류가 발생해도 기존에 입력한 값이 유지

3) 답변 등록 기능에 폼 적용하기
package com.example.sbb.answer;
import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class AnswerForm {
@NotEmpty(message = "내용은 필수항목입니다.")
private String content;
}
package com.example.sbb.answer;
import com.example.sbb.question.Question;
import com.example.sbb.question.QuestionService;
import jakarta.validation.Valid;
import lombok.Getter;
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 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);
}
}
- AnswerForm 사용하게 변경
@Valid, BindingResult 사용하여 검증 진행
- 실패할 경우
question_detail 템플릿 출력 > 다시 답 입력 가능
question_detail 템플릿은 Question 객체가 필요
- model 객체에 Question 객체를 저장하고 question_detail 템플릿 출력
<html layout:decorate="~{layout}">
<div layout:fragment="content" 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}|}" 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>
th:object : 답변 등록 form의 입력 항목과 AnswerForm을 타임리프에 연결
- 검증이 실패할 경우 #fields.hasAnyErrors()와 #fields.allErrors()를 사용하여 오류 메시지를 표시
- 답변 등록 기능의 content 항목도 th:field 속성을 사용하도록 변경
package com.example.sbb.question;
import com.example.sbb.answer.Answer;
import com.example.sbb.answer.AnswerForm;
import jakarta.validation.Valid;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Value;
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;
@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, AnswerForm answerForm) {
Question question = this.questionService.getQuestion(id);
model.addAttribute("question",question);
return "question_detail";
}
@GetMapping("/create")
public String questionCreate(QuestionForm questionForm){
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";
}
}
- question_detail 수정하였으므로 detail 메서드도 수정 필요

4) 공통 템플릿 만들기
- 오류 메시지를 출력하는 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" : 다른 템플릿에서 이 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}"> : th:replace 속성에 의해 div 요소의 내용을 form_error 템플릿으로 대체하라는 의미
formErrorsFragment : form_errors 템플릿에서 작성한 내용 일부를 가리키는 것
<html layout:decorate="~{layout}">
<div layout:fragment="content" 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}|}" 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>