[Spring Boot] 폼 클래스 만들기

DANI·2023년 10월 3일
0
post-thumbnail

📕 폼(form)이란?

폼이란 데이터를 담아놓은 집합 같은 걸로 폼을 전송하게 되면 태그 안에 있는 데이터를 전송할 수 있게 된다. 물론 다른 폼에 있는 데이터를 가져올 수도 있지만 submit을 할 때 폼 자체를 전송하거나 폼 내부의 값들을 serialize하여 전송하는 경우가 대부분이다.


📖 폼 데이터 주고 받기

  • 목표 : 사용자로부터 폼 데이터를 받고, 이를 컨트롤러에서 확인하기
  • 폼 데이터 : HTML 요소인 form 태그에 담긴 데이터

컨트롤러는 이 데이터를 객체에 담아 받는다. 이때, 폼 데이터를 받는 객체를 DTO(Data Transfer Object)라고 한다.

참고 : https://cloudstudying.kr/lectures/438


📝 질문 등록할 때 빈 값으로 등록이 불가능하게 해보자!

  1. 클라이언트의 입력을 받아 서버에 전송한다.
  2. 서버에서 입력 값을 검증한다. (DTO객체 생성)

Spring Boot Validation


화면에서 전달받은 입력 값을 검증하려면 Spring Boot Validation 라이브러리가 필요하다. build.gradle 파일에 다음을 추가한다.
implementation 'org.springframework.boot:spring-boot-starter-validation'

라이브러리를 설치한 후에는 서버를 재시작 해야한다

❓Spring Boot Validation 라이브러리란?


  • 주석을 통해 객체 모델에 대한 제약 조건을 표현할 수 있습니다.
  • 확장 가능한 방식으로 사용자 지정 제약 조건을 작성할 수 있습니다.
  • 객체 및 객체 그래프의 유효성을 검사하는 API를 제공합니다.
  • 매개변수의 유효성을 검사하고 메서드와 생성자의 값을 반환하는 API를 제공합니다.
  • 일련의 위반 사항을 보고합니다(현지화).
  • Java SE에서 실행되며 Jakarta EE 9 및 10에 통합되었습니다.

💾 QuestionForm 클래스(DTO 만들기)

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;
}
  • @NotEmpty : 해당 값이 Null 또는 빈 문자열("")을 허용하지 않음을 의미. message 속성은 검증이 실패할 경우 화면에 표시할 오류 메시지이다.
  • @Size(max=200)은 최대 길이가 200 바이트를 넘으면 안된다는 의미.

💾 QuestionController 수정

(... 생략 ...)
import jakarta.validation.Valid;
import org.springframework.validation.BindingResult;
(... 생략 ...)
public class QuestionController {

