본문 바로가기
개발/스프링

[혼자 구현하는 웹서비스] 4. 머스태치로 화면 구성하기

by 카펀 2021. 7. 20.

0. 개요

1. 서버 템플릿 엔진과 머스태치 소개

2. 기본 페이지 만들기

3. 게시글 등록 화면 만들기

4. 전체 조회 화면 만들기

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

 

0. 개요

템플릿 엔진에 대한 소개 (서버 템플릿 엔진, 클라이언트 템플릿 엔진)와 함께 JSP와 비교해 보고, 이어서 머스태치를 통해 기본적인 CRUD 화면 개발 과정에 대해 다룬다.

 

1. 템플릿 엔진과 머스태치 소개

먼저 템플릿 엔진의 개념에 대해 다루겠다. 이 글을 참고해도 좋다.

웹 개발에서 템플릿 엔진이란, 지정된 템플릿 양식과 데이터가 합쳐셔 HTML 문서를 출력하는 소프트웨어를 뜻한다.

JSP, Freemarker, React, Vue 등이 떠오른다면, 그게 맞다. 단, JSP, Freemaker는 서버 템플릿 엔진이으로, React, Vue는 클라이언트 템플릿 엔진으로 분류된다. 이 둘의 차이를 잘 알아둬야 한다.

 

서버 템플릿 엔진을 이용한 화면 생성은 "서버에서" Java 코드로 문자열을 만든 뒤, 이를 HTML로 변환하여 브라우저로 전달한다. 즉, 서버에서 HTML 코드까지 완성한 뒤 이를 브라우저로 보내는 것이다.

반면에, 클라이언트 템플릿 엔진은 "브라우저에서" 작동한다. 따라서 브라우저에서 이미 작동을 시작하면, 서버 템플릿 엔진의 손을 벗어났으므로 더 이상 제어할 수가 없다. Vue.js나 React.js를 잉요한 SPA (Single Page Application)는 브라우저에서 화면을 생성한다. JSON 혹은 xml 형식의 데이터만 전달하고 클라이언트에서 조립하는 식이다.

 

머스태치란, 수많은 언어를 지원하는 심플한 템플릿 엔진이다. 루비, 자바스크립트, 파이썬, PHP, 자바 등 여러 언어를 지원한다.

따라서 Java에서는 서버 템플릿 엔진, JavaScript에서는 클라이언트 템플릿 엔진으로 모두 사용할 수 있다.

 

Java에서 사용되는 다른 서버 템플릿 엔진들은 다음과 같은 단점이 있다.

- JSP, Velocity: Spring Boot에서는 권장하지 않는 템플릿 엔진이다.

- Freemaker: 템플릿 엔진으로는 너무 과하게 많은 기능을 지원한다.

- Thymeleaf: Spring에서 적극적으로 밀고 있으나, 문법이 어렵다.

머스태치의 장점은 다음과 같다.

- 문법이 심플하다.

- 로직 코드를 사용할 수 없다. 따라서 View 역할과 서버의 역할이 명확하게 분리된다.

- 하나의 문법으로 클라이언트/서버 템플릿을 모두 사용 가능하다.

- IntellIJ Community Edition에서도 플러그인을 사용할 수 있다.

 

특히 입문자는 머스태치를 사용하는 것이 여러모로 편리해 보인다.

아래와 같이 IntellIJ에서 플러그인을 설치한 후 진행하자.

 

Handlebars/Mustache를 설치하면 된다.

 

2. 기본 페이지 만들기

지난 번 JPA 때처럼, Spring Boot에서 무언가를 사용하려면 dependencies (의존성)에 등록해야 한다.

bluild.gradle의 dependencies 아래에 다음과 같은 코드를 추가하자.

 

하이라이트한 코드 한 줄을 추가하면 된다.

 

코드에서 알 수 있듯, 머스태치는 Spring Boot에서 공식 지원하는 템플릿 엔진이다. 의존성 하나만 추가하면 편리하게 이용할 수 있다.

src/main/resources/templates가 머스태치의 기본 파일 위치이다.

아래와 같이 resources 아래에 templates 폴더를 만들고, 그 아래에 index.mustache를 만들어 주자 (New File을 선택하고 확장자까지 입력해 주면 된다).

 

