Spring
[스프링 부트와 AWS로 혼자 구현하는 웹 서비스] 4. 타임리프로 화면 구성하기
주니어주니
2023. 8. 18. 17:34
책은 머스테치로 했지만 나는 타임리프로
1. 템플릿 엔진
1) JSP (JavaServer Pages)
- 자바 기반의 서버 사이드 템플릿 엔진
- 자바 코드( <% %> ) + HTML 코드 혼합
- 서버 측에서 페이지 동적 생성
- 서버에서 자바코드로 문자열을 만든 뒤 HTML로 변환하여 브라우저로 전달
2) Thymeleaf
- 자바 기반의 서버 사이드 템플릿 엔진
- HTML 문서 내에서 Thymeleaf 태그 사용
- 스프링 프레임워크와 통합이 잘 되어있음
- HTML을 유효한 HTML로 유지하면서, 동시에 동적 페이지를 생성할 수 있음
(서버에서 페이지 동적 생성 + 서버가 없어도 브라우저에서 실행 가능)
3) React
- 클라이언트 템플릿 엔진
- 자바스크립트 프론트엔드 라이브러리
- 서버에서 벗어난, 브라우저에서 화면 생성
- 서버에서는 Json, xml 데이터만 전달하고 클라이언트에서 조립
4) Vue.js
- 클라이언트 템플릿 엔진
- 자바스크립트 프론트엔드 라이브러리
- 브라우저에서 화면 생성
- 서버에서는 Json, xml 데이터만 전달하고 클라이언트에서 조립
- 리액트보다 진입장벽 낮음
2. 타임리프로 기본페이지 만들기
1) 타임리프 의존성 추가
*build.gradle
dependencies {
...
// 타임리프 추가
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
}
2) 기본 경로
src > main > resources > static : css 같은 정적 파일을 담는 폴더
src > main > resources > templates : 타임리프 파일을 담는 폴더
3) 기본 파일 생성
* index.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<link th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
</head>
<body>
<h1>스프링 부트로 시작하는 웹 서비스</h1>
</body>
</html>
- <html xmlns:th="http://www.thymeleaf.org">
- 타임리프 사용 선언
- 타임리프 표현식을 사용하려면 타임리프 사용 선언을 해줘야 함
- 타임리프 표현식을 사용하고 있지만, <a th:href>, <span th:text> 등 태그의 속성 값으로 사용되는 경우에는 타임리프 선언 없이 사용 가능
- <link th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
- 다운받아서 css 폴더 밑에 저장한 부트스트랩의 css 적용할 것임
4) IndexController
package com.example.studyspringbootwebservice.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class IndexController {
@GetMapping("/")
public String index() {
return "index";
}
}
- 타임리프와 URL 매핑
- return "index" : 뷰 리졸버가 templates 폴더에 있는 index.html 뷰로 반환해줌
5) 테스트
package com.example.studyspringbootwebservice.web;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.extension.Extension;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = RANDOM_PORT)
class IndexControllerTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
public void 메인페이지_로딩() {
//when
String body = this.restTemplate.getForObject("/", String.class);
//then
assertThat(body).contains("스프링 부트로 시작하는 웹 서비스");
}
}
6) 화면 확인
Application의 메인메소드 실행
3. 게시글 등록 화면
- 부트스트랩, 제이쿼리 등 라이브러리 사용법
- 직접 라이브러리 다운받아서 경로 작성
- CDN 사용
레이아웃 만들기 -> 페이지 로딩속도 ↑
1) header.html (경로 : src>main>resources>templates>fragments>header.html)
<head th:fragment="main-header">
<meta charset="UTF-8">
<link th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
</head>
- 부트스트랩 css 다운 -> resources>static>css 경로에 파일 저장
2) footer.html (경로 : src>main>resources>templates>fragments>footer.html)
<footer th:fragment="main-footer">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.bundle.min.js" integrity="sha384-HwwvtgBNo3bZJJLYd8oVXjrBZt8cqVSpeBNS5n7C8IVInixGAoxmnlMuBnhbgrkm" crossorigin="anonymous"></script>
</footer>
- 제이쿼리 CDN
- 부트스트랩 js CDN
3) index.html 수정
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- header 조각 -->
<div th:replace="~{fragments/header :: main-header}"></div>
<body>
<h1>스프링 부트로 시작하는 웹 서비스</h1>
<div class="col-md-12">
<div class="row">
<div class="col-md-6">
<a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
</div>
</div>
<!-- footer 조각 -->
<div th:replace="~{fragments/footer :: main-footer}"></div>
</body>
</html>
4) 글 등록 컨트롤러 IndexController
@RequiredArgsConstructor
@Controller
public class IndexController {
private final PostsService postsService;
@GetMapping("/posts/save")
public String postsSave() {
return "posts-save";
}
}
5) 글 등록 화면 posts-save.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- header 조각 -->
<div th:replace="~{fragments/header :: main-header}"></div>
<body>
<h1>게시글 등록</h1>
<div class="col-md-12">
<div class="col-md-4">
<form>
<div class="form-group">
<label for="title">제목</label>
<input type="text" class="form-control" id="title" placeholder="제목을 입력하세요">
</div>
<div class="form-group">
<label for="author">작성자</label>
<input type="text" class="form-control" id="author" placeholder="작성자를 입력하세요">
</div>
<div class="form-group">
<label for="content">내용</label>
<textarea type="text" class="form-control" id="content" placeholder="내용을 입력하세요"></textarea>
</div>
</form>
<a href="/" role="button" class="btn btn-secondary">취소</a>
<button type="button" class="btn btn-primary" id="btn-save">등록</button>
</div>
</div>
<!-- footer 조각 -->
<div th:replace="~{fragments/footer :: main-footer}"></div>
</body>
</html>
7) 등록 버튼에 JS 기능 추가 index.js (src>main>resources>static>js>app>index.js)
var main = {
init : function () {
var _this = this;
$('#btn-save').on('click', function () {
_this.save();
});
},
save : function () {
var data = {
title: $('#title').val(),
author: $('#author').val(),
content: $('#content').val()
};
$.ajax({
type: 'POST',
url: '/api/v1/posts',
dataType: 'json',
contentType: 'application/json; charset=utf-8',
data: JSON.stringify(data)
}).done(function() {
alert('글이 등록되었습니다.');
window.location.href = '/'; // 글 등록 성공하면 메인페이지로 이동
}).fail(function(error) {
alert(JSON.stringify(error));
});
}
};
main.init();
- 자바스크립트 파일을 따로 분리 -> footer에 자바스크립트 파일 추가
- 굳이 맨 위에 var main = { ... } 선언 후 그 안에 작성하는 이유
- 만약 이름이 같은 init(), save() 함수가 중복 생성될 경우, 다른 JS와 겹칠 위험
- main의 init() 실행
8) footer에 자바스크립트 추가
<footer th:fragment="main-footer">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.bundle.min.js" integrity="sha384-HwwvtgBNo3bZJJLYd8oVXjrBZt8cqVSpeBNS5n7C8IVInixGAoxmnlMuBnhbgrkm" crossorigin="anonymous"></script>
<!-- index.js 추가 -->
<script src="/js/app/index.js"></script>
</footer>
- 절대 경로 (/)로 바로 시작
- 스프링 부트는 기본적으로 src>main>resources>static에 위치한 자바스크립트, CSS, 이미지 등 정적 파일들을 URL에서 /로 설정함
4. 전체 목록 조회 화면
1) index.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- header 조각 -->
<div th:replace="~{fragments/header :: main-header}"></div>
<body>
<h1>스프링 부트로 시작하는 웹 서비스</h1>
<div class="col-md-12">
<div class="row">
<div class="col-md-6">
<a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
</div>
</div>
<!-- 목록 출력 영역 -->
<table class="table table-horizontal table-bordered">
<thead class="thead-strong">
<tr>
<th>게시글번호</th>
<th>제목</th>
<th>작성자</th>
<th>최종수정일</th>
</tr>
</thead>
<tbody id="tbody">
<tr th:each="post : ${posts}">
<td th:text="${post.id}">id</td>
<td th:text="${post.title}">title</td>
<td th:text="${post.author}">author</td>
<td th:text="${post.modifiedDate}">modifiedDate</td>
</tr>
</tbody>
</table>
</div>
<!-- footer 조각 -->
<div th:replace="~{fragments/footer :: main-footer}"></div>
</body>
</html>
2) Repository 인터페이스에 조회 쿼리 추가
package com.example.studyspringbootwebservice.domain.posts;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface PostsRepository extends JpaRepository<Posts, Long> {
@Query("SELECT p FROM Posts p ORDER BY p.id DESC")
List<Posts> findAllDesc();
}
3) PostsService에 목록 조회 추가
package com.example.studyspringbootwebservice.service.posts;
import com.example.studyspringbootwebservice.domain.posts.Posts;
import com.example.studyspringbootwebservice.domain.posts.PostsRepository;
import com.example.studyspringbootwebservice.web.dto.PostsResponseDto;
import com.example.studyspringbootwebservice.web.dto.PostsListResponseDto;
import com.example.studyspringbootwebservice.web.dto.PostsSaveRequestDto;
import com.example.studyspringbootwebservice.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
// 등록
// 수정
// 조회
// 전체 조회
@Transactional(readOnly = true)
public List<PostsListResponseDto> findAllDesc() {
return postsRepository.findAllDesc().stream()
.map(PostsListResponseDto::new)
.collect(Collectors.toList());
}
}
- @Transactional(readOnly = true)
- 트랜잭션 범위 유지 + 조회 기능만 -> 조회 속도 개선
- 트랜잭션 범위 유지 + 조회 기능만 -> 조회 속도 개선
- postsRepository.findAllDesc().stream().map(PostsListResponseDto::new).collect(Collectors.toList());
- findAllDesc().stream() : 조회한 Posts 목록을 stream으로 변환
- map(PostsListResponseDto::new) : 스트림 내의 각 Posts를 PostsListResponseDto의 생성자를 통해 새로운 PostsListResponseDto 객체로 변환 ( = map(posts -> new PostsListResponseDto(posts) )
- collect(Collectors.toList()) : 스트림 요소를 List 컬렉션으로 반환
4) PostsListResponseDto
package com.example.studyspringbootwebservice.web.dto;
import com.example.studyspringbootwebservice.domain.posts.Posts;
import lombok.Getter;
import java.time.LocalDateTime;
@Getter
public class PostsListResponseDto {
private Long id;
private String title;
private String author;
private LocalDateTime modifiedDate;
public PostsListResponseDto(Posts entity) {
this.id = entity.getId();
this.title = entity.getTitle();
this.author = entity.getAuthor();
this.modifiedDate = entity.getModifiedDate();
}
}
5) IndexController
@RequiredArgsConstructor
@Controller
public class IndexController {
private final PostsService postsService;
@GetMapping("/")
public String index(Model model) {
model.addAttribute("posts", postsService.findAllDesc());
return "index";
}
}
5. 게시글 수정, 삭제
1) 해당 게시글로 이동하는 링크 추가 index.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- header 조각 -->
<div th:replace="~{fragments/header :: main-header}"></div>
<body>
<h1>스프링 부트로 시작하는 웹 서비스</h1>
<div class="col-md-12">
<div class="row">
<div class="col-md-6">
<a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
</div>
</div>
<!-- 목록 출력 영역 -->
<table class="table table-horizontal table-bordered">
<thead class="thead-strong">
<tr>
<th>게시글번호</th>
<th>제목</th>
<th>작성자</th>
<th>최종수정일</th>
</tr>
</thead>
<tbody id="tbody">
<tr th:each="post : ${posts}">
<td th:text="${post.id}">id</td>
<td><a th:href="@{/posts/update/{id}(id = ${post.id})}" th:text="${post.title}">title</a></td>
<td th:text="${post.author}">author</td>
<td th:text="${post.modifiedDate}">modifiedDate</td>
</tr>
</tbody>
</table>
</div>
<!-- footer 조각 -->
<div th:replace="~{fragments/footer :: main-footer}"></div>
</body>
</html>
2) 수정화면으로 이동하는 컨트롤러 IndexController
@RequiredArgsConstructor
@Controller
public class IndexController {
private final PostsService postsService;
@GetMapping("/")
public String index(Model model) {
model.addAttribute("posts", postsService.findAllDesc());
return "index";
}
@GetMapping("/posts/save")
public String postsSave() {
return "posts-save";
}
@GetMapping("/posts/update/{id}")
public String postsUpdate(@PathVariable Long id, Model model) {
PostsResponseDto dto = postsService.findById(id);
model.addAttribute("post", dto);
return "posts-update";
}
}
3) 수정, 삭제 로직 PostsService
package com.example.studyspringbootwebservice.service.posts;
import com.example.studyspringbootwebservice.domain.posts.Posts;
import com.example.studyspringbootwebservice.domain.posts.PostsRepository;
import com.example.studyspringbootwebservice.web.dto.PostsResponseDto;
import com.example.studyspringbootwebservice.web.dto.PostsListResponseDto;
import com.example.studyspringbootwebservice.web.dto.PostsSaveRequestDto;
import com.example.studyspringbootwebservice.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
// 등록
@Transactional
public Long save(PostsSaveRequestDto requestDto) {
return postsRepository.save(requestDto.toEntity()).getId();
}
// 수정
@Transactional
public Long update(Long id, PostsUpdateRequestDto requestDto) {
Posts posts = postsRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
posts.update(requestDto.getTitle(), requestDto.getContent());
return id;
}
// 조회
public PostsResponseDto findById(Long id) {
Posts entity = postsRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("해당 게시물이 없습니다. id=" + id));
return new PostsResponseDto(entity);
}
// 전체 조회
@Transactional(readOnly = true)
public List<PostsListResponseDto> findAllDesc() {
return postsRepository.findAllDesc().stream()
.map(PostsListResponseDto::new)
.collect(Collectors.toList());
}
// 삭제
@Transactional
public void delete(Long id) {
Posts posts = postsRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
postsRepository.delete(posts);
}
}
4) 수정, 삭제 API 컨트롤러 PostsApiController
package com.example.studyspringbootwebservice.web;
import com.example.studyspringbootwebservice.service.posts.PostsService;
import com.example.studyspringbootwebservice.web.dto.PostsResponseDto;
import com.example.studyspringbootwebservice.web.dto.PostsSaveRequestDto;
import com.example.studyspringbootwebservice.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
@RestController
public class PostsApiController {
private final PostsService postsService;
// 등록
@PostMapping("/api/v1/posts")
public Long save(@RequestBody PostsSaveRequestDto requestDto) {
return postsService.save(requestDto);
}
// 수정
@PutMapping("/api/v1/posts/{id}")
public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) {
return postsService.update(id, requestDto);
}
// 조회
@GetMapping("/api/v1/posts/{id}")
public PostsResponseDto findById(@PathVariable Long id) {
return postsService.findById(id);
}
// 삭제
@DeleteMapping("/api/v1/posts/{id}")
public Long delete(@PathVariable Long id) {
postsService.delete(id);
return id;
}
}
5) 수정, 삭제 게시글 화면 posts-update.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- header 조각 -->
<div th:replace="~{fragments/header :: main-header}"></div>
<body>
<h1>게시글 수정</h1>
<div class="col-md-12">
<div class="col-md-4">
<form>
<div class="form-group">
<label for="id">글 번호</label>
<input type="text" class="form-control" id="id" th:value="${post.id}" readonly>
</div>
<div class="form-group">
<label for="author">작성자</label>
<input type="text" class="form-control" id="author" th:value="${post.author}" readonly>
</div>
<div class="form-group">
<label for="title">제목</label>
<input type="text" class="form-control" id="title" th:value="${post.title}">
</div>
<div class="form-group">
<label for="content">내용</label>
<textarea type="text" class="form-control" id="content" th:text="${post.content}"></textarea>
</div>
</form>
<a th:href="@{/}" role="button" class="btn btn-secondary">취소</a>
<button type="button" class="btn btn-primary" id="btn-update">수정</button>
<button type="button" class="btn btn-danger" id="btn-delete">삭제</button>
</div>
</div>
<!-- footer 조각 -->
<div th:replace="~{fragments/footer :: main-footer}"></div>
</body>
</html>
6) 수정, 삭제 자바스크립트 index.js
var main = {
init : function () {
var _this = this;
$('#btn-save').on('click', function () {
_this.save();
});
$('#btn-update').on('click', function () {
_this.update();
});
$('#btn-delete').on('click', function () {
_this.delete();
});
},
save : function () {
var data = {
title: $('#title').val(),
author: $('#author').val(),
content: $('#content').val()
};
$.ajax({
type: 'POST',
url: '/api/v1/posts',
dataType: 'json',
contentType: 'application/json; charset=utf-8',
data: JSON.stringify(data)
}).done(function() {
alert('글이 등록되었습니다.');
window.location.href = '/'; // 글 등록 성공하면 메인페이지로 이동
}).fail(function(error) {
alert(JSON.stringify(error));
});
},
update : function () {
var data = {
title : $('#title').val(),
content: $('#content').val()
};
var id = $('#id').val();
$.ajax( {
type: 'PUT',
url: '/api/v1/posts/' + id,
dataType: 'json',
contentType: 'application/json; charset=utf-8',
data: JSON.stringify(data)
}).done(function() {
alert('글이 수정되었습니다.');
window.location.href = '/';
}).fail(function(error) {
alert(JSON.stringify(error));
});
},
delete : function () {
var id = $('#id').val();
$.ajax( {
type : 'DELETE',
url : '/api/v1/posts/' + id,
dataType : 'json',
contentType : 'application/json; charset=utf-8',
}).done(function() {
alert('글이 삭제되었습니다.');
window.location.href = '/';
}).fail(function(error) {
alert(JSON.stringify(error));
})
}
};
main.init();