질문등록 버튼 생성

 

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>
    <a th:href="@{/question/create}" class="btn btn-primary"> 질문 등록하기 </a> <!--추가-->
</div>
</html>

 

결과 화면

질문 추가 버튼을 클릭하면 /question/create URL이 호출될 것이다.

 

 


QuestionController.java 수정

 

package com.myspringboot.ssh.question;

import java.util.List;

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 lombok.RequiredArgsConstructor;

@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
public class QuestionController {

	// private final QuestionRepository questionRepository;
	private final QuestionService questionService;
	
    @RequestMapping("/list")
    public String list(Model model) {
    	
    	//List<Question> questionList = questionRepository.findAll();
    	List<Question> questionList = this.questionService.getList();
    	model.addAttribute("questionList", questionList);
    	
        return "question_list";
    }
    
    @RequestMapping(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(value="/create")
    public String questionCeate() {
    	return "question_form";
    }
}

버튼을 통한 /question/create 요청은 GET 요청에 해당하므로 @GetMapping 애너테이션을 사용하였다.

questionCreate 메서드는 question_form 템플릿을 렌더링하여 출력한다.

 


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>

 

 

실행결과

 


위에 작성한 템플릿의 form 의 method 는 POST 이기 때문에 현재 컨트롤러에서는 처리를 할 수 있는 부분이 없다.

 

QuestionController.java 수정

package com.myspringboot.ssh.question;

import java.util.List;

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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

import lombok.RequiredArgsConstructor;

@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
public class QuestionController {

	// private final QuestionRepository questionRepository;
	private final QuestionService questionService;
	
    @RequestMapping("/list")
    public String list(Model model) {
    	
    	//List<Question> questionList = questionRepository.findAll();
    	List<Question> questionList = this.questionService.getList();
    	model.addAttribute("questionList", questionList);
    	
        return "question_list";
    }
    
    @RequestMapping(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 questionCeate() {
    	return "question_form";
    }
    
    // 추가
    @PostMapping("/create")
    public String questionCreate(@RequestParam String subject, @RequestParam String content) {
        this.questionService.create(subject,content);
        return "redirect:/question/list"; // 질문 저장후 질문목록으로 이동
    }
}

 

컨트롤러에서 호출하는 서비스의 create 를 작성을 해줘야 에러가 없어질 것이다.

 

QuestionService.java 수정

package com.myspringboot.ssh.question;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

import org.springframework.stereotype.Service;

import com.myspringboot.ssh.DataNotFoundException;

import lombok.RequiredArgsConstructor;

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

}

 

실행결과

질문 등록창

 

 

리스트에 추가한 질문이 보여진다

 

 


폼(form)

 

필수로 입력해야 하는 값을 체크하기 위한 여러 방법중 폼을 사용하여 입력값을 체크하는 방법을 사용

 

build.gradle 수정

plugins {
	id 'org.springframework.boot' version '2.7.2'
	id 'io.spring.dependency-management' version '1.0.12.RELEASE'
	id 'java'
}

group = 'com.myspringboot'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-web'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
    implementation 'org.springframework.boot:spring-boot-starter-validation' << 추가
}

tasks.named('test') {
	useJUnitPlatform()
}

수정하고 난 이후에 꼭 Refresh Gradle Project로 필요한 라이브러리를 설치한다. 설치 후 꼭 서버 재시작

 

"Spring Boot Validation"을 설치하면 다음과 같은 애너테이션들을 사용하여 입력 값을 검증할 수 있다.

 

항목설명

@Size 문자 길이를 제한한다.
@NotNull Null을 허용하지 않는다.
@NotEmpty Null 또는 빈 문자열("")을 허용하지 않는다.
@Past 과거 날짜만 가능
@Future 미래 날짜만 가능
@FutureOrPresent 미래 또는 오늘날짜만 가능
@Max 최대값
@Min 최소값
@Pattern 정규식으로 검증

보다 많은 기능은 다음의 URL을 참고하도록 하자.


폼 클래스

 

QuestionForm.java 작성

package com.myspringboot.ssh.question;