index.mustache 파일을 만들자. 콧수염이 귀여워 보인다.

코드는 아래와 같이 입력해 주자.

 

index.mustache

 

아주아주 간단한 HTML 코드다.

다음으로 이 머스태치에 URL을 매핑한다. URL 매핑은 Controller에서 진행한다.

web 패키지 아래에 IndexController를 생성하고, 아래와 같이 입력한다.

 

IndexController.java

 

머스태치 스타터 덕분에, 컨트롤러에서 문자열을 반환할 때, 앞의 경로와 뒤의 파일 확장자는 자동으로 지정된다.

이게 무슨 말이냐면, 앞의 경로는 src/main/resources/templates로, 뒤의 확장자는 .mustache가 된다는 것이다.

따라서 src/main/resources/templates/index.mustache로 전환되어, View Resolver가 처리하게 된다.

(View Resolver는 URL 요청의 결과를 전달할 타입과 값을 지정하는 관리자 역할 정도로 생각하면 된다.)

 

다음으로는 이를 테스트 코드로 검증해 보겠다.

test 패키지에 IndexControllerTest 클라스를 만든 후 아래와 같이 입력하자.

 

IndexControllerTest.java

 

매우 간단한 테스트 코드이다. URL 호출 시, 페이지의 내용이 제대로 호출되는지에 대한 테스트이다.

전체 코드를 다 검증할 필요는 없고, index.mustache에 작성했던 "스프링 부트로 시작하는 웹 서비스" 라는 내용이 포함되는지 여부를 확인합니다.

 

테스트 통과

 

테스트를 통과한 것을 확인할 수 있다.

정말로 잘 작동하는지 브라우저에서도 확인해 보자. Application.java를 실행하고, http://localhost:8080 으로 접속하면 아래와 같이 나온다.

실제로 HTML 문서가 잘 생성되었다.

 

이제 머스태치를 이용해서 본격적으로 화면을 구성해 보자.

 

4.3 게시글 등록 화면 만들기

오픈소스인 부트스트랩을 이용해 화면을 만들어 보도록 하겠다. (나는 프론트엔드는 잘 모르는 분야이다.)

프론트엔드 라이브러리를 사용하는 방법은 크게 두 가지가 있다. 하나는 외부 CDN을 사용하는 것이고, 다른 하나는 직접 라이브러리를 받아서 사용하는 것이다.

여기서는 외부 CDN을 사용하기로 하겠다. HTML/JSP/Mustache에 코드 한 줄 만 추가하면 되니 간단하다.

단, 현업에서는 잘 사용하지 않는 방식이다. 외부 서비스에 의존하는 형태가 되는데, CDN을 서비스하는 곳에 문제가 생기면 덩달아 문제가 되기 때문이다.

 

2개의 라이브러리 부트스트랩과 제이쿼리를 index.mustache에 추가하겠다.

레이아웃 방식으로 추가하고자 하는데, 레이아웃 방식이란 공통 영역을 별도의 파일로 분리하여 필요한 곳에서 가져다 쓰는 방식을 뜻한다.

이번에 추가하는 두 라이브러리는 머스태치 화면 어디서나 필요하므로, 레이아웃 방식으로 추가하는 것이 현명하다.

 

src/main/resources/templates 아래에 layout 디렉토리를 만들고, footer.mustache, header.mustache를 각각 만들자.

 

header.mustache
footer.mustache

HTML은 위에서부터 아래로 코드를 읽기 때문에, css를 header에 배치하고 JavaScript를 footer에 배치했다.

css는 화면을 그리는 역할이기 때문에 로딩이 늦으면 깨진 화면이 보일 수 있고, JavaScript는 실행 속도가 느리므로 다른 코드가 먼저 실행되도록 하였다.

또한, bootstrap.js의 경우 jquery가 꼭 있어야 하기 때문에 (bootstrap이 jquery에 의존한다) jquery가 먼저 실행되도록 코드를 작성하였다.

 

라이브러리와 기타 HTML 태그들이 모두 레이아웃에 추가되었으므로, index.mustache에는 꼭 필요한 코드만 남기면 된다.

 

