티스토리 뷰
이동욱 님의 '스프링부트와 AWS로 혼자 구현하는 웹 서비스' 책 내용을 정리한 것입니다.
4장 머스테치로 화면 구성하기
서버 템플릿 엔진: JSP, Freemarker
클라이언트 템플릿 엔진: React, Vue
머스테치
다양한 언어를 지원하는 템플릿 엔진
(장점)
- 다른 템플릿 엔진보다 문법이 심플
- View의 역할과 서버의 역할이 명확하게 분리
- 하나의 문법으로 클라이언트와 서버 모두 사용 가능

index.mustache - 파일 위치는 src/main/resources/templates
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>스프링부트 웹 서비스</title>
</head>
<body>
<h1>스프링부트로 시작하는 웹 서비스</h1>
</body>
</html>
➡️ 이 머스테치에 URL 매핑 (Controller에서 진행)
IndexController.java
package com.example.project.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class IndexController {
@GetMapping("/")
public String index() {
return "index";
}
}
문자열 반환 시 앞의 경로와 뒤의 파일 확장자는 자동으로 지정
IndexControllerTest.java
package com.example.project.web;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
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.junit.jupiter.SpringExtension;
@ExtendWith(SpringExtension.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
Assertions.assertThat(body).contains("스프링부트로 시작하는 웹 서비스");
}
}


❗️mustache 한글 깨짐 해결 방법 → application.properties에 코드 추가
server.servlet.encoding.force=true
프론트엔드 라이브러리 사용 방법
1. 외부 CDN을 사용 (✔️)
2. 직접 라이브러리를 받아서 사용
➡️ 2개의 라이브러리 부트스트랩과 제이쿼리 레이아웃 방식으로 추가
레이아웃 방식: 공통 영역을 별도의 파일로 분리하여 필요한 곳에서 가져다 쓰는 방식
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>
▶ 페이지 로딩속도를 높이기 위해 CSS는 Header에, JS는 Footer에 위치
▶ 부트스트랩의 경우 제이쿼리가 필요하기 때문에 제이쿼리 먼저 호출
index.mustache
{{>layout/header}} // 현재 머스테치 파일 기준으로 다른 파일 가져옴
<h1>스프링부트로 시작하는 웹 서비스</h1>
{{>layout/footer}}
index.mustache - a 태그 이용하여 글 등록 버튼 추가
{{>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}}

IndexController.java
package com.example.project.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class IndexController {
@GetMapping("/")
public String index() {
return "index";
}
// 게시글 등록 화면 연결
@GetMapping("/posts/save")
public String postsSave() {
return "posts-save";
}
}
posts-save.mustache - 게시글 등록 화면 UI
{{>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.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();
index.js만의 유효범위를 만들어서 사용 (해당 객체를 만들어서 객체에 필요한 모든 function을 선언)
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>


index.mustache - 게시글 전체 조회를 위한 화면 UI
{{>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}} <!-- posts라는 List를 순회 -->
<tr>
<td>{{id}}</td> <!-- List에서 뽑아낸 객체 필드 사용 -->
<td>{{title}}</td>
<td>{{author}}</td>
<td>{{modifiedDate}}</td>
</tr>
{{/posts}}
</tbody>
</table>
</div>
{{>layout/footer}}
PostsRepository.java
package com.example.project.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();
}
PostsService.java
package com.example.project.service.posts;
import com.example.project.domain.posts.Posts;
import com.example.project.domain.posts.PostsRepository;
import com.example.project.web.dto.PostsResponseDto;
import com.example.project.web.dto.PostsSaveRequestDto;
import com.example.project.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
// ~~ 게시글 등록, 수정, 조회 메서드 ~~
// 게시글 전체 목록 조회
@Transactional(readOnly = true) // 트랜잭션 범위는 유지 but 조회 기능만 남겨둠
public List<PostsListResponseDto> findAllDesc() {
return postsRepository.findAllDesc().stream()
.map(PostsListResponseDto::new)
.collect(Collectors.toList());
}
}
.map(PostsListResponseDto::new)
⬇️ 똑같은 의미
.map(posts -> new PostsListResponseDto(posts))
PostsListResponseDto.java
package com.example.project.web.dto;
import com.example.project.domain.posts.Posts;
import lombok.Getter;
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();
}
}
IndexController.java
package com.example.project.web;
import com.example.project.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;
@GetMapping("/")
public String index(Model model) {
model.addAttribute("posts", postsService.findAllDesc()); // 게시글 전체 조회 목록
return "index";
}
// 게시글 등록 화면 연결
@GetMapping("/posts/save")
public String postsSave() {
return "posts-save";
}
}

posts-update.mustache - 게시글 수정, 삭제 화면 UI
{{>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> <!-- Post 클래스의 id 접근 -->
</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}}
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();
});
$('#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));
});
}
};
main.init();
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}} <!-- posts라는 List를 순회 -->
<tr>
<td>{{id}}</td> <!-- List에서 뽑아낸 객체 필드 사용 -->
<td><a href="/posts/update/{{id}}">{{title}}</a></td> <!-- 타이틀 클릭 시 게시글 수정 화면으로 이동 -->
<td>{{author}}</td>
<td>{{modifiedDate}}</td>
</tr>
{{/posts}}
</tbody>
</table>
</div>
{{>layout/footer}}
IndexController.java
package com.example.project.web;
import com.example.project.service.posts.PostsService;
import com.example.project.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";
}
}




index.js - delete function 추가
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();
PostsService.java
package com.example.project.service.posts;
import com.example.project.domain.posts.Posts;
import com.example.project.domain.posts.PostsRepository;
import com.example.project.web.dto.PostsListResponseDto;
import com.example.project.web.dto.PostsResponseDto;
import com.example.project.web.dto.PostsSaveRequestDto;
import com.example.project.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) // 트랜잭션 범위는 유지 but 조회 기능만 남겨둠
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);
}
}
PostsApiController.java
package com.example.project.web;
import com.example.project.service.posts.PostsService;
import com.example.project.web.dto.PostsResponseDto;
import com.example.project.web.dto.PostsSaveRequestDto;
import com.example.project.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
@RestController
public class PostsApiController {
private final PostsService postsService;
// 게시글 등록
@PostMapping("/api/v1/posts")
public Long save(@RequestBody PostsSaveRequestDto requestDto) {
return postsService.save(requestDto);
}
// 게시글 수정
@PutMapping("/api/v1/posts/{id}")
public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) {
return postsService.update(id, requestDto);
}
// 게시글 조회
@GetMapping("/api/v1/posts/{id}")
public PostsResponseDto findById(@PathVariable Long id) {
return postsService.findById(id);
}
// 게시글 삭제
@DeleteMapping("/api/v1/posts/{id}")
public Long delete(@PathVariable Long id) {
postsService.delete(id);
return id;
}
}

'Spring' 카테고리의 다른 글
Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) #5 (0) | 2023.07.28 |
---|---|
Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) #4 (0) | 2023.07.28 |
스프링부트와 AWS로 혼자 구현하는 웹 서비스 3장 (1) | 2023.07.15 |
스프링부트와 AWS로 혼자 구현하는 웹 서비스 1,2장 (0) | 2023.05.25 |
스프링 입문 강의 정리 #6 (0) | 2023.05.22 |