Spring Boot

Spring 3주차(게시판 기능 수정하기)

잔잔한 흐름 2025. 5. 11. 01:16

이 글은 <점프 투 스프링 부트!> 3판을 참고하여 만들어졌습니다.

2-11 URL 프리픽스 알아 두기

⇒ 질문 상세 페이지에서 답변을 입력할 수 있도록 프로그램을 만들어 보자.

QuestionController.java에는 다음 2개의 URL 매핑되어 있다.

  1. @GetMapping(”/question/list”)
  2. @GetMapping(value=”/question/detail/{id}”)

⇒ URL 매핑 시 value 매개변수는 생략할 수 있다.

URL의 프리픽스가 모두 /question으로 시작한다는 것을 알 수 있다.

  • 프리픽스(prefix)란? URL의 접두사 또는 시작 부분을 가리키는 말
  • QuestionController에 속하는 URL 매핑은 항상 /question 프리픽스로 시작하도록 설정할 수 있다.
  • QuestionController 클래스명 위에는 @RequestMapping(”/question”) 애너테이션을 추가하고 메서드 단위에서는 /question을 생략하고 그 뒷부분만 적으면 된다.
package com.mysite.sbb.question;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;

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";
    }
}

앞으로 QuestionController.java에서 URL을 매핑할 때 반드시 /question으로 시작한다는 것을 기억해 두자.

(but URL 프리픽스는 선택사항)

2-12 답변 기능 만들기

질문 목록을 확인 + 질문 제목을 클릭하면 내용을 상세하게 볼 수 있는 페이지까지 만들어 보았습니다.

텍스트 창과 등록 버튼 만들기

  1. 상세 페이지 템플릿인 question_detail.html에 답변 저장을 위한 form, textarea, input 요소를 추가해 보자.
<h1 th:text="${question.subject}" xmlns:th="http://www.w3.org/1999/xhtml" ></h1>
<div th:text="${question.content}"xmlns:th="http://www.w3.org/1999/xhtml"></div>
<form th:action="@{|answer/create/${question.id}|}" method="post" xmlns:th="http://www.w3.org/1999/xhtml">
    <textarea name="content" id="content" rows="15"></textarea>
    <input type="submit" value="답변 목록">
</form>

⇒ [답변 등록] 버튼을 누르면 전송되는 form의 action은 타임리프의 th:action 속성으로 생성한다. 텍스트 창에 답변을 작성하고, 답변 등록 버튼을 클릭하면 /answer/create/2와 같은 URL이 post 방식으로 호출된 것이다.

  1. 질문 상세 페이지에 접속
  2. [답변 등록] 버튼을 누르면 POST 방식으로 /answer/create/<question id> URL이 호출될 것이다. ⇒ 아직 /answer/create/<question id> URL을 매핑하지 않았으므로 404 에러가 뜬다. (POST 방식은 주로 데이터를 저장)

이 오류를 해결하기 위해서는 답변 컨트롤러를 만들고 http://localhost:8080/answer/create/2/URL을 매핑해야 한다.

답변 컨트롤러 만들기

AnswerController.java

package com.mysite.sbb.answer;

import com.mysite.sbb.question.Question;
import com.mysite.sbb.question.QuestionService;
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);
        // TODO: 답변을 저장한다
        return String.format("redirect:/question/detail/%s", id);
    }
}

  • /answer/create/{id}와 같은 URL 요청 시 createAnswer 메서드가 호출되도록 @PostMapping으로 매핑했다.
  • @PostMapping 애너테이션은 @GetMapping과 동일하게 URL 매핑을 담당하는 역할, POST 요청을 처리하는 경우에 사용 (value 생략 가능)
  • createAnswer 매개변수에는 @RequestParam(value=”content”) String content가 추가한 이유는 템플릿(question_detail.html)에서 답변으로 입력한 내용(content)을 얻으려고 추가한 것
  • 템플릿의 답변 내용에 해당하는 <textarea>의 name 속성명이 content이므로 변수명을 content로 지정
  • 오류가 뜬 이유: 질문 엔티티의 id 값이 없을 경우 404오류 발생

