4.1 서버 템플릿 엔진과 머스테치 소개
템플릿 엔진 : 지정된 템플릿 야익과 데이터가 합쳐져 HTML 문서를 출력하는 소프트웨어
서버 템플릿 엔진을 이용한 화면 생성 : 서버에서 java 코드로 문자열을 만든 뒤 이 문자열을 HTML로 변환하여 브라우저로 전달
자바스크립트 : 브라우저 위에서 작동
흔히 얘기하는 Vue.js, React.js는 브라우저에서 화면을 생성 -> 서버에서 이미 코드가 벗어난 경우
머스테치란
머스테치 : 수많은 언어를 지원하는 가장 심플한 템플릿 엔진
자바에서 사용된 때는 서버 템플릿 엔진으로, 자바스크립트에서 사용될 때는 클라이언트 템플릿 엔진으로 사용됨
자바 진영에서는 JSP, Velocity, Freemarker, Thymeleaf 등 다양한 서버 템플릿 엔진 존재
머스테치의 장점
- 문법이 다른 템플릿 엔진보다 심플
- 로직 코드를 사용할 수 없어 view의 역할과 서버의 역할이 명확하게 분리됨
- Mustache.js와 Mustache.java 2가지가 다 있어, 하나의 문법으로 클라이언트/서버 템플릿 모두 사용 가능
머스테치 플러그인 설치
인텔리제이 커뮤니티 버전을 사용해서 플러그인 사용 가능, Thymeleaf나 JSP 등은 유료 버전에서만 지원
플러그인 사용하면 머스테치의 문법 체크, html 문법 지원, 자동완성 등이 지원
4.2 기본 페이지 만들기
머스테치 스타터 의존성을 build.gradle에 등록
implementation('org.springframework.boot:spring-boot-starter-mustache')
첫 페이지를 담당할 index.mustache 작성
이 머스테치에 URL 매핑 -> Controller에서 진행
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
src/main/resources/templates/index.mustache로 전환
IndexControllerTest 클래스 생성
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;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
@ExtendWith(SpringExtension.class)
@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("스프링 부트로 시작하는 웹 서비스");
}
}
http://localhost:8080 접속해보면
확인 가능
4.3 게시글 등록 화면 만들기
부트스트랩, 제이쿼리 등 프론트엔드 라이브러리를 사용할 수 있는 방법
- 외부 CDN 사용 -> 이거 사용
- 직접 라이브러리를 받아서 사용하는 방법
레아이웃 방식으로 추가 : 공통 영역을 별도의 파일로 분리하여 필요한 곳에서 가져다 쓰는 방식
header.mustache, footer.mustache 추가
페이지의 로딩속도를 높이기 위해 css는 header에, js는 footer에 둠 HTML은 위에서부터 코드가 실행되기 때문에 head가 실행되고서야 body가 실행됨
{{>layout/header}} : {{> }} 현재 머스테치 파일을 기준으로 다른 파일을 가져옴
index.mustache
{{>layout/header}}
<h1>스프링 부트로 시작하는 웹 서비스</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}}
이동할 페이지 주소 : /posts/save
이 주소에 해당하는 컨트롤러 생성 - 페이지에 관련된 컨트롤러는 모두 IndexController
@GetMapping("posts/save")
public String postsSave() {
return "posts-save";
}
추가
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.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();
window.location.href = '/' : 글 등록이 성공하면 메인페이지(/)로 이동함
브라우저의 스코프는 공용 공간으로 쓰이기 때문에 나중에 로딩된 js의 init, save가 먼저 로딩된 js의 function을 덮어쓰게 됨 여러 사람이 참여하는 플젝에서는 중복된 함수 이름 자주 발생 가능 -> 이런 문제 피하려고 유효범위 만들어 사용
var index라는 객체를 만들어 해당 객체에서 필요한 모든 function을 선언하는 것 -> index 객체 안에서만 function이 유효하기 때문에 다른 js와 겹칠 위험이 사라짐
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>
4.4 전체 조회 화면 만들기
index.mustache UI 수정
{{>layout/header}}
<h1>스프링 부트로 시작하는 웹 서비스</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>
<!-- 목록 출력 영역 -->
<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}}</a></td>
<td>{{author}}</td>
<td>{{modifiedDate}}</td>
</tr>
{{/posts}}
</tbody>
</table>
</div>
{{>layout/footer}}
{{#posts}} : posts라는 List 순회
{{id}} 등의 {{변수명}} : List에서 뽑아낸 객체의 필드 사용
PostsRepository 인터페이스에 쿼리 추가
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에서 제공하기 않는 메소드는 위처럼 쿼리로 작성해도 됨
PostsService에 추가
import org.springframework.transaction.annotation.Transactional;
@Transactional
public List<PostsListResponseDto> findAllDesc() {
return postsRepository.findAllDesc.stream()
.map(PostsListResponseDto::new)
// posts -> new PostsListResponseDto(posts) 와 동일
.collect(Collectors.toList());
}
postsRepository 결과로 넘어온 Posts의 Stream을 map을 통해 PostsListResponseDto 변환 -> List 반환하는 메소드
PostsListResponseDto
import com.jojoldu.book.springboot.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();
}
}
Controller 변경
import com.jojoldu.book.springboot.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";
}
}
4.5 게시글 수정, 삭제 화면 만들기
게시글 수정
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>
</div>
</div>
{{>layout/footer}}
{{post.id}} : 머스테치는 객체의 필드 접근 시 점으로 구분
readonly : input 태그에 읽기 기능만 허용
index.js
var main = {
init : function () {
var _this = this;
//btn-save란 id를 가진 HTML 엘리먼트에 CLICK 이벤트가 발생할때
$('#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/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({
//여러 HTTP METHOD 중 PUT 메소드 선택
type: 'PUT',
//어느 게시글을 수정할지 구분하기 위해 id 추가
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 수정
<tbody id="tbody">
{{#posts}}
<tr>
<td>{{id}}</td>
<td><a href="/posts/update/{{id}}">{{title}}</a></td>
//타이틀에 a tag 추가, 타이틀 클릭하면 해당 게시글의 수정 화면으로 이동
<td>{{author}}</td>
<td>{{modifiedDate}}</td>
</tr>
{{/posts}}
</tbody>
IndexController 메소드 추가
@PutMapping("/posts/update/{id}")
public String postsUpdate(@PathVariable Long id, Model model) {
PostsResponseDto dto = postsService.findById(id);
model.addAttribute("post", dto);
return "posts-update";
}
게시글 삭제
PostsService 추가
@Transactional
public void delete(Long id) {
Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("헤당 게시글이 없습니다. id=" + id));
postsRepository.delete(posts);
}
PostsApiController 추가
@DeleteMapping("/api/v1/posts/{id}")
public Long delete (@PathVariable Long id) {
postsService.delete(id);
return id;
}
'GDSC > Spring 입문' 카테고리의 다른 글
[AWS] RDS 30만원 과금 폭탄맞고 환불받은 후기 .. (1) | 2024.01.08 |
---|---|
Chap 03 - 스프링 부트에서 JPA로 데이터베이스 다뤄보자 (0) | 2023.11.12 |
[Spring 입문] 1주차 레퍼런스 (0) | 2023.11.05 |