[스프링부트 시리즈19] 질문 등록 기능 추가하기

Featured image

질문 등록 기능 추가하기

이번에는 질문을 등록하는 기능을 만들어 보자. 질문 목록에 질문 등록을 위한 버튼을 추가하고 질문을 등록할 수 있는 화면을 만들어 질문 등록 기능을 완성해 보자.

질문 등록 버튼과 화면 만들기


질문 등록을 할 수 있도록 먼저 질문 목록 페이지에 [질문 등록하기] 버튼을 만들어야 한다. question_list.html 파일을 열고 다음과 같이 한 줄의 코드를 추가하여 질문 목록 아래에 버튼을 생성하자.

[파일이름:/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>

<a> ... </a>요소를 추가하여 부트스트랩의 btn btn-primary 클래스를 적용하면 다음과 같이 화면에 버튼 형태로 보인다. 버튼

[질문 등록하기] 버튼을 한번 클릭해 보자. 아마 /question/create URL이 호출될 것이다. 하지만 현 상태에서는 404 오류가 발생한다.

404 매핑하기

이제 404 오류가 발생하면 무엇을 해야 하는지 잘 알 것이다. QuestionController/question/create에 해당하는 URL 매핑을 추가하자.

[파일명:/question/QuestionController.java]

(... 생략 ...)

public class QuestionController {

    (... 생략 ...)

    @GetMapping("/create")
    public String questionCreate() {
        return "question_form";
    }
}

[질문 등록하기] 버튼을 통한 /question/create 요청은 GET 요청에 해당하므로 @GetMapping 애너테이션을 사용했다. questionCreate 메서드는 question_form 템플릿을 출력한다.

NOTE
GET 요청이란?

  • 정보를 서버로부터 요청할 때 사용
  • URL 의 쿼리 문자열에 데이터를 첨부해 서버에 전송
  • 주소표시줄에서 볼 수 있어 보안 취약
  • 데이터 길이 제한
  • 데이터 캐시 가능

NOTE POST 요청이란?

  • 서버에 데이터를 제출할 때 사용
  • HTTP 요청 본문에 데이터가 포함되어 전송
  • 데이터 길이제한 없음
  • 로그인 정보 전송, 양식 제출 등 사용자 작업에 사용
  • 데이터 캐시 불가능

    템플릿 만들기

1) 질문 등록 화면을 만들기 위해 templatesquestion_form.html 파일을 생성하고 다음 내용을 작성해 보자.

[파일명:/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>

이와 같이 제목과 내용을 입력하여 질문을 등록할 수 있는 템플릿을 작성했다.

템플릿에는 제목과 내용을 입력할 수 있는 텍스트 창을 추가했다. 제목은 일반적인 input 텍스트 창을 사용하고 내용은 글자 수에 제한이 없는 textarea 창을 사용했다. 그리고 입력한 내용을 /question/create URL로 post 방식을 이용해 전송할 수 있도록 form과 버튼을 추가했다.

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

3) POST 요청을 처리할 수 있도록 다음과 같이 QuestionController를 수정해 보자.

[파일명:/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(value="subject") String subject, @RequestParam(value="content") String content) {
        // TODO 질문을 저장한다.
        return "redirect:/question/list"; // 질문 저장후 질문목록으로 이동
    }
}

POST 방식으로 요청한 /question/create URL을 처리하도록 @PostMapping 애너테이션을 지정한 questionCreate 메서드를 추가했다. 메서드명은 @GetMapping에서 사용한 questionCreate 메서드명과 동일하게 사용할 수 있다(단, 매개변수의 형태가 다른 경우에 가능하다.).

이와 같이 자바에서 한 클래스에서 동일한 메서드명을 사용할 수 있는 것을 메서드 오버로딩(overloading)이라고 한다.

questionCreate 메서드는 화면에서 입력한 제목(subject)과 내용(content)을 매개변수로 받는다. 이때 질문 등록 템플릿(question_form.html)에서 입력 항목으로 사용한 subject, content의 이름과 RequestParam의 value 값이 동일해야 함을 기억하자. 그래야 입력 항목의 값을 제대로 얻을 수 있다.

