점프 투 스프링부트 2장 (12~16)

이동원·2024년 4월 15일

2-12 답변 기능 만들기

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

<h1 th:text="${question.subject}"></h1>
<div th:text="${question.content}"></div>

<form th:action="@{|/answer/create/${question.id}|}" method="post">
    <textarea name="content" id="content" rows="15"></textarea>
    <input type="submit" value="답변등록">
</form>
  • th:action : [답변등록] 버튼을 누르면 전송되는 form 의 action은 타임리프 속성으로 생성
    • 답변등록 버을 누르면 /answer/create/(question.id)와 같은 URL이 post 방식으로 호출
      • post : 데이터를 저장하는 용도로 주로 사용
    • 답변등록 버튼 클릭 후 화면(아직 매핑 전이므로 오류가 뜬다)

2) 답변 컨트롤러 만들기

package com.example.sbb.answer;

import com.example.sbb.question.Question;
import com.example.sbb.question.QuestionService;
import lombok.Getter;
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);
        return String.format("redirect:/question/detail/%s",id);
    }
}
  • @PostMapping : URL 매핑을 담당하는 역할을 하지만, POST 요청을 처리하는 경우에 사용
    • /answer/create/{id}와 같은 URL 요청 시 createAnswer 메서드가 호출
  • @RequestParam(value ="content") String content : question_detail 에서 답변으로 입력한 내용(content)을 얻기 위한 구문
    • name의 속성명이 content 이므로 변수명은 content로 사용 필수
  • create/{id} : {id}는 질문 엔티티의 id

3) 답변 서비스 만들기

  • 답변을 저장하려면 답변 서비스가 필요하다
package com.example.sbb.answer;

import com.example.sbb.question.Question;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;

@RequiredArgsConstructor
@Service
public class AnswerService {

    private  final AnswerRepository answerRepository;

   // 입력받은 2개의 매개변수를 이용하여 Answer 객체 생성
    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 메서드 : 답변을 생성하기 위한 메서드
package com.example.sbb.answer;

import com.example.sbb.question.Question;
import com.example.sbb.question.QuestionService;
import lombok.Getter;
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;
    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);
    }
}
  • AnswerService의 create 메서드 호출하여 답변 저장

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

<h1 th:text="${question.subject}"></h1>
<div th:text="${question.content}"></div>
<h5 th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|"></h5>
<div>
    <ul>
        <li th:each="answer:${question.answerList}" th:text="${answer.content}"></li>
    </ul>
</div>
<form th:action="@{|/answer/create/${question.id}|}" method="post">
    <textarea name="content" id="content" rows="15"></textarea>
    <input type="submit" value="답변등록">
</form>
  • lists.size(객체) : 객체의 길이를 반환
    • lists.size(question.answerList) : 답변의 개수

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

  • CSS : 색상이나 크기 등의 디자인을 적용할 때 사용하는 것

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

  • src/main/resources/static : css 파일 등을 저장
textarea{
    width : 100%;
}

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

2) 템플릿에 스타일 적용하기

<link rel="stylesheet" href="text/css" th:href="@{/style.css}">

<h1 th:text="${question.subject}"></h1>
<div th:text="${question.content}"></div>
<h5 th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|"></h5>
<div>
    <ul>
        <li th:each="answer:${question.answerList}" th:text="${answer.content}"></li>
    </ul>
</div>
<form th:action="@{|/answer/create/${question.id}|}" method="post">
    <textarea name="content" id="content" rows="15"></textarea>
    <input type="submit" value="답변등록">
</form>
  • <link> : HTML에서 외부 리소스를 연결할 때 사용하는 태그 (css 파일 연결)
  • rel="stylesheet" : 연결된 리소스가 스타일 시트임을 명시
  • href="text/css" : 연결할 리소스의 타입을 명시
    • href는 연결될 파일의 경로나 URL을 지정하는 속성
  • th:href="@{/style.css}" : 이 부분은 Thymeleaf 템플릿 엔진의 문법. 서버 사이드에서 처리되며, 최종적으로 클라이언트에게 전달될 때는 해당 서버의 컨텍스트에 맞는 실제 경로로 변환. 이 경우 style.css 파일을 참조하도록 설정.

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

  • 부트스트랩 : 트위터(Twitter)를 개발하면서 만들어졌으며 현재 지속적으로 관리되고 있는 오픈소스 프로젝트로, 웹 디자이너의 도움 없이도 개발자 혼자서 상당히 괜찮은 수준의 웹 페이지를 만들 수 있게 도와주는 프레임워크

1) 부트스트랩 설치하기

2) 부트스트랩 적용하기

question_list에 적용하기

// bootstrap.min.css 사용할 수 있도록 링크 추가
<link rel="stylesheet" type="text/css" th:href="@{/bootstrap.min.css}">
<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}">
            <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 : 테이블 항목으로 번호 추가
    • questionList 항목을 반복할 때 현재 순서
  • #temporals.format(날짜 객체,날자 포맷) : 날자 객체를 날짜 포맷에 맞게 변환
  • class="container my-3" class="table" class="table-dark" 등 : 이미 정의되어있는 클래스들로 간격을 조정하고 테이블에 스타일을 지정하는 용도