답변 서비스 만들기

AnswerService.java

package com.mysite.sbb.answer;

import com.mysite.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 메서드를 AnswerController에 적용

AnswerController.java

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);
    }
}

상세 페이지에 답변 표시하기

question_detail.html

<h1 th:text="${question.subject}" xmlns:th="http://www.w3.org/1999/xhtml" ></h1>
<div th:text="${question.content}"xmlns:th="http://www.w3.org/1999/xhtml"></div>
<h5 th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|" xmlns:th="http://www.w3.org/1999/xhtml"></h5>
<div>
    <ul>
        <li th:each="answer : ${question.answerList}" th:text="${answer.content}" xmlns:th="http://www.w3.org/1999/xhtml"></li>
    </ul>
</div>
<form th:action="@{|answer/create/${question.id}|}" method="post" xmlns:th="http://www.w3.org/1999/xhtml">
    <textarea name="content" id="content" rows="15"></textarea>
    <input type="submit" value="답변 등록">
</form>

⇒ 기존 코드에 답변을 확인할 수 있는 영역을 추가했다. #list.size(question.answerList)는 답변 개수를 의미

2-13 웹 페이지 디자인하기

웹 개발에서는 색상이나 크기 등의 디자인을 적용할 때 스타일시트(stylesheet, CSS)를 사용한다.

스태틱 디렉터리와 스타일시트 이해하기

스태틱 디렉터리를 확인했으니 앞으로 CSS 파일은 스태틱 디렉터리에 저장한다.

textarea{
    width:100%;
}

input[type=submit]{
    margin-top:10px;
}

텍스트 창의 넓이와 버튼 상단의 마진을 설정합니다.

템플릿에 스타일 적용하기

<link rel="stylesheet" type="text/css" th:href="@{/style.css}" xmlns:th="http://www.w3.org/1999/xhtml">
<h1 th:text="${question.subject}" xmlns:th="http://www.w3.org/1999/xhtml" ></h1>
<div th:text="${question.content}"xmlns:th="http://www.w3.org/1999/xhtml"></div>
<h5 th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|" xmlns:th="http://www.w3.org/1999/xhtml"></h5>
<div>
    <ul>
        <li th:each="answer : ${question.answerList}" th:text="${answer.content}" xmlns:th="http://www.w3.org/1999/xhtml"></li>
    </ul>
</div>
<form th:action="@{|answer/create/${question.id}|}" method="post" xmlns:th="http://www.w3.org/1999/xhtml">
    <textarea name="content" id="content" rows="15"></textarea>
    <input type="submit" value="답변 등록">
</form>

⇒ style.css를 사용할 수 있는 링크를 추가하여 스타일 시트 파일을 상세 페이지 템플릿에 적용했다

  • static 디렉터리가 스태틱 파일들의 루트 디렉터리이므로 적을 필요가 없기 때문이다.

2-14 부트스트랩으로 화면 꾸미기

부트스트랩(bootstrap)은 트위터(Twitter)를 개발하면서 만들어졌으며 현재 지속적으로 관리되고 있는 오픈소스 프로젝트

⇒ 웹 디자이너의 도움 없이도 개발자 혼자서 상당히 괜찮은 수준의 웹 페이지를 만들 수 있게 도와주는 프레임워크

question_list.html

<link rel="stylesheet" type="text/css" th:href="@{/bootstrapmin.css}" xmlns:th="http://www.w3.org/1999/xhtml">
<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}" xmlns:th="http://www.w3.org/1999/xhtml">
                <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를 사용하여 표시
  • loop.count는 questionList의 항목을 th:each로 반복할 때 현재의 순서를 나타냄
  • 날짜를 보기 위해 #temporals.format 기능 사용
  • #temporals.format(날짜 객체, 날짜 포맷)와 같이 사용 ⇒ 날짜 객체를 날짜 포맷에 맞게 변환