import javax.validation.constraints.NotEmpty;
import javax.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;
}

QuestionController.java 수정

package com.myspringboot.ssh.question;

import java.util.List;

import javax.validation.Valid;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
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.RequestMapping;

import lombok.RequiredArgsConstructor;

@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
public class QuestionController {

	// private final QuestionRepository questionRepository;
	private final QuestionService questionService;
	
    @RequestMapping("/list")
    public String list(Model model) {
    	
    	//List<Question> questionList = questionRepository.findAll();
    	List<Question> questionList = this.questionService.getList();
    	model.addAttribute("questionList", questionList);
    	
        return "question_list";
    }
    
    @RequestMapping(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 questionCeate() {
    	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 메서드의 매개변수를 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이 실패하여 다시 질문 등록 화면에 머물러 있을 것이다. 

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

 

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>

템플릿을 위와 같이 수정할 경우 QuestionController의 GetMapping으로 매핑한 메서드도 다음과 같이 변경해야 한다.

왜냐하면 question_form.html 템플릿은 "질문 등록하기" 버튼을 통해 GET 방식으로 요청되더라도 th:object에 의해 QuestionForm 객체가 필요하기 때문이다.

 


QuestionController.java 수정

package com.myspringboot.ssh.question;

import java.util.List;

import javax.validation.Valid;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
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.RequestMapping;

import lombok.RequiredArgsConstructor;

@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
public class QuestionController {

	// private final QuestionRepository questionRepository;
	private final QuestionService questionService;
	
    @RequestMapping("/list")
    public String list(Model model) {
    	
    	//List<Question> questionList = questionRepository.findAll();
    	List<Question> questionList = this.questionService.getList();
    	model.addAttribute("questionList", questionList);
    	
        return "question_list";
    }
    
    @RequestMapping(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 questionCeate(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_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>

 

 

실행결과

 


답변 등록에도 동일하게 생성 및 적용하기

 

AnswerForm.java 작성

package com.myspringboot.ssh.answer;

import javax.validation.constraints.NotEmpty;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class AnswerForm {
    @NotEmpty(message = "내용은 필수항목입니다.")
    private String content;
}

 


AnswerController.java 수정

package com.myspringboot.ssh.answer;

import javax.validation.Valid;

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 com.myspringboot.ssh.question.Question;
import com.myspringboot.ssh.question.QuestionService;

import lombok.RequiredArgsConstructor;

@RequestMapping("/answer")
@RequiredArgsConstructor
@Controller
public class AnswerController {
	
	private final QuestionService questionService;
	private final AnswerService answerService;
	
	@PostMapping("/create/{id}") // "/answer/create/{id}"와 같은 URL 요청시 createAnswer 메서드가 호출되도록 함
	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}">
<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>

답변 등록 폼의 속성이 AnswerForm을 사용하기 때문에 th:object 속성을 추가했다. 그리고 검증이 실패할 경우 오류메시지를 출력하기 위해 #fields.hasAnyErrors()와 #fields.allErrors()를 사용하여 오류를 표시했다. 그리고 content 항목도 th:field 속성을 사용하도록 변경했다.

그리고 question_detail 템플릿이 AnswerForm을 사용하기 때문에 QuestionController의 detail 메서드도 다음과 같이 수정해야 한다.

 


QuestionController.java 수정

package com.myspringboot.ssh.question;

import java.util.List;

import javax.validation.Valid;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
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.RequestMapping;

import com.myspringboot.ssh.answer.AnswerForm;

import lombok.RequiredArgsConstructor;

@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
public class QuestionController {

	// private final QuestionRepository questionRepository;
	private final QuestionService questionService;
	
    @RequestMapping("/list")
    public String list(Model model) {
    	
    	//List<Question> questionList = questionRepository.findAll();
    	List<Question> questionList = this.questionService.getList();
    	model.addAttribute("questionList", questionList);
    	
        return "question_list";
    }
    
    @RequestMapping(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 questionCeate(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"; // 질문 저장후 질문목록으로 이동
    }
}

 

 

실행결과

+ Recent posts