지정된 템플릿 양식과 데이터가 합쳐져 HTML 문서를 출력하는 소프트웨어 입니다. 템플릿 엔진의 종류에는 서버 템플릿 엔진, 클라이언트 템플릿 엔진이 있습니다.
ex) jsp, Freemarker, React, View 등
서버 템플릿 엔진은 서버에서 구동이 됩니다. 서버 템플릿 엔진을 이용한 화면 생성은 서버에서 Java 코드로 문자열을 만든 뒤 이 문자열을 HTML로 변환하여 브라우저로 전달합니다. 반면, 클라이언트 템플릿 엔진은 브라우저에서 화면을 생성합니다. 즉, 서버에서 코드가 이미 벗어난 경우입니다. 이 경우 서버에서는 Json 이나 Xml 형식의 데이터만 전달하고 클라이언트에서 조립합니다.
머스테치는 수많은 언어를 지원하는 가장 심플한 템플릿 엔진입니다.
저자인 이동욱 님께서는 "개인적으로 템플릿 엔진은 화면 역할에만 충실해야 한다고 생각한다"고 하셨습니다. 템플릿 엔진에서 너무 많은 기능을 제공하면 나중에 유지보수 하기가 굉장히 어렵다고 합니다.
실습 하기에 앞서 spring devtools 의존성을 추가하여 사용하시는 걸 추천드립니다. 저는 실습을 하면서 코드를 변경할 때마다 애플리케이션을 재실행 하여 테스트하는것이 번거롭다고 느껴져서 Automatic restart 기능을 사용하였습니다. 이와 관련해서 설정하는 법에 대한 글도 간단하게 정리 해놓았으니 참고하시면 좋을 것 같습니다.
build.gradle에 의존성을 추가합니다.
implementation('org.springframework.boot:spring-boot-starter-mustache')
머스테치는 스프링 부트에서 공식 지원하는 템플릿 엔진입니다. 그렇기 때문에 의존성 하나만 추가하면 추가 설정할 필요없이 사용할 수 있습니다. 머스테치 파일 위치는 기본적으로 src/main/resources/templates 입니다.
<!DOCTYPE HTML>
<html>
<head>
<title>스프링 부트 웹서비스</title>
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8" />
</head>
<body>
<h1>스프링부트로 시작하는 웹 서비스</h1>
</body>
</html>
index.mustache에 URL을 매핑해줍니다.
@RequiredArgsConstructor
@Controller
public class IndexController {
private final PostsService postsService;
@GetMapping("/")
public String index() {
return "index";
}
}
머스테치 스타터 덕분에 컨트롤러에서 문자열을 반환할 때 앞의 경로와 뒤의 파일 확장자는 자동으로 지정됩니다.
jsp를 사용하셨던 분들이라면 application.properties 파일에 다음과 같이 추가로 설정하셨을 텐데요,
server.servlet.jsp.init-parameters.development=true spring.mvc.view.prefix=/WEB-INF/views/ spring.mvc.view.suffix=.jsp
머스테치는 의존성 하나만 추가하면 되니 편리한 것 같습니다.
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = RANDOM_PORT)
public class IndexControllerTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
public void 메인페이지_로딩() {
//when
String body = this.restTemplate.getForObject("/", String.class);
//then
assertThat(body).contains("스프링 부트로 시작하는 웹 서비스");
}
}
TestRestTemplate을 통해 "/"로 호출했을 때 index.mustache에 "스프링 부트로 시작하는 웹 서비스" 문자열이 있는지 확인합니다.
test가 통과한 것을 확인 후 Application.java를 실행한 후 http://localhost:8080 으로 접속하여 확인합니다.
부트 스트랩을 이용하여 화면을 만듭니다.
부트스트랩, 제이쿼리 등 프론트엔드 라이브러리를 사용할 수 있는 방법은 다음과 같이 2가지가 있습니다.
실습에서는 외부 CDN을 사용합니다. 하지만 현업에서는 외부 서비스에 의존하게 되어 CDN을 서비스하는 곳에서 문제가 생기면 같이 문제가 생기기 때문에 이 방법을 잘 사용하지 않는다고 합니다.
레이아웃 방식이란 공통 영역을 별도의 파일로 분리하여 필요한 곳에서 가져다 쓰는 방식을 말합니다. 부트스트랩과 제이쿼리는 머스테치 화면 어디에서나 필요하고 화면마다 코드를 추가해주는 것은 번거롭기 때문에 레이아웃 파일을 만들어 추가합니다.
spring boot + jsp 로 프로젝트를 했을 때, <jsp:include page="..."> 태그로 비슷하게 사용한 적이 있는데 저와 같은 경험이 있으시다면 좀 더 이해하기 수월할 것 같습니다.
src/main/resources/templates 에 layout 디렉토리를 생성하고 그 안에 footer.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>
<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는 header에, js는 footer에 두었습니다. HTML은 head가 다 실행되고서야 body가 실행됩니다. js파일의 용량이 크면 body의 실행이 늦어지기 때문에 js는 하단에 두어 화면이 다 그려진 뒤 호출하는 것이 좋습니다. 반면에 css는 화면을 그리는 역할을 하므로 head에서 불러오는 것이 좋습니다. 추가로, bootstrap.js의 경우 제이쿼리가 꼭 있어야만 하기 때문에 부트스트랩보다 먼저 호출되도록 하였습니다.
{{>layout/header}}
<h1>스프링부트로 시작하는 웹 서비스 Ver.2</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}}
{{> }}는 현재 머스테치 파일을 기준으로 다른 파일을 가져옵니다.
...
@GetMapping("/posts/save")
public String postsSave() {
return "posts-save";
}
index.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}}
메인화면에서 글 등록 버튼을 클릭하여 글 등록 화면으로 이동합니다.
등록 버튼에 기능을 추가하기 위해 API를 호출하는 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();
브라우저의 스코프는 공용 공간으로 쓰이기 때문에 나중에 추가된 js의 init, save가 먼저 로딩된 js의 function을 덮어쓰게 됩니다. 여러 사람이 참여하는 프로젝트에서 함수 이름이 중복되는 것을 방지하기 위해 모든 함수명을 확인할 수는 없기 때문에 var index 라는 객체를 만들어 해당 객체에서 필요한 모든 function을 선언합니다. 이렇게 하면 index 객체 안에서만 function이 유효하기 때문에 다른 js와 겹칠 위험이 사라집니다.
footer.mustache에서 index.js를 사용할 수 있도록 추가합니다.
<!--index.js 추가-->
<script src="/js/app/index.js"></script>
게시글 등록을 하고 h2-console에 접속해서 데이터를 확인합니다.
등록한 글을 메인화면에서 확인할 수 있게 추가합니다.
{{>layout/header}}
<h1>스프링부트로 시작하는 웹 서비스 Ver.2</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">
<thead class="thead-strong">
<tr>
<th>게시글번호</th>
<th>제목</th>
<th>작성자</th>
<th>최종수정일</th>
</tr>
</thead>
<tbody id="tbody">
{{#posts}}
<tr>
<td>{{id}}</td>
<td>{{title}}</td>
<td>{{author}}</td>
<td>{{modifiedDate}}</td>
</tr>
{{/posts}}
</tbody>
</table>
</div>
{{>layout/footer}}
posts 라는 List를 순회합니다.
List에서 뽑아낸 객체의 필드를 사용합니다.
jsp의 EL문법 ${변수명}과 같다고 생각을 했습니다. jsp에서는 List를 순회하려면 jstl의 foreach를 사용해 출력을 했었는데 그런 과정없이 사용할 수 있어 편리한 것 같습니다.
쿼리를 추가합니다.
@Query("SELECT p FROM Posts p ORDER BY p.id DESC")
List<Posts> findAllDesc();
SpringDataJpa에서 제공하지 않는 메서드는 위처럼 쿼리로 작성할 수 있습니다.
...
@Transactional(readOnly = true)
public List<PostsListResponseDto> findAllDesc() {
return postsRepository.findAllDesc().stream()
.map(PostsListResponseDto::new)
.collect(Collectors.toList());
}
@Transactional에 readOnly=true 옵션을 추가하면 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도가 개선되기 때문에 등록, 수정, 삭제 기능이 없는 서비스 메서드에 사용하는것을 추천합니다.
@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();
}
}
@GetMapping("/")
public String index(Model model) {
model.addAttribute("posts", postsService.findAllDesc());
return "index";
}
만들어놓았던 index 메서드를 위처럼 바꿔줍니다.
PostsApiController 에 만들어놓은 API로 요청하는 화면을 만듭니다.
{{>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="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}}
머스테치는 객체의 필드 접근 시 점으로 구분합니다.
var main = {
init : function () {
var _this = this;
...
$('#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,
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();
REST에서 CRUD는 다음과 같이 HTTP Method에 매핑됩니다.
지금은 update 기능을 구현하기 때문에 PUT을 사용해야 하며 PostsApiController에도 이미 @PUTMapping으로 선언해놓았습니다.
글 제목을 클릭하여 수정 페이지로 이동할 수 있도록 페이지 이동 기능을 추가합니다.
<tbody id="tbody">
{{#posts}}
<tr>
<td>{{id}}</td>
<td><a href="/posts/update/{{id}}">{{title}}</a></td>
<td>{{author}}</td>
<td>{{modifiedDate}}</td>
</tr>
{{/posts}}
</tbody>
...
@GetMapping("/posts/update/{id}")
public String postsUpdate(@PathVariable Long id, Model model) {
PostsResponseDto dto = postsService.findById(id);
model.addAttribute("post", dto);
return "posts-update";
}
글 제목을 클릭해 수정 페이지로 이동합니다.
수정할 내용을 입력 후 수정완료 버튼을 클릭합니다.
수정 완료된 것을 확인할 수 있습니다.
수정 기능이 구현되었으니, 삭제 기능도 구현하겠습니다.
...
</form>
<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>
var main = {
init : function () {
var _this = this;
...
$('#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));
});
}
}
main.init();
...
@Transactional
public void delete(Long id) {
Posts posts = postsRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id = " + id));
postsRepository.delete(posts);
}
...
@DeleteMapping("/api/v1/posts/{id}")
public Long delete(@PathVariable Long id) {
postsService.delete(id);
return id;
}
게시글 수정 페이지로 이동 후 삭제 버튼을 클릭해 게시물을 삭제합니다.
이번 장에서는 머스테치를 사용해서 CRUD 화면을 구성했습니다. API는 구현이 되어있으니 나중에 Thymeleaf로 리팩토링 해보는 것도 좋을 것 같습니다.