스프링부트 실습 Chapter04 머스테치로 화면 구성하기
이동욱 作 '스프링 부트와 AWS로 혼자 구현하는 웹 서비스'
🔎 서버 템플릿 엔진과 클라이언트 템플릿 엔진의 차이
템플릿 엔진
지정된 템플릿 양식과 데이터가 합쳐져 HTML문서를 출력하는 소프트웨어
서버 템플릿 엔진
- 서버에서 구동
서버 템플릿 엔진을 이용한 화면 생성
- 서버에서 Java 코드로 문자열을 만든 뒤 이 문자열을 HTML로 변환하여 브라우저로 전달
ex) JSP, Freemarker
클라이언트 템플릿 엔진
클라이언트 템플릿 엔진을 이용한 화면 생성
- 서버에서 Json 혹은 Xml 형식의 데이터만 전달하고 클라이언트에서 조립
ex) 리액트, 뷰
자바스크립트는 브라우저 위에서 작동한다.
만약 서버 템플릿 엔진을 사용하게 된다면?
✔ 자바스크립트가 브라우저에서 작동될 때 서버 템플릿 엔진의 손을 벗어나 제어할 수가 없다.
🔎 머스테치의 기본 사용 방법
머스테치
수많은 언어를 지원하는 가장 심플한 템플릿 엔진
대부분 언어를 지원하고 있기 때문에 자바에서 사용될 때는 서버 템플릿 엔진으로,
자바스크립트에서 사용될 때는 클라이언트 템플릿 엔진으로 모두 사용할 수 있다.
머스테치의 장점
문법이 다른 템플릿 엔진보다 심플하다
로직 코드를 사용할 수 없어 View의 역할과 서버의 역할을 명확하게 분리된다.
Mustache.js와 Mustache.java 2가지가 다 있어, 하나의 문법으로 클라이언트/서버 템플릿 모두 사용 가능
👉 머스테치 플러그인 설치
인텔리제이 Marketplace에서 머스테치 플러그인 설치 - 인텔리제이 재시작
👉 머스테치 스타터 의존성 등록
compile('org.springframework.boot:spring-boot-starter-mustache')
dependencies {
compile('org.springframework.boot:spring-boot-starter-web')
compile('org.projectlombok:lombok')
annotationProcessor('org.projectlombok:lombok')
compile('org.springframework.boot:spring-boot-starter-data-jpa')
compile('com.h2database:h2')
compile('org.springframework.boot:spring-boot-starter-mustache')
testCompile('org.springframework.boot:spring-boot-starter-test')
}
👉 머스테치 파일 위치
머스테치 파일 위치는 기본적으로 src/main/resources/templates
이 위치에 머스테치 파일을 두면 스프링 부트에서 자동으로 로딩한다.
New - File - '파일명.mustache' 생성하기
💡 기본적으로 인텔리제이에는 머스테치 파일 템플릿이 없기 때문에 새로 만들어두면 편하다.
File - New - Edit File Templates
🔎 스프링 부트에서의 화면 처리를 해보자
👉 머스테치 파일 생성
{{>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><a href="/posts/update/{{id}}">{{title}}</a></td>
<td>{{author}}</td>
<td>{{modifiedDate}}</td>
</tr>
{{/posts}}
</tbody>
</table>
</div>
{{>layout/footer}}
{{>layout/header}}
{{>}}는 현재 머스테치 파일을 기준으로 다른 파일을 가져온다.
{{#posts}}
posts라는 List를 순회한다.
java의 for문과 동일
{{id}} 등의 {{변수명}}
List에서 뽑아낸 객체의 필드를 사용한다.
👉 API를 호출하는 js 생성
js 디렉토리의 파일 경로는 src/main/resources/static/js/app
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();
🔎 js 객체를 이용하여 브라우저의 전역 변수 충돌 문제를 회피하는 방법
해당 js 파일의 첫 문장에 main 이라는 변수의 속성으로 funcion을 추가한 이유
다중 js파일을 사용할 시 이름이 중복되게 되는 경우가 생길 수 있기 때문이다.
예를 들어,
main.js 파일에도 init, save 이름의 function이
a.js 파일에도 init, save 이름의 function이 있게 되면
먼저 로딩된 js의 function을 덮어쓰게 된다.
✔ 이러한 문제를 피하기 위해 main.js만의 유효범위를 만들어 사용한다.
유효범위를 만드는 방법은,
위 코드처럼 var main 이라는 객체를 만들어 해당 객체에서 필요한 모든 function을 선언하는 것이다.
👉 Repository 코드 추가
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에서 제공하는 기본 메서드(save, update ... )외에 다른 메소드를 추가하고 싶을 경우
@Query를 사용하여 메서드를 추가해준다.
👉 Service 코드 추가
import com.jojoldu.book.springboot.domain.posts.Posts;
import com.jojoldu.book.springboot.domain.posts.PostsRepository;
import com.jojoldu.book.springboot.web.dto.PostsListResponseDto;
import com.jojoldu.book.springboot.web.dto.PostsResponseDto;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
import com.jojoldu.book.springboot.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
@Transactional
public Long save(PostsSaveRequestDto requestDto) {
return postsRepository.save(requestDto.toEntity()).getId();
}
@Transactional
public Long update(Long id, PostsUpdateRequestDto requestDto) {
Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 사용자가 없습니다. id="+id));
posts.update(requestDto.getTitle(), requestDto.getContent());
return id;
}
public PostsResponseDto findById (Long id) {
Posts entity = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 사용자가 없습니다. id="+id));
return new PostsResponseDto(entity);
}
@Transactional(readOnly = true)
public List<PostsListResponseDto> findAllDesc() {
return postsRepository.findAllDesc().stream()
.map(PostsListResponseDto::new)
.collect(Collectors.toList());
}
@Transactional
public void delete (Long id) {
Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id="+id));
postsRepository.delete(posts);
}
}
@Transactional(readOnly = true)
트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도가 개선된다.
등록, 수정, 삭제 기능이 전혀 없는 메소드에 사용하는 것을 추천
.map(PostsListResponseDto::new)
람다식 코드
.map(posts -> new PostsListResponseDto(posts))
위 코드와 같다
해당 메서드(findAllDesc())는
postsRepository 결과로 넘어온 Posts의 Stream을
map을 통해 PostsListResponseDto 변환 -> List로 반환하는 메소드이다.
👉 Service단에서 사용 될 Dto 클래스 추가
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();
}
}
👉 Controller에서 해당 머스테치 URL 매핑
import com.jojoldu.book.springboot.service.posts.PostsService;
import com.jojoldu.book.springboot.web.dto.PostsResponseDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@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";
}
@GetMapping("/posts/update/{id}")
public String postsUpdate(@PathVariable Long id, Model model) {
PostsResponseDto dto = postsService.findById(id);
model.addAttribute("post", dto);
return "posts-update";
}
}
머스테치 스타터로 인해서 컨트롤러에서 문자열을 반환할 때
앞의 경로와 뒤의 파일 확장자가 자동으로 지정된다!
앞의 경로
src/main/resources/templates
뒤의 파일 확장자
.mustache
index가 반환하는 문자열은 "index"
따라서 src/main/resources/templates/index.mustache
👉 테스트 코드로 검증
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = 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 호출 시 페이지의 내용이 제대로 호출되는지에 대한 테스트
assertThat(body).contains()로
해당 문자열이 포함되어 있는지만 비교해주면 된다.
정상적으로 코드가 수행된다면 테스트 성공!
🔎 js/css 선언 위치를 다르게 하여 웹사이트의 로딩 속도를 향상하는 방법
해당 프로젝트는 부트스트랩을 사용하기 때문에 브트스트랩과 제이쿼리를 각각의 머스테치 파일에 추가시켜야 한다. 하지만 매번 해당 라이브러리를 추가시키기에는 번거롭기 때문에 레이아웃 파일들을 만들어 추가해준다.
header.mustache
<!DOCTYPE HTML>
<html>
<head>
<title>스프링 부트 웹서비스</title>
<meta http-equiv="content-type" content="text/html; charset=UTF-8"/>
<link rel="stylesheet" href="http://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
</head>
<body>
footer.mustache
<script src="http://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="http://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
<!--index.js 추가-->
<script src="/js/app/index.js"></script>
</body>
</html>
코드를 보면 css와 js의 위치가 서로 다른 걸 알 수 있다. 이는 페이지 로딩속도를 높이기 위함이다.
HTML은 위에서부터 코드가 실행되기 때문에 head가 다 실행된 후 body가 실행된다.
head를 다 불러지지 않으면 사용자 쪽에는 백지 화면만 노출되게 되는 것!
js의 용량이 커질수록 body 부분의 실행이 늦어지기 때문에
js는 body 하단에 두어 화면이 다 그려진 후 호출하는 것이 좋다.
css는 화면을 그리는 역할이기 때문에 head에서 불러오는 것이 좋음.
bootstrap.js의 경우 제이쿼리에 의존하기 떄문에 부트스트랩보다 먼저 호출되도록 하였다.
✨정리
생성한 머스테치 파일들
메인 인덱스 페이지, 등록페이지, 수정페이지
페이지에서 사용될 기능을 담은 js 파일
Controller에서는 매핑 작업을 할 메서드를 추가하고
Service에는 모든 사용자 찾기와 삭제 메서드를 추가하였다.
Service 메서드에서는 각각의 기능을 수행할 때 유효한 지를 검증하는 IllegalArgumentException 을 사용한다.
postsRepository.findAllDesc().stream() .map(PostsListResponseDto::new) .collect(Collectors.toList());
람다식 중요 !
PostsRepository클래스에 @Query를 사용해서 메서드를 만들었다.
✨ 해당 실습 코드는 GitHub에 업로드 합니다.