그런데 여기서는 일단 질문 데이터를 저장하는 작업은 잠시 뒤로 미루고(해야 할 일을 TODO 주석으로 작성했다.) [저장하기] 버튼을 클릭해 질문이 저장되면 질문 목록 페이지로 이동하는 것까지 완성해 보았다.

서비스 수정하기

1) 앞서 잠시 미룬 작업을 진행해 보자. 질문 데이터를 저장하기 위해 QuestionService.java를 다음과 같이 수정해 보자.

[파일명:/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);
    }
}

제목(subject)과 내용(content)을 입력받아 이를 질문으로 저장하는 create 메서드를 만들었다.

2) 다시 QuestionController.java로 돌아가 이 서비스를 사용할 수 있도록 다음과 같이 수정해 보자.

[파일명:/question/QuestionController.java]

(... 생략 ...)
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 주석문 대신 QuestionServicecreate 메서드를 호출하여 질문 데이터(subject, content)를 저장하는 코드를 작성했다.

이렇게 수정하고 질문을 작성하고 저장하면 잘 동작하는 것을 확인할 수 있다. 질문 등록 화면에서 다음과 같이 질문과 내용을 입력한 후에 [저장하기] 버튼을 클릭하면 질문이 저장되고 질문 목록 화면으로 이동하는 것을 확인할 수 있다.

폼 활용하기


우리는 질문을 등록하는 기능을 구현했다. 하지만 질문을 등록할 때 비어 있는 값으로도 등록할 수 있다는 점을 간과했다. 아무것도 입력하지 않은 상태에서 질문이 등록될 수 없도록 하려면 여러 방법이 있지만 여기서는 폼 클래스를 사용하여 입력값을 체크하는 방법을 사용해 보자.

폼(form) 클래스 또한 컨트롤러, 서비스와 같이 웹 프로그램을 개발하는 주요 구성 요소 중 하나로, 웹 프로그램에서 사용자가 입력한 데이터를 검증하는 데 사용한다.

Spring Boot Validation 라이브러리 설치하기

폼 클래스를 사용해 사용자로부터 입력받은 값을 검증하려면 먼저 Spring Boot Validation 라이브러리가 필요하다. 이 라이브러리를 설치하기 위해서 다음과 같이 build.gradle.kts 파일을 수정해 보자.

[파일명:build.gradle.kts]

(... 생략 ...)

dependencies {
    (... 생략 ...)
    implementation 'org.springframework.boot:spring-boot-starter-validation'
}

(... 생략 ...)

오른쪽 그레이들 패널에서 모든 그레이들 프로젝트를 리로딩한다.

설치 후 로컬 서버를 재시작해야한다.

Spring Boot Validation 라이브러리를 설치하면 다음과 같은 애너테이션을 사용하여 사용자가 입력한 값을 검증할 수 있다.

항목 설명
@Size 문자 길이를 제한한다.
@NotNull Null을 금지한다.
@NotEmpty Null 또는 빈 문자열(““)을 금지한다.
@Past 과거 날짜만 입력할 수 있다.
@Future 미래 날짜만 입력할 수 있다.
@FutureOrPresent 미래 또는 오늘 날짜만 입력할 수 있다.
@Max 최댓값 이하의 값만 입력할 수 있다.
@Min 최소값 이하의 값만 입력할 수 있다.
@Pattern 입력값을 정규식 패턴으로 검증한다.

폼 클래스 만들기

질문 등록 페이지에서 사용자로부터 입력받은 값을 검증하는데 필요한 폼 클래스를 만들어 보자. 먼저, com.mysite.sbb.question 패키지에 QuestionForm.java 파일을 만들어 입력 항목인 subject, content에 대응하는 QuestionForm 클래스를 다음과 같이 작성해 보자.

