▼ What ?
커리큘럼 4주차에서 배웠던 내용을 이어서 정리해보려고 한다. 답변 등록 기능을 구현했듯이 질문 등록 기능을 구현해주고, 답변이나 질문을 등록할 때 오류를 표시하는 기능을 추가적으로 구현해봤다. 후자의 기능처럼 자주 사용될 만한 템플릿 코드는 공통 템플릿으로 만들어 필요할 때마다 삽입하여 사용할 수 있도록 하는 방식도 있다.
▼ 질문 / 답변 등록 & Form
질문 등록
- 질문 등록하기 버튼 생성 - HTML
[ /web-board/src/main/resources/templates/question_list.html ]
<html 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>
🔻 "질문 등록하기" 버튼을 누르면 "/question/create/" URL 이 호출된다.
➜ 이제 이 URL에 대한 매핑 처리를 해주자 !
- 질문 컨트롤러 수정
[ .../webboard/question/QuestionController.java ]
➜ "/question/create" 요청은 GET 요청에 해당하므로 @GetMapping 애너테이션 사용 !
questionCreate 메서드
➜ question_from 템플릿을 렌더링하여 출력한다.
...
public class QuestionController {
...
@GetMapping("/create")
public String questionCreate() {
return "question_form";
}
}
- 질문 등록 템플릿 생성
[ /web-board/src/main/resources/templates/question_form.html ]
<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>
- "질문 등록하기" 버튼 클릭시 나타나는 화면
- 질문과 내용을 입력하고 "저장하기" 버튼을 누르면 405 에러 발생 !
➜ "저장하기" 버튼으로 폼을 전송하면 "<form method="post">" 에 의해 POST 방식으로 요청된다.
➜ 하지만, "/question/create" URL은 POST 방식으로 처리할 수 없다.
➜ POST 요청을 처리할 수 있도록 Controller 클래스를 수정해야 한다.
questonCreate 메서드
➜ 화면에서 입력한 제목(subject)과 내용(content)을 매개변수로 받는데, 질문 등록 템플릿에서 필드 항목으로 사용했던 subject, content의 이름과 동일하게 해야 한다 !
package com.gdsc.webboard.question;
...
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
...
public class QuestionController {
...
@GetMapping("/create")
public String questionCreate() {
return "question_form";
}
@PostMapping("/create")
public String questionCreate(@RequestParam String subject, @RequestParam String content) {
// TODO 질문을 저장한다.
return "redirect:/question/list"; // 질문 저장후 질문목록으로 이동
}
}
🔻 @GetMapping시 사용했던 questionCreate 메서드명과 동일하게 사용 가능 !
- 질문을 저장하기 위해 Service 클래스 수정하자.
[ .../webboard/question/QuestionService.java ]
create 메서드
➜ 제목과 내용을 입력으로 하여 질문 데이터를 저장한다.
...
import java.time.LocalDateTime;
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);
}
}
- 컨트롤러 클래스에서 이 서비스를 사용할 수 있도록 수정하자.
[ .../webboard/question/QuestionController.java ]
➜ QuestionService 객체로 create 메서드 호출한다.
...
public class QuestionController {
...
@PostMapping("/create")
public String questionCreate(@RequestParam String subject, @RequestParam String content) {
this.questionService.create(subject, content);
return "redirect:/question/list";
}
}
폼 (Form) - 질문 등록
- 질문 등록시 비어 있는 값으로 등록 불가능하게 하기 위해 폼을 사용하여 입력값을 체크하자 !
- 화면에서 전달받은 입력값을 검증하려면 Spring Boot Validation 라이브러리가 필요하다.
➜ build.gradle 파일을 수정 후 설치 (로컬서버 재시작)
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-validation'
}
- 설치하면 아래와 같은 애너테이션들을 사용하여 입력 값 검증 가능 !
참고 자료 - https://beanvalidation.org/
Jakarta Bean Validation - Home
Jakarta Bean Validation is a Java specification which lets you express constraints on object models via annotations lets you write custom constraints in an extensible way provides the APIs to validate objects and object graphs provides the APIs to validate
beanvalidation.org
- 폼 클래스
➜ 화면의 입력항목 subject, content에 대응하는 QuestionForm 클래스 생성 !
[ .../webboard/question/QuestionForm.java ]
package com.gdsc.webboard.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) // 최대 길이가 200 Byte이고, 넘어서 ㄹ경우 오류 발생
private String subject;
@NotEmpty(message="내용은 필수항목입니다.")
private String content;
}
- 이렇게 작성한 폼 클래스를 사용할 수 있도록 Controller 클래스를 수정하자.
[ .../webboard/question/QuestionController.java ]
@Valid
➜ 해당 폼 클래스에 설정한 검증 기능(@NotEmpty, @Size)이 동작한다.
bindingResult
➜ @Valid로 인해 검증이 수행된 결과를 의미하는 객체이다.
( 항상 @Valid 매개변수 바로 뒤에 위치해야 한다 ! )
hasErrors 메서드
➜ 오류가 있는 경우엔 다시 폼을 작성(return "question_form";)하게 하고, 오류가 없을 경우에만 질문 등록(create)이 진행되도록 했다.
...
import jakarta.validation.Valid;
import org.springframework.validation.BindingResult;
public class QuestionController {
...
@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 속성이 자동으로 바인딩된다 !
( Spring 프레임워크의 바인딩 기능 )
- 검증에 실패한 오류 메시지 출력하기 위해 템플릿을 수정하자.
[ .../resources/templates/question_form.html ]
th:object:"${questionForm}"
➜ 질문 등록 폼의 속성들이 QuestionForm의 속성(subject, content)들로 구성된다는 점을 Thymeleaf 엔진에 알려준다
<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>
...
🔻 따라서, question_form.html 템플릿은 "질문 등록하기" 버튼을 통해 GET 방식으로 요청해도 "th:object"에 의해 QuestionForm 객체가 필요하다 !
➜ QuestionController의 GetMapping으로 매핑한 메서드도 수정해줘야 한다.
- 질문 컨트롤러 수정
[ .../webboard/question/QuestionController.java ]
...
public class QuestionController {
...
@GetMapping("/create")
public String questionCreate(QuestionForm questionForm) {
return "question_form";
}
...
}
🔻 GET 방식에서도 question_form 템플릿에 QuestionForm 객체를 전달 가능 !
( QuestionForm questionForm처럼 매개변수로 바인딩한 객체는 Model 객체로 전달하지 않아도 템플릿에서 사용 가능 ! )
- 검증에 실패한 오류 메시지가 표시된 화면
- 오류 발생시 입력한 내용이 사라지지 않고 유지되게 하려면 ?
질문 폼 템플릿 수정
[ .../resources/templates/question_form.html ]name="subject", name="content"
th:field="*{subject}", th:field="*{content}"
➜ id, name, value 속성이 모두 자동으로 생성되고 Thymeleaf가 value 속성에 기존 값을 채워 넣어준다 !
...
<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>
...
폼 (Form) - 질문 등록
- 답변 등록할 때에도 질문 등록에 폼을 적용한 방법과 동일한 방법으로 적용 !
- 답변 등록에 사용할 폼 클래스 생성
[ .../webboard/question/QuestionForm.java ]
package com.gdsc.webboard.answer;
import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class AnswerForm {
@NotEmpty(message = "내용은 필수항목입니다.")
private String content;
}
- 답변 컨트롤러 수정
[ ... /answer/AnswerController.java ]
➜ AnswerController가 AnswerForm을 사용한다.
@Valid, BindingResult
➜ 검증 진행
model.addAttribute("question", question);
➜ question_detail 템플릿은 Question 객체가 필요하므로 question_detail 템플릿을 렌더링(return "question_detail";)할 때 Model 객체를 이용해 Question 객체를 전달해줘야 한다.
...
import jakarta.validation.Valid;
import org.springframework.validation.BindingResult;
public class AnswerController {
...
@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);
}
}
- 질문 상세 템플릿 수정
[ .../resources/templates/question_detail.html ]
답변 등록 폼의 속성이 AnswerForm을 사용한다.
➜ 역시나 "th:obejct" 속성을 추가해주자.
#fields.hasAnyErrors(), #fields.allErrors()
➜ 오류를 표시 (질문 폼과 동일)
<html layout:decorate="~{layout}">
<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>
- 검증에 실패한 오류 메시지가 출력된 화면
▼ 공통 템플릿
공통 템플릿 ?
- 앞으로 추가적으로 만들 템플릿들에도 위와 같이 오류를 표시하는 부분이 필요 !
➜ 이렇게 반복적으로 사용하는 문장은 공통 템플릿으로 만들고 필요한 부분에 삽입하는 것이 편리하다.
- 오류 메시지 공통 템플릿
[ ... /resources/templates/form_errors.html ]
th:fragment="formErrorsFragment"
➜ 출력할 오류메시지 부분
<div th:fragment="formErrorsFragment" class="alert alert-danger"
role="alert" th:if="${#fields.hasAnyErrors()}">
<div th:each="err : ${#fields.allErrors()}" th:text="${err}" />
</div>
- 공통 템플릿 적용 - 질문 등록
[ ... /resources/templates/question_form.html ]
th:replace
➜ Thymeleaf의 속성으로, 공통 템플릿을 템플릿 내에 삽입하는 기능이다.
( div 엘리먼트를 form_errors.html 파일의 th:fragment 속성명이 formErrorsFragment인 엘리먼트로 교체되었다. )
<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">
...
- 공통 템플릿 적용 - 질문 상세
[ ... /resources/templates/question_detail.html ]
<html layout:decorate="~{layout}">
<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>