머스태치가 무엇인가!…
머스태치는 서버 사이드 템플릿 엔진의 일종인데, 템플릿 엔진은 지정된 템플릿 양식과 데이터를 합쳐서 HTML을 출력해주는 역할을 한다. 템플릿 엔진에는 서버사이드 템플릿 엔진과, 클라이언트 사이드 템플릿 엔진이 있다.
- 브라우저 위에서 작동
- 서버에서 벗어나 브라우저에서 화면을 생성
- 서버에서는 Json형태의 데이터만을 전달하고, 이를 클라이언트에서 조립
- 소스코드는 브라우저에서 실행된다
implementation('org.springframework.boot:spring-boot-starter-mustache')
Mustache 파일의 위치는 기본적으로 src/main/resources/templates입니다.
해당 위치에 있는 머스태치 파일을 스프링 부트가 자동으로 로드합니다.
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>
{{#userName}} <!-- userName이 있다면 이를 노출 -->
Logged in as : <span id="user"> {{userName}}</span>
<a href = "/logout" class="btn btn-info active" role = "button">Logout</a>
{{/userName}}
{{^userName}} <!--userName이 존재하지 않는 경우 로그인버튼 -->
<a href ="/oauth2/authorization/google"
class = "btn btn-success active" role="button">Google Login</a>
{{/userName}}
</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}} <!--posts 리스트를 순회, {{id}} 등의 변수 는 여기서 뽑아낸 객체의 필드, 컨트롤러에서 model 객체를 통해 넘겨줌-->
<tr>
<td>{{id}}</td>
<td><a href="/posts/update/{{id}}">{{title}}</a></td>
<td>{{author}}</td>
<td>{{modifiedDate}}</td>
</tr>
{{/posts}}
</tbody>
</table>
</div>
{{>layout/footer}}
{{>layout/header}}
, {{>layout/footer}}
: {{>}}는 현재 파일을 기준으로 다른 파일을 가져옵니다.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}}
<button type="button" class="btn btn-primary" id="btn-save">등록</button>
: 등록 버튼에 실제 등록 기능을 추가해줘야 합니다.이를 위해 API를 호출하는 JS를 src/main/resources/static/js/app에 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: 'POST',
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();
var main = { } 안에 모든 코드를 작성합니다
브라우저의 스코프는 공용공간으로 쓰이기에, 나중에 로딩된 js의 init, save등의 함수가 먼저 로딩된 init, save를 덮어씁니다.
이를 방지하기 위해 유효범위를 만드는데, index.js 만의 유효범위를 만들어 사용하기 위해, 객체를 만들어 해당 객체에서 필요한 모든 function을 선언합니다
$('#btn-save').on('click', function () { _this.save(); });
- btn-save라는 id를 가진 HTML엘리먼트에 click이벤트 발생시, update function을 실행
posts-update.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="id" value="{{post.id}}" readonly> <!--필드 접근시 .(dot)으로 구분-->
</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> <!-- id와 author는 수정불가 -->
</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>
<button type="button" class="btn btn-danger" id="btn-delete">삭제</button>
</div>
</div>
{{>layout/footer}}
공통 영역을 별도의 파일로 분리하여 필요할때 가져다 쓰는 방식을 레이아웃 방식이라 합니다.
이를 활용하여 머스태치 화면 어디서나 필요한 부트스트랩과 제이쿼리를 추가해 보겠습니다.
templates/layout에 footer.mustache와 header.mustache를 생성합니다.
<!doctype html>
<html lang="en">
<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>
<!--index.js 추가-->
<script src="/js/app/index.js"></script>
</body>
</html>
mustache파일에 URL을 매핑하는 것은 Controller에서 담당합니다.
@RequiredArgsConstructor
@Controller
public class IndexController {
private final PostsService postsService;
private final HttpSession httpSession;
@GetMapping("/")
public String index(Model model) {
model.addAttribute("posts", postsService.findAllDesc());
SessionUser user = (SessionUser) httpSession.getAttribute("user");
if (user != null) {
model.addAttribute("userName", user.getName());
}
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";
}
}
{{^userName}} <!--userName이 존재하지 않는 경우 로그인버튼 -->
— 로그인 버튼이 보입니다@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
@Transactional(readOnly = true) //트랜젝션 범위는 유지하나, 조회 기능만 남겨서 조회 속도를 개선함
public List<PostsListResponseDto> findAllDesc() {
return postsRepository.findAllDesc().stream()
.map(posts -> new PostsListResponseDto(posts)) // Posts의 스트림을 map을 통해 dto의 리스트로 반환
.collect(Collectors.toList());
}
}
@Query("SELECT p FROM Posts p ORDER BY p.id DESC")
List<Posts> findAllDesc();