index.mustache

{{>layout/header} 는 현재 mustache 파일을 기준으로, 다른 파일을 가져온다는 뜻이다.

현재 index.mustache와 같은 위치에 layout 디렉토리가 있고, 그 아래에 header.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> 태그를 이용해 글 등록 페이지로 이동하는 글 등록 버튼을 만들었다.

이동할 페이지의 주소는 /posts/save 이다.

마찬가지로, 이 주소에 해당하는 컨트롤러를 생성해야 한다. 페이지에 관련된 컨트롤러는 모두 앞서 만들었던 IndexController를 사용한다.

 

IndexController.java

 

아까와 마찬가지로, /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 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}}

 

위처럼 완성한 후, 다시 프로젝트를 실행하고 localhost:8080으로 가 보면, 다음과 같은 화면을 볼 수 있다.

 

좌측 화면에서 '글 등록' 을 누르면, 우측 화면이 나온다.

 

물론 지금까지는 화면 모양만 구성한 것이므로, 기능은 아무것도 구현되지 않았다.

API를 호출하는 JavaScript가 필요한데, 이어서 추가하면 된다.

 

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));
        });
    },
    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();

 

중간에 window.location.href = '/' 는 글 등록을 성공하면 메인 페이지로 이동시키는 역할을 한다.

브라우저의 스코프는 공용 공간이기 때문에, 나중에 로딩된 js의 init, save가 먼저 로딩된 js의 function을 덮어쓰게 된다.

여러 사람이 사용하는 프로젝트에서, 중복된 함수명은 자주 발생할 수 있으므로, index.js만의 유효범위 (scope)를 만들어 사용하기 위해, var index 라는 객체를 만들고 해당 객체에서 필요한 모든 function을 선언하였다.

 

생성된 index.js를 머스태치 파일이 쓸 수 있도록 footer.mustache를 조금 수정해 주자.

 

footer.mustache.  3번 줄의 코드를 새로 추가하였다.

 

코드를 전부 추가하였으니 다시 실행해 보자.

 

게시글 등록 화면

 

빈 칸을 채운 후 '등록' 을 누르면, 성공적으로 등록되었다면 '글이 등록되었습니다' 팝업창이 뜬다.

 

잘 저장되었는지 확인해 보자.

localhost:8080/h2-console 에 접속 후 아래와 같이 입력한다.

 

SELECT * FROM POSTS

 

앞에서 정상적으로 기록되었다면 아래와 같은 결과를 얻을 수 있다.

 

POSTS 테이블에 저장된 데이터

 

4. 전체 조회 화면 만들기

전체 조회를 위해 index.mustache의 UI를 아래와 같이 변경하겠다.

 

index.mustache 내용 추가

11번째 줄부터 기존 내용에 더해 추가되었다.

여기서 처음으로 mustache만의 문법이 사용되었다.

