1. 화면 구성하기
타임리프란?
- 스프링의 대표적인 템플릿 엔진*
- 컨트롤러에서 모델을 통해 데이터를 설정하면, 모델은 뷰에서 사용할 수 있게 데이터를 전달해 줌
- 내추럴 템플릿으로 HTML의 모양을 유지하면서 뷰 템플릿 적용 가능
*템플릿 엔진
: 데이터를 넘겨받아 HTML에 데이터를 넣어 동적인 웹페이지를 만들어주는 도구
타임리프 표현식과 문법
타임리프 표현식
표현식 | 설명 |
${...} | 변수의 값 표현식 |
#{...} | 속성 파일 값 표현식 |
@{...} | URL 표현식 |
*{...} | 선택한 변수의 표현식 th:object에서 선택한 객체에 접근 |
타임리프 문법
표현식 | 설명 | 예제 |
th:text | 텍스트를 표현할 때 사용 | th:text=${person.name} |
th:each | 컬렉션을 반복할 때 사용 | th:each="person:${persons}" |
th:if | 조건이 true인 때만 표시 | th:if="${person.age}>=20" |
th:unless | 조건이 false인 때만 표시 | th:unless="${person.age}>=20" |
th:href | 이동 경로 | th:herf="@{/persons(id=${person.id})}" |
th:with | 변수값으로 지정 | th:with="name=${person.name}" |
th:object | 선택한 객체로 지정 | th:object="${person}" |
2. 타임리프 사용을 위한 의존성 추가
프로젝트 생성 시 추가하긴 했지만 한 번 더 확인차~!~!
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
3. html, js 파일 추가
resources/templates/fragments
- resources/templates 하위에 fragments 파일 생성
- 각 페이지마다 공통으로 들어가는 부분을 작성
bodyHeader.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<div class="header" th:fragment="bodyHeader">
<div class="p-5 mb-5 text-center</> bg-light">
<h1 class="mb-3">개굴개굴 🐸</h1>
<h4 class="mb-3">프로그의 블로그에 오신 것을 환영합니다.</h4>
</div>
</div>
resources/templates/articles
- resources/templates 하위에 articles 파일 생성
- article과 관련된 뷰를 구현
article.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>블로그 글</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
<div th:replace="~{fragments/bodyHeader :: bodyHeader}"/>
<div class="container mt-5">
<div class="row">
<div class="col-lg-8">
<article>
<input type="hidden" id="article-id" th:value="${article.id}">
<header class="mb-4">
<h1 class="fw-bolder mb-1" th:text="${article.title}"></h1>
<div class="text-muted fst-italic mb-2" th:text="|Posted on ${#temporals.format(article.createdAt, 'yyyy-MM-dd HH:mm')}|"></div>
</header>
<section class="mb-5">
<p class="fs-5 mb-4" th:text="${article.content}"></p>
</section>
<button type="button" id="modify-btn"
th:onclick="|location.href='@{/new-article?id={articleId}(articleId=${article.id})}'|"
class="btn btn-primary btn-sm">수정</button>
<button type="button" id="delete-btn"
class="btn btn-secondary btn-sm">삭제</button>
</article>
</div>
</div>
</div>
<script src="/js/article.js"></script>
</body>
articleList.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>블로그 글 목록</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
<div th:replace="~{fragments/bodyHeader :: bodyHeader}"/>
<div class="container">
<button type="button" id="create-btn"
th:onclick="|location.href='@{/new-article}'|"
class="btn btn-secondary btn-sm mb-3">글 등록</button>
<div class="row-6" th:each="article : ${articles}">
<div class="card">
<div class="card-header" th:text="${article.id}">
</div>
<div class="card-body">
<h5 class="card-title" th:text="${article.title}"></h5>
<p class="card-text" th:text="${article.content}"></p>
<a th:href="@{/articles/{articleId}(articleId=${article.id})}" class="btn btn-primary">보러가기</a>
</div>
</div>
<br>
</div>
</div>
<script src="/js/article.js"></script>
</body>
newArticleForm.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>블로그 글</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
<div th:replace="~{fragments/bodyHeader :: bodyHeader}"/>
<div class="container mt-5">
<div class="row">
<div class="col-lg-8">
<article>
<header class="mb-4">
<input type="text" class="form-control" placeholder="제목" id="title" th:value="${article.title}">
</header>
<section class="mb-5">
<textarea class="form-control h-25" rows="10" placeholder="내용" id="content" th:text="${article.content}"></textarea>
</section>
<button th:if="${article.id} != null" type="button" id="modify-btn" class="btn btn-primary btn-sm">수정</button>
<button th:if="${article.id} == null" type="button" id="create-btn" class="btn btn-primary btn-sm">등록</button>
</article>
</div>
</div>
</div>
<script src="/js/article.js"></script>
</body>
resources/static/js
- 특정 버튼을 눌렀을 때 BlogApiController에서 구현한 API가 호출될 수 있도록 작성
article.js
// 생성 기능
const createButton = document.getElementById('create-btn');
if (createButton) {
createButton.addEventListener('click', event => {
fetch('/api/articles', {
method: 'POST',
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: document.getElementById('title').value,
content: document.getElementById('content').value
})
})
.then(() => {
alert('등록 완료되었습니다.');
location.replace('/articles');
});
});
}
// 수정 기능
const modifyButton = document.getElementById('modify-btn');
if (modifyButton) {
modifyButton.addEventListener('click', event => {
let params = new URLSearchParams(location.search);
let id = params.get('id');
fetch(`/api/articles/${id}`, {
method: 'PUT',
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: document.getElementById('title').value,
content: document.getElementById('content').value
})
})
.then(() => {
alert('수정이 완료되었습니다.');
location.replace(`/articles/${id}`);
});
});
}
// 삭제 기능
const deleteButton = document.getElementById('delete-btn');
if (deleteButton) {
deleteButton.addEventListener('click', event => {
let id = document.getElementById('article-id').value;
fetch(`/api/articles/${id}`, {
method: 'DELETE'
})
.then(() => {
alert('삭제가 완료되었습니다.');
location.replace('/articles');
});
});
}
- Article 생성 기능
- create-btn을 클릭하면 실행
- 버튼 클릭 시, /api/articles 경로로 POST 요청을 보냄
- 이때, 요청 본문(body)은 title과 content 입력 필드의 값을 JSON 형식으로 포함
- 요청이 성공하면 '등록 완료되었습니다.'라는 알림 창이 뜨고, /articles 페이지로 이동
- Article 수정 기능
- modify-btn을 클릭하면 실행
- 버튼 클릭 시, 현재 URL의 쿼리스트링에서 articleId를 추출하여, /api/article/{id} 경로로 PUT 요청을 보냄
- 이때, 요청 본문(body)은 title과 content 입력 필드의 값을 JSON 형식으로 포함
- 요청이 성공하면, '수정이 완료되었습니다.'라는 알림 창이 뜨고, 해당 게시글의 페이지로 이동
- Article 삭제 기능
- delete-btn을 클릭하면 실행
- 버튼 클릭 시, article-id 입력 필드에서 articleId를 추출하여, /api/article/{id} 경로로 DELETE 요청을 보냄
- 요청이 성공하면, '삭제가 완료되었습니다.'라는 알림 창이 뜨고, /articles 페이지로 이동
- 각 버튼은 특정 ID를 가진 HTML 요소와 연동되며, 클릭 이벤트를 통해 API 요청을 보내고, 요청이 성공한 후 알림을 표시하고 페이지를 이동하는 것을 알 수 있음!
4. DTO 추가하기
뷰에게 데이터를 전달하기 위한 객체를 생성하자!
ArticleViewForm
package me.progfrog.flog.dto.article;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ArticleViewForm {
private Long id;
private String author;
private String title;
private String content;
}
ArticleViewDto
package me.progfrog.flog.dto.article;
import java.time.LocalDateTime;
public record ArticleViewDto(
Long id,
String title,
String content,
LocalDateTime createdAt
) {
public ArticleViewDto(ArticleDto dto) {
this(
dto.id(),
dto.title(),
dto.content(),
dto.createdAt()
);
}
}
5. BlogViewController
- @ResponseBody가 없으면 뷰 리졸버가 실행되어서 뷰를 찾고 렌더링
- @ResponseBody가 있으면 뷰 리졸버를 실행하지 않고 메서드의 반환 값이 HTTP 응답 본문으로 직접 전송됨
- 아래에서 뷰의 논리 이름 articles/articleList를 반환하면, 다음 경로의 뷰 템플릿이 렌더링 되는 것을 확인할 수 있다.
- resources/templates/articles/articleList.html
package me.progfrog.flog.web;
import lombok.RequiredArgsConstructor;
import me.progfrog.flog.dto.article.ArticleDto;
import me.progfrog.flog.dto.article.ArticleViewDto;
import me.progfrog.flog.dto.article.ArticleViewForm;
import me.progfrog.flog.service.BlogService;
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.RequestParam;
import java.util.List;
@Controller
@RequiredArgsConstructor
public class BlogViewController {
private final BlogService blogService;
@GetMapping("/articles")
public String getAllArticles(Model model) {
List<ArticleDto> articles = blogService.findAll();
model.addAttribute("articles", articles);
return "articles/articleList";
}
@GetMapping("/articles/{articleId}")
public String getArticle(@PathVariable(name = "articleId") Long articleId, Model model) {
ArticleDto article = blogService.findById(articleId);
model.addAttribute("article", new ArticleViewDto(article));
return "articles/article";
}
@GetMapping("/new-article")
public String getArticleForm(@RequestParam(required = false, name = "id") Long articleId, Model model) {
if (articleId == null) {
model.addAttribute("article", new ArticleViewForm());
} else {
ArticleDto article = blogService.findById(articleId);
model.addAttribute("article", new ArticleViewDto(article));
}
return "articles/newArticleForm";
}
}
6. 마무리
flog-mysql.sh을 실행하고, 컨테이너가 뜨는 것을 확인!
http://localhost:8080/articles 접속 시 잘 동작하는 것을 확인하고, 조회/등록/수정/삭제도 테스트해 본다.
굿굿~~다음 시간에는 스프링 시큐리티로 회원가입, 로그인, 로그아웃을 구현해보도록 한다 :)
반응형