question_detail에 적용하기

<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 : 어떤 내용을 그룹화하여 보여 줄 때 사용
  • style="white-space: pre-line;" : 사용자가 입력한 대로 줄 바꿈이 적용되도록 만들어준다

참고

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

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

  • 어떤 웹 브라우저를 사용하더라도 웹 페이지가 동일하게 보이고 정상적으로 작동하게 하려면 반드시 웹 표준을 지키는 HTML 문서로 작성

1) 표준 HTML 구조 살펴보기

  • 표준 HTML 문서의 구조
    • 요소 : html, head, body
    • < head > : CSS 파일, meta, title 요소 등이 포함

2) 템플릿 상속하기

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

layout.html 기본 틀 만들기

<!doctype html>
<html lang="ko">
<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> 은 상속한 템플릿에서 개별적으로 구현해야 하는 영역

question_list.html에 템플릿 상속

<html layout:decorate="~{layout}">
<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}">
            <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>
  • <html layout:decorate="~{layout}"> : 템플릿을 상속하는 역할
    • layout:decorate : 템플릿의 레이아웃(부모 템플릿)으로 사용할 템플릿 설정
    • ~{파일명} : 상속받을 파일 명
  • layout:fragment="content" : 부모 클래스에서 개별적으로 구현해야 한다고 한 부분을 자식 클래스에서 구현해주었다
    • th:block 요소의 내용이 자식 템플릿의 div 요소의 내용으로 교체 (상단 layout 참고)

question_detail.html에 템플릿 상속

<html layout:decorate="~{layout}">
<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}|}" 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>
</html>

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

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

<html layout:decorate="~{layout}">
<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}">
            <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>
  • <a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a> 추가하여 버튼 만들기

URL 매핑하기

package com.example.sbb.question;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
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";
    }

}

템플릿 만들기

<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과 버튼을 추가
  • 저장하기를 누르면 오류가 뜬다 : POST 방식으로 create 처리 불가라는 뜻

post 요청 처리 가능하게 수정

package com.example.sbb.question;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
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(@RequestParam(value = "subject") String subject,
                                 @RequestParam(value = "content") String content){
        return "redirect:/question/list";
    }
}
  • @PostMapping("/create") : POST 방식으로 요청한 URL을 처리하도록 매서드 추가
  • 오버로딩 : 한 클래스에서 동일한 메서드명을 사용할 수 있는 것
    • 매개변수 형태가 다른 GET, POST 에서 동일한 메서드명 사용
  • questionCreate() : 화면에서 입력한 제목과 내용을 매개변수로 받는다
    • question_form에서 입력 항목으로 사용한 이름과 RequsetParam의 value 값은 동일해야한다

서비스 수정하기

package com.example.sbb.question;


import com.example.sbb.DataNotFoundException;
import lombok.RequiredArgsConstructor;
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);
    }
}
  • create() : 제목과 내용을 입력 받아 이를 질문으로 저장하는 메서드
package com.example.sbb.question;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
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(@RequestParam(value = "subject") String subject,
                                 @RequestParam(value = "content") String content){
        this.questionService.create(subject,content);
        return "redirect:/question/list";
    }
}
  • this.questionService.create(subject,content); : create 메서드 호출하여 질문 데이터 저장

2) 폼 활용하기

  • 아무것도 입력하지 않은 상태에서 질문이 등록될 수 없도록 하기 위하여 폼 클래스 활용
  • 폼 클래스 : 컨트롤러, 서비스와 같이 웹 프로그램을 개발하는 주요 구성 요소 중 하나로, 웹 프로그램에서 사용자가 입력한 데이터를 검증하는 데 사용

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

   implementation 'org.springframework.boot:spring-boot-starter-validation'
  • Spring Boot Validation 라이브러리 기능
항목설명
@Size문자 길이를 제한한다.
@NotNullNull을 허용하지 않는다.
@NotEmptyNull 또는 빈 문자열("")을 허용하지 않는다.
@Past과거 날짜만 입력할 수 있다.
@Future미래 날짜만 입력할 수 있다.
@FutureOrPresent미래 또는 오늘 날짜만 입력할 수 있다.
@Max최댓값 이하의 값만 입력할 수 있도록 제한한다.
@Min최솟값 이상의 값만 입력할 수 있도록 제한한다.
@Pattern입력값을 정규식 패턴으로 검증한다.

폼 클래스 만들기

package com.example.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;
}
  • @NotEmpty : 해당 값이 Null 또는 빈 문자열을 허용하지 않음을 의미
    • message = "제목은 필수항목입니다." : 검증이 실패할 경우 화면에 표시할 오류 메시지
  • @Size : 규격을 정의해준다
  • 참고) 폼 클래스는 입력값 검증 뿐 아니라 입력 항목을 바인딩할 때도 사용한다
    • 바인딩 : 템플릿의 항목과 form 클래스의 속성이 매핑되는 과정