    (... 생략 ...)

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

public String questionCreate(@Valid QuestionForm questionForm, BindingResult bindingResult)


매개변수를 subject, content 대신 QuestionForm 객체로 변경했다. subject, content 항목을 지닌 폼이 전송되면 QuestionForm의 subject, content 속성이 자동으로 바인딩 된다. 이것은 스프링 프레임워크의 바인딩 기능이다.

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

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

만약 2개의 매개변수의 위치가 정확하지 않다면 @Valid만 적용이 되어 입력값 검증 실패 시 400 오류가 발생한다.

따라서 questionCreate 메서드는 bindResult.hasErrors()를 호출하여 오류가 있는 경우에는 다시 폼을 작성하는 화면을 렌더링하게 했고 오류가 없을 경우에만 질문 등록이 진행되도록 했다.


public String questionCreate(QuestionForm questionForm)
아래의 템플릿을 수정하는 과정에서 폼의 속성들이 QuestionForm의 속성들로 구성되도록 수정하였기 때문에 GetMapping방식에서도 매개변수가 QuestionForm의 속성들이 전달될 것이므로 수정해줘야 한다.



💾 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>

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

#fields.hasAnyErrors()true인 경우는 QuestionForm 검증이 실패한 경우이다.
QuestionForm에서 검증에 실패한 오류 메시지#fields.allErrors()로 구할 수 있다.
부트스트랩의 alert alert-danger 클래스를 사용하여 오류는 붉은 색으로 표시되도록 했다.
그리고 이렇게 오류를 표시하기 위해서는 타임리프의 th:object 속성이 반드시 필요하다. th:object를 사용하여 폼의 속성들이 QuestionForm의 속성들로 구성된다는 점을 타임리프 엔진에 알려줘야 하기 때문이다.


💾 question_form.html 수정2

<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>

✅ 수정 전 : <input type="text" name="subject" id="subject" class="form-control">
수정 후 : <input type="text" th:field="*{subject}" class="form-control">


✅ 수정 전 : <textarea name="content" id="content" class="form-control" rows="10"></textarea>
✅ 수정 후 : <textarea th:field="*{content}" class="form-control" rows="10"></textarea>

name="subject" id="subject" -> th:field="*{subject}"
name="content" id="content" -> th:field="*{content}"

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



💻 접속해보기

공란으로 입력했을 때 메세지가 출력된다

제목만 입력하고 저장하기를 누르면 제목이 지워지지 않고 오류메세지만 출력된다!

내용만 입력하고 저장하기를 누르면 내용이 지워지지 않고 오류메세지만 출력된다!



📝 답변 등록할 때도 빈 값으로 등록이 불가능하게 해보자!

💾 AnswerForm 클래스(DTO 만들기)

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 수정

package com.mysite.sbb.answer;

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;

import com.mysite.sbb.question.Question;
import com.mysite.sbb.question.QuestionService;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

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

✅ 매개변수를 @RequestParam~에서 @Valid AnswerForm answerForm, BindingResult bindingResult로 수정하였다.

검증에 실패할 경우에는 다시 답변을 등록할 수 있는 question_detail 템플릿을 렌더링하게 했다. 이때 question_detail 템플릿은 Question 객체가 필요하므로 model 객체에 Question 객체를 저장한 후에 question_detail 템플릿을 렌더링해야 한다.



💾 question_detail.html 템플릿 파일 수정

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">

    (... 생략 ...)
    <!-- 답변 작성 -->
    <form th:action="@{|/answer/create/${question.id}|}" th:object="${answerForm}" method="post" class="my-3">
        <div class="alert alert-danger" role="alert" th:if="${#fields.hasAnyErrors()}">
            <div th:each="err : ${#fields.allErrors()}" th:text="${err}" />
        </div>
        <textarea th:field="*{content}" rows="10" class="form-control"></textarea>
        <input type="submit" value="답변등록" class="btn btn-primary my-2">
    </form>
</div>
</html>

✅ form 속성에 th:object="${answerForm}"을 추가했다.

<div class="alert alert-danger" role="alert" th:if="${#fields.hasAnyErrors()}">
<div th:each="err : ${#fields.allErrors()}" th:text="${err}" />
검증이 실패했을 시 오류메세지를 빨간색으로 출력한다.

<textarea th:field="*{content}" rows="10" class="form-control"></textarea>
검증이 실패할 경우에도 내용이 지워지지 않는다.



💾 QuestionController 수정

(... 생략 ...)
import com.mysite.sbb.answer.AnswerForm;
(... 생략 ...)
public class QuestionController {

    (... 생략 ...)

    @GetMapping(value = "/detail/{id}")
    public String detail(Model model, @PathVariable("id") Integer id, AnswerForm answerForm) {
        (... 생략 ...)
    }

    (... 생략 ...)
}

템플릿의 form 속성이 answerForm객체의 속성을 사용하므로 QuestionController의 @GetMapping의 detail()메소드도 매개변수에 폼 객체를 추가해야 한다.



💻 접속해보기

내용을 입력하지 않고 답변등록을 눌렀을 때 오류메세지가 출력된다.


0개의 댓글