[파일명:/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 바이트(byte)를 넘으면 안 된다는 의미로, 이와 같이 설정하면 길이가 200 바이트보다 큰 제목이 입력되면 오류가 발생한다. content 속성 역시 @NotEmpty 애너테이션을 적용하여 빈 값을 허용하지 않도록 했다.

폼 클래스는 입력값 검증할 때뿐만 아니라 입력 항목을 바인딩할 때도 사용한다. 즉, question_form.html 템플릿의 입력 항목인 subjectcontent가 폼 클래스의 subject, content 속성과 바인딩된다. 여기서 바인딩이란 템플릿의 항목과 form 클래스의 속성이 매핑되는 과정을 말한다.

컨트롤러에 전송하기

QuestionForm을 컨트롤러에서 사용할 수 있도록 다음과 같이 컨트롤러(QuestionController)를 수정해 보자.

[파일명:/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 항목을 지닌 폼이 전송되면 QuestionFormubject, content 속성이 자동으로 바인딩된다. 이렇게 이름이 동일하면 함께 연결되어 묶이는 것이 바로 폼의 바인딩 기능이다.

여기서 QuestionForm 매개변수 앞에 @Valid애너테이션을 적용했다. @Valid 애너테이션을 적용하면 QuestionForm@NotEmpty, @Size 등으로 설정한 검증 기능이 동작한다. 그리고 이어지는 BindingResult 매개변수는 @Valid 애너테이션으로 검증이 수행된 결과를 의미하는 객체이다.

BindingResult 매개변수는 항상 @Valid 매개변수 바로 뒤에 위치해야 한다. 만약 두 매개변수의 위치가 정확하지 않다면 @Valid만 적용되어 입력값 검증 실패 시 400 오류가 발생한다.

따라서 questionCreate 메서드는 bindResult.hasErrors()를 호출하여 오류가 있는 경우에는 다시 제목과 내용을 작성하는 화면으로 돌아가도록 했고, 오류가 없을 경우에만 질문이 등록되도록 만들었다.

여기까지 수정했다면 질문 등록 화면에서 아무런 값도 입력하지 말고 [저장하기] 버튼을 클릭해 보자. 아무런 입력값도 입력하지 않았으므로 QuestionForm@NotEmpty에 의해 Validation이 실패하여 다시 질문 등록 화면에 머물러 있을 것이다. 하지만 QuestionForm에 설정한 ‘제목은 필수 항목입니다.’와 같은 오류 메시지는 보이지 않는다. 오류 메시지가 보이지 않는다면 어떤 항목에서 검증이 실패했는지 알 수가 없다. 어떻게 해야 할까?

템플릿 수정하기

1) 검증에 실패했다는 오류 메시지를 보여 주기 위해 question_form 템플릿을 다음과 같이 수정해 보자.

[파일명:/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()로 확인할 수 있다. #fields.allErrors()에는 오류의 내용이 담겨 있다.

그리고 부트스트랩의 alert alert-danger 클래스를 사용하여 오류 메시지가 붉은 색으로 표시되도록 했다. 이렇게 오류를 표시하려면 타임리프의 th:object 속성이 반드시 필요한데, th:object<form>의 입력 항목들이 QuestionForm과 연결된다는 점을 타임리프에 알려주는 역할을 한다.

2) 그런데 여기까지 수정하고 테스트하기 위해 [질문 등록하기] 버튼을 클릭하면 오류가 발생할 것이다.

템플릿의 form 태그에 th:object 속성을 추가했으므로 QuestionControllerGetMapping으로 매핑한 메서드도 다음과 같이 변경해야 오류가 발생하지 않는다. 왜냐하면 question_form.html은 [질문 등록하기] 버튼을 통해 GET 방식으로 URL이 요청되더라도 th:object에 의해 QuestionForm객체가 필요하기 때문이다.

[파일명:/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 객체로 전달하지 않아도 템플릿에서 사용할 수 있다.

오류

3) 이렇게 수정하고 제목 또는 내용에 값을 채우지 않은 상태로 질문 등록을 진행하면([저장하기] 버튼을 클릭하면) 화면에 다음과 같은 오류가 표시될 것이다. 저장하기

오류처리하기

테스트를 진행하다 보니 또 다른 문제를 발견했다. 예를 들어 제목을 입력하고 내용을 비워 둔 채로 [저장하기] 버튼을 누르면 오류 메시지가 나타남과 동시에 이미 입력한 제목도 사라진다는 점이다. 입력한 제목은 남아 있어야 하지 않겠는가?

1) 이러한 문제를 해결하기 위해 이미 입력한 값이 유지되도록 다음과 같이 템플릿을 수정해 보자.

