Goal
- 서버 템플릿 엔진 엔진과 클라이언트 템플릿 엔진의 차이
- 머스테치의 기본 사용방법
- 스프링 부트에서 화면 처리 방식
- js/css 선언 위치를 다르게 해서 웹 사이트의 로딩 속도 향상하는 방법
- js 객체를 이용하여 브라우저의 전역 변수 충돌 문제를 회피하는 방법
템플릿 엔진이란?
지정된 템플릿 양식과 데이터가 합쳐져 HTML문서를 출력하는 소프트웨어
구분 | 서버 템플릿 엔진 | 클라이 언트 템플릿 엔진 |
종류 | JSP, Freemarker, Velocity, Mustache, Thymeleaf | 리액트, 뷰 |
특징 | 서버에서 Java 코드로 문자열을 만든 뒤 이 문자열을 HTML로 변환하여 브라우저로 전달 -> 이때, 자바스크립트 코드는 단순한 문자열 |
브라우저에서 화면 생성 즉, 서버에서 이미 코드가 벗어난 경우 |
머스테치란?
수많은 언어를 지원하는 가장 심플한 템플릿 엔진
- 문법이 다른 템플릿 엔진보다 심플
- 로직 코드를 사용할 수 없어 View의 역할과 서버의 역할이 명확하게 분리
- Mustache.js와 Mustache.java 2가지가 다 있어, 하나의 문법으로 클라이언트/서버 템플릿을 모두 사용 가능
예제1. 기본 페이지 만들기
1.1 build.gradle에 dependency 추가하기
implementation('org.springframework.boot:spring-boot-starter-mustache')
1.2 src/main/resources 경로에 template 폴더 추가후 index.mustache 생성
<!DOCTYPE HTML>
<html>
<head>
<title>스프링 부트 웹 서비스 </title>
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8"/>
</head>
<body>
<h1>스프링부트로 시작하는 웹 서비스 Ver.2</h1>
</body>
</html>
1.3 index.mustache에 URL 매핑해줄 IndexController 생성
package com.jojoldu.book.springboot.web;
@RequiredArgsConstructor
@Controller
public class IndexController {
@GetMapping("/")
public String index(){
return "index";
}
}
1.4 테스트 코드 작성 (IndexControllerTest)
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class IndexTestController {
@Autowired
private TestRestTemplate restTemplate;
@Test
public void mainpage_loading(){
// when
String body = this.restTemplate.getForObject("/", String.class);
// then
assertThat(body).contains("스프링부트로 시작하는 웹 서비스 Ver.2");
}
}
예제2. 게시글 등록 화면 만들기
2.1 공통 영역을 별도 파일로 분리하고 외부 라이브러리(부트스트랩, 제이쿼리) 추가하기
2.1.1 src/main/resources/template 에 layout 디렉토리를 생성하고 header.mustache, footer.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>
<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와 js 위치가 서로다르다
페이징 로딩속도를 높이기 위해 css는 header에 js는 footer에 둔다. HTML은 위에서 부터 코드가 생성되기에 head가 다 실행되고서야 body가 실행된다.
2.2 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>
{{#userName}}
Logged in as: <span id="user">{{userName}}</span>
<a href="/logout" class="btn btn-info active" role="button">Logout</a>
{{/userName}}
{{^userName}}
<a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
<a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
{{/userName}}
</div>
</div>
<br>
</div>
{{>layout/footer}}
- 필요한 코드만 남게 된다.
- {{>layout/header}}
- {{>}}는 현재 머스테치 파일(index.mustache)을 기준으로 다른 파일을 가져온다.
- {{# /}}는 userName이 있을 경우 / 반복의 개념도 있음
- {{^ /}}는 userName이 없 경우
2.3 이동할 페이지 주소에 해당하는 컨트롤러인 IndexController에 등록페이지 메소드 생성
@RequiredArgsConstructor
@Controller
public class IndexController {
@GetMapping("/posts/save")
public String postsSave(){
return "posts-save";
}
}
- index.mustache와 마찬가지로 /posts/save를 호출하면 posts-save.mustache를 호출하는 메소드가 추가됨
2.3 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}}
- index.mustache와 같은 위치
2.4 localhost:8080/ 에서 등록 클릭시 다음과 같은 화면 확인
2.5 등록 API를 호출할 JS 생성
2.5.1 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 = '/'
- 글 등록 성공시 메인페이지 /로 이동
- var main = {}을 선언한 이유
- 나중에 로딩된 js의 init, save가 먼저 로딩된 js의 function을 덮어쓰게 되고, 중복된 함수 이름 발생의 가능성
- var index란 객체를 만들어 해당 객체에서 필요한 모든 function을 선언 -> index객체 안에서만 function 유효
2.6 index.js를 mustache 파일이 쓸 수 있도록 footer.mustache에 추가
<!--index.js 추가-->
<script src="/js/app/index.js"></script>
2.7 등록 기능 테스트
예제3. 전체 조회 화면 만들기
3.1 전체 조회를 위한 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>
{{#userName}}
Logged in as: <span id="user">{{userName}}</span>
<a href="/logout" class="btn btn-info active" role="button">Logout</a>
{{/userName}}
{{^userName}}
<a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
<a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver 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}}
<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}}
- {{#posts}}
- posts라는 List를 순회한다
- Java의 For문과 동일
- {{id}}등의 {{변수명}}
- List에서 뽑아낸 객체의 필드를 사용
3.2 PostsRepository 인터페이스에 쿼리 추가
public interface PostsRepository extends JpaRepository<Posts, Long> {
@Query(value = "select p.* from posts p order by p.id desc", nativeQuery = true)
List<Posts> findAllDesc();
}
- SpringDataJpa에서 제공하지 않는 메소드는 @Query를 사용하여 쿼리처럼 작성 가능
- 가독성이 좋은 장점
- nativeQuery = true 작성해줘야 쿼리 오류가 나지 않는다.
3.3 PostService에 findAllDesc 메소드 추가
@Transactional(readOnly = true)
public List<PostsListResponseDto> findAllDesc(){
return postsRepository.findAllDesc().stream()
.map(PostsListResponseDto::new)
.collect(Collectors.toList());
}
(readOnly = true)를 주면 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도가 개선
-> 실제로 이후 delete메소드 추가하고 삭제가 안되서 왜 안되지? 하고 있었는데 readOnly=true 자동생성되서 안됨.
3.4 PostsListResponseDto 클래스 생성
package com.jojoldu.book.springboot.web.dto;
@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();
}
}
3.4 IndexController 변경
@RequiredArgsConstructor
@Controller
public class IndexController {
private final PostsService postsService;
@GetMapping("/")
public String index(Model model){
model.addAttribute("posts", postsService.findAllDesc());
return "index";
}
}
- Model
- 서버 템플릿 엔진에서 사용할 수 있는 객체를 저장할 수 있다.
- 여기서는 postsService.findAllDesc()로 가져온 결과를 posts로 index.mustache에 전달
3.4 localhost:8080/ 에서 다음과 같은 화면 확인
예제4. 게시글 수정 화면 만들기
4.1 수정 API는 이전 글에서 생성
https://dev-rosiepoise.tistory.com/42 의 2.5 PostsApiController에 수정/조회 API추가 참고
4.2 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>
</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}}
- {{post.id}}
- 머스테치는 객체의 필드 접근 시 점(Dot)으로 구분
- 즉, Post 클래스의 id에 대한 접근은 post.id로 사용 가능
4.2 index.js에 upate function 추가
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));
});
},
4.3 index.mustache 수정
<td><a href="/posts/update/{{id}}">{{title}}</a></td>
- title에 a 태그 추가
4.3 indexController에 update페이지 이동 메소드 추가
@GetMapping("/posts/update/{id}")
public String postsUpdate(@PathVariable Long id, Model model) {
PostsResponseDto dto = postsService.findById(id);
model.addAttribute("post", dto);
return "posts-update";
}
4.4 localhost:8080/ 에서 수정 기능 확인
예제5. 게시글 삭제 화면 만들기
5.1 이미 4.1의 posts-update.mustache 삭제 버튼 생성됨
5.2 index.js에 delete function 추가
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));
});
}
5.3 PostsService클래스에서 삭제 API 생성
@Transactional
public void delete (Long id){
Posts posts = postsRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id="+id));
postsRepository.delete(posts);
}
(readOnly = true)가 자동 생성 되었는지 꼭 확인할 것
-> 조회 기능만 유지
- postsRespository.delete(posts)
- JpaRepository에서 이미 delete메소드를 지원
- 엔티티를 파라미터로 삭제 또는 deleteById 메소드를 이용하면 id로 삭제 가능
- 존재하는 Posts인지 확인 위해 엔티티 조회 후 그대로 삭제
5.3 PostsApiController에 삭제 API 추가
@DeleteMapping("/api/v1/posts/{id}")
public Long delete (@PathVariable Long id){
// 삭제
postsService.delete(id);
return id;
}
5.4 localhost:8080/ 에서 삭제 기능 확인
'책 > 스프링부트와 AWS로 혼자 구현하는 웹서비스' 카테고리의 다른 글
[스프링 부트와 AWS로 혼자 구현하는 웹 서비스 5장] - 스프링 시큐리티와 OAuth2.0으로 로그인 기능 구현하기 (0) | 2023.03.21 |
---|---|
[스프링 부트와 AWS로 혼자 구현하는 웹 서비스 3장] - 스프링 부트에서 JPA로 데이터베이스 다뤄보자 (0) | 2023.03.15 |
[스프링 부트와 AWS로 혼자 구현하는 웹 서비스 2장] - 스프링 부트에서 테스트 코드를 작성하자 (0) | 2023.03.13 |