-
4장 ) 머스테치로 화면 구성하기Java & Spring/스프링 부트와 AWS로 혼자 구현하는 웹 서비스 2020. 7. 1. 01:26반응형
서버 템플릿 엔진과 머스테치 소개
- 템플릿 엔진
- 지정된 템플릿 양식과 데이터가 합쳐서 HTML 문서를 출력하는 소프트웨어
- 서버 템플릿 엔진 : JSP, Freemarker -> 서버에서 구동 ~> 브라우저로 전달
- 클라이언트 템플릿 엔진 : React, Vue -> 브라우저에서 화면 생성
- JSP, Velocity : Spring-boot에서 권장 X
- Freemarker - 과한 기능, 자유도가 높아서 숙련도가 낮을 수록 사용하기 힘들다.
- Thymeleaf - Spring에서 강하게 밀고있지만, 문법이 어렵다.
- 머스테치
- 서버 템플릿엔진, 클라이언트 템플릿 엔진으로 모두 이용 가능 (다양한 언어 지원)
- 장점
- 문법이 간단하다.
- View의 역할과 서버의 역할이 명확하게 분리된다.
- Mustache.js와 Mustache.java 2가지가 다 있어서, 하나의 문법으로 클라이언트/서버 템플릿 모두 이용 가능
- IntelliJ 에서 설치하기
- [ Ctrl + Shift + A ] -> plugins -> Market Place -> Mustache 검색
- Handlebars ~ 설치
- [ Ctrl + Shift + A ] -> plugins -> Market Place -> Mustache 검색
기본 페이지 만들기
의존성 등록하기
compile('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> <h1>스프링 부트로 시작하는 웹 서비스</h1> </body> </html>
web 패키지 안에 IndexController 생성하기
package com.zin0.book.springboot.web; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @Controller public class IndexController { @GetMapping("/") public String index(Model model) { model.addAttribute("posts", postsService.findAllDesc()); return "index"; } }
- 머스테치 스타터 덕분에 문자열을 반환할 때, 앞의 경로와 뒤의 파일 확장자는 자동으로 지정된다.
- 위의 경우 index를 기준으로 앞에는 src/main/resources/templates/ 가 붙고, 뒤에는 .mustache가 붙게 된다.
- 변환된 주소는 View Resolver가 처리한다.
test 패키지에 IndexControllerTest 클래스 생성하기
package com.zin0.book.springboot.web; import org.junit.Test; import org.junit.runner.RunWith; 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.junit4.SpringRunner; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; @RunWith(SpringRunner.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("스프링 부트로 시작하는 웹 서비스"); } }
게시글 등록 화면 만들기
- 프론트엔드 라이브러리를 사용하는 방법
- 외부 CDN을 사용
- 직접 라이브러리를 받아서 사용 (npm,webpack 등도 여기에 해당)
- 실제 서비스에서는 외부 서비스에 의존성을 줄이기 위해, 2번째 방법을 활용하지만, 지금은 토이 프로젝트가 목적이니까 외부 CDN을 이용함.
- 부트스트랩과 제이쿼리를 레이아웃 방식으로 추가하기
- 레이아웃 방식 - 공통 영역을 별도의 파일로 분리하여, 필요한 곳에서 가져다 쓰는 방식
- src/main/resources/templates 디렉토리에 layout 디렉토리를 추가로 생성
- 위의 디렉토리에 footer.mustache와 header.mustache를 생성한다.
스프링 부트 웹 서비스_Zin0 .another_category { border: 1px solid #E5E5E5; padding: 10px 10px 5px; margin: 10px 0; clear: both; } .another_category h4 { font-size: 12px !important; margin: 0 !important; border-bottom: 1px solid #E5E5E5 !important; padding: 2px 0 6px !important; } .another_category h4 a { font-weight: bold !important; } .another_category table { table-layout: fixed; border-collapse: collapse; width: 100% !important; margin-top: 10px !important; } * html .another_category table { width: auto !important; } *:first-child + html .another_category table { width: auto !important; } .another_category th, .another_category td { padding: 0 0 4px !important; } .another_category th { text-align: left; font-size: 12px !important; font-weight: normal; word-break: break-all; overflow: hidden; line-height: 1.5; } .another_category td { text-align: right; width: 80px; font-size: 11px; } .another_category th a { font-weight: normal; text-decoration: none; border: none !important; } .another_category th a.current { font-weight: bold; text-decoration: none !important; border-bottom: 1px solid !important; } .another_category th span { font-weight: normal; text-decoration: none; font: 10px Tahoma, Sans-serif; border: none !important; } .another_category_color_gray, .another_category_color_gray h4 { border-color: #E5E5E5 !important; } .another_category_color_gray * { color: #909090 !important; } .another_category_color_gray th a.current { border-color: #909090 !important; } .another_category_color_gray h4, .another_category_color_gray h4 a { color: #737373 !important; } .another_category_color_red, .another_category_color_red h4 { border-color: #F6D4D3 !important; } .another_category_color_red * { color: #E86869 !important; } .another_category_color_red th a.current { border-color: #E86869 !important; } .another_category_color_red h4, .another_category_color_red h4 a { color: #ED0908 !important; } .another_category_color_green, .another_category_color_green h4 { border-color: #CCE7C8 !important; } .another_category_color_green * { color: #64C05B !important; } .another_category_color_green th a.current { border-color: #64C05B !important; } .another_category_color_green h4, .another_category_color_green h4 a { color: #3EA731 !important; } .another_category_color_blue, .another_category_color_blue h4 { border-color: #C8DAF2 !important; } .another_category_color_blue * { color: #477FD6 !important; } .another_category_color_blue th a.current { border-color: #477FD6 !important; } .another_category_color_blue h4, .another_category_color_blue h4 a { color: #1960CA !important; } .another_category_color_violet, .another_category_color_violet h4 { border-color: #E1CEEC !important; } .another_category_color_violet * { color: #9D64C5 !important; } .another_category_color_violet th a.current { border-color: #9D64C5 !important; } .another_category_color_violet h4, .another_category_color_violet h4 a { color: #7E2CB5 !important; } - css를 상단에, js를 하단에 위치한 이유
- 페이지 로딩 속도를 높이기 위해
- head에 무거운 js 파일이 들어가면, 흰색 빈 화면이 오랫동안 보일 것임
- 그렇다고 css도 하단에 같이 넣으면, 깨진 화면이 송출될 수 있음
- 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}}
- {{>layout/header}}
- {{> }} - 현재 머스테치 파일을 기준으로 다른 파일을 가져온다.
- {{>layout/header}}
IndexController 수정하기 (posts-save를 위한 수정)
// import ... @RequiredArgsConstructor @Controller public class IndexController { // ... @GetMapping("/posts/save") public String postsSave() { return "posts-save"; // 앞서 말한대로 경로와 mustache가 붙어서 감 } }
posts-save.mustache 생성
{{>layout/header}} <h1>게시글 등록</h1> <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 herf="/" role="button" class="btn btn-secondary">취소</a> <button type ="button" class="btn btn-primary" id ="btn-save">등록</button> </div> {{>layout/footer}}
resources 하위에 static/js/app 디렉토리 생성 ~> 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라는 변수 속성으로 function을 추가한 이유
- 브라우저의 스코프는 공용 공간이기 때문에, 나중에 로딩된 JS가 같은 함수명을 쓰고 있다면 덮어 쓰게 되기 때문(방지하기 위함)
- 코드는 아까 상단에서 미리 추가를 해뒀다..
- 스프링 부트는 기본적으로 src/main/resources/static에 위치한 JS, CSS, 이미지 등 정적 파일들은 URL에서 / (절대 경로)로 설정된다.
전체 조회 화면 만들기
- index.mustached 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> <!-- 목록 출력 Area--> <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를 순회한다 ~> for문이라고 생각하면 된다.
- {{id}}
- List에서 뽑아낸 객체의 필드를 사용 -> id, author, title 등
- {{#posts}}
PostsRepository 인터페이스에 쿼리 추가하기
package com.zin0.book.springboot.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> { // JpaRepository<Entity 클래스, PK 타입> 상속 ~> CRUD 자동 생성 @Query("SELECT p FROM Posts p ORDER BY p.id DESC") List<Posts> findAllDesc(); }
- SpringDataJpa 에서 제공하지 않는 메소드는 위처럼 쿼리로 작성해도 사용 가능하다.
- 대규모 프로젝트에서는 조회용 프레임워크를 추가로 사용한다. -> Querydsl 추천
PostsService에 코드 추가하기
// import ... @RequiredArgsConstructor @Service public class PostsService { private final PostsRepository postsRepository; //... @Transactional(readOnly = true) // readOnly에 error 뜨길래 뭐지하고 찾아보니까, import에 javax.Transactional 이 임포트 돼있었음.. springframework.transaction~ 이다.. public List<PostsListResponseDto> findAllDesc() { return postsRepository.findAllDesc().stream().map(PostsListResponseDto::new).collect(Collectors.toList()); } }
- @Transactional (readOnly = true)
- 트랜잭션 범위는 유지하되, 조회 기능만 남겨서 조회 속도가 개선되게 설정함
- .map(PostsListResponseDto::new)
- 람다식을 사용한 코드
- 풀면 .map(posts -> new PostsListResponseDto(posts)) 와 같다
- @Transactional (readOnly = true)
PostsListResponseDto 생성
package com.zin0.book.springboot.web.dto; import com.zin0.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(); } }
IndexController 변경
- 상기에 작성한 IndexController가 이미 변경되어 있는 상태다.
- 파라메터로 Model을 받아주었는데, Model은 서버 템플릿 엔진에서 사용할 수 있는 객체를 저장할 수 있다.
- 여기서는 postsService.findAllDesc()로 가져온 결과를 posts로 index.mustache에 전달
게시글 수정, 삭제 화면 만들기
게시글 수정을 위해 posts-update.mustache 만들기
{{>layout/header}} <h1>게시글 수정</h1> <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="{{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 herf="/" role="button" class="btn btn-secondary">취소</a> <button type ="button" class="btn btn-primary" id ="btn-update">수정 완료</button> </div> </div> {{>layout/footer}}
- {{post.id}}
- 머스테치는 객체의 필드 접근 시 점(Dot)으로 구분
- Post 클래스의 id에 대한 접근 ~> post.id로 사용
- readonly
- input 태그에 읽기 가능만 허용하는 속성
- {{post.id}}
Index.js에 update function 추가하기
var main = { init : function () { // ... $('#btn-update').on('click', function () { _this.update(); }); }, save : function () { //... }, update : function () { var data = { title: $('#title').val(), // title을 읽어올 때, #을 빼주는 실수를 했었다..ㅠ 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();
- $('#btn-update').on('click')
- btn-update란 id를 가진 HTML 엘리먼트에 click 이벤트가 발생할 때, update function 실행하도록 이벤트를 등록한다.
- update : function()
- 신규 추가 될 update function
- url:'/api/v1/posts/'+id
- 수정할 게시글을 URL PATH로 구분하기 위해 id를 추가
- $('#btn-update').on('click')
- index.mustache 코드 수정
- 위에서 이미 수정된 코드를 올림.
<a href ="/posts/update/{{id}}"></a>
- 타이틀(title)에 a 태그를 추가
- 타이틀을 클릭하면 해당 게시글의 수정 화면으로 이동
- 위에서 이미 수정된 코드를 올림.
IndexController에 update 추가하기
// import ... @RequiredArgsConstructor @Controller public class IndexController { //... @GetMapping("/posts/update/{id}") public String postsUpdate(@PathVariable Long id, Model model) { PostsResponseDto dto = postsService.findById(id); model.addAttribute("post", dto); return "posts-update"; } }
화면에 게시글 삭제 추가 (posts-update.mustache에 추가)
{{>layout/header}} <h1>게시글 수정</h1> <div class="col-md-12"> <div class="col-md-4"> <form> ... </form> <a herf="/" 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에 delete 추가하기
var main = { init : function () { var _this = this; // ... $('#btn-delete').on('click', function () { _this.delete(); }); }, save : function () { //... }, update : 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)); }) } }; main.init();
PostsService에 기능 추가하기
// import ... @RequiredArgsConstructor @Service public class PostsService { // ... @Transactional public void delete(Long id) { Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id =" + id)); postsRepository.delete(posts); } }
- postRepository.delete(posts)
- JpaRepositoy에서 지원하는 delete 메소드
- 엔터티를 파라미터로 삭제할 수도 있고, deleteById 메소드를 이용하면 id로 삭제할 수도 있다.
- 존재하는 Posts인지 확인을 위해 엔티티 조회 후 그대로 삭제
- postRepository.delete(posts)
ApiController에 delete 추가하기
// import ... @RequiredArgsConstructor @RestController public class PostsApiController { // ... @DeleteMapping("/api/v1/posts/{id}") public Long delete(@PathVariable Long id) { postsService.delete(id); return id; } }
중간에 오타로 인해서 오류를 맞이해서 그런지, 시간이 오래걸렸다.
대신 오래 걸린 만큼 조금 더 이해하기 수월해진 것 같다.
spring-boot에 대해서도 아주 조금은 더 가까워지고 있는 것 같지만, 여전히 왜 이렇게 되는지 모르는 경우도 존재한다.
결론 : 원초적인 실수는 하지말자.. ㅠㅠ
반응형'Java & Spring > 스프링 부트와 AWS로 혼자 구현하는 웹 서비스' 카테고리의 다른 글
6장) AWS 서버 환경을 만들어보자 - AWS EC2 (0) 2020.07.07 5장) 스프링 시큐리티와 OAuth2.0으로 로그인 기능 구현하기 (0) 2020.07.05 3장) 스프링 부트에서 JPA로 데이터베이스 다뤄보자 (0) 2020.06.28 2장) 스프링 부트에서 테스트 코드를 작성하자 (2) 2020.06.27 1장) IntelliJ로 스프링부트 시작하기 (3) 2020.06.26 - 템플릿 엔진