해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다
자바 8, 스프링부트 2.7.7버전 입니다.
질문 등록
질문 등록을 위한 질문 등록 버튼을 question_list 템플릿에 생성하자
<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>
링크 엘리먼트(<a>..</a>)에 부트스트랩의 btn btn-primary 클래스를 적용시켜 버튼화 했다
"질문 등록하기" 버튼을 누르면 /question/create URL이 호출될 것이다.
URL 매핑
컨트롤러에 /question/create에 해당되는 URL 매핑을 추가하자
(... 생략 ...)
public class QuestionController {
(... 생략 ...)
@GetMapping("/create")
public String questionCreate() {
return "/question/question_form";
}
}
"질문 등록하기" 버튼을 통한 /question/create 요청은 GET 요청에 해당하므로 @GetMapping 애너테이션을 사용하였다.
questionCreate 메서드는 question_form 템플릿을 렌더링하여 출력한다.
템플릿
질문 등록을 위한 question_form.html 템플릿을 작성하자
파일 경로 : /practice/src/main/resources/templates/question/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 오류가 발생한다.
question_form.html 에서 "저장하기" 버튼으로 폼을 전송하면 <form method="post">에 의해
POST 방식으로 데이터가 요청된다.
POST 요청을 처리할 수 있도록 서비스와 컨트롤러를 수정하자
(... 생략 ...)
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);
}
}
(... 생략 ...)
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
(... 생략 ...)
public class QuestionController {
(... 생략 ...)
@PostMapping("/create")
public String questionCreate(@RequestParam String subject, @RequestParam String content) {
this.questionService.create(subject, content);
return "redirect:/question/list";
}
}
질문을 저장하기 위해 서비스에 create 메서드를 추가했다.
QuestionController에서 해당 서비스의 메서드를 활용할 수 있게 컨트롤러에도 메서드를 추가했다
POST 방식으로 요청한 /question/create URL을 처리하기 위해 @PostMapping 애너테이션을 지정한 questionCreate 메서드를 추가했다. 메서드명은 @GetMapping시 사용했던 questionCreate 메서드명과 동일하게 사용할 수 있다.
(단, 매개변수의 형태가 다른 경우에 가능하다. - 메서드 오버로딩)
questonCreate 메서드는 화면에서 입력한 제목(subject)과 내용(content)을 매개변수로 받는다.
이 때 질문 등록 템플릿에서 필드 항목으로 사용했던 subject, content의 이름과 동일하게 해야 함에 주의하자.
QuestionService로 질문 데이터를 저장하는 코드를 작성하였다.
이렇게 수정하고 질문을 작성하고 저장하면 잘 동작하는 것을 확인할 수 있을 것이다.
폼 클래스
화면에서 전달받은 입력 값을 검증하기위해 Spring Boot Validation 라이브러리를 build.gradle 파일에 추가했다.
dependencies {
(... 생략 ...)
implementation 'org.springframework.boot:spring-boot-starter-validation'
}
"Spring Boot Validation"을 설치하면 애너테이션들을 사용하여 입력 값을 검증할 수 있다.
애너테이션들과 기능은 https://beanvalidation.org/에서 혹은
화면에서 전달되는 입력 값을 검증하기 위해서는 폼 클래스가 필요하다.
화면의 입력항목 subject, content에 대응하는 QuestionForm 클래스를 작성하자.
package com.example.board.question;
import javax.validation.constraints.NotEmpty;
import javax.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와 @Size 애너테이션이 적용되었다.
@NotEmpty는 해당 값이 Null 또는 빈 문자열("")을 허용하지 않음을 의미한다.
그리고 여기에 사용된 message 속성은 검증이 실패할 경우 화면에 표시할 오류 메시지이다.
@Size(max=200)은 최대 길이가 200 바이트를 넘으면 안된다는 의미이다.
이와 같이 설정하면 길이가 200 byte 보다 큰 제목이 입력되면 오류가 발생할 것이다.
content 속성 역시 @NotEmpty로 빈 값을 허용하지 않도록 했다.
컨트롤러
QuestionForm을 컨트롤러에서 사용할 수 있도록 컨트롤러를 수정하자.
(... 생략 ...)
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/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에 설정한 "제목은 필수항목입니다." 와 같은 오류 메시지는 보이지 않는다.
템플릿
검증에 실패한 오류메시지를 보여주기 위해 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}" 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()로 구할 수 있다.
부트스트랩의 alert alert-danger 클래스를 사용하여 오류는 붉은 색으로 표시되도록 했다.
그리고 이렇게 오류를 표시하기 위해서는 타임리프의 th:object 속성이 반드시 필요하다.
th:object를 사용하여 폼의 속성들이 QuestionForm의 속성들로 구성된다는 점을 타임리프 엔진에 알려줘야 하기 때문이다.
QuestionController의 GetMapping으로 매핑한 메서드도 변경해야 한다.
question_form.html 템플릿은 "질문 등록하기" 버튼을 통해 GET 방식으로 요청되더라도 th:object에 의해 QuestionForm 객체가 필요하기 때문이다.
(... 생략 ...)
public class QuestionController {
(... 생략 ...)
@GetMapping("/create")
public String questionCreate(QuestionForm questionForm) {
return "/question/question_form";
}
(... 생략 ...)
}
GetMapping으로 매핑한 questionCreate 메서드에 매개변수로 QuestionForm 객체를 추가했다.
이렇게 하면 이제 GET 방식에서도 question_form 템플릿에 QuestionForm 객체가 전달될 것이다.
QuestionForm과 같이 매개변수로 바인딩한 객체는 Model 객체로 전달하지 않아도 템플릿에서 사용이 가능하다.
오류 발생 시 입력한 내용 유지하기
오류 발생 시 입력한 내용 유지를 위해 question_form 템플릿을 수정하자.
(... 생략 ...)
<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>
(... 생략 ...)
name="subject", name="content"와 같이 사용하던 부분을 위와 같이 th:field 속성을 사용하도록 변경하였다.
이렇게 하면 해당 태그의 id, name, value 속성이 모두 자동으로 생성되고 타임리프가 value 속성에 기존 값을 채워 넣어 오류가 발생하더라도 기존에 입력한 값이 유지된다.
답변 등록
질문 등록과 동일하게 하면 된다. AnswerForm을 작성하자.
package com.example.board.answer;
import javax.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class AnswerForm {
@NotEmpty(message = "내용은 필수항목입니다.")
private String content;
}
AnswerController을 사용하도록 변경하자.
package com.example.board.answer;
import javax.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/question_detail";
}
this.answerService.create(question, answerForm.getContent());
return String.format("redirect:/question/detail/%s", id);
}
}
QuestionForm을 사용했던 방법과 마찬가지로 @Valid와 BindingResult를 사용하여 검증을 진행한다.
검증에 실패할 경우에는 다시 답변을 등록할 수 있는 question_detail 템플릿을 렌더링하게 했다.
이때 question_detail 템플릿은 Question 객체가 필요하므로 model 객체에 Question 객체를 저장한 후에 question_detail 템플릿을 렌더링해야 한다.
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 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>
답변 등록 폼의 속성이 AnswerForm을 사용하기 때문에 th:object 속성을 추가했다. 그리고 검증이 실패할 경우 오류메시지를 출력하기 위해 #fields.hasAnyErrors()와 #fields.allErrors()를 사용하여 오류를 표시했다.
content 항목도 th:field 속성을 사용하도록 변경했다.
question_detail 템플릿이 AnswerForm을 사용하기 때문에 QuestionController의 detail 메서드도 수정해야 한다.
(... 생략 ...)
import com.mysite.sbb.answer.AnswerForm;
(... 생략 ...)
public class QuestionController {
(... 생략 ...)
@GetMapping(/detail/{id}")
public String detail(Model model, @PathVariable("id") Integer id, AnswerForm answerForm) {
(... 생략 ...)
}
(... 생략 ...)
}
2023.04 ~ 백엔드 개발자의 기록
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!