이 프로젝트의 구조는 서버에서는 API를 제공하고, 클라이언트 템플릿 엔진으로 HTML 문서를 생성하는 구조이다.
이번 포스팅에서는 클라이언트 템플릿 엔진으로 머스테치를 사용하여 게시글 등록, 조회 화면을 구현해보자.
1) 머스테치 플러그인 추가
mustache 플러그인 설치 후 인텔리제이를 재시작하여 플러그인이 작동하는 것을 확인한다.
2) build.gradle에 머스테치 플러그인 추가
//mustache
implementation('org.springframework.book:spring-boot-starter-mustache')
머스테치의 파일 기본 위치는 다음과 같다. 이 위치에 머스테치 파일을 두면 스프링 부트에서 자동으로 로딩한다.
A. 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>
B. IndexController
index.mustache에 URL을 매핑할 Controller를 생성한다.
package com.spring.book.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class IndexController {
@GetMapping("/")
public String index() {
return "index"; //(1)
}
}
ViewResolver는 URL 요청의 결과를 전달할 타입과 값을 지정하는 관리자 격으로 볼 수 있다.
실제로 URL 호출 시 페이지의 내용이 제대로 호출되는지에 대한 테스트한다. TestRestTemplate를 통해 “/”를 호출했을 때 index.mustache에 포함된 코드들이 있는지 확인할 수 있다.
package com.spring.book.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;
@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("스프링 부트로 시작하는 웹 서비스");
}
}
화면을 구현할 때 HTML만 사용하기에는 멋이 없기 때문에 오픈소스인 부트스트랩을 이용하겠다.
부트스트랩을 사용하기 위해서는 부트스트랩과 제이쿼리를 mustache 파일에 추가해야 한다.
프론트엔드 라이브러리(부트스트랩, 제이쿼리 등)를 사용할 수 있는 방법은 크게 두 가지가 있다.
이 프로젝트에서는 외부 CDN을 사용하여 라이브러리를 이용하겠다.
부트스트랩과 제이쿼리 라이브러리는 index.mustache에 추가해야 한다. 바로 추가할 수도 있지만 레이아웃 방식으로 추가할 수도 있다.
레이아웃 방식은 공통 영역을 별도의 파일로 분리하여 필요한 곳에 가져다 쓰는 방식을 이야기한다.
A. 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>
B. 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>
C. index.mustache
{{>layout/header}}
<h1>스프링부트로 시작하는 웹 서비스</h1>
{{>layout/footer}}
공통 HTML, 라이브러리 코드를 레이아웃으로 분리하였기 때문에 index.mustache는 위와 같이 간결해진다.
{{>layout/header}}: {{> }}는 현재 머스테치 파일(index.mustache)을 기준으로 다른 파일은 가져온다.
메인 화면을 구성하는 코드는 3개의 파일로 분리하였다. css와 js 코드를 따로 분리하였는데 css는 header에, js는 footer에 위치시켜 css → index.mustache → js순서로 실행된다.
이렇게 코드를 분리하면 다음과 같은 장점이 있다.
A. (화면-메인) index.mustache
/posts/save로 페이지 주소로 이동하도록 코드를 추가해주었다.
{{>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}}
B. (컨트롤러) IndexController.java
"/posts/save" 경로에 대한 요청을 받는 컨트롤러이다. 컨트롤러에 /posts/save를 호출하면 posts-save.mustache를 호출하는 메소드 추가하였다.
@RequiredArgsConstructor
@Controller
public class IndexController {
...
@GetMapping("/posts/save")
public String postsSave() {
return "posts-save";
}
}
C. (화면-게시글 등록) 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}}
프로젝트를 실행하고 브라우저에서 화면을 확인한다. 컨트롤러가 /posts/save 요청을 받아 posts-save.mustache 화면을 잘 출력하였다.
D. index.js
게시글 등록 화면에 등록 버튼 기능 추가하고 JS를 통해 API를 호출한다.
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();
여러 사람이 참여하는 프로젝트에서는 중복된 함수 이름이 자주 발생할 수 있다. 모든 function 이름을 확인하면서 만들 수 없기 때문에 index.js 만의 유효범위를 만들어 사용해야 한다.
방법은 var index이란 객체를 만들어 해당 객체에서 필요한 모든 function을 선언하면 index 객체 안에서만 function이 유효하기 때문에 다른 JS와 겹칠 위험이 사라진다.
E. footer.mustache에서 index.js를 사용할 수 있게 추가
<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>
index.js 호출 코드를 보면 절대 경로(/)로 바로 시작한다.
스프링부트는 기본적으로 src/main/resources/static에 위치한 자바스크립트, CSS, 이미지 등 정적 파일들은 URL에서 /로 설정된다.
A. index.mustache
메인 화면인 index.mustache에 조회 화면을 구현한다. 여기서 머스테치 문법이 처음으로 사용된다.
{{>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}}
이제 컨트롤러, 서비스, 레포지토리 코드를 추가해보자.
B. (레포지토리) PostsRepository
package com.spring.book.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’을 사용하여 쿼리로 작성해도 된다.
실제로 SpringDataJpa에서 제공하는 기본 메소드만으로 해결할 수 있다.
다만, ‘@Query’가 훨씬 가독성은 좋으니 선택해서 사용해도 된다.
C. (서비스) PostsService
package com.spring.book.service.posts;
...
import java.util.List;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
...
@Transactional(readOnly = true)
public List<PostsListResponseDto> findAllDesc() {
return postsRepository.findAllDesc().stream()
.map(PostsListResponseDto::new)
.collect(Collectors.toList());
}
}
.map(PostsListResponseDto::new)
= .map(posts -> new PostsListResponseDto)
D. (DTO) PostsListResponseDto
package com.spring.book.web.dto;
import com.spring.book.domain.posts.Posts;
import java.time.LocalDateTime;
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();
}
}
E. (컨트롤러) IndexController
package com.spring.book.web;
import com.spring.book.service.posts.PostsService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@RequiredArgsConstructor
@Controller
public class IndexController {
private final PostsService postsService; //@RequiredArgsConstructor 생성자로 초기화함.
@GetMapping("/")
public String index(Model model) {
model.addAttribute("posts", postsService.findAllDesc());
return "index";
}
...
}
http://localhost:8080/ 로 접속한 뒤 등록 화면을 이용해 데이터를 등록한 후, 조회하였다.