질문 상세 템플릿에도 부트스트랩을 적용하자.

<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 컴포넌트를 사용했다.
  • 부트스트랩의 card 컴포넌트는 어떤 내용을 그룹화하여 보여 줄때 사용한다.

부트스트랩 클래스 설명

card, card-body, card-text card 컴포넌트를 적용하는 클래스
badge badge 컴포넌트를 적용하는 클래스
form-control 텍스트 창에 form 컴포넌트를 적용하는 클래스
boarder-bottom 아래 방향 테두리 선을 만드는 클래스
my-3 상하 마진값으로 3을 지정하는 클래스
py-2 상하좌우 패딩값으로 2를 지정하는 클래스
d-flex justify-content-end HTML 요소를 오른쪽으로 정렬하는 클래스
bg-light 연회색으로 배경을 지정하는 클래스
text-dark 글자색을 검정색으로 지정하는 클래스
text-start 글자를 왼쪽으로 정렬하는 클래스
bin bin-primary 버튼 컴포넌트를 적용하는 클래스

2-15. 표준 HTML 구조로 변경하기

질문 목록(question_list.html), 질문 상세(question_detail.html) 템플릿은 표준 HTML 구조 X.

표준 HTML 문서의 구조는 html, body, head 요소가 있어야 하며 css 파일은 <head> 태그 안에 링크되어야 한다.

또한 <head> 태그 안에는 meta, title 요소 등이 포함되어야 한다.

템플릿 상속하기

타임리프는 템플릿 파일들을 모두 표준 HTML로 변경했을때의 중복의 불편함을 해소하기 위해 템플릿 상속 기능을 제공

템플릿 상속은 기본 틀이 되는 템플릿을 먼저 작성하고 다른 템플릿에서 그 템플릿을 상속해 사용하는 방법이다.

layout.html로 기본 틀 만들기

<!doctype html>
<html lang="ko" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
  <!-- Required meta tags -->
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <!-- Bootstrap CSS -->
  <link rel="stylesheet" type="text/css" th:href="@{/bootstrap.min.css}">
  <!-- sbb CSS -->
  <link rel="stylesheet" type="text/css" th:href="@{/style.css}">
  <title>Hello, sbb!</title>
</head>
<body>
<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->
</body>
</html>
  • layout.html은 모든 템플릿이 상속해야 하는 템플릿(표준 HTML 문서 구조로 정리된 기본틀)
  • body 요소 안의 <th:block layout:fragment=”content”></th:block>은 layout.html을 상속한 템플릿에서 개별적으로 구현해야 하는 영역

question_list.html에 템플릿 상속하기

<html layout:decorate="~{layout}" xmlns:layout="http://www.w3.org/1999/xhtml">
<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}" xmlns:th="http://www.w3.org/1999/xhtml">
                <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>

⇒ 타임 리프의 layout:decorate 속성은 템플릿의 레이아웃(부모 템플릿)으로 사용할 템플릿을 설정

question_detail.html에 템플릿 상속하기

2-16. 질문 등록 기능 추가하기

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

question_list.html

<html layout:decorate="~{layout}" xmlns:layout="http://www.w3.org/1999/xhtml" xmlns:th="http://www.w3.org/1999/xhtml">
<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}" xmlns:th="http://www.w3.org/1999/xhtml">
                <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>

⇒ 질문 등록하기 버튼을 누르면 /question/create URL이 호출될 것이다.

(현 상태에서는 404 오류 발생)

URL 매핑하기

QuestionController.java

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

⇒ [질문 등록하기] 버튼을 통한 /question/create 요청은 GET 요청에 해당

템플릿 만들기

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>

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

템플릿에는 제목과 내용을 입력할 수 있는 텍스트 창을 추가

  • 제목은 일반적인 input 텍스트 창을 사용
  • 내용은 글자 수에 제한이 없는 textarea 창을 사용
  • 입력한 내용을 /question/create URL로 post 방식을 이용해 전송(form과 버튼 추가)