{{#posts}}는 posts라는 list를 순회하는 역할을 하며, Java의 for문을 생각하면 편하다.

{{id}} 등의 변수명은 list에서 뽑아낸 객체의 필드를 사용하는 역할을 한다.

 

이어서 Controller, Service, Repository 코드를 이어서 작성하자.

Repository부터 시작하겠다. 기존에 있던 PostsRepository 인터페이스에 쿼리를 추가하자.

 

PostsRepository.java

 

혹시나 위치를 못 찾겠다면, domain/posts 아래에 있다.

 

SpringDataJpa에서 제공하지 않는 메소드는 위치럼 @Query를 이용하여 쿼리로 작성해도 된다.

앞의 코드는 SpringDataJpa에서 제공하는 기본 메소드만으로 해결할 수 있지만, @Query가 훨씬 가독성이 좋다.

 

다음은 PostsService에 코드를 추가하도록 하겠다.

 

PostsService.java

34~39번째 줄의 내용이 추가되었다.

findAllDesc 메소드의 트랜잭선 어노테이션(@Transactional)에 옵션이 하나 추가되었다.

readOnly = true로 설정하면, 트랜잭선 범위는 유지하되, 조회 기능만 남겨두어 (읽기 전용이므로) 조회 속도가 개선된다.

따라서 등록, 수정, 삭제 기능이 전혀 없는 메소드에서는 사용하는 것이 좋다.

 

메소드 내부의 코드에선 아래와 같은 코드가 생소할 수 있다.

 

.map(PostsListResponseDto::new)

 

이 코드는 실제로는 아래와 같다.

 

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

 

postsRepository 결과로 넘어온 Posts의 stream을 map을 통해 PostsListResponseDto 변환 -> List로 반환하는 메소드이다.

우리는 아직 PostsListResponseDto 클라스를 작성하지 않았기 때문에, 이어서 작성해 주도록 하자.

 

PostsListResponseDto.java

 

web/dto 아래에 만들면 된다.

마지막으로 Collector를 수정해 주자.

 

IndexController.java

 

위처럼 수정하면 완성되었다.

http://localhost:8080/으로 접속하고, 등록 화면을 통해 하나의 데이터를 등록해 보자.

 

글 등록 절차

글 등록이 잘 되는 것을 확인할 수 있다.

 

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

게시글 등록 화면을 만들었으니, 이어서 수정, 삭제 화면을 만들어 보자. 수정 API는 앞에서 이미 만들어 둔 바 있다.

 

PostsApiController.java

 

위 API를 요청하는 화면을 만들어 보겠다.

먼저 게시글 수정을 위한 mustache 파일을 만들어 보자.

 

resources/templates/post-update.mustache

10번째 줄의 {{post.id}}에서 적힌 머스태치 코드를 보면, 객체의 필드 접근 시 점으로 구분한다. Post 클래스의 id에 대한 접근을 post.id로 사용할 수 있다.

이어 붙은 readonly는 input 태그에 읽기 가능만 허용하는 속성으로, id와 author는 수정할 수 없고 읽기만 가능하도록 하였다.

 

btn-update 버튼을 클릭하면 update(수정) 기능을 호출할 수 있도록, index.js 파일에도 update function을 하나 추가하도록 하자.

 

index.js

 

init 내부에 btn-update가 클릭될 시 update 함수가 실행되도록 하고, 이어 update 함수를 작성해 주면 된다.

 

마지막으로 전체 목록에서 수정 페이지로 이동할 수 있도록 페이지 이동 기능을 추가해 보겠다.]

index.mustache의 코드를 아주 살짝 수정하면 된다.

 

index.mustache

<tr> 태그 내에 있는 <td> 태그들 중 title에 해당하는 코드를 약간 수정했다.

 

화면쪽 작업이 다 끝났으니, 당므으로는 수정 화면을 연결할 Controller 코드를 작업하도록 하자.

IndexController 내에 아래와 같은 코드를 추가한다.

 

IndexController.java

 

앞에서 등록, 조회 화면을 만들며 익숙해진 코드들이라 크게 어려움이 없다.

추가된 기능을 사용해 보자.

 

글 수정 절차

 

수정 기능이 정상적으로 구현되었다.

마지막으로 삭제 기능을 구현해 보겠다. 삭제 버튼은 본문을 확인하고 진행해야 하므로, 수정 화면에 추가하도록 하겠다.

 

posts-update.mustache

 

27번째 줄에 내용을 한 줄 추가했다.

이어서 JavaScript 코드도 수정해 주자.

 

index.js

 

앞에서처럼 init 내에 함수 호출 관련 코드를 추가하고, 위에 보이듯 delete 함수를 추가해 주면 된다.

 

마지막으로 삭제 API를 만들어 보겠다.

서비스 메소드부터 수정하면 된다.

 

PostsService.java

 

서비스에서 만든 delete 메소드를 controller가 사용하도록 코드를 추가한다.

 

PostsApiController.java

 

컨트롤러까지 생성하였으니, 마찬가지로 잘 작동되는지 테스트를 해 보자.

 

글 삭제 절차

 

삭제가 잘 작동하는 것을 확인할 수 있다.

 

본 글에서 다른 화면 작성과, 웹 요청에서의 테스트 코드 작성은 매우 중요하니 염두에 두길 바란다.

 

 

 

*이 글은 '스프링 부트와 AWS로 혼자 구현하는 웹 서비스' (프리렉, 이동욱 저)를 공부하며 내용을 정리한 글입니다.

댓글