지정된 템플릿 양식과 데이터가 합쳐져 HTML 문서를 출력하는 소프트웨어
e.g. JSP, Freemaker, React, Vue
JSP나 Freemaker는 서버 템플릿 엔진, React와 Vue는 클라이언트 템플릿 엔진이라고 불림
자바스크립트에서 JSP나 Freemaker처럼 자바 코드를 사용할 수 있을까?
<script type = "text/javascript">
$(document).ready(function(){
if(a=="1") {
<%
System.out.println("test"); // if문 상관없이 항상 test가 출력
%>
}
});
서버 템플릿 엔진은 서버에서 구동
System.out.println("test");
을 실행할 뿐이며, 이때 js는 단순 문자열그러나 js는 브라우저 위에서 작동
Vue.js나 React.js를 이용한 SPA(Single Page Application)는 브라우저에서 화면 생성
서버 사이드 렌더링
build.gradle
implementation('org.springframework.boot:spring-boot-starter-mustache')
index.mustache
<!DOCTYPE HTML>
<html>
<head>
<title>스프링 부트 웹서비스</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<h1>스프링 부트로 시작하는 웹 서비스</h1>
</body>
</html>
IndexController.java
package com.jojoldu.book.springboot.web.dto;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class IndexController
{
@GetMapping("/")
public String index()
{
return "index";
}
}
IndexControllerTest.java
package com.jojoldu.book.springboot.web;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
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.assertThat;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = RANDOM_PORT)
public class IndexControllerTest
{
@Autowired
private TestRestTemplate restTemplate;
@Test
public void loadMainPage()
{
// when
String body = this.restTemplate.getForObject("/", String.class);
// then
assertThat(body).contains("스프링 부트로 시작하는 웹 서비스");
}
}
PostsApiController로 API를 구현했으니 이제 화면을 개발
부트스트랩을 사용
예제에서는 CDN을 채택
부트스트랩과 제이쿼리를 index.mustache에 레이아웃 방식으로 추가
header.mustache
<!DOCTYPE HTML>
<html>
<head>
<title>스프링 부트 웹서비스</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
</head>
</body>
footer.mustache
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
</body>
</html>
css와 js의 위치가 다름
페이지 로딩속도를 높이기 위해 css는 header에, js는 footer에 넣었음
HTML은 위에서부터 코드가 실행
js의 용량이 클수록 body 부분의 실행이 늦어짐
css는 화면을 그리는 역할
bootstrap.js는 제이쿼리가 꼭 있어야 함(bootstrap.js가 제이쿼리에 의존)
라이브러리를 비롯한 기타 HTML 태그들이 모두 레이아웃에 추가됨
index.mustache
{{>layout/header}}
<h1>스프링 부트로 시작하는 웹 서비스</h1>
{{>layout/footer}}
index.mustache(글 등록 버튼 추가)
{{>layout/header}}
<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>
</div>
{{>layout/footer}}
<a>
태그를 사용하여 글 등록 페이지로 이동하는 버튼 생성
IndexController.java
public class IndexController
{
// 기존 코드
@GetMapping("/posts/save")
public String postSave()
{
return "posts-save";
}
}
posts-save.mustache
{{>layout/header}}
<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 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>
{{>layout/footer}}
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();
index.js에서 var main = {}을 선언하여 index라는 변수의 속성으로 function을 추가한 이유
e.g.
var init = fuction (){
//...
};
var save = function (){
//...
};
init();
만약 index.mustache에 a.js가 추가돼 init과 save function을 가진다면?
footer.mustache
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
<script src="/js/app/index.js"></script>
</body>
</html>
이후 브라우저에서 테스트할 때, 다음과 같은 에러 메시지가 나올 수 있다
not-null property references a null or transient value
어떤 에러인지는 스택오버플로우를 참고하자. Posts.java에서 nullable을 전부 true로 바꿔주니 해결하긴 했다.
index.mustache
{{>layout/header}}
<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>
<br>
<!--목록 출력 영역-->
<table class="table table-horizontal table-bordered">
<thread class="thread=strong">
<tr>
<th>게시글번호</th>
<th>제목</th>
<th>작성자</th>
<th>최종수정일</th>
</tr>
</thread>
<tbody id="tbody">
{{#posts}}
<tr>
<td>{{id}}</td>
<td>{{title}}</td>
<td>{{author}}</td>
<td>{{modifiedDate}}</td>
</tr>
{{/posts}}
</tbody>
</table>
</div>
{{>layout/footer}}
PostsRepository.java
package com.jojoldu.book.springboot.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();
}
PostsService.java
// 기존 코드
import com.jojoldu.book.springboot.web.dto.PostsListResponseDto;
import java.util.List;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@Service
public class PostsService
{
// 기존 코드
@Transactional(readOnly = true) // 트랜잭션 범위는 유지하되 조회 기능만 남겨두어 조회 속도 개선
public List<PostsListResponseDto> findAllDesc()
{
return postsRepository.findAllDesc().stream()
.map(PostsListResponseDto::new)
.collect(Collectors.toList());
}
}
PostListResponseDto
package com.jojoldu.book.springboot.web.dto;
import com.jojoldu.book.springboot.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();
}
}
IndexController.java
// 기존 코드
import org.springframework.ui.Model;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Controller
public class IndexController
{
private final PostsService postsService;
@GetMapping("/")
public String index(Model model) // 기존 index 수정
{
model.addAttribute("posts", postsService.findAllDesc());
return "index";
}
// 기존 코드
}
PostsApiController.java
public class PostsApiController
{
// 아래 내용 추가
@PutMapping("/api/v1/posts/{id}")
public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto)
{
return postsService.update(id, requestDto);
}
}
posts-update.mustache
{{>layout/header}}
<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" value="{{post.id}}" readonly>
</div>
<div class="form-group">
<label for="title">제목</label>
<input type="text" class="form-control" id="title" value="{{post.title}}">
</div>
<div class="form-group">
<label for="author">작성자</label>
<input type="text" class="form-control" id="author" value="{{post.author}}" readonly>
</div>
<div class="form-group">
<label for="content">내용</label>
<textarea class="form-control" id="content">{{post.content}}</textarea>
</div>
</form>
<a href="/" role="button" class="btn btn-secondary">취소</a>
<button type="button" class="btn btn-primary" id="btn-update">수정 완료</button>
</div>
</div>
{{>layout/footer}}
index.js
var main = {
init : function () {
// ...
// btn-update란 id를 가진 HTML 엘리먼트에 click 이벤트가 발생할 때 update function을 실행하는 이벤트 등록
$('#btn-update').on('click', function (){
_this.update();
});
},
save : function () {
//...
},
update : function (){
var data = {
title: $('#title').val(),
content: $('#content').val()
};
var id = $('#id').val();
$.ajax({
type: 'PUT',
url: '/api/v1/posts/'+id, // 어느 게시글을 수정할지 URL PATH로 구분하기 위해 id 추가
dataType: 'json',
contentType: 'application/json; charset=urf-8',
data: JSON.stringify(data)
}).done(function (){
alert('글이 수정되었습니다.');
window.location.href = '/';
}).fail(function (error)
{
alert(JSON.stringify(error));
});
}
};
type: 'PUT
index.mustache
//...
<tbody id="tbody">
{{#posts}}
<tr>
<td>{{id}}</td>
<td><a href="/posts/update/{{id}}">{{title}}</a></td>
<td>{{author}}</td>
<td>{{modifiedDate}}</td>
</tr>
IndexController.java
@RequiredArgsConstructor
@Controller
public class IndexController
{
//...
@GetMapping("/posts/update/{id}")
public String postsUpdate(@PathVariable Long id, Model model)
{
PostsResponseDto dto = postsService.findById(id);
model.addAttribute("post", dto);
return "posts-update";
}
}
posts-update.mustache
<div class = "col-md-12">
<div class = "col-md-4">
// ...
<a 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>
index.js
var main = {
init : function () {
//...
$('#btn-delete').on('click', function (){
_this.delete();
});
},
//...
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))
});
}
};
PostsService.java
@RequiredArgsConstructor
@Service
public class PostsService
{
// ...
@Transactional
public void delete (Long id)
{
Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
// JpaRepository에서 지원하는 메소드 활용
// deleteById(id)도 사용 가능
postsRepository.delete(posts);
}
}
PostsApiController.java
@RequiredArgsConstructor
@RestController
public class PostsApiController
{
//...
@DeleteMapping("/api/v1/posts/{id}")
public Long delete(@PathVariable Long id)
{
postsService.delete(id);
return id;
}
}