겉바속촉

[점프투스프링부트] 2-15. 질문 등록과 form 본문

IT 일기 (상반기)/SPRING 기초

[점프투스프링부트] 2-15. 질문 등록과 form

겉바속촉 2023. 3. 2. 15:01
728x90
반응형

 

점프투스프링부트 2-05

 

 

 

목표 : 질문 등록 기능 만들기

 

 

 

 

1. 질문 등록

질문 등록을 하려면 먼저 "질문 등록하기" 버튼을 만들어야 한다.

다음처럼 질문 목록 하단에 버튼을 생성하자.

 

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

링크 엘리먼트(<a> ... </a>)이지만 부트스트랩의 btn btn-primary 클래스를 적용하면 버튼으로 보인다.

 

 

 

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

 

 

 

 

 

2. 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 템플릿을 렌더링하여 출력한다.

 

 

 

 

3. 템플릿

질문 등록을 위한 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를 사용하여 질문을 저장해야 한다.

일단 질문 저장은 잠시 뒤로 미루고 질문이 저장되면 질문 목록 페이지로 이동하도록 했다.

 

 

 

 

 

4.  서비스

질문을 저장하려면 서비스에 해당 기능을 추가해야 한다.

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

 

QuestionService로 질문 데이터를 저장하는 코드를 작성하였다.

이렇게 수정하고 질문을 작성하고 저장하면 잘 동작하는 것을 확인할 수 있을 것이다.

 

"안녕하세요"라는 제목으로 글을 작성하고 저장해봤다.

 

 

 

5.  폼(form)

위에서 질문을 등록하는 기능을 구현했다.

하지만 질문 등록시 간과한 것이 있다.

 

그것은 바로 질문이나 내용을 등록할 때 비어 있는 값으로 등록이 가능하다는 점이다.

 

빈 값으로 등록이 불가능하게 하려면 여러 방법이 있지만

여기서는 폼을 사용하여 입력값을 체크하는 방법을 사용해 보자.

 

5-1. 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을 참고하도록 하자.

 

 

 

5-2. 폼 클래스

 

화면에서 전달되는 입력 값을 검증하기 위해서는 폼 클래스가 필요하다.

화면의 입력항목 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로 빈 값을 허용하지 않도록 했다.

 

 

 

 

5-3. 컨트롤러

 

그리고 위에서 작성한 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에 설정한 "제목은 필수항목입니다." 와 같은 오류 메시지는 보이지 않는다.

오류메시지가 보이지 않는다면 어떤 항목에서 검증이 실패했는지 알 수가 없다.

 

어떻게 해야 할까?

 

 

 

 

 

5-4. 템플릿

 

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

 

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

 

이렇게 수정하고 제목 또는 내용에 값을 채우지 않은 상태로 질문 등록을 진행하면 다음과 같은 오류가 화면에 표시될 것이다.

 

 

검증에 실패한 오류 메시지가 표시되는 것을 확인할 수 있다.

 

 

 

 

5-5. 오류 발생시 입력한 내용 유지하기

 

하지만 테스트를 진행하다보니 또 다른 문제를 발견했다.

그것은 이미 입력한 "제목"이나 "내용"이 사라진다는 점이다.

 

즉, 제목에 값을 채우고 내용을 비워둔 채로 "저장하기" 버튼을 누르면 오류 메시지가 나타남과 동시에 이미 입력한 제목의 내용도 사라진다는 점이다.

입력한 제목은 남아 있어야 하지 않겠는가?

 

이러한 문제를 해결하려면 이미 입력한 값이 유지되도록 다음과 같이 템플릿을 수정해야 한다.

 

 

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

 

이제 위와 같이 수정하고 다시 질문 등록을 진행해 보자.

이전에 입력했던 값이 유지되는 것을 확인할 수 있을 것이다.

 

제목에 아무 값을 채우고 내용에는 값을 비워둔채로 "저장하기" 버튼을 누르면 위와 같이 제목에 입력한 내용이 사라지지 않고 남아있다.

 

 

 

 

 

 

 

 

 

6. 답변 등록

 

질문 등록에 폼을 적용한 것처럼 답변 등록을 할 때에도 폼을 적용해 보자.

질문 등록과 동일한 방법이므로 조금 빠르게 적용해 보자.

먼저 답변을 등록할 때 사용할 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) {
        (... 생략 ...)
    }

    (... 생략 ...)
}

 

 

이와 같이 수정하고 답변 등록을 진행해 보자.

만약 답변 내용 없이 답변을 등록하려고 시도하면 다음과 같은 검증 오류가 발생할 것이다.

 

 

 

 

 

 

728x90
반응형