Framework/Spring

[2주차] Mustache를 이용한 게시글 CRUD 화면 및 기능 구현

MINGYUM 2022. 5. 15. 03:03

템플릿 엔진(Template Engine)

입력 자료를 가공하여 결과물(웹 문서)을 출력하는 소프트웨어를 의미한다. 

웹 템플릿 엔진에는 두 가지 종류가 있는데, 

1) 서버 템플릿 엔진 (Server Side Template Engine)

: 서버에서 가져온 데이터를 Template 엔진에 넣어서 HTML을 생성, 클라이언트에 전달해주는 역할을 한다. 

ex) JSP(Java Server Page) : 서버 템플릿 엔진을 이용한 화면 생성. 서버 단에서 Java 코드로 문자열을 만든 뒤 이 문자열을 HTML로 변환하여 브라우저로 전달한다.

 

2) 클라이언트 템플릿 엔진 (Client Side Template Engine)  

HTML 형태로 코드를 작성, 동적으로 DOM(Documnet Object Model) 을 그리게 해주는 역할을 한다. 

ex) JavaScript : 브라우저 단에서 화면을 생성, 서버에서는 Json 혹은 Xml 형식의 데이터만 전달하고 이를 클라이언트에서 조립하는 것이다. 

 

자바 진영에서는 JSP, Velocity, Freemarker, Thymeleaf 등의 다양한 서버 템플릿 엔진이 존재한다. 

그 중에서 Mustache라는 서버 템플릿 엔진을 사용할 건데, 그 이유는 다음과 같다. 

  • 문법이 다른 템플릿 엔진보다 심플하다 
  • 로직 코드를 사용할 수 없어, View와 서버의 역할이 명확히 구분
  • 하나의 문법으로 클라이언트/서버 템플릿을 모두 사용할 수 있다. 

 


Mustache 사용하기

<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
    <title>스프링 부트 웹서비스</title>

</head>
<body>
    <h1>스프링 부트로 시작하는 웹 서비스</h1>
</body>
</html>

main/resources/templates/index.mustache

'mustache'라는 확장자를 가지고 있는 파일이다. 

단순히 HTML 코드로 이루어져 있으며 아래 Controller에 의해 View Resolver가 반환된 index를 처리한다. 

@Controller
public class IndexController {
    @GetMapping("/")
    public String index(){
        return "index";
    }
}

IndexControllerTest.java

@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("스프링 부트로 시작하는 웹 서비스");
    }
}

TestRestTemplate을 사용해 "/"를 호출했을 때, index.mustache에 포함된 코드가 있는 지 확인하면 된다.

 


게시글 등록 화면 만들기

 위 mustache파일의 header와 footer파일을 나눠 분리해서 아래와 같이 만들 수 있다. 

{{>layout/header}}
<h1>스프링 부트로 시작하는 웹 서비스</h1>
{{>layout/footer}}

{{ > }} : 현재 머스테치 파일을 기준으로 다른 파일을 가져온다. 

{{>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}}

글 등록 버튼을 만들고, 이동할 페이지를 "/post/save"로 설정한다. 

이제 API를 호출할 수 있는 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) // JS 객체를 JSON 문자열로 반환
        }).done(function(){
            alert('글이 등록되었습니다. ');
            window.location.href = '/';
        }).fail(function(error){
            alert(JSON.stringify(error));
        });
    }
};

main.init();

구조는 대강, index의 변수의 속성으로 main function을 추가하였고, 

그 내부에서 index.js의 init과 save function을 구현하였다. 

 

init과 save를 하나의 함수로 취급해서 모든 자바스크립트 파일의 내용을 구현하지 않도록 하는 이유이다. 

 

{{#posts}} : posts라는 List를 순회

{{id}} 등의 변수명 : List에서 뽑아낸 객체의 필드를 사용한 부분

 


public interface PostsRepository extends JpaRepository<Posts, Long> {
    @Query("select p from Posts p order by p.id desc")
    List<Posts> findAllDesc(); 
}

PostsRepository에 id를 기준으로 정렬된 Posts를 가져오는 메서드를 선언한다. 

 

PostsService에 메서드 추가

map 함수를 사용한 부분에서 에러가 뜨는데, 

map(PostsListResponseDto::new)
map(posts -> new PostsListResponseDto(posts))

postsRepository에서 불러온 Posts의 Stream을 map을 통해 PostsListResponseDto로 변환하고, 

이를 다시 Collectors의 내부 메서드를 통해 List로 변환하는 작업이다. 

 

즉 생성자 중에 매개변수를 Posts 객체로 받는 것이 PostsListReponseDto에 위치해있어야 하는 것이다. 

@Getter
public class PostsListResponseDto {
    private Long id;
    private String title;
    private String author;
    private LocalDateTime modifiedDate;

    // convert Posts to PostsListResponseDto
    public PostsListResponseDto(Posts entity){
        this.id = entity.getId();
        this.title = entity.getTitle();
        this.author = entity.getAuthor();
        this.modifiedDate = entity.getModifiedDate();
    }
}

이렇게 생성자를 만들어줌으로써 해결!

 

posts라는 이름으로 findAllDesc()의 결괏값을 View에 넘겨준다. 

이때 사용하는 Model은 아래의 특징을 가진다. 

  • 서버 템플릿 엔진에서 사용할 수 있는 객체를 저장할 수 있다. 
  • postsService.findAllDesc()의 결과를 posts라는 이름으로 index.mustache에게 전송한다. 

게시글 등록, 삭제 화면 만들기

먼저 posts-udpate.mastche 파일을 생성해 view 화면을 만들어준다. 

{{>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>
        <button type="button" class="btn btn-danger" id="btn-delete">삭제</button>
    </div>
</div>

{{>layout/footer}}

 

이런 readonly 기능은 Input 태그에 읽기 가능만 허용하는 속성이다. 

 

아래는 index.js의 수정본이다.

var main = {
    init : function(){
        var _this = this;
        $('#btn-save').on('click', function(){
            _this.save();
        }); // 저장 버튼 생성
        $('#btn-update').on('click', function(){ // (1)
            _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) // JS 객체를 JSON 문자열로 반환
        }).done(function(){
            alert('글이 등록되었습니다. ');
            window.location.href = '/';
        }).fail(function(error){
            alert(JSON.stringify(error));
        });
    },
    update : function(){ // (2)
        var data = {
            title : $('#title').val(),
            content: $('#content').val()
        };

        var id = $('#id').val();

        $.ajax({
            type : 'PUT', // (3)
            url : '/api/v1/posts/' + id, // (4)
            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();

(1) btn-update라는 id를 가진 버튼이 활성화되면, update function을 실행하도록 함

(4) 게시글 번호를 지정하여 url을 호출한다. 이때 id = $("#id").val()이다. 

 

게시글 삭제는 생략