Discover us

About us

Projects

Blog

Events

Members

Development Blog

GDGoC CAU 개발자와 디자이너의 작업 과정과
결과물을 공유하는 공간입니다.

어떻게 프로젝트를 시작하게 되었고,
진행하면서 느낀 개발자와 디자이너의
생생한 스토리를 직접 확인해보세요!

Development

머스테치로 화면 구성해보기

  • #Back-End
  • IlGoo Yeo
  • 2023. 9. 18.

머스테치로 화면 구성해보기

스프링 스터디 2주차(챕터4)

서버 템플릿 엔진과 머스테치

템플릿 엔진이란

  • 지정된 템플릿 양식과 데이터가 합쳐져 HTML 문서를 출력하는 소프트웨어
  • e.g. JSP, Freemaker, React, Vue
  • JSP나 Freemaker는 서버 템플릿 엔진, React와 Vue는 클라이언트 템플릿 엔진이라고 불림
  • 자바스크립트에서 JSP나 Freemaker처럼 자바 코드를 사용할 수 있을까?
    • <script type = "text/javascript">$(document).ready(function(){ if(a=="1") { <% System.out.println("test"); // if문 상관없이 항상 test가 출력 %> }});
  • 서버 템플릿 엔진은 서버에서 구동
    • 서버에서 Java코드로 문자열을 만듦
    • 문자열을 HTML로 변환하여 브라우저로 전달
    • HTML을 만드는 과정에서 System.out.println("test");을 실행할 뿐이며, 이때 js는 단순 문자열
  • 그러나 js는 브라우저 위에서 작동
    • 위의 js코드가 실행되는 장소는 서버가 아닌 브라우저
    • 즉, 브라우저에서 작동될 때는 서버 템플릿 엔진의 영향력을 벗어나 제어가 불가능
  • Vue.js나 React.js를 이용한 SPA(Single Page Application)는 브라우저에서 화면 생성
    • 즉, 서버에서 이미 코드가 벗어난 경우
    • 서버에서는 json 혹은 Xml 형식의 데이터만 전달하고 클라이언트에서 조립
  • 서버 사이드 렌더링
    • 자바스크립트 프레임워크의 화면 생성 방식을 서버에서 실행하는 것
    • V8 엔진 라이브러리들이 지원

머스테치

  • 다양한 언어를 지원하는 템플릿 엔진
  • JSP와 같이 HTML을 만들어 줌
  • 기존 템플릿 엔진들의 단점
    • JSP, Velocity
      • 스프링 부트에서 권장하지 않음
    • Freemaker
      • 자유도가 높고 과하게 많은 기능
      • 숙련도가 낮을 경우 Freemaker 안에 비즈니스 로직이 추가될 확률 있음
    • Thymeleaf
      • 스프링 진영에서 밀고 있는데 문법이 어렵다
      • 자바스크립트 프레임워크 배우는 기분
      • Vue.js 사용 경험이 있어 애크 속성 방식이 익숙하면 나쁘지 않음
  • 머스테치의 장점
    • 문법이 간단하다
    • 로직 코드를 사용할 수 없다
      • View와 서버의 역할 구분이 명확
    • Mustache.js와 Mustache.java 2가지가 다 존재
      • 하나의 문법으로 클라이언트/서버템플릿 모두 사용 가능

기본 페이지 만들기

  • build.gradle
    • implementation('org.springframework.boot:spring-boot-starter-mustache')
  • 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>
  • IndexController.java
    • package com.jojoldu.book.springboot.web.dto;import org.springframework.stereotype.Controller;import org.springframework.web.bind.annotation.GetMapping;@Controllerpublic class IndexController{ @GetMapping("/") public String index() { return "index"; }}
    • 머스테치 스타더 덕분에 컨트롤러에서 문자열을 반환할 때, 앞의 경로와 뒤의 파일 확장자는 자동으로 지정됨
      • 앞의 경로는 src/main/resources/templates
      • 뒤의 파일 확장자는 .mustache
      • 여기선 “index”가 리턴
    • 따라서, src/main/resources/templates/index.mustache로 전환되어 View Resolver가 처리
      • View Resolver는 URL 요청의 결과를 전달할 타입과 값을 지정해줌
  • IndexControllerTest.java
    • package com.jojoldu.book.springboot.web;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 loadMainPage() { // when String body = this.restTemplate.getForObject("/", String.class); // then assertThat(body).contains("스프링 부트로 시작하는 웹 서비스"); }}

게시글 등록 화면 만들기

  • PostsApiController로 API를 구현했으니 이제 화면을 개발
  • 부트스트랩을 사용
    • 외부 CDN을 사용하거나 직접 라이브러리를 받아서 사용 가능
  • 예제에서는 CDN을 채택
    • 프로젝트에 직접 내려받을 필요 없고 사용 방법도 간단
    • 단, 실서비스에서는 잘 사용하지 않음
      • 서비스가 외부 서비스에 의존하게 돼버려서, CDN에 문제가 생기면 서비스가 안 되기 때문
  • 부트스트랩과 제이쿼리를 index.mustache에 레이아웃 방식으로 추가
    • 레이아웃 방식이란 공통 영역을 별도의 파일로 분리하여 필요한 곳에서 가져다 쓰는 방식
  • 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와 js의 위치가 다름
  • 페이지 로딩속도를 높이기 위해 css는 header에, js는 footer에 넣었음
  • HTML은 위에서부터 코드가 실행
    • head가 다 실행돼야 body가 실행
    • head가 다 불러지지 않으면 사용자 쪽에선 백지 화면만 보임
  • js의 용량이 클수록 body 부분의 실행이 늦어짐
    • js는 body의 하단에 두어 화면이 다 그려진 뒤에 호출
  • css는 화면을 그리는 역할
    • head에서 부르는 것이 좋음
    • 그러지 않을 경우 css가 적용되지 않아 깨진 화면을 사용자가 볼 수 있음
  • bootstrap.js는 제이쿼리가 꼭 있어야 함(bootstrap.js가 제이쿼리에 의존)
    • 부트스트랩보다 먼저 호출되도록 작성
  • 라이브러리를 비롯한 기타 HTML 태그들이 모두 레이아웃에 추가됨
    • index.mustache에 필요한 코드만 남김
  • index.mustache
    • {{>layout/header}}<h1>스프링 부트로 시작하는 웹 서비스</h1>{{>layout/footer}}
    • {{>}}는 현재 머스테치 파일(index.mustache)를 기준으로 다른 파일을 가져옴
  • 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}}
    • <a> 태그를 사용하여 글 등록 페이지로 이동하는 버튼 생성
  • IndexController.java
    • public class IndexController{ // 기존 코드 @GetMapping("/posts/save") public String postSave() { 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(); }); $('#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();
  • index.js에서 var main = {}을 선언하여 index라는 변수의 속성으로 function을 추가한 이유
    • e.g.
      • var init = fuction (){ //...};var save = function (){ //...};init();
    • 만약 index.mustache에 a.js가 추가돼 init과 save function을 가진다면?
      • 브라우저의 스코프는 공용 공간이므로 나중에 로딩된 js의 init, save가 먼저 로딩된 js의 function을 덮어씀
      • 중복된 함수 명으로 발생할 수 있는 문제를 피하고자 index.js만의 유효범위(scope)를 만들어서 사용
      • 위 예시에서는 var index란 객체를 만들어 해당 객체에서 필요한 모든 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><script src="/js/app/index.js"></script></body></html>
  • 이후 브라우저에서 테스트할 때, 다음과 같은 에러 메시지가 나올 수 있다
    • not-null property references a null or transient value
  • 어떤 에러인지는 스택오버플로우를 참고하자. Posts.java에서 nullable을 전부 true로 바꿔주니 해결하긴 했다.

전체 조회 화면 만들기

  • 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> <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>{{title}}</td> <td>{{author}}</td> <td>{{modifiedDate}}</td> </tr> {{/posts}} </tbody> </table></div>{{>layout/footer}}
    • {{#post}}: posts라는 List를 순회하는 일종의 for문
    • {{id}}: List에서 뽑아낸 객체의 필드를 사용
  • PostsRepository.java
    • package com.jojoldu.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>{ @Query("SELECT p FROM Posts p ORDER BY p.id DESC") List<Posts> findAllDesc();}
    • SpringDataJpa에서 제공하는 메소드로도 위의 동작을 할 수 있으나, 쿼리로 작성하는 것이 더 가독성이 좋아 @Query 사용
  • PostsService.java
    • // 기존 코드import com.jojoldu.book.springboot.web.dto.PostsListResponseDto;import java.util.List;import java.util.stream.Collectors;@RequiredArgsConstructor@Servicepublic class PostsService{ // 기존 코드 @Transactional(readOnly = true) // 트랜잭션 범위는 유지하되 조회 기능만 남겨두어 조회 속도 개선 public List<PostsListResponseDto> findAllDesc() { return postsRepository.findAllDesc().stream() .map(PostsListResponseDto::new) .collect(Collectors.toList()); }}
    • .map(PostsListResponseDto::new)는 람다식
      • .map(posts -> new PostsListResponseDto(posts))와 같음
  • PostListResponseDto
    • package com.jojoldu.book.springboot.web.dto;import com.jojoldu.book.springboot.domain.posts.Posts;import lombok.Getter;import java.time.LocalDateTime;@Getterpublic 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
    • // 기존 코드import org.springframework.ui.Model;import lombok.RequiredArgsConstructor;@RequiredArgsConstructor@Controllerpublic class IndexController{ private final PostsService postsService; @GetMapping("/") public String index(Model model) // 기존 index 수정 { model.addAttribute("posts", postsService.findAllDesc()); return "index"; } // 기존 코드}

게시글 수정, 삭제 화면 만들기

게시글 수정

  • PostsApiController.java
    • public class PostsApiController{ // 아래 내용 추가 @PutMapping("/api/v1/posts/{id}") public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) { return postsService.update(id, requestDto); }}
  • 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> </div></div>{{>layout/footer}}
    • 머스테치는 객체의 필드 접근 시 점(Dot)으로 구분(e.g. {{post.id}} )
      • post.id를 통해 Post 클래스의 id에 대한 접근 가능
    • readonly는 Input 태그에 읽기 가능만 허용하는 속성
  • index.js
    • var main = { init : function () { // ... // btn-update란 id를 가진 HTML 엘리먼트에 click 이벤트가 발생할 때 update function을 실행하는 이벤트 등록 $('#btn-update').on('click', function (){ _this.update(); }); }, save : function () { //... }, update : function (){ var data = { title: $('#title').val(), content: $('#content').val() }; var id = $('#id').val(); $.ajax({ type: 'PUT', url: '/api/v1/posts/'+id, // 어느 게시글을 수정할지 URL PATH로 구분하기 위해 id 추가 dataType: 'json', contentType: 'application/json; charset=urf-8', data: JSON.stringify(data) }).done(function (){ alert('글이 수정되었습니다.'); window.location.href = '/'; }).fail(function (error) { alert(JSON.stringify(error)); }); }};
    • type: 'PUT
      • HTTP 메소드 중 PUT을 선택
      • PostsApiController에 있는 API에 @PutMapping으로 선언했기 때문에 PUT을 사용(REST 규약에 따른 설정)
      • REST에서 CRUD는 다음 HTTP 메소드에 맵핑
        • Create(생성)-POST
        • Read(읽기)-GET
        • Update(수정)-PUT
        • Delete(삭제)-DELETE
  • index.mustache
    • //... <tbody id="tbody"> {{#posts}} <tr> <td>{{id}}</td> <td><a href="/posts/update/{{id}}">{{title}}</a></td> <td>{{author}}</td> <td>{{modifiedDate}}</td> </tr>
    • 타이틀에 a tag를 추가해 타이틀을 클릭하면 해당 게시글 수정 화면으로 이동
  • IndexController.java
    • @RequiredArgsConstructor@Controllerpublic 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
    • <div class = "col-md-12"> <div class = "col-md-4"> // ... <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>
  • index.js
    • var main = { init : function () { //... $('#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.java
    • @RequiredArgsConstructor@Servicepublic class PostsService{ // ... @Transactional public void delete (Long id) { Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id)); // JpaRepository에서 지원하는 메소드 활용 // deleteById(id)도 사용 가능 postsRepository.delete(posts); }}
  • PostsApiController.java
    • @RequiredArgsConstructor@RestControllerpublic class PostsApiController{ //... @DeleteMapping("/api/v1/posts/{id}") public Long delete(@PathVariable Long id) { postsService.delete(id); return id; }}