컨트롤러에 전송하기

package com.example.sbb.question;

import jakarta.validation.Valid;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Value;
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")
    // bindingResult.hasErrors() 호출하여 
    // 오류가 있는 경우 다시 제목과 내용을 작성하는 화면으로 돌아가고, 오류가 없으면 질문이 등록
    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 객체로 변결
    • 이름이 동일하면 함께 연결되어 묶임(바인딩)
  • @Valid : 검증 기능(@NotEmpty,@Size 등)이 동작
  • BindingResult : @Valid 으로 검증이 수행된 결과를 의미
    • 항상 @Valid 뒤 위치해야한다. 만약 위치가 정확하지 않다면 @Valid만 적용되어 입력값 검증 실패 시 400 오류가 발생

오류메시지가 뜨게 템플릿 수정하기

<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라면 검증이 실패
    • 실패 이유는 #fields.allErrors()로 확인 가능
  • alert alert-danger : 오류 메시지가 붉은 색으로 표시
  • th:object : < form > 의 입력 하목들이 QuestionForm 과 연결되는 점을 타임리프에 알려줌
package com.example.sbb.question;

import jakarta.validation.Valid;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Value;
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")
    // question_form.html은 질문 등록하기 버튼을 통해 GET 방식으로 URL이 요청되더라도 
    // th:object에 의해 QuestionForm 객체가 필요하다
    // 그러므로 매개변수 선언 필요
    public String questionCreate(QuestionForm questionForm){
        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";
    }
}

오류 처리하기

  • 제목 입력하고 내용 빈채로 저장하기 누르면 오류 메시지가 나타나고 이미 입력한 제목도 사라진다
    • 입력한 제목은 그대로 남게 만들어야 한다
<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>
  • th:field 속성 : name="subject", name="content" 대신 사용
    • 해당 태그의 id,name,value 속성이 모두 자동 생성
    • 타임리프가 value 속성에 기존에 입력된 값을 채워 넣어 오류가 발생해도 기존에 입력한 값이 유지

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

package com.example.sbb.answer;

import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class AnswerForm {
    @NotEmpty(message = "내용은 필수항목입니다.")
    private String content;
}
  • AnswerForm 생성
package com.example.sbb.answer;

import com.example.sbb.question.Question;
import com.example.sbb.question.QuestionService;
import jakarta.validation.Valid;
import lombok.Getter;
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;
import org.springframework.web.bind.annotation.RequestParam;

@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 사용하게 변경
    • @Valid, BindingResult 사용하여 검증 진행
    • 실패할 경우 question_detail 템플릿 출력 > 다시 답 입력 가능
  • question_detail 템플릿은 Question 객체가 필요
    • model 객체에 Question 객체를 저장하고 question_detail 템플릿 출력
<html layout:decorate="~{layout}">
<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 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>
  • th:object : 답변 등록 form의 입력 항목과 AnswerForm을 타임리프에 연결
    • 검증이 실패할 경우 #fields.hasAnyErrors()와 #fields.allErrors()를 사용하여 오류 메시지를 표시
    • 답변 등록 기능의 content 항목도 th:field 속성을 사용하도록 변경
package com.example.sbb.question;

import com.example.sbb.answer.Answer;
import com.example.sbb.answer.AnswerForm;
import jakarta.validation.Valid;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Value;
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, AnswerForm answerForm) {
        Question question = this.questionService.getQuestion(id);
        model.addAttribute("question",question);
        return "question_detail";
    }
    @GetMapping("/create")
    public String questionCreate(QuestionForm questionForm){
        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";
    }
}
  • question_detail 수정하였으므로 detail 메서드도 수정 필요

4) 공통 템플릿 만들기

  • 오류 메시지를 출력하는 HTML 코드는 질문, 답변 등록시 모두 반복해서 사용
  • 반복 사용 문장은 공통 템플릿으로 만들고 필요한 부분에만 삽입하여 쓸 수 있으면 편하다

오류 메시지 템플릿 만들기

<div th:fragment="formErrorsFragment" class="alert alert-danger"
     role="alert" th:if="${#fields.hasAnyErrors()}">
    <div th:each="err : ${#fields.allErrors()}" th:text="${err}" />
</div>
  • th:fragment="formErrorsFragment" : 다른 템플릿에서 이 div 태그 영역을 사용할 수 있도록 이름을 설정한 것

기존 템플릿에 적용

<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}"> : th:replace 속성에 의해 div 요소의 내용을 form_error 템플릿으로 대체하라는 의미
  • formErrorsFragment : form_errors 템플릿에서 작성한 내용 일부를 가리키는 것
<html layout:decorate="~{layout}">
<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>

0개의 댓글