Spring Study 1주차 ~ 4장
"머스테치로 화면 구성하기"
- 서버 템플릿 엔진과 클라이언트 템플릿 엔진의 차이는 무엇인가?
- 왜 JSP말고 머스테치?
- 머스테치를 이용한 CRUD화면 개발 방법
4.1 서버 템플릿 엔진과 머스테치
템플릿 엔진 : 지정된 템플릿 양식과 데이터가 합쳐져서 HTML 문서를 출력하는 소프트웨어
서버 템플릿 엔진 : JSP, Freemaker 등
클라이언트 템플릿 엔진 : React, Vue의 view파일 등
✅ 서버 템플릿 엔진을 이용한 화면생성은 서버에서 JAVA코드로 문자열을 만든뒤, 이 문자열을 HTML로 변환하여 브라우저로 전달. 클라이언트 템플릿 엔진은 브라우저 위에서 화면을 생성하기에 이미 서버에서 코드가 벗어난 경우임.
✅ 머스테치란?
다양한 언어를 지원하는 템플릿 엔진, 문법이 다른 엔진에 비해 심플하고 로직 코드를 사용할 수 없기에 서버와 View가 완전히 분리. (화면 역할에만 충실할 수 있음)
4.2 기본 페이지 만들기
우선 머스테치 플러그인을 설치하고, 의존성을 build.gradle에 등록한다.
implementation('org.springframework.boot:spring-boot-starter-mustache')
머스테치 파일의 위치는 src/main/resources/templates이다.
- 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>
- web/IndexController : mustache는 앞의 경로와 뒤의 파일 확장자를 자동으로 지정해준다.
@Controller
public class IndexController {
@GetMapping("/")
public String index(Model model) {
return "index"; // 문자열을 반환할 때 앞의 경로, 뒤 확장자는 자동지정됨.
// 즉, src/main/resources/templates 까지는 자동, return 문의 index, 그 뒤 다시 자동으로 .mustache 확장자
}
}
즉 위 코드는, src/main/resources/templates가 지정이 되어 있고, return되는 index가 경로에 붙은다음 확장자 .mustache는 다시 자동으로 붙는다. 이 경로를 View Resolver가 처리하게 된다.
- IndexControllerTest
package firstSpring.practice.web;
import org.junit.Test;
import org.junit.runner.RunWith;
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.junit4.SpringRunner;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = RANDOM_PORT)
public class IndexControllerTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
public void 메인페이지_로딩() {
String body = this.restTemplate.getForObject("/", String.class);
assertThat(body).contains("스프링 부트로 시작하는 웹 서비스");
}
}

4.3 게시글 등록 화면 만들기
부트스트랩을 레이아웃 방식으로 추가하여 사용.
*) 레이아웃 방식 : 공통 영역을 별도의 파일로 분리하여 필요한 곳에서 가져다 쓰는 방식

- 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는 header, js는 footer에 둔다. HTML은 head가 다 실행된 후에 body가 실행되기 때문이다.
이렇게 레이아웃 틀을 만들어두었기 때문에 index.mustache코드는 간결해진다.
<!DOCTYPE HTML>
<html>
{{>layout/header}}
<h1> 스프링 부트로 시작하는 웹 서비스</h1>
{{>layout/footer}}
그리고 글을 등록하기 위한 버튼도 추가해보자. <h1>태그와 footer사이에 추가.
<div class="row">
<div class="col-md-6">
<a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
</div>
</div>
<a> 태그를 이용해서 글 등록 버튼을 누르면 등록 페이지로 이동할 수 있다. 페이지에 관련된 작업들은 모두 IndexController를 사용한다.
...
@GetMapping("/posts/save")
public String postsSave() {
return "posts-save"; // posts/save 호출하면 posts-save.mustache 호출하는 메소드 추가
}
...
✅ /posts/save호출하면 posts-save.mustache호출하는 메소드 추가. posts-save.mustache파일도 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 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>
{{>layout/footer}}
여기까지 작성하고 다시 프로젝트를 실행해보면 아래처럼 글 등록 화면도 완성된다.
이제 등록 버튼에 실제로 등록할 수 있게끔 API를 호출하는 코드를 작성한다.

- 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));
});
}
};
main.init();
✅ window.location.href = '/'; : 성공시 메인 페이지(/)로 돌아간다
✅ 함수의 이름이 중복되는 경우가 자주 발생할 수 있으므로, index라는 변수의 속성으로 function을 추가함으로써 유효범위를 만들어 사용할 수 있다.
✅ 머스테치 파일이 index.js를 사용할 수 있게끔, footer.mustache에도 추가해준다.
<script src="/js/app/index.js"></script>


