1. 부트스트랩으로 화면 꾸미기
부트스트랩(bootstrap)
- 트위터에서 개발하면서 만들어진 프론트엔드 라이브러리
1) 부트스트랩 설치하기
Download · Bootstrap v5.3 (getbootstrap.com)
- 압축 파일 안에 bootstrap.min.css 파일을 복사하여 스태틱 디렉터리에 저장
2) 부트스트랩 적용하기
templates/question_list.html
<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>
- 가장 윗줄에 bootstrap.min.css를 사용할 수 있도록 링크를 추가
- loop.count는 questionList의 항목을 th:each로 반복할 때 현재의 순서를 나타냄
- 날짜를 보기 좋게 출력하기 위해 타임리프의 #temporals.foramt 기능을 사용
- #temporals.format은 #temporals.format(날짜 객체, 날짜 포맷) 와 같이 사용
templates/question_detail.html
<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-body, card-text | card 컴포넌트를 적용하는 클래스들 |
badge | badge 컴포넌트를 적용하는 클래스들 |
form-control | 텍스트 창에 form 컴포넌트를 적용하는 클래스들 |
border-bottom | 아래 방향 테두리 선을 만드는 클래스들 |
my-3 | 상하 마진값으로 3을 지정하는 클래스 |
py-2 | 상하 패딩값으로 2를 지정하는 클래스 |
p-2 | 상하좌우 패딩값으로 2를 지정하는 클래스 |
d-flex justify-content-end | HTML 요소를 오른쪽으로 정렬하는 클래스 |
bg-light | 연회색으로 배경을 지정하는 클래스 |
text-dark | 글자색을 검은색으로 지정하는 클래스 |
text-start | 글자를 왼쪽으로 정렬하는 클래스 |
btn btn-primary | 버튼 컴포넌트를 적용하는 클래스 |
2. 표준 HTML 구조로 변경하기
- 어떤 웹 브라우저를 사용하더라도 웹 페이지가 동일하게 보이고 정상적으로 작동하게 하려면 반드시 웹 표준을 지키는 HTML 문서로 작성해야 함
- 표준 HTML 문서의 구조는 html, head, body 요소가 있어야 하며, CSS 파일은 <head> 태그 안에 링크되어야 함
- <head> 태그 안에는 meta, title 요소 등이 포함되어야 함
1) 템플릿 상속하기
템플릿 상속
- 기본 틀이 되는 템플릿을 먼저 작성하고 다른 템플릿에서 그 템플릿을 상속해 사용하는 방법
/templates/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>
- 모든 템플릿이 상속해야 하는 템플릿으로, 표준 HTML 문서 구조로 정리된 기본 틀이 됨
- body 요소 안의 <th:block layout:fragment="content"></th:block>은 layout.html을 상속한 템플릿에서 개별적으로 구현해야하는 영역이 됨
2) question_list.html에 템플릿 상속하기
templates/question_list.html
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<table class="table">
...
</html>
- layout.html 템플릿을 상속하려고 <html layout:decorate="~{layout}">을 사용
- 타임 리프의 layout:decorate 속성은 템플릿의 레이아웃(부모 템플릿)으로 사용할 템플릿을 설정
3) question_detail.html에 템플릿 상속하기
templates/question_detail.html
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<!-- 질문 -->
...
</html>
3. 질문 등록 기능 추가하기
1) 질문 등록 버튼과 화면 만들기
templates/question_list.html
...
</table>
<a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a>
</div>
URL 매핑
- QuestionController에 /question/create에 해당하는 URL 매핑
- 질문 등록 버튼을 통한 /question/create 요청은 GET 요청에 해당하므로 @GetMapping 애너테이션을 사용
QuestionController.java
...
@GetMapping("/create")
public String questionCreate() {
return "question_form";
}
템플릿 만들기
- 제목과 내용을 입력할 수 있는 텍스트 창을 추가
- 제목은 일반적인 input 텍스트 창을 사용하고 내용은 글자 수에 제한이 없는 textarea창을 사용
- 입력한 내용을 /question/create URL로 post 방식을 이용해 전송할 수 있도록 form과 버튼을 추가
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 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>
POST 요청 처리
- POST 방식으로 요청한 /question/create URL을 처리하도록 @PostMapping 애너테이션을 지정한 questionCreate 메서드를 추가
- questionCreate 메서드는 화면에서 입력한 제목(subject)과 내용(content)을 매개변수로 받음
- question_form.html에서 입력 항목으로 사용한 subject, content의 이름과 RequestParam의 value값이 동일해야 함
QuestionController.java
package com.example.demo.question;
...
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
...
@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"; //질문 저장 후 질문 목록으로 이동
}
}
질문 데이터 저장
- 질문 데이터를 저장하기 위해 QuestionService 수정
- 제목(subject)과 내용(content)을 입력받아 질문으로 저장하는 create 메서드 작성
QuestionService.java
package com.example.demo.question;
import java.util.List;
import java.util.Optional;
import java.time.LocalDateTime;
import com.example.demo.DataNotFoundException;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class QuestionService {
private final QuestionRepository questionRepository;
...
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);
}
}
QuestionController.java
- 수정한 서비스를 사용할 수 있도록 수정
- QuestionService의 create 메서드를 호출하여 질문 데이터(subject, content)를 저장하는 코드 작성
@PostMapping("/create")
public String questionCreate(@RequestParam(value="subject")String subject,
@RequestParam(value="content")String content) {
this.questionService.create(subject,content);
return "redirect:/question/list"; //질문 저장 후 질문 목록으로 이동
}
2) 폼 활용하기
- 폼 클래스를 사용하여 입력값 체크
- 폼 클래스는 웹 프로그램에서 사용자가 입력한 데이터를 검증하는 데 사용
Spring Boot Validation 라이브러리
- 폼 클래스를 사용해 사용자로부터 입력받은 값을 검증하려면 Spring Boot Validation 라이브러리가 필요
항목 | 설명 |
@Size | 문자 길이를 제한 |
@NotNull | Null을 허용하지 않음 |
@NotEmpty | Null 또는 빈 문자열("")을 허용하지 않음 |
@Past | 과거 날짜만 입력 가능 |
@Future | 미래 날짜만 입력 가능 |
@FutureOrPresent |
미래 또는 오늘 날짜만 입력 가능 |
@Max | 최댓값 이하의 값만 입력 가능 |
@Min | 최솟값 이상의 값만 입력 가능 |
@Pattern | 입력값을 정규식 패턴으로 검증 |
폼 클래스
- 질문 등록 페이지에서 사용자로부터 입력받은 값을 검증하는 데 필요한 폼클래스 작성
QuestionForm.java
package com.example.demo.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;
}
컨트롤러에 전송
- QuestionForm을 컨트롤러에서 사용할 수 있도록 QuestionController 수정
QuestionController.java
package com.example.demo.question;
import java.util.List;
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.RequestMapping;
import org.springframework.web.bind.annotation.PostMapping;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
public class QuestionController{
private final QuestionService questionService;
...
@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.hasErrors()를 호출하여 오류가 있는 경우에는 다시 제목과 내용을 작성하는 화면으로 돌아가도록 했고, 오류가 없을 경우에만 질문이 등록되도록 만듬
오류 메시지 노출
- 검증에 실패했다는 오류 메시지를 보여주기 위해 question_form 템플릿 수정
templates/question_form.java
- #fields.hasAnyErrors가 true라면 QuestionForm 검증이 실패
- QuestionForm 검증이 실패한 이유는 #fields.allErrors()로 확인 가능
- th:object는 <form>의 입력 항목들이 QuestionForm과 연결된다는 점을 타임리프에게 알려줌
QuestionController.java
- 템플릿의 form 태그에 th:object 속성을 추가했으므로 GetMapping으로 매핑한 메서드에 QuestionForm 객체 전달해야 함
...
@GetMapping("/create")
public String questionCreate(QuestionForm questionForm) {
return "question_form";
}
...
입력한 제목 사라짐 오류 해결
- name="subject", name="content" 대신 th:field 속성을 사용하도록 변경
- 해당 태그의 id, name, value 속성이 모두 자동으로 생성되고 타임리프가 value 속성에 기존에 입력된 값을 채워 넣어 오류가 발생하더라도 기존에 입력한 값 유지
templates/question_form.html
3) 답변 등록 기능에 폼 적용하기
답변 폼 작성
AnswerForm.java
package com.example.demo.answer;
import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class AnswerForm {
@NotEmpty(message="내용이 필수 항목입니다.")
private String content;
}
AnswerForm을 사용하도록 AnswerController 변경
- @Valid와 BindingResult를 사용하여 검증 진행
AnswerController.java
package com.example.demo.answer;
import com.example.demo.question.Question;
import com.example.demo.question.QuestionService;
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 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);
}
}
템플릿 수정
- 답변 등록 form의 입력 항목과 AnswerForm을 타임리프에 연결하기 위해 th:object 속성을 추가
- 검증이 실패할 경우 #fields.hasAnyErros()와 #fields.allErrors()를 사용하여 오류 메시지를 표시하도록 함
- 답변 등록 기능의 content 항목도 th:field 속성을 사용하도록 변경
question_detail.html
QuestionController의 detail 메서드 수정
- question_detail 템플릿을 수정하였으므로 QuestionController의 detail 메서드도 수정
QuestionController.java
...
@GetMapping("/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";
}
...
4) 공통 템플릿 만들기
- 오류 메시지를 출력하는 HTML 코드는 질문 등록과 답변 등록 페이지에서 모두 반복해서 사용
- 반복적으로 사용하는 코드를 공통 템플릿으로 만들어 사용
오류 메시지 템플릿
templates/form.errors.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="formErrosFragment"는 다른 템플릿에서 이 div 태그의 영역을 사용할 수 있도록 이름을 설정
기존 템플릿에 적용
question_form.html
question_detail.html
'SpringBoot' 카테고리의 다른 글
[Do it] 3장 스프링 부트 고급 기능 익히기(2) (0) | 2024.01.23 |
---|---|
[Do it] 3장 스프링 부트 고급 기능 익히기(1) (0) | 2024.01.22 |
[Do it] 2장 스프링 부트 기본 기능 익히기(5) (0) | 2024.01.21 |
[Do it] 2장 스프링 부트 기본 기능 익히기 (4) (0) | 2024.01.21 |
[Do it] 2장 스프링 부트 기본 익히기(3) (0) | 2024.01.19 |