개발자의 서재
Chapter.04 : 머스테치로 게시판 화면 구성하기 본문
Chapter.04 : 머스테치로 게시판 화면 구성하기
ironmask431 2022. 2. 11. 23:00
"스프링부트와 AWS로 혼자 구현하는 웹 서비스" 라는 책을 바탕으로 학습목적의 프로젝트를 진행하고 있습니다.
소스 : https://github.com/ironmask431/springboot_aws_01
Chapter 04. 머스테치(mustache)로 화면구성하기
4.1 서버템플릿 엔진과 머스테치 소개
템플릿엔진 : 지정된 템플릿 양식과 데이터가 합쳐서 html문서를 출력하는 소프트웨어
ex) jsp, Thymeleaf (서버템플릿엔진) 리액트, 뷰 (클라이언트 템플릿엔진)
서버템플릿엔진을 이용한 화면 생성은 서버에서 java코드로 문자열을 만든 뒤 html로 변환하여 브라우저로 전달함.
반면, 자바스크립트는 브라우저위에서 작동함. vue나 react를 이용한 SPA는 브라우저에서 화면을 생성함.
머스테치란?
jsp, Thymeleaf 와 같은 템플릿엔진.
머스테치의 장점
1. 문법이 다른 템플릿엔진보다 심플함.
2. 로직코드를 사용할수없어 view와 서버의 역할이 명확하게 분리됨
3. Mustache.js 와 Mustache.java 두가지가 다 있어 클라이언트/서버템플릿 모두 사용가능
머스테치 플러그인 설치 > 완료
4.2 기본페이지 만들기
build.gradle 에 머스테치 스타터 라이브러리 추가 (머스테치는 스프링부트 공식 지원 템플릿)
//mustache 추가
implementation('org.springframework.boot:spring-boot-starter-mustache')
머스테치의 파일 위치는 기본적으로 아래 와같다.
src/main/resources/templates
이 경로에 머스테치 파일을 두면 스프링부트에서 자동으로 로딩함.
첫페이지를 담당할 index.mustache 생성
<!DOCTYPE HTML>
<html>
<head>
<title>스프링부트 웹서비스</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>
<h4>스프링부트로 시작하는 웹서비스</h4>
</body>
</html>
IndexController.java 생성
@Controller
public class IndexController {
@GetMapping("/")
public String index(Model model){
return "index";
}
}
머스테치 스타터로 인해 url의 앞의 경로와 뒤의 파일 확장자는 자동으로 지정됨.
(앞의 경로 : src/main/resources/templates)
(뒤의 파일확장자 : .mustache)
index.mustache 로 전환되어 viewResolver가 처리하게됨.
IndexControllerTest.java 파일 생성
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class IndexControllerTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
public void 메인페이지_로딩(){
//when
String body = restTemplate.getForObject("/",String.class);
//then
//html 전체를 확인할 필요는 없으니 일부내용만 있는지 확인
assertThat(body).contains("스프링부트로 시작하는 웹서비스");
}
}
==> test passed 정상확인
Application.java에서 main 구동 후 localhost:8080 에서 확인
==> 정상 확인
4.3 게시글 등록화면 만들기
header.mustache 생성 (부트스트랩 stylesheet 참조)
<!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="https://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>
</body>
</html>
** css stylesheet는 header에, js는 footer에 넣은 이유는 페이지 로딩속도를 높이기 위해서임.
html은 위에서부터 코드가 실행되기 때문에 head가 다 실행되고 나서 body가 실행된다.
즉, head가 다 불러지지않으면 사용자 화면은 백지만 노출된다.
특히 head에서 조회하는 js 의 용량이 크면 클수록 body 실행이 늦어지므로, js는 하단에 두는 게 좋다.
반면 css는 head에서 먼저불러오지 않으면, 사용자가 css가 미적용된 깨진화면을 볼수있으므로 상단에 둔다.
index.mustache 변경 + 글 등록페이지 버튼 추가
{{>layout/header}}
<h4>스프링부트로 시작하는 웹서비스</h4>
<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 에 글 저장 매핑url 생성
@GetMapping("/posts/save")
public String postsSave(){
return "posts-save";
}
posts-save.mustache 생성
{{>layout/header}}
<h4>게시글 등록</h4>
<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>
<input type="text" class="form-control" id="content" placeholder="내용을 입력하세요.">
</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}}
/src/main/resources/static/js/app 디렉토리생성
index.js 파일 생성
var index = {
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));
})
}
} //index
main.init();
* 별도로 var index = { } 을 선언하고 내부에 init()와 save() function 을 속성으로 둔 이유
브라우저의 스코프(scope)는 공용공간으로 쓰이기 때문에 나중에 로딩된 다른 js에도 init(), save()가 있다면
먼저로딩된 js의 function들을 덮어쓰게됨. 여러사람이 참여하는 프로젝트는 중복된 함수명이 발생할 수 있음.
모든 함수명을 일일히 확인할 수 없으므로, 이렇게 index.js만의 유효범위 var index = { } 을 만들어 사용하는것.
footer.mustache 에 index.js 링크
<script src="/js/app/index.js"></script>
스프링부트는 기본적으로 /src/main/resources/static 에 위치한 자바스크립트, css, 이미지등 정적파일들
url 에서 "/"로 설정된다.
localhost:8080/ 에서 게시글등록화면, 게시글 등록 테스트
==> 정상 확인
localhost:8080/h2-console 에 접속해서 실제 DB에 데이터가 등록되는지 확인함.
4.3 전체 조회화면 만들기
index.mustache 에 목록 영역 추가
<!-- 글 목록 -->
<table class="table table-horizontal table-bordered">
<thead class="thead-strong">
<tr>
<th>게시글번호</th>
<th>제목</th>
<th>작성자</th>
<th>최종수정일1</th>
</tr>
</thead>
<tbody id="tbody">
{{#posts}}
<tr>
<td>{{id}}</td>
<td>{{title}}</td>
<td>{{content}}</td>
<td>{{modifiedDate}}</td>
</tr>
{{/posts}}
</tbody>
</table>
* {{posts}} ~ {{/posts}} : posts 라는 List를 for문 순회함
* {{id}} - List for문 순회중 내부 객체의 필드값 출력
PostsListResponseDto.java 생성
@Getter
public class PostsListResponseDto {
private Long id;
private String title;
private String content;
private LocalDateTime modifiedDate;
public PostsListResponseDto(Posts entity){
this.id = entity.getId();
this.title = entity.getTitle();
this.content = entity.getTitle();
this.modifiedDate = entity.getModifiedDate();
}
}
Repository.java에 findAllDesc() 코드 추가
//findAll()은 jpa기본제공이지만, SpringDataJpa 에서 제공하지 않는 메소드는
//아래처럼 @Query로 실제쿼리로 작성하는예시를 보여줌.
//구동시 Validation failed for query for method public abstract java.util.List .. 에러
//nativeQuery = true 를 추가해줘야함.
@Query(value = "SELECT * FROM Posts p ORDER BY p.ID DESC", nativeQuery = true)
List<Posts> findAllDesc();
* 규모가 큰 프로젝트의 데이터조회는 조인, 복잡한 조건등으로 entity클래스만으로 조회하기는 어려워서
조회용 프레임워크를 씀. 대표적인 예시로 querydsl , jooq, Mybatis 등이 있음. 조회는 프레임워크를 쓰고,
등록/수정/삭제는 SpringDataJpa를 통해 진행한다. (쿠팡,배민등 jpa를 적극적으로 쓰는 회사는querydsl 사용)
PostsService.java 에 findAllDesc() 코드추가
//@Transactional(readOnly = true) : readOnly = true를 추가하면 트랜잭션 범위는 추가하되,
//조회기능만 남겨두어 조회속도가 개선됨. 등록,수정,삭제가 없는 서비스메소드에 사용하는것을 추천
@Transactional(readOnly = true)
public List<PostsListResponseDto> findAllDesc(){
return postsRepository.findAllDesc().stream()
.map(posts -> {return new PostsListResponseDto(posts);}).collect(Collectors.toList());
//postsRepository의 결과 List<posts>의 stream 을 map을 통해
//PostsListResponseDto 변환 -> List로 변환
//.findAllDesc() 대신 .findAll() 를 사용해도 정상동작 (findAll()은 jpa기본 메소드)
}
IndexController.java 에 index() 수정
@GetMapping("/")
public String index(Model model){
model.addAttribute("posts", postsService.findAllDesc());
return "index";
}
브라우저에서 글 작성 후 글 목록 보이는 것 확인
4.4 게시글 수정/삭제 화면 만들기
posts-update.mustache 생성
{{>layout/header}}
<h4>게시글 수정</h4>
<div class="col-md-12">
<div class="col-md-4">
<form>
<div class="form-group">
<label for="id">번호</label>
<input type="text" class="form-control" id="id" value="{{posts.id}}" readonly>
</div>
<div class="form-group">
<label for="title">제목</label>
<input type="text" class="form-control" id="title" value="{{posts.title}}">
</div>
<div class="form-group">
<label for="author">작성자</label>
<input type="text" class="form-control" id="author" value="{{posts.author}}" readonly>
</div>
<div class="form-group">
<label for="content">내용</label>
<textarea class="form-control" id="content">{{posts.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, delete function 추가
init : function(){
var _this = this;
....
$('#btn-update').on('click',function(){
_this.update();
});
$('#btn-delete').on('click',function(){
_this.delete();
});
},
....
update : function(){
var id = $('#id').val();
var data = {
title:$('#title').val(),
content:$('#content').val()
};
$.ajax({
type:'PUT'
,url:'/api/v1/posts/'+id
,dataType:'json'
,contentType:'application/json; charset=utf-8'
,data:JSON.stringify(data)
})
.done(function(){
alert('수정되었습니다.');
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('삭제되었습니다.');
location.href='/';
})
.fail(function(error){
alert(JSON.stringify(error));
})
}
index.mustache 수정 (목록에서 제목 클릭 시 수정페이지 이동)
<td><a href="/posts/update/{{id}}">{{title}}</a></td>
IndexController.java 에 수정페이지 url mapping 생성
@GetMapping("/posts/update/{id}")
public String postsUpdate(@PathVariable Long id, Model model){
PostsResponseDto dto = postsService.findById(id);
model.addAttribute("posts",dto);
return "posts-update";
}
브라우저에서 수정 확인
PostsService.java 에 delete() 추가
@Transactional
public void delete(Long id){
Posts posts = postsRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id="+id));
postsRepository.delete(posts);
//.deleteById(id) 로 id로도 삭제할 수 있음.
//존재하는 Posts인지 먼저확인하기위해 findById()로 조회후 delete() 실행
}
PostsApiController.java 에 delete() 추가
//게시글 삭제
@DeleteMapping("/api/v1/posts/{id}")
public Long Delete(@PathVariable Long id){
postsService.delete(id);
return id;
}
브라우저 삭제 테스트 실행 정상.
'SpringBootProject > SpringBoot_ Oauth_AWS' 카테고리의 다른 글
Chapter.05-2 : 스프링 시큐리티와 OAuth 2.0 으로 로그인 구현하기(코드 개선 + 테스트) (0) | 2022.02.18 |
---|---|
Chapter.05-1 : 스프링 시큐리티와 OAuth 2.0 으로 로그인 구현하기 (0) | 2022.02.11 |
Chapter.03 : 스프링부트에서 JPA 로 데이터베이스를 다뤄보자 (0) | 2022.02.11 |
Chapter.02 : 스프링부트에서 테스트 코드를 작성하자. (0) | 2022.02.11 |
Chapter.01 : 인텔리제이로 스프링부트 시작하기 (0) | 2022.02.03 |