테스트를 해보면 위처럼 글이 등록되었다는 alert가 뜨고, database에도 저장이 되어 있다.
4.4 전체 조회 화면 만들기
- index.mustache를 다시 수정해보자
<!DOCTYPE HTML>
<html>
{{>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><a href="/posts/update/{{id}}">{{title}}</a></td>
<td>{{author}}</td>
<td>{{modifiedDate}}</td>
</tr>
{{/posts}}
</tbody>
</table>
</div>
{{>layout/footer}}
</html>
✅ {{#posts}} : posts라는 List를 순회한다. for문의 기능을 한다고 생각하면 된다.
✅ {{id}} : List에서 뽑아낸 객체의 필드.
- PostsRepository
package firstSpring.practice.practice.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();
}
✅ SpringDataJpa에서 제공하지 않는 메소드는 쿼리로 작성가능. Entity만으로 해결하기 어려운 복잡한 쿼리의 경우 직접 쿼리문을 작성하는 방안이 있다.
- PostsService에 추가
@Transactional(readOnly = true) // 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 속도 향상. 등록, 수정, 삭제 전혀 없는 메소드에서 사용 추천.
public List<PostsListResponseDto> findAllDesc() {
return postsRepository.findAllDesc().stream()
.map(PostsListResponseDto::new) // posts -> new PostsListResponseDto(posts))의 람다식.
.collect(Collectors.toList());
}
- PostsResponseDto
package firstSpring.practice.practice.web.dto;
import firstSpring.practice.practice.domain.posts.Posts;
import lombok.Getter;
@Getter
public class PostsResponseDto {
private Long id;
private String title;
private String content;
private String author;
public PostsResponseDto(Posts entity) {
this.id = entity.getId();
this.title = entity.getTitle();
this.content = entity.getContent();
this.author = entity.getAuthor();
}
}
- IndexController
@RequiredArgsConstructor
@Controller
public class IndexController {
private final PostsService postsService;
@GetMapping("/")
public String index(Model model) {
model.addAttribute("posts", postsService.findAllDesc());
return "index"; // 문자열을 반환할 때 앞의 경로, 뒤 확장자는 자동지정됨.
// 즉, src/main/resources/templates 까지는 자동, return 문의 index, 그 뒤 다시 자동으로 .mustache 확장자
}
}
여기까지 코드를 완성하면 글 작성, 글 목록 2가지 기능이 구현되어 있다.

4.5 게시글 수정, 삭제 화면 만들기
마지막으로, 만들어 두었던 게시글 수정 API를 위한 화면 개발, 삭제 화면 및 삭제 API를 작성하면 기본 기능 구현이 끝난다.
- 수정을 위한 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>
<button type="button" class="btn btn-danger" id="btn-delete">삭제</button>
</div>
</div>
{{>layout/footer}}
✅ readonly : Input태그에서 값을 수정하지 못하도록 만들려면 readonly를 추가하면 된다. id와 author은 수정할 수 없도록 readonly 속성을 부여
- index.js에 update function 추가
$('#btn-update').on('click', function() {
_this.update();
});
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));
});
},
✅ $('#btn-update').on('click') : btn-update라는 id를 가진 HTML 요소에 click이벤트 발생하면 update함수 실행
✅ 수정을 위해 PUT 메소드 이용, url에는 게시물 구분을 위해 경로에 id가 붙는다.
- 수정 페이지로 이동하기 위한 index.mustache코드
{{#posts}}
<tr>
<td>{{id}}</td>
<td><a href="/posts/update/{{id}}">{{title}}</a></td>
<td>{{author}}</td>
<td>{{modifiedDate}}</td>
</tr>
{{/posts}}
- 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 : 삭제 버튼 추가
<button type="button" class="btn btn-danger" id="btn-delete">삭제</button>
- index.js에 삭제 이벤트 추가
$('#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추가
@Transactional
public void delete (Long id) {
Posts posts = postsRepository.findById(id) // 존재하는지 먼저 확인
.orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
postsRepository.delete(posts); // deleteById(id)로 바로 삭제도 가능. 엔티티를 파라미터로 주는것도 가능.
}
- 마지막으로 PostsApiController에 삭제 API를 구현해주면 끝난다!
@DeleteMapping("/api/v1/posts/{id}")
public Long delete(@PathVariable Long id) {
postsService.delete(id);
return id;
}
- 삭제 버튼이 추가된 것을 확인할 수 있다.



참고 책 : http://www.yes24.com/Product/Goods/83849117
스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - YES24
가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링
www.yes24.com