[파일명:/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 속성에 기존에 입력된 값을 채워 넣어 오류가 발생하더라도 기존에 입력한 값이 유지된다.

2) 이제 코드를 수정하고 제목만 입력한 후, [저장하기] 버튼을 클릭해 질문 등록을 진행해 보자. 이전에 입력했던 값(제목)이 유지되는 것을 확인할 수 있을 것이다. 제목유지

답변 등록 기능에 폼 적용하기


질문 등록 기능에 폼을 적용한 것처럼 답변 등록 기능에도 폼을 적용해 보자. 질문 등록 기능을 만들 때와 동일한 방법이므로 조금 빠르게 만들어 보자.

1) 먼저 답변을 등록하기 위해 필요한 폼인 AnswerForm을 다음과 같이 작성해 보자.

[파일명:/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;
}

2) 이어서 AnswerController를 다음과 같이 수정하자.

[파일명:/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);
    }
}

AnswerControllerAnswerForm을 사용하도록 변경했다. QuestionForm을 사용했던 방법과 마찬가지로 @ValidBindingResult를 사용하여 검증을 진행한다. 검증에 실패할 경우에는 다시 답변을 등록할 수 있는 question_detail 템플릿을 출력하게 했다. 이때 question_detail 템플릿은 Question 객체가 필요하므로 model 객체에 Question 객체를 저장한 후에 question_detail 템플릿을 출력해야 한다.

3) 템플릿 question_detail.html을 다음과 같이 수정하자.

[파일명:/templates/question_detail.html]

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
```html
    (... 생략 ...)
    <!-- 답변 작성 -->
    <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 속성을 사용하도록 변경했다.

4) AnswerForm을 사용하기 위해 question_detail 템플릿을 수정하였으므로 QuestionControllerdetail 메서드도 다음과 같이 수정해야 한다.

[파일명:/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) {
        (... 생략 ...)
    }

    (... 생략 ...)
}

5) 수정을 완료한 후, 답변 등록 기능이 제대로 동작하는지 확인해 보자. 먼저, 질문 등록 페이지에서 제목과 내용을 입력하고 저장한다. 동작 6) 질문 목록 페이지에서 제목을 클릭하면 오른쪽과 같이 질문 제목과 내용이 등장하고, 그 아래에 답변을 입력할 수 있는 공간이 있다. 만약 내용 없이 답변을 등록하려고 시도하면 검증 오류가 발생할 것이다. 오류

공통 템플릿 만들기


오류 메시지를 출력하는 HTML 코드는 질문 등록과 답변 등록 페이지에서 모두 반복해서 사용한다. 이렇게 반복적으로 사용하는 코드를 공통 템플릿으로 만들어 사용해 보자.

앞서 우리는 질문 등록과 답변 등록 기능을 만들 때 입력값이 없어 오류가 발생하면 다음과 같이 오류를 표시하도록 코드를 작성했다.

<div class="alert alert-danger" role="alert" th:if="${#fields.hasAnyErrors()}">
    <div th:each="err : ${#fields.allErrors()}" th:text="${err}" />
</div>

앞으로 추가로 만들 템플릿에도 이와 같이 오류를 표시하는 부분이 필요할 것이다. 이렇게 반복해서 사용하는 문장은 공통 템플릿으로 만들고 필요한 부분에 삽입하여 쓸 수 있다면 편리하지 않을까? 오류 메시지를 출력하는 부분을 공통 템플릿으로 만들어 필요한 곳에 삽입할 수 있도록 해보자.

기존 템플릿에 적용하기

1) 이제 위에서 작성한 오류 메시지 관련 내용이 담긴 공통 템플릿을 사용해 보자. 먼저 질문 등록 기능을 위한 question_form.html 파일에 적용해 보자.

[파일명:/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 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 템플릿으로 대체하라는 의미이다. 여기서 formErrorsFragment는 앞서 form_errors 템플릿에서 작성한 내용 일부를 가리키는 것이다.

2) 답변을 등록하는 question_detail.html 파일도 다음과 같이 수정하자.

[파일명:/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>

3) 이렇게 변경을 완료한 후, 질문 등록과 답변 등록 기능이 제대로 동작하는지 확인해 보자. 이전과 동일하게 동작하는 것을 확인할 수 있다.