
'스프링 부트3 백엔드 개발자 되기' 책을 참고하며 작성 중 입니다.
타임리프 = 템플릿 엔진
스프링 서버에서 데이터를 받아 HTML 상에 그 데이터를 넣어 보여주는 도구
동적인 웹 페이지 구현이 가능
jsp, thymeleaf, 프리마커
표현식
| 표현식 | 설명 |
|---|---|
| ${..} | 변수의 값 표현식 |
| #{..} | 속성 파일 값 표현식 |
| @{..} | 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:href="@{/persons/{id}(id=${person.id})}" |
| th:with | 변숫값으로 지정 | th:with="name=${person.name}" |
| th:object | 선택한 객체로 지정 | th:object="${person}" |
의존성 추가
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
}
@Controller //컨트롤러라는 것을 명시적으로 표시
public class ExampleController {
@GetMapping("/thymeleaf/example")
public String thymeleafExample(Model model){ //Model : HTML(뷰) 쪽으로 값을 넘겨주는 객체
Person examplePerson = new Person();
examplePerson.setId(1L);
examplePerson.setName("A");
examplePerson.setAge(11);
examplePerson.setHobbies(List.of("운동","독서"));
model.addAttribute("person", examplePerson); //모델에 값을 저장 (Person 객체 저장)
model.addAttribute("today", LocalDate.now());
return "example"; //view의 이름 (example.html)
}
@Getter
@Setter
class Person{
private Long id;
private String name;
private int age;
private List<String> hobbies;
}
}
Model : HTML(뷰) 쪽으로 값을 넘겨주는 객체
addAttribute() : 키-값 형식으로 모델에 값을 저장
example : @Controller 이므로 View의 이름을 반환, 즉 example.html을 반환
Spring Boot는 @Controller 애너테이션을 보고 '반환하는 값의 이름을 가진 뷰의 파일을 찾아라' 라고 이해
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>타임리프 익히기</h1>
<p th:text="${#temporals.format(today, 'yyyy-MM-dd')}"></p>
<!--LocalDate 타입의 today를 yyyy-MM-dd 형식의 String타입으로 포매팅-->
<div th:object="${person}"> <!--person을 선택한 객체로 지정-->
<p th:text="|이름 : *{name}|"></p> <!-- *{} : 부모 태그에 적용한 객체 값에 접근 -->
<p th:text="|나이 : *{age}|"></p>
<p>취미</p>
<ul th:each="hobby : *{hobbies}"> <!-- th:each : 반복 -->
<li th:text="${hobby}"></li>
<span th:if="${hobby == '운동'}">(대표 취미)</span>
</ul>
</div>
<a th:href="@{/api/articles/{id}(id=${person.id})}">글 보기</a>
</body>
</html>
결과
![]()
뷰에게 데이터를 전달하기 위한 객체
@Getter
public class ArticleListViewResponse {
private final Long id;
private final String title;
private final String content;
public ArticleListViewResponse(Article article) {
this.id = article.getId();
this.title = article.getTitle();
this.content = article.getContent();
}
}
요청을 받아 사용자에게 뷰를 보여주려면 뷰 Controller가 필요
@RequiredArgsConstructor
@Controller
public class BlogViewController {
private final BlogService blogService;
@GetMapping("/articles")
public String getArticles(Model model){
List<ArticleListViewResponse> articles = blogService.findAll()
.stream()
.map(ArticleListViewResponse::new)
.toList();
model.addAttribute("articles", articles);
//"articles" 키에 글 리스트를 저장
return "articleList"; //articleList.html을 반환
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>블로그 글 목록</title>
<link rel="stylesheet" href="http://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
<!-- bootstrap : 웹 애플리케이션의 화면을 쉽고 빠르게 만들어주는 라이브러리 -->
</head>
<body>
<div class="p-5 mb-5j text-center</> bg-light">
<h1 class="mb-3">My Blog</h1>
<h4 class="mb-3">Welcome to my Blog</h4>
</div>
<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="item : ${articles}">
<!-- th:each => "articles" 키에 담긴 데이터 개수만큼 반복, 앞으로 item으로 선언 -->
<div class="card">
<div class="card-header" th:text="${item.id}">
<!-- th:text => item 의 id값 반환 -->
</div>
<div class="card-body">
<h5 class="card-title" th:text="${item.title}"></h5>
<p class="card-text" th:tex="${item.content}"></p>
<a th:href="@{/articles/{id}(id=${item.id})}" class="btn btn-primary">보러 가기</a>
</div>
</div>
</div>
</div>
</body>
</html>
결과
@Entity //엔티티로 지정
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class) //Entity의 생성 및 수정 시간을 자동으로 감시 및 기록
public class Article {
//..생략
@CreatedDate
@Column(name="created_at")
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name="updated_at")
private LocalDateTime updatedAt;
}
@CreatedDate : Entity가 생성될 때 생성 시간을 저장
@LastModifiedDate : Entity가 수정될 때 마지막으로 수정된 시간을 저장
@EntityListenrs(AuditingEntityListener.class) : Entity의 생성 및 수정 시간을 자동으로 감시하고 기록
created_at, updated_at 추가
INSERT INTO article(title, content, created_at, updated_at) VALUES ('title1','content1', NOW(), NOW())
INSERT INTO article(title, content, created_at, updated_at) VALUES ('title2','content2', NOW(), NOW())
INSERT INTO article(title, content, created_at, updated_at) VALUES ('title3','content3', NOW(), NOW())
@SpringBootApplication
@EnableJpaAuditing //created_at, updated_at 자동 업데이트
public class SpringBootDeveloperApplication {
public static void main(String[] args){
SpringApplication.run(SpringBootDeveloperApplication.class, args);
}
}
@EnableJpaAuditing : created_at, updated_at 자동 업데이트
뷰에서 사용할 DTO
@NoArgsConstructor
@Getter
public class ArticleViewResponse {
private Long id;
private String title;
private String content;
private LocalDateTime createdAt;
public ArticleViewResponse(Article article) {
this.id = article.getId();
this.title = article.getTitle();
this.content = article.getContent();
this.createdAt = article.getCreatedAt();
}
}
@GetMapping("/articles/{id}")
public String getArticle(@PathVariable Long id, Model model){
Article article = blogService.findById(id);
model.addAttribute("article", new ArticleViewResponse(article));
return "article";
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>블로그 글</title>
<link rel="stylesheet" href="http://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
<div class="p-5 mb-5j text-center</> bg-light">
<h1 class="mb-3">My Blog</h1>
<h4 class="mb-3">Welcome to my Blog</h4>
</div>
<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>
</html>
결과
const deleteButton = document.getElementById('delete-btn');
//HTML에서 delete-btn으로 설정한 element를 찾아
if (deleteButton) {
//그 element에서 click 이벤트가 발생한다면
deleteButton.addEventListener('click', event => {
let id = document.getElementById('article-id').value;
//fetch() 메서드를 통해 API를 요청
fetch(`/api/articles/${id}`, {
method : 'DELETE'
})
//fetch()가 잘 완료되면 연이어 실행
.then(() => {
alert('삭제가 완료되었습니다'); //팝업 띄우기
location.replace('/articles'); //화면을 현재 주소를 기반해 옮겨주는 역할
});
});
}
결과 (1번 게시물 삭제)
쿼리 파라미터의 유무에 따라 등록 / 수정을 판단

@GetMapping("/new-article")
public String newArticle(@RequestParam(required=false)Long id, Model model){
if (id==null){
model.addAttribute("article", new ArticleViewResponse());
//없으면 생성
} else{
Article article = blogService.findById(id);
model.addAttribute("article", new ArticleViewResponse(article));
//있으면 수정
}
return "newArticle";
}
@RequestParam : id 키를 가진 쿼리 파라미터의 값을 id 변수에 매핑
reqired = false 을 통해 선택적이라는 것을 명시
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 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");
})
})
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>블로그 글</title>
<link rel="stylesheet" href="http://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
<div class="p-5 mb-5j text-center</> bg-light">
<h1 class="mb-3">My Blog</h1>
<h4 class="mb-3">Welcome to my Blog</h4>
</div>
<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">
<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-secondary btn-sm">등록</button>
</article>
</div>
</div>
</div>
<script src="/js/article.js"></script>
</body>
</html>
결과
- 수정
- 등록
템플릿 엔진인 Thymeleaf를 사용해서 게시글 리스트 뷰, 상세 뷰, 삭제 기능, 수정 기능, 생성 기능을 추가했습니다.
@Controller, 템플릿 엔진, Thymeleaf