4.1 서버 템플릿 엔진과 머스테치 소개
템플릿 엔진이란, 지정된 템플릿 양식과 데이터가 합쳐져 HTML 문서를 출력하는 소프트웨어를 이야기한다.
결과적으로 지정된 템플릿과 데이터를 이용하여 HTML을 생성하는 템플릿 엔진이다.
머스테치란
수많은 언어를 지원하는 가장 심플한 템플릿 엔진이다.
머스테치의 장점
- 문법이 다른 템플릿 엔진보다 심플하다.
- 로직 코드를 사용할 수 없어 View의 역할과 서버의 역할이 명확하게 분리된다.
- Mustache.js와 Mustache.java 2가지가 다 있어, 하나의 문법으로 클라이언트/서버 템플릿을 모두 사용 가능하다.
템플릿 엔진은 화면 역할에만 충실해야 한다고 생각한다.
4.2 기본 페이지 만들기
build.gradle 추가하기
// view
implementation 'org.springframework.boot:spring-boot-starter-mustache'
머스테치에 URL 매핑하기
URL 매핑은 Controller에서 진행 indexController 생성
IndexController
package com.example.smileboard.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class IndexController {
@GetMapping("/")
public String index(){
return "index";
}
}
머스테치 스타터로 인해 컨트롤러에서 문자열을 반환할 때 앞의 경로와 뒤의 파일 확장자가 자동으로 지정된다.
앞의 경로 : src/main/resources/templates
뒤의 파일 확장자 : .mustache가 붙게 된다.
index를 반환하므로,
src/main/resources/templates/index.mustache로 전환되어 View Resolver가 처리하게 된다.
테스트 코드로 검증하기
IndexControllerTest
package com.example.smileboard.web;
import static org.assertj.core.api.Assertions.*;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.*;
import org.junit.jupiter.api.DisplayNameGeneration;
import org.junit.jupiter.api.DisplayNameGenerator;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
@SpringBootTest(webEnvironment = RANDOM_PORT)
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
@SuppressWarnings("NonAsciiCharacters")
public class IndexControllerTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void 메인페이지_로딩() throws Exception{
//when
String body = restTemplate.getForObject("/", String.class);
System.out.println(body);
//then
assertThat(body).contains("스프링 부트로 시작하는 웹 서비스");
}
}
테스트 결과
4.3 게시글 등록 화면 만들기
부트스트랩을 이용해서 화면 만들기
사용방법은 외부 CDN, 직접 라이브러리를 받아서 사용하는 두가지 방법이 존재하지만, 외부 CDN을 이용하는 방법을 선택
외부CDN은 직접 내려받아 사용할 필요도 없고, 사용 방법도 HTML/JSP/Mustache에 코드만 한 줄 추가하면 된다.
2개의 라이브러리 부트스트랩과 제이쿼리를 index.mustache에 추가해야 한다. 바로 추가하지는 않고 레이아웃 방식으로 추가해 보자!
레이아웃 방식이란 공통 영역을 별도의 파일로 분리하며 필요한 곳에서 가져다 쓰는 방식이다.
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>
<!--index.js 추가-->
<script src="/js/app/index.js"></script>
</body>
</html>
css와 js의 위치가 다르다.
페이지 로딩속도를 높이기 위해 css는 header에, js는 footer에 두었다. HTML은 위에서부터 코드가 실행되기 때문에 head가 다 실행되고나서야 body가 실행된다.
head가 다 불러지지 않으면 사용자 쪽에선 백지 화면만 노출된다.
js의 용량이 크면 클수록 body 부분의 실행이 늦어지기 때문에 js는 body 하단에 두어 화면이 다 그려진 뒤에 호출하는 것이 좋다.
css는 화면을 그리는 역할이므로 head에서 불러오는 것이 좋다. 그렇지 않으면 css가 적용되지 않은 깨진 화면을 사용자가 볼 수 있기 때문이다. bootstrap.js경우 제이쿼리가 꼭 있어야만 하기 때문에 부트스트랩보다 먼저 호출되게 작성
layout사용
<!DOCTYPE>
{{>layout/header}}
<h1>스프링 부트로 시작하는 웹 서비스</h1>
{{>layout/footer}}
글 등록 버튼 추가하기
<!DOCTYPE>
{{>layout/header}}
<h1>스프링 부트로 시작하는 웹 서비스</h1>
<div class="col-md-12">
<div class="row">
<div class="col-md-6">
<a href="/post/save" role="button" class="btn btn-primary">글 등록</a>
</div>
</div>
</div>
{{>layout/footer}}
<a> 태그를 이용해 글 등록 페이지로 이동하는 글 등록 버튼 생성
이동할 페이지의 주소 : /posts/save
글 등록 페이지
{{>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를 호출하는 JS가 전혀 없기 때문이다.
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/post',
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.js의 첫 문장에 var main={...}라는 코드를 선언했다.
굳이 index라는 변수의 속성으로 function을 추가한 이유는 뭘까?
ex) index.js가 다음과 같이 function을 작성한 상황이라고 정한다고 가정
index.mustache에 a.js가 추가되어도 a.js만의 init과 save function이 있다면 어떻게 될까?
브라우저의 스코프는 공용 공간으로 쓰이므로 나중에 로딩된 js의 init, save가 먼저 로딩된 js의 function을 덮어쓰게 된다.
여러 사람이 참여하는 프로젝트에서는 중복된 함수이름이 자주 발생할 수 있다. 모든 function 이름을 확인하면서 만들 수는 없다.
이러한 문제를 피하기 위해서 index.js만의 유효 범위를 만들어 사용한다.
var index이란 객체를 만들어서 해당 객체에서 필요한 모든 function을 선언하는 것이다.
이렇게 하면, index 객체 안에서만 function이 유효하기 때문에, 다른 JS와 겹칠 위험이 사라진다.
index.js를 머스테치가 사용할 수 있게 추가하기
<script src="/js/app/index.js"></script>
게시글 등록 테스트하기
서버 단에서도 Hibernate가 쿼리를 날린다.
DB에도 저장이 잘 되었다.
4.4 전체 조회 화면 만들기
<!DOCTYPE>
{{>layout/header}}
<h1>스프링 부트로 시작하는 웹 서비스</h1>
<div class="col-md-12">
<div class="row">
<div class="col-md-6">
<a href="/post/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">
{{#post}}
<tr>
<td>{{id}}</td>
<td><a href="/posts/update/{{id}}">{{title}}</a></td>
<td>{{author}}</td>
<td>{{modifiedDate}}</td>
</tr>
{{/post}}
</tbody>
</table>
</div>
{{>layout/footer}}
{{#post}}
- post라는 List를 순회한다.
- Java의 for문과 동일하게 생가하면 된다.
{{id}} 등의 {{변수명}}
- List에서 뽑아낸 객체의 필드를 사용한다.
2024/05/31
Controller, Service, Repository 코드 작성
PostRepository
package com.example.smileboard.domain.post;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("select p from Post p order by p.id desc ")
List<Post> findAllDesc();
}
SpringDataJpa에서 제공하지 않는 메소드는 쿼리로 직접 작성해도 된다.(@Query 사용)
@Query를 사용하면 훨씬 가독성이 좋으니 선택해서 사용하면 된다.
참고사항
규모가 있는 프로젝트에서의 데이터 조회는 FK의 조인, 복잡한 조건 등으로 인해 이런 Entity 클래스만으로 처리하기 어려워 조회용 프레임워크를 추가로 사용한다. 대표적으로 querydsl, jooq, MyBatis 등이 있다. 조회는 3가지 프레임워크 중 하나를 토해 조회하고, 등록/수정/삭제 등은 SpringDataJpa를 통해 진행한다. querydsl을 추천한다.
Querydsl을 추천하는 이유
1. 타입 안정성이 보장된다.
단순한 문자열로 쿼리를 생성하는 것이 아닌, 메소드를 기반으로 쿼리를 생성하므로 오타나 존재하지 않는 컬럼명을 명시할 경우 IDE에서 자동으로 검출된다.
2. 국내 많은 회사에서 사용 중이다.
쿠팡, 배민 등 JPA를 적극적으로 사용하는 회사에서는 Querydsl을 적극적으로 사용중이다.
3. 레퍼런스가 많다.
많은 회사와 개발자들이 사용하므로 국내 자료가 많다. 어떤 문제가 발생했을 때 여러 커뮤니티에 질문하고 그에 대한 답변을 들을 수 있다.
PostService - findAllDesc
@Transactional(readOnly = true)
public List<PostResponseDto> findAllDesc(){
return postRepository.findAllDesc().stream()
.map(PostResponseDto::new)
.collect(Collectors.toList());
}
findAllDesc 메소드의 트랜잭션 어노테이션(@Transactional)에 옵션이 한개 추가
(readOnly = true)를 주면 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도가 개선되기 때문에 등록,수정,삭제 기능이 전혀 없는 서비스 메소드에서 사용하는 것을 추천
readOnly = true를 하게 되면?
Dirty Checking을 생략함으로써 엔티티에 대한 Snapshot을 관리하는 메모리를 절감할 수 있고 DB에 쓰기 작업을 발생시키지 않게 된다.
람다식
.map(PostResponseDto::new) => .map(posts -> new PostResponseDto(post))
PostResponseDto
package com.example.smileboard.web.dto;
import com.example.smileboard.domain.post.Post;
import java.time.LocalDateTime;
import lombok.Getter;
@Getter
public class PostResponseDto {
private Long id;
private String title;
private String content;
private LocalDateTime modifiedDate;
public PostResponseDto(Post entity) {
this.id = entity.getId();
this.title = entity.getTitle();
this.content = entity.getContent();
this.modifiedDate = entity.getModifiedDate();
}
}
Controller
package com.example.smileboard.web;
import com.example.smileboard.service.post.PostService;
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 PostService postService;
@GetMapping("/")
public String index(Model model){
model.addAttribute("posts", postService.findAllDesc());
return "index";
}
@GetMapping("/post/save")
public String postSave(){
return "post-save";
}
}
Model
- 서버 템플릿 엔진에서 사용할 수 있는 객체를 저장한다.
- postsService.findAllDesc()로 가져온 결과를 posts로 index.mustache에 전달한다.
4.5 게시글 수정, 삭제 화면 만들기
게시글 수정
PostController
package com.example.smileboard.web;
import com.example.smileboard.service.post.PostService;
import com.example.smileboard.web.dto.PostSaveRequestDto;
import com.example.smileboard.web.dto.PostResponseDto;
import com.example.smileboard.web.dto.PostUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RequiredArgsConstructor
@RestController
public class PostController {
private final PostService postService;
@PostMapping("/api/v1/post")
public Long save(@RequestBody PostSaveRequestDto postSaveRequestDto) {
return postService.save(postSaveRequestDto);
}
@PutMapping("/api/v1/post/{id}")
public Long update(@PathVariable("id") Long id, @RequestBody PostUpdateRequestDto postUpdateRequestDto) {
log.info("update Controller 실행");
return postService.update(id, postUpdateRequestDto);
}
@GetMapping("/api/v1/post/{id}")
public PostResponseDto findById(@PathVariable("id") Long id) {
return postService.findById(id);
}
}
update함수 추가 (ajax)
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/post',
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/post/' + 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();
${'#btn-update').on('click')
- btn-update란 id를 가진 HTML 엘리먼트에 click 이벤트가 발생할 때 update function을 실행하도록 이벤트 등록
update: function()
- 신규로 추가될 update function
type: 'PUT'
- HTTP Method중 PUT 메소드를 선택한다.
- PostController 있는 API에서 이미 @PutMapping으로 선언했기 때문에 PUT을 사용해야 한다. REST 규약에 맞게 설정된 것이다.
- REST에서 CRUD는 다음과 같이 HTTP Method에 매핑된다.
- 생성(Create) - POST
- 읽기(Read) - GET
- 수정(Update) - PUT
- 삭제(Delete) - DELETE
url:'/api/v1/post/' + id
- 어느 게시글을 수정할지 URL Path로 구분하기 위해 Path에 id를 추가한다.
전체 목록에서 수정 페이지로 이동할 수 있게 페이지 이동 기능 추가
<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>
<a href="/post/update/{{id}}"></a>
- 타이틀(title)에 a tag 추가하기
- 타이틀을 클릭하면 해당 게시글의 수정 화면으로 이동한다.
Controller 연동하기
@GetMapping("/post/update/{id}")
public String postUpdate(@PathVariable Long id, Model model) {
PostResponseDto dto = postService.findById(id);
model.addAttribute("post", dto);
return "post-update";
}
게시글 삭제
<button type="button" class="btn btn-danger" id="btn-delete">삭제</button>
삭제 버튼을 추가해준다.
btn-delete
- 삭제 버튼을 수정 완료 버튼 옆에 추가한다.
- 해당 버튼 클릭시 JS에서 이벤트를 수신할 예정이다.