: 지정된 템플릿 양식과 데이터가 합쳐져 HTML 문서를 출력하는 소프트웨어
ex) JSP, Freemarker, React, Vue ...
서버 템플릿 엔진을 이용한 화면 생성은 서버에서 Java 코드로 문자열을 만든 뒤, 문자열을 HTML로 변환하여 브라우저로 전달한다.
반면, 자바스크립트는 서버가 아닌, 브라우저 위에서 작동한다.
브라우저에서 작성될 때는 서버 템플릿 엔진의 손을 벗어나 제어할 수 없다.
: 수많은 언어를 지원하는 가장 심플한 템플릿 엔진 (http://mustache.github.io)
머스테치 외에 자바 진영에서는 JSP, Velocity, Freemarker, Thymeleaf 등 다양한 서버 템플릿 엔진이 존재한다.
머스테치의 장점는 아래와 같다
- 문법이 다른 템플릿 엔진보다 심플
- 로직 코드를 사용할 수 없어 View의 역할과 서버의 역할이 명확하게 분리됨
- Mustache.js와 Mushtache.java 2가지가 다 있어, 하나의 문법으로 클라이언트/서버 템플릿을 모두 사용 가능
- 인텔리제이 커뮤니티 버전에서도 플러그인 제공 (Thymeleaf나 JSP는 유료 버전에서만 공식 지원)
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>
이제 이 머스테치에 URL을 매핑하자.
URL 매핑은 Controller에서 진행한다.
web 패키지 안에 IndexController를 생성하자.
package com.example.demo.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class IndexController {
@GetMapping("/")
public String index() {
return "index";
}
}
머스테치 스타터 덕분에 컨트롤러에서 문자열을 반환할 때 앞의 경로와 뒤의 파일 확장자는 자동으로 지정된다.
즉 "index"는 src/main/resources/templates/index.mustache로 전환되어 View Resolver가 처리하게 된다.
이제 테스트 코드로 검증 고고
package com.example.demo.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 메인페이지_로딩() {
// when
String body = this.restTemplate.getForObject("/", String.class);
// then
assertThat(body).contains("스프링 부트로 시작하는 웹 서비스");
}
}
실제로 URL 호출 시 페이지의 내용이 제대로 호출되는지에 대한 테스트이다.
전체 코드를 다 검증할 필요는 없으니, "스프링 부트로 시작하는 웹 서비스"라는 문자열이 포함되어 있는지만 비교한다.
Application.java의 main 메소드를 실행하고 브라우저에서 localhost:8080을 접속해서 화면으로 직접 확인할 수도 있다 ^ㅅ^
이번에는 오픈소스인 부트스트랩을 이용하여 게시글 등록 화면을 구현한다.
부트스트랩, 제이쿼리 등 프론트엔드 라이브러리를 사용하는 방법에는 1️⃣ 외부 CDN 사용, 2️⃣ 직접 라이브러리를 받아서 사용 두 가지 방법이 있다.
외부 CDN을 사용하면 라이브러리를 내려받을 필요도 없고, 코드만 한 줄 추가하면 되므로 외부 CDN을 사용하는 방식을 사용하자.
(실제 서비스에서는 결국 외부 서비스에 의존하게 되는 것이므로 이 방법을 잘 사용하지 않는다.)
부트스트랩과 제이쿼리는 머스테치 화면 어디서나 필요하다. 그러므로 레이아웃 파일로 만들어두자
🥸 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가 실행된다.
js의 용량이 클수록 body의 실행이 늦어지기 때문에 js는 body 하단에 두어 화면이 다 그려진 뒤 호출하는 것이 좋다 !
라이브러리를 비롯해 기타 HTML 태그가 모두 레이아웃에 추가되니 이제 index.mustache에는 필요한 코드만 남게 된다.
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}}
이제 IndexController에 글 등록 페이지 URL 매핑을 하고, posts-save.mustache 파일을 만들자.
@GetMapping("/posts/save")
public String postsSave() {
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}}
이번에는 등록 버튼을 눌렀을 때 게시글 등록이 가능하도록 API를 호출해보자.
먼저 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();
브라우저의 스코프(scope)는 공용 공간으로 쓰이기 때문에 나중에 로딩된 js의 init, save가 먼저 로딩된 js의 function을 덮어쓰게 된다.
여러 사람이 참여하는 프로젝트에서는 중복된 함수 이름은 자주 발생할 수 있다.
그런 상황의 문제를 피하기 위해 index.js만의 유효 범위(scope)를 만들어 사용한다.
index라는 객체를 만들어 해당 객체에서 필요한 모든 function을 선언하면 해당 함수는 index 객체 안에서만 유효하기 때문에 다른 JS와 겹칠 위험이 사라진다.
요즘 ES6를 비롯한 최신 자바스크립트 버전이나 앵귤러, 리액트 뷰 등은 이런 기능을 프레임워크 레벨에서 지원한다.
이제 index.js를 머스테치 파일이 쓸 수 있도록 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>
호출 코드를 보면 절대 경로(/)로 바로 시작한다.
스프링 부트는 기본적으로 src/main/resources/static
에 위치한 정적 파일들은 URL에서 /로 설정된다.
브라우저에서 테스트 시 등록 기능이 잘 작동하는 것을 알 수 있다! 실제로 DB에 데이터가 등록되었는지도 h2-console을 통해 확인해보자.
굿 ~
전체 조회를 위해 index.mustache의 UI를 변경해보자.
{{>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">
<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}}
이제 Controller, Service, Repository 코드를 작성하자.
🤓 PostsRepository
package com.example.demo.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에서 제공되지 않는 메소드는 @Query를 사용하여 해결할 수 있다.
🐹 참고
규모가 있는 프로젝트에서의 데이터 조회는 FK의 조인, 복잡한 조건 등으로 Entity 클래스만으로 처리하기 어려워 조회용 프레임워크를 추가로 사용한다.
대표적인 예로 querydsl, jooq, MyBatis 등이 있다.
조회는 위 3가지 중 하나를 사용하고, 등록/수정/삭제 등은 SpringDataJpa를 통해 진행한다.
저자는 그 중 Querydsl을 추천한다.
- 타입 안정성이 보장됨
: 메소드를 기반으로 쿼리를 생성하므로 오타나 존재하지 않는 컬럼명을 명시할 경우 IDE에서 자동으로 검출된다.
- 국내 많은 회사에서 사용 중
- 많은 레퍼런스
<@Transactional(readOnly = true)
public List<PostsListResDto> findAllDesc() {
return postsRepository.findAllDesc().stream()
.map(PostsListResDto::new)
.collect(Collectors.toList());
}
}
(원본 코드에는 맨 앞에 '<'가 없는데 벨로그 오류인지 계속 앞에 '<'가 붙는다 ㅠ)
@Transactional에 (readOnly = true)
옵션을 추가하여 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도가 개선된다. → 💥 등록, 수정, 삭제 기능이 전혀 없는 서비스 메소드에서 사용하는 것을 추천! 💥
반환 값에 람다식이 들어가 생소하게 느껴질 수 있는데 프로세스는 아래와 같다
postsRepository 결과로 Postsdml Stream이 넘어옴 → map을 통해 PostsListResDto 변환 → List로 변환
아직 PostsListResDto 클래스가 없으니까 만들어야 한다 !
package com.example.demo.web.dto;
import com.example.demo.domain.posts.Posts;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Getter;
import java.time.LocalDateTime;
@Getter
public class PostsListResDto {
private Long id;
private String title;
private String author;
private LocalDateTime modifiedDate;
public PostsListResDto(Posts entity) {
this.id = entity.getId();
this.title = entity.getTitle();
this.author = entity.getAuthor();
this.modifiedDate = entity.getModifiedDate();
}
}
마지막으로 IndexController를 아래처럼 수정하자.
package com.example.demo.web;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import com.example.demo.service.posts.PostsService;
@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";
}
}
postsService.findAllDesc()
로 가져온 결과를 posts로 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="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}}
그 다음, btn-update 버튼을 클릭하면 수정 기능을 호출할 수 있도록 index.js 파일에 update function을 추가한다.
var main = {
init : function () {
var _this = this;
$('#btn-save').on('click', function () {
_this.save();
});
$('#btn-update').on('click', function () {
_this.update();
});
},
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));
});
}
};
main.init();
<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>
title에 a 태그를 추가하여 제목을 클릭하면 해당 게시글의 수정 화면으로 이동하도록 하였다.
화면 쪽 작업이 다 끝났으니 수정 화면을 연결할 Controller 코드를 만들어보자.
🤓 IndexController
@GetMapping("/posts/update/{id}")
public String postsUpdate(@PathVariable Long id, Model model) {
PostsResDto dto = postsService.findById(id);
model.addAttribute("post", dto);
return "posts-update";
}
이제 브라우저에서 확인해보자 !
수정되었다는 팝업창이 뜨고, 내용이 수정된 것을 확인할 수 있다.
수정 완료 기능을 처음 테스트할 때는 위와 같은 오류가 생겼었다 ㅠ.ㅠ
이는 내가 index.js에서 경로를 잘못 적어 발생한 문제이다.
하지만 index.js를 수정하고 계속 다시 빌드/다시 실행을 해보아도, 수정한 내용이 반영이 안되었다 ... 왜지 ?!
https://github.com/jojoldu/freelec-springboot2-webservice/issues/270
위 링크에서 원인을 찾을 수 있었다.
나는 코드를 수정하였지만, 브라우저에 남아있는 캐시 파일로 인해 갱신이 되지 않는 것이었다.
이럴 때는 캐시를 지우거나, 개발자 도구로 캐시 파일을 확인하여 footer.mustache의 script 태그를 수정해보자.
수정 기능 완성 !
이번에는 삭제 기능도 구현해보자.
삭제 버튼은 본문을 확인하고 진행해야 하기 때문에, 수정 화면에 추가하여야 한다.
post.update.mustache에서 수정 완료 버튼 태그 아래 삭제 버튼 태그를 추가해주자.
<button type="button" class="btn btn-danger" id="btn-delete">삭제</button>
이제 index.js에 삭제 이벤트를 진행할 delete 메서드를 만들자.
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 () {
alert(JSON.stringify(error));
});
}
};
main.init();
그 다음에 Service 메서드를 만들자.
@Transactional
public void delete(Long id) {
Posts post = postsRepository.findById(id)
.orElseThrow(()->new IllegalArgumentException("해당 게시글이 없습니다. id = " + id));
postsRepository.delete(post);
}
서비스에서 만든 delete 메서드를 컨트롤러가 사용할 수 있도록 PostsController에 코드를 추가하자.
@DeleteMapping("/api/v1/posts/{id}")
public Long delete(@PathVariable Long id) {
postsService.delete(id);
return id;
}
브라우저로 테스트했을 때, 게시글이 삭제가 잘 된 것을 확인할 수 있다 굿 !!
이렇게 CRUD API와 화면까지 모두 완성하였다 !!!