⇒ 하지만 405 오류 발생(”Method Not Allowed”)

 /question/create URL을 POST 방식으로는 처리할 수 없음을 나타납니다.
  • question_form_html에서 [저장하기] 버튼으로 폼을 전송하면 POST 방식으로 데이터가 요청된다.

QuestionController.java

package com.mysite.sbb.question;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.List;

@RequiredArgsConstructor
@Controller
public class QuestionController {

    private final QuestionService questionService;

    @GetMapping("/question/list")
    public String list(Model model){
        List<Question> questionList = this.questionService.getList();
        model.addAttribute("questionList", questionList);
        return "question_list";
    }

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

  • POST 방식으로 요청한 /question/create URL을 처리하도록 @PostMapping 애너테이션을 지정한 questionCreate 메서드를 추가
  • 메서드명은 @GetMapping에서 사용한 questionCreate 메서드명과 동일하게 사용
  • questionCreate 메서드는 화면에서 입력한 제목(subject)과 내용(content)을 매개변수로 받는다.
  • question_form.html에서 입력 항복으로 사용한 subject, content의 이름과 RequestParam의 value값이 동일해야 함을 기억해야 된다.

서비스 수정하기

QuestionService.java

package com.mysite.sbb.question;

import lombok.RequiredArgsConstructor;
import com.mysite.sbb.DataNotFoundException;
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);
    }
}

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

QuestionController.java

@PostMapping("/create")
    public String questionCreate(@RequestParam(value="subject") String subject,
                                 @RequestParam(value = "content") String content){
        this.questionService.create(subject, content);
        return "redirect:/question/list"; // 질문 저장 후 질문 목록으로 이동
   }

⇒ 주석 대신 QuestionService의 create 메서드를 호출해 질문 데이터(subject, content)를 저장하는 코드

폼 활용하기

질문을 등록하는 기능은 구현 완료.

질문을 등록할 때 비어 있는 값으로 등록할 수 있다는 점은 간과했다.

아무것도 입력하지 않은 상태에서 질문이 등록될 수 없도록 폼 클래스를 사용해보자.

폼(form) 클래스 또한 주요 구성 요소 중 하나다

Spring Boot Validation 라이브러리 설치가히

implementation 'org.springframework.boot:spring-boot-starter-validation'

어떤 라이브러리가 있을까

항목 설명

@Size 문자 길이를 제한한다.
@NotNull Null을 허용하지 않는다.
@NotEmpty Null 또는 빈 문자열(””)을 허용하지 않는다.
@Past 과거 날짜만 입력할 수 있다.
@Future 미래 날짜만 입력할 수 있다.
@FutureOrPresent 미래 또는 오늘 날짜만 입력할 수 있다.
@Max 최댓값 이하의 값만 입력할 수 있도록 제한한다.
@Min 최솟값 이상의 값만 입력할 수 있도록 제한한다.
@Pattern 입력값을 정규식 패턴으로 검증한다.

폼 클래스 만들기

질문 등록 페이지에서 사용자로부터 입력받은 값을 검증하는데 필요한 폼 클래스를 만들어 보자.

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)를 넘으면 안된다 ⇒ 오류 발생
  • content 속성 역시 @NotEmpty 애너테이션을 적용하여 빈 값을 허용 x

⇒ 폼 클래스는 입력값 검증할 때뿐만 아니라 입력 항목을 바인딩할 때도 사용한다.

 여기서 바인딩이란 템플릿의 항목과 form 클래스의 속성이 매핑되는 과정을 의미한다.

컨트롤러에 전송하기

QuestionController.java

package com.mysite.sbb.question;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
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 객체로 변경
  • subject, content 항목을 지닌 폼이 전송되면 QuestionForm의 subject, content 속성이 자동으로 바인딩
  • 여기서 QuestionForm 매개변수 안에 @Valid 애너테이션을 적용했다.
  • @Valid 애너테이션을 적용하면 QuestionForm의 @NotEmpty, @Size 등으로 설정한 검증 기능이 동작한다.
  • BindingResult 매개변수는 @Valid 애너테이션으로 검증이 수행된 결과를 의미하는 객체

