02O. 질문 등록과 폼
02O. 질문 등록과 폼 관련
이번에는 질문을 등록하는 기능을 만들어 보자.
질문 등록
질문 등록을 하려면 먼저 "질문 등록하기" 버튼을 만들어야 한다. 다음처럼 질문 목록 하단에 버튼을 생성하자.
파일이름:
/sbb/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 매핑
그리고 컨트롤러에 /question/create
에 해당되는 URL 매핑을 추가하자.
파일명:
/sbb/src/main/java/com/mysite/sbb/question/
QuestionController.java
// (... 생략 ...)
public class QuestionController {
// (... 생략 ...)
@GetMapping("/create")
public String questionCreate() {
return "question_form";
}
}
"질문 등록하기" 버튼을 통한 /question/create
요청은 GET 요청에 해당하므로 @GetMapping
애너테이션을 사용하였다. questionCreate
메서드는 question_form
템플릿을 렌더링하여 출력한다.
템플릿
질문 등록을 위한 question_form.html
템플릿은 다음과 같이 작성하자.
파일명:
/sbb/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 오류가 발생한다. 405 오류는 "Method Not Allowed" 오류로 /question/create
URL을 POST 방식으로는 처리할 수 없음을 의미한다.
.
question_form.html
에서 "저장하기" 버튼으로 폼을 전송하면<form method="post">
에 의해 POST 방식으로 데이터가 요청된다.
따라서 POST 요청을 처리할 수 있도록 다음처럼 컨트롤러를 수정해야 한다.
파일명:
/sbb/src/main/java/com/mysite/sbb/question/
QuestionController.java
package com.mysite.sbb.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"; // 질문 저장후 질문목록으로 이동
}
}
POST 방식으로 요청한 /question/create
URL을 처리하기 위해 @PostMapping
애너테이션을 지정한 questionCreate
메서드를 추가했다. 메서드명은 @GetMapping
시 사용했던 questionCreate
메서드명과 동일하게 사용할 수 있다. (단, 매개변수의 형태가 다른 경우에 가능하다. - 메서드 오버로딩)
questonCreate
메서드는 화면에서 입력한 제목(subject
)과 내용(content
)을 매개변수로 받는다. 이 때 질문 등록 템플릿에서 필드 항목으로 사용했던 subject
, content의
이름과 동일하게 해야 함에 주의하자.
이제 입력으로 받은 subject
, content
를 사용하여 질문을 저장해야 한다. 일단 질문 저장은 잠시 뒤로 미루고(TODO 주석만 작성했다.) 질문이 저장되면 질문 목록 페이지로 이동하도록 했다.
서비스
질문을 저장하려면 서비스에 해당 기능을 추가해야 한다. QuestionService
를 다음과 같이 수정하자.
파일명:
/sbb/src/main/java/com/mysite/sbb/question/
QuestionService.java
// (... 생략 ...)
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);
}
}
제목과 내용을 입력으로 하여 질문 데이터를 저장하는 create
메서드를 만들었다. 이제 Question
컨트롤러에서 이 서비스를 사용할수 있도록 다음과 같이 수정하자.
파일명:
/sbb/src/main/java/com/mysite/sbb/question/
QuestionController.java
// (... 생략 ...)
public class QuestionController {
// (... 생략 ...)
@PostMapping("/create")
public String questionCreate(@RequestParam String subject, @RequestParam String content) {
this.questionService.create(subject, content);
return "redirect:/question/list";
}
}
TODO 주석문 대신 QuestionService
로 질문 데이터를 저장하는 코드를 작성하였다. 이렇게 수정하고 질문을 작성하고 저장하면 잘 동작하는 것을 확인할 수 있을 것이다.
폼(form)
위에서 질문을 등록하는 기능을 구현했다. 하지만 질문 등록시 간과한 것이 있다. 그것은 바로 질문이나 내용을 등록할 때 비어 있는 값으로 등록이 가능하다는 점이다. 빈 값으로 등록이 불가능하게 하려면 여러 방법이 있지만 여기서는 폼을 사용하여 입력값을 체크하는 방법을 사용해 보자.
Spring Boot Validation
화면에서 전달받은 입력 값을 검증하려면 Spring Boot Validation 라이브러리가 필요하다. 다음과 같이 build.gradle 파일을 수정하자.
파일명:
/sbb/
build.gradle
// (... 생략 ...)
dependencies {
// (... 생략 ...)
implementation "org.springframework.boot:spring-boot-starter-validation"
}
// (... 생략 ...)
이와 같이 수정하고 ["Refresh Gradle Project"]
로 필요한 라이브러리를 설치하자.
라이브러리를 설치한 후에는 반드시 로컬서버를 재시작해야 한다.
"Spring Boot Validation"을 설치하면 다음과 같은 애너테이션들을 사용하여 입력 값을 검증할 수 있다.
항목 | 설명 |
---|---|
@Size | 문자 길이를 제한한다. |
@NotNull | Null을 허용하지 않는다. |
@NotEmpty | Null 또는 빈 문자열("" )을 허용하지 않는다. |
@Past | 과거 날짜만 가능 |
@Future | 미래 날짜만 가능 |
@FutureOrPresent | 미래 또는 오늘날짜만 가능 |
@Max | 최대값 |
@Min | 최소값 |
@Pattern | 정규식으로 검증 |
보다 많은 기능은 다음의 URL을 참고하도록 하자.
폼 클래스
화면에서 전달되는 입력 값을 검증하기 위해서는 폼 클래스가 필요하다. 화면의 입력항목 subject, content에 대응하는 QuestionForm
클래스를 다음과 같이 작성하자.
폼 클래스는 입력 값의 검증에도 사용하지만 화면에서 전달한 입력 값을 바인딩할 때에도 사용한다.
파일명:
/sbb/src/main/java/com/mysite/sbb/question/
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 바이트를 넘으면 안된다는 의미이다. 이와 같이 설정하면 길이가 200 byte 보다 큰 제목이 입력되면 오류가 발생할 것이다. content
속성 역시 @NotEmpty
로 빈 값을 허용하지 않도록 했다.
컨트롤러
그리고 위에서 작성한 QuestionForm
을 컨트롤러에서 사용할 수 있도록 다음과 같이 컨트롤러를 수정하자.
파일명:
/sbb/src/main/java/com/mysite/sbb/question/
QuestionController.java
// (... 생략 ...)
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
속성이 자동으로 바인딩 된다. 이것은 스프링 프레임워크의 바인딩 기능이다.
그리고 QuestionForm
매개변수 앞에 @Valid
애너테이션을 적용했다. @Valid
애너테이션을 적용하면 QuestionForm의 @NotEmpty
, @Size
등으로 설정한 검증 기능이 동작한다. 그리고 이어지는 BindingResult
매개변수는 @Valid
애너테이션으로 인해 검증이 수행된 결과를 의미하는 객체이다.
BindingResult
매개변수는 항상@Valid
매개변수 바로 뒤에 위치해야 한다. 만약 2개의 매개변수의 위치가 정확하지 않다면@Valid
만 적용이 되어 입력값 검증 실패 시 400 오류가 발생한다.
따라서 questionCreate
메서드는 bindResult.hasErrors()
를 호출하여 오류가 있는 경우에는 다시 폼을 작성하는 화면을 렌더링하게 했고 오류가 없을 경우에만 질문 등록이 진행되도록 했다.
여기까지 수정하고 질문 등록 화면에서 아무런 값도 입력하지 말고 "저장하기" 버튼을 눌러보자. 아무런 입력값도 입력하지 않았기 때문에 QuestionForm
의 @NotEmpty
에 의해 Validation
이 실패하여 다시 질문 등록 화면에 머물러 있을 것이다. 하지만 QuestionForm
에 설정한 "제목은 필수항목입니다." 와 같은 오류 메시지는 보이지 않는다.
오류메시지가 보이지 않는다면 어떤 항목에서 검증이 실패했는지 알 수가 없다. 어떻게 해야 할까?
템플릿
검증에 실패한 오류메시지를 보여주기 위해 템플릿을 다음과 같이 수정하자.
파일명:
/sbb/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}" 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
객체가 필요하기 때문이다.
파일명:
/sbb/src/main/java/com/mysite/sbb/question/
QuestionController.java
// (... 생략 ...)
public class QuestionController {
// (... 생략 ...)
@GetMapping("/create")
public String questionCreate(QuestionForm questionForm) {
return "question_form";
}
// (... 생략 ...)
}
GetMapping
으로 매핑한 questionCreate
메서드에 매개변수로 QuestionForm
객체를 추가했다. 이렇게 하면 이제 GET 방식에서도 question_form
템플릿에 QuestionForm
객체가 전달될 것이다.
QuestionForm
과 같이 매개변수로 바인딩한 객체는 Model 객체로 전달하지 않아도 템플릿에서 사용이 가능하다.
이렇게 수정하고 제목 또는 내용에 값을 채우지 않은 상태로 질문 등록을 진행하면 다음과 같은 오류가 화면에 표시될 것이다.
오류 발생시 입력한 내용 유지하기
하지만 테스트를 진행하다보니 또 다른 문제를 발견했다. 그것은 이미 입력한 "제목"이나 "내용"이 사라진다는 점이다. 즉, 제목에 값을 채우고 내용을 비워둔 채로 "저장하기" 버튼을 누르면 오류 메시지가 나타남과 동시에 이미 입력한 제목의 내용도 사라진다는 점이다. 입력한 제목은 남아 있어야 하지 않겠는가?
이러한 문제를 해결하려면 이미 입력한 값이 유지되도록 다음과 같이 템플릿을 수정해야 한다.
파일명:
/sbb/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}" 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
속성에 기존 값을 채워 넣어 오류가 발생하더라도 기존에 입력한 값이 유지된다.
이제 위와 같이 수정하고 다시 질문 등록을 진행해 보자. 이전에 입력했던 값이 유지되는 것을 확인할 수 있을 것이다.
답변 등록
질문 등록에 폼을 적용한 것처럼 답변 등록을 할 때에도 폼을 적용해 보자. 질문 등록과 동일한 방법이므로 조금 빠르게 적용해 보자. 먼저 답변을 등록할 때 사용할 AnswerForm
을 다음과 같이 작성하자.
파일명:
/sbb/src/main/java/com/mysite/sbb/answer/
AnswerForm.java
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;
}
그리고 AnswerController
를 다음과 같이 수정하자.
파일명:
/sbb/src/main/java/com/mysite/sbb/answer/
AnswerController.java
(... 생략 ...)
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);
}
}
AnswerForm
을 사용하도록 컨트롤러를 변경했다. QuestionForm
을 사용했던 방법과 마찬가지로 @Valid
와 BindingResult
를 사용하여 검증을 진행한다. 검증에 실패할 경우에는 다시 답변을 등록할 수 있는 question_detail
템플릿을 렌더링하게 했다. 이때 question_detail
템플릿은 Question
객체가 필요하므로 model
객체에 Question
객체를 저장한 후에 question_detail
템플릿을 렌더링해야 한다.
그리고 question_detail.html
템플릿 파일은 다음과 같이 수정하자.
파일명:
/sbb/src/main/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 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 메서드도 다음과 같이 수정해야 한다.
파일명:
/sbb/src/main/java/com/mysite/sbb/question/
QuestionController.java
// (... 생략 ...)
import com.mysite.sbb.answer.AnswerForm;
// (... 생략 ...)
public class QuestionController {
// (... 생략 ...)
@GetMapping(value = "/detail/{id}")
public String detail(Model model, @PathVariable("id") Integer id, AnswerForm answerForm) {
// (... 생략 ...)
}
// (... 생략 ...)
}
이와 같이 수정하고 답변 등록을 진행해 보자.