-
Spring Study 1주차 ~ 4장BackEnd/Spring 2022. 3. 31. 17:03
"머스테치로 화면 구성하기"
- 서버 템플릿 엔진과 클라이언트 템플릿 엔진의 차이는 무엇인가?
- 왜 JSP말고 머스테치?
- 머스테치를 이용한 CRUD화면 개발 방법
4.1 서버 템플릿 엔진과 머스테치
템플릿 엔진 : 지정된 템플릿 양식과 데이터가 합쳐져서 HTML 문서를 출력하는 소프트웨어
서버 템플릿 엔진 : JSP, Freemaker 등
클라이언트 템플릿 엔진 : React, Vue의 view파일 등
✅ 서버 템플릿 엔진을 이용한 화면생성은 서버에서 JAVA코드로 문자열을 만든뒤, 이 문자열을 HTML로 변환하여 브라우저로 전달. 클라이언트 템플릿 엔진은 브라우저 위에서 화면을 생성하기에 이미 서버에서 코드가 벗어난 경우임.
✅ 머스테치란?
다양한 언어를 지원하는 템플릿 엔진, 문법이 다른 엔진에 비해 심플하고 로직 코드를 사용할 수 없기에 서버와 View가 완전히 분리. (화면 역할에만 충실할 수 있음)
4.2 기본 페이지 만들기
우선 머스테치 플러그인을 설치하고, 의존성을 build.gradle에 등록한다.
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> <h1>스프링 부트로 시작하는 웹 서비스</h1> </body> </html>
- web/IndexController : mustache는 앞의 경로와 뒤의 파일 확장자를 자동으로 지정해준다.
@Controller public class IndexController { @GetMapping("/") public String index(Model model) { return "index"; // 문자열을 반환할 때 앞의 경로, 뒤 확장자는 자동지정됨. // 즉, src/main/resources/templates 까지는 자동, return 문의 index, 그 뒤 다시 자동으로 .mustache 확장자 } }
즉 위 코드는, src/main/resources/templates가 지정이 되어 있고, return되는 index가 경로에 붙은다음 확장자 .mustache는 다시 자동으로 붙는다. 이 경로를 View Resolver가 처리하게 된다.
- IndexControllerTest
package firstSpring.practice.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 메인페이지_로딩() { String body = this.restTemplate.getForObject("/", String.class); assertThat(body).contains("스프링 부트로 시작하는 웹 서비스"); } }
메인 메소드를 실행하고 localhost:8080 접속하면 확인해볼 수 있다.
4.3 게시글 등록 화면 만들기
부트스트랩을 레이아웃 방식으로 추가하여 사용.
*) 레이아웃 방식 : 공통 영역을 별도의 파일로 분리하여 필요한 곳에서 가져다 쓰는 방식
이런 디렉토리 구조 - 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> </body> </html>
✅ 페이지 로딩 속도를 높이기 위해 css는 header, js는 footer에 둔다. HTML은 head가 다 실행된 후에 body가 실행되기 때문이다.
이렇게 레이아웃 틀을 만들어두었기 때문에 index.mustache코드는 간결해진다.
<!DOCTYPE HTML> <html> {{>layout/header}} <h1> 스프링 부트로 시작하는 웹 서비스</h1> {{>layout/footer}}
그리고 글을 등록하기 위한 버튼도 추가해보자. <h1>태그와 footer사이에 추가.
<div class="row"> <div class="col-md-6"> <a href="/posts/save" role="button" class="btn btn-primary">글 등록</a> </div> </div>
<a> 태그를 이용해서 글 등록 버튼을 누르면 등록 페이지로 이동할 수 있다. 페이지에 관련된 작업들은 모두 IndexController를 사용한다.
... @GetMapping("/posts/save") public String postsSave() { return "posts-save"; // posts/save 호출하면 posts-save.mustache 호출하는 메소드 추가 } ...
✅ /posts/save호출하면 posts-save.mustache호출하는 메소드 추가. posts-save.mustache파일도 index.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 type="text" 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를 호출하는 코드를 작성한다.
- src/main/resources/static/js/app/index.js
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)); }); } }; main.init();
✅ window.location.href = '/'; : 성공시 메인 페이지(/)로 돌아간다
✅ 함수의 이름이 중복되는 경우가 자주 발생할 수 있으므로, index라는 변수의 속성으로 function을 추가함으로써 유효범위를 만들어 사용할 수 있다.
✅ 머스테치 파일이 index.js를 사용할 수 있게끔, footer.mustache에도 추가해준다.
<script src="/js/app/index.js"></script>
테스트를 해보면 위처럼 글이 등록되었다는 alert가 뜨고, database에도 저장이 되어 있다.
4.4 전체 조회 화면 만들기
- index.mustache를 다시 수정해보자
<!DOCTYPE HTML> <html> {{>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> <br> <!--목록 출력 영역 ---> <table class="table table-horizontal table-bordered"> <thread class="thread-strong"> <tr> <th>게시글 번호</th> <th>제목</th> <th>작성자</th> <th>최종수정일</th> </tr> </thread> <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}} </html>
✅ {{#posts}} : posts라는 List를 순회한다. for문의 기능을 한다고 생각하면 된다.
✅ {{id}} : List에서 뽑아낸 객체의 필드.
- PostsRepository
package firstSpring.practice.practice.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(); }
✅ SpringDataJpa에서 제공하지 않는 메소드는 쿼리로 작성가능. Entity만으로 해결하기 어려운 복잡한 쿼리의 경우 직접 쿼리문을 작성하는 방안이 있다.
- PostsService에 추가
@Transactional(readOnly = true) // 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 속도 향상. 등록, 수정, 삭제 전혀 없는 메소드에서 사용 추천. public List<PostsListResponseDto> findAllDesc() { return postsRepository.findAllDesc().stream() .map(PostsListResponseDto::new) // posts -> new PostsListResponseDto(posts))의 람다식. .collect(Collectors.toList()); }
- PostsResponseDto
package firstSpring.practice.practice.web.dto; import firstSpring.practice.practice.domain.posts.Posts; import lombok.Getter; @Getter public class PostsResponseDto { private Long id; private String title; private String content; private String author; public PostsResponseDto(Posts entity) { this.id = entity.getId(); this.title = entity.getTitle(); this.content = entity.getContent(); this.author = entity.getAuthor(); } }
- IndexController
@RequiredArgsConstructor @Controller public class IndexController { private final PostsService postsService; @GetMapping("/") public String index(Model model) { model.addAttribute("posts", postsService.findAllDesc()); return "index"; // 문자열을 반환할 때 앞의 경로, 뒤 확장자는 자동지정됨. // 즉, src/main/resources/templates 까지는 자동, return 문의 index, 그 뒤 다시 자동으로 .mustache 확장자 } }
여기까지 코드를 완성하면 글 작성, 글 목록 2가지 기능이 구현되어 있다.
4.5 게시글 수정, 삭제 화면 만들기
마지막으로, 만들어 두었던 게시글 수정 API를 위한 화면 개발, 삭제 화면 및 삭제 API를 작성하면 기본 기능 구현이 끝난다.
- 수정을 위한 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 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}}
✅ readonly : Input태그에서 값을 수정하지 못하도록 만들려면 readonly를 추가하면 된다. id와 author은 수정할 수 없도록 readonly 속성을 부여
- index.js에 update function 추가
$('#btn-update').on('click', function() { _this.update(); }); 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)); }); },
✅ $('#btn-update').on('click') : btn-update라는 id를 가진 HTML 요소에 click이벤트 발생하면 update함수 실행
✅ 수정을 위해 PUT 메소드 이용, url에는 게시물 구분을 위해 경로에 id가 붙는다.
- 수정 페이지로 이동하기 위한 index.mustache코드
{{#posts}} <tr> <td>{{id}}</td> <td><a href="/posts/update/{{id}}">{{title}}</a></td> <td>{{author}}</td> <td>{{modifiedDate}}</td> </tr> {{/posts}}
- 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 : 삭제 버튼 추가
<button type="button" class="btn btn-danger" id="btn-delete">삭제</button>
- index.js에 삭제 이벤트 추가
$('#btn-delete').on('click', function() { _this.delete(); }); 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)); }); }
- PostsService추가
@Transactional public void delete (Long id) { Posts posts = postsRepository.findById(id) // 존재하는지 먼저 확인 .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id)); postsRepository.delete(posts); // deleteById(id)로 바로 삭제도 가능. 엔티티를 파라미터로 주는것도 가능. }
- 마지막으로 PostsApiController에 삭제 API를 구현해주면 끝난다!
@DeleteMapping("/api/v1/posts/{id}") public Long delete(@PathVariable Long id) { postsService.delete(id); return id; }
- 삭제 버튼이 추가된 것을 확인할 수 있다.
참고 책 : http://www.yes24.com/Product/Goods/83849117
스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - YES24
가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링
www.yes24.com
'BackEnd > Spring' 카테고리의 다른 글
Spring 입문 강의 섹션 0 ~ 섹션 3 (0) 2022.07.29 Spring Study 3주차 8장 (0) 2022.05.01 Spring Study 2주차 ~ 6장, 7장 (0) 2022.04.07 Spring Study 2주차 ~ 5장 (0) 2022.04.06 Spring Study 1주차 ~ 2,3장 (0) 2022.03.30