⇒ BindingResult 매개변수는 항상 @Valid 매개변수 바로 뒤에 위치해야 한다.

따라서 questionCreate 메서드는 bindResult.hasErrors()를 호출하여 오류가 있는 경우에 다시 제목과 내용을 작성하는 화면으로 돌아가도록 설정

오류가 없을 경우에는 질문이 등록

아무런 입력값도 입력하지 않았으므로 QuestionForm의 @NotEmpty에 의해 Validation이 실패하여 다시 질문 등록 화면에 머물러 있을 것이다.

but, QuestionForm에 설정한 ‘제목은 필수 항목입니다.’와 같은 오류 메시지는 보이지 않는다. 오류 메시지가 보이지 않는다면 어떤 항목에서 검증이 실패했는지 알 수 없다.

템플릿 수정하기

question_form.html

<html layout:decorate="~{layout}" xmlns:th="http://www.w3.org/1999/xhtml" xmlns:layout="http://www.w3.org/1999/xhtml">
<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>

검증에 실패할 경우 오류 메시지 출력

  • #fields.has.AnyErrors가 true라면 QuestionForm 검증이 실패 (⇒ #fields.allErrors()로 확인)
  • 부트스트랩의 alert alert-danger 클래스를 사용하여 오류메시지를 붉은 색으로 출력
  • 오류가 표시하려면 타임리프의 th:object 속성이 반드시 필요하다
  • th:object는 <form>의 입력 항목들이 QuestionForm과 연결된다는 점을 타임리프에게 알려주는 역할을 한다.

그러나 오류 발생

QuestionController의 GetMapping한 메서드가 같이 변경해야 함.

⇒ question_form.html은 GET 방식으로 URL이 요청되더라도 th:object에 의해 QuestionForm 객체가 필요하다.

@GetMapping으로 매핑한 questionCreate 메서드에 매개변수로 QuestionForm 객체를 추가했다.

오류 처리하기

ex. 제목을 입력하고 내용을 비워 둔 채로 [저장하기] 버튼을 누르면 오류 메시지가 나타남과 동시에 이미 입력한 제목도 사라진다.

⇒ name=”subject”, name=”content” 대신 th:field 속성을 사용

해당 태그의 id, name, value 속성이 모두 자동으로 생성(타임리프가 value 속성에 기존에 입력된 값을 채워 오류가 발생하더라도 기존에 입력한 값이 유지)

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

AnswrForm.java

package com.mysite.sbb.answer;

import com.mysite.sbb.question.Question;
import com.mysite.sbb.question.QuestionService;
import jakarta.validation.Valid;
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;

@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 사용해서 변경
  • QuestionForm을 사용했던 방법과 마찬가지로 @Valid와 BindingResult를 사용하여 검증을 진행
  • 검증에 실패할 경우에는 다시 답변을 등록할 수 있는 question_detail 템플릿을 출력
  • question_detail 템플릿은 Question 객체가 필요하므로 model 객체에 Question 객체를 지정한 후에 question_detail 템플릿 출력

question_detail.html

<html layout:decorate="~{layout}" xmlns:layout="http://www.w3.org/1999/xhtml" xmlns:th="http://www.w3.org/1999/xhtml">
<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>

⇒ 답변 등록 form의 입력 항목과 AnswerForm을 타임리프에 연결하기 위해 th:object 속성 추가

 검증에 실패할 경우 #fields.hasAnyErrors()와 #fields.allErrors()를 사용하여 오류 메시지를 표시

QuestionController의 detail 메서드의 매개변수에 AnswerForm을 추가해야 한다.

공통 템플릿 만들기

오류 메시지를 출력하는 HTML 코드는 질문 등록과 답변 등록 페이지에서 모두 반복해서 사용한다.

⇒ 오류를 표시하는 부분이 필요

오류 메시지 템플릿을 만들어 기존 템플릿에 적용시킨다.