본문 바로가기

SpringBoot

[Do it] 스프링 부트 기본 기능 익히기(6)

1. 부트스트랩으로 화면 꾸미기 

 

부트스트랩(bootstrap)

- 트위터에서 개발하면서 만들어진 프론트엔드 라이브러리 

 

1) 부트스트랩 설치하기 

 

Download · Bootstrap v5.3 (getbootstrap.com)

 

Download

Download Bootstrap to get the compiled CSS and JavaScript, source code, or include it with your favorite package managers like npm, RubyGems, and more.

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