[4장] 머스테치로 화면 구성하기

미천한 개발중생·2023년 9월 18일
0

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

템플릿 엔진이란?

지정된 템플릿 양식과 데이터가 합쳐져 HTML 문서를 출력하는 소프트웨어 입니다. 템플릿 엔진의 종류에는 서버 템플릿 엔진, 클라이언트 템플릿 엔진이 있습니다.
ex) jsp, Freemarker, React, View 등

서버 템플릿 엔진과 클라이언트 템플릿 엔진의 차이

서버 템플릿 엔진은 서버에서 구동이 됩니다. 서버 템플릿 엔진을 이용한 화면 생성은 서버에서 Java 코드로 문자열을 만든 뒤 이 문자열을 HTML로 변환하여 브라우저로 전달합니다. 반면, 클라이언트 템플릿 엔진은 브라우저에서 화면을 생성합니다. 즉, 서버에서 코드가 이미 벗어난 경우입니다. 이 경우 서버에서는 Json 이나 Xml 형식의 데이터만 전달하고 클라이언트에서 조립합니다.


머스테치

머스테치는 수많은 언어를 지원하는 가장 심플한 템플릿 엔진입니다.

템플릿 엔진들의 단점

  • Jsp, Velocity : 스프링 부트에서는 권장하지 않는 템플릿 엔진입니다.
  • Freemarker : 템플릿 엔진으로는 너무 많은 기능을 지원합니다. 높은 자유도로 인해 숙련도가 낮을수록 Freemarker 안에 비즈니스 로직이 추가될 확률이 높습니다.
  • Thymeleaf : 스프링에서 적극적으로 밀고있지만 문법이 어렵습니다. HTML 태그 속성으로 템플릿 기능을 사용하는 방식이 높은 허들로 느껴지는 경우가 많습니다.

머스테치의 장점

  • 문법이 다른 템플릿 엔진보다 심플합니다.
  • 로직 코드를 사용할 수 없어 View의 역할과 서버의 역할이 명확하게 분리됩니다.
  • Mustache.js 와 Mustache.java 2가지가 다 있어, 하나의 문법으로 클라이언트/서버 템플릿을 모두 사용 가능합니다.
  • 인텔리제이 커뮤니티 버전에서도 플러그인을 사용할 수 있다.

저자인 이동욱 님께서는 "개인적으로 템플릿 엔진은 화면 역할에만 충실해야 한다고 생각한다"고 하셨습니다. 템플릿 엔진에서 너무 많은 기능을 제공하면 나중에 유지보수 하기가 굉장히 어렵다고 합니다.


기본 페이지

실습 하기에 앞서 spring devtools 의존성을 추가하여 사용하시는 걸 추천드립니다. 저는 실습을 하면서 코드를 변경할 때마다 애플리케이션을 재실행 하여 테스트하는것이 번거롭다고 느껴져서 Automatic restart 기능을 사용하였습니다. 이와 관련해서 설정하는 법에 대한 글도 간단하게 정리 해놓았으니 참고하시면 좋을 것 같습니다.

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>

IndexController

index.mustache에 URL을 매핑해줍니다.

@RequiredArgsConstructor
@Controller
public class IndexController {
    private final PostsService postsService;

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

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

jsp를 사용하셨던 분들이라면 application.properties 파일에 다음과 같이 추가로 설정하셨을 텐데요,

server.servlet.jsp.init-parameters.development=true
spring.mvc.view.prefix=/WEB-INF/views/
spring.mvc.view.suffix=.jsp

머스테치는 의존성 하나만 추가하면 되니 편리한 것 같습니다.


IndexControllerTest

@ExtendWith(SpringExtension.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에 "스프링 부트로 시작하는 웹 서비스" 문자열이 있는지 확인합니다.

결과

test가 통과한 것을 확인 후 Application.java를 실행한 후 http://localhost:8080 으로 접속하여 확인합니다.


게시글 등록 화면

부트 스트랩을 이용하여 화면을 만듭니다.
부트스트랩, 제이쿼리 등 프론트엔드 라이브러리를 사용할 수 있는 방법은 다음과 같이 2가지가 있습니다.

  • 외부 CDN을 이용하는 방법
  • 직접 라이브러리를 받아서 사용하는 방법

실습에서는 외부 CDN을 사용합니다. 하지만 현업에서는 외부 서비스에 의존하게 되어 CDN을 서비스하는 곳에서 문제가 생기면 같이 문제가 생기기 때문에 이 방법을 잘 사용하지 않는다고 합니다.


레이아웃

레이아웃 방식이란 공통 영역을 별도의 파일로 분리하여 필요한 곳에서 가져다 쓰는 방식을 말합니다. 부트스트랩과 제이쿼리는 머스테치 화면 어디에서나 필요하고 화면마다 코드를 추가해주는 것은 번거롭기 때문에 레이아웃 파일을 만들어 추가합니다.

spring boot + jsp 로 프로젝트를 했을 때, <jsp:include page="..."> 태그로 비슷하게 사용한 적이 있는데 저와 같은 경험이 있으시다면 좀 더 이해하기 수월할 것 같습니다.

레이아웃 구조

src/main/resources/templates 에 layout 디렉토리를 생성하고 그 안에 footer.mustache, header.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는 header에, js는 footer에 두었습니다. HTML은 head가 다 실행되고서야 body가 실행됩니다. js파일의 용량이 크면 body의 실행이 늦어지기 때문에 js는 하단에 두어 화면이 다 그려진 뒤 호출하는 것이 좋습니다. 반면에 css는 화면을 그리는 역할을 하므로 head에서 불러오는 것이 좋습니다. 추가로, bootstrap.js의 경우 제이쿼리가 꼭 있어야만 하기 때문에 부트스트랩보다 먼저 호출되도록 하였습니다.


index.mustache - 글 등록 버튼

{{>layout/header}}

<h1>스프링부트로 시작하는 웹 서비스 Ver.2</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}}

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


IndexController - URL매핑

...
@GetMapping("/posts/save")
public String postsSave() {
    return "posts-save";
}

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

브라우저에서 확인

메인화면에서 글 등록 버튼을 클릭하여 글 등록 화면으로 이동합니다.


index.js

등록 버튼에 기능을 추가하기 위해 API를 호출하는 js 파일을 생성합니다. src/main/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의 init, save가 먼저 로딩된 js의 function을 덮어쓰게 됩니다. 여러 사람이 참여하는 프로젝트에서 함수 이름이 중복되는 것을 방지하기 위해 모든 함수명을 확인할 수는 없기 때문에 var index 라는 객체를 만들어 해당 객체에서 필요한 모든 function을 선언합니다. 이렇게 하면 index 객체 안에서만 function이 유효하기 때문에 다른 js와 겹칠 위험이 사라집니다.


footer.mustache에서 index.js를 사용할 수 있도록 추가합니다.

<!--index.js 추가-->
<script src="/js/app/index.js"></script>

게시글 등록 테스트

게시글 등록을 하고 h2-console에 접속해서 데이터를 확인합니다.


전체 조회 화면

등록한 글을 메인화면에서 확인할 수 있게 추가합니다.

index.mustache - UI변경

{{>layout/header}}

<h1>스프링부트로 시작하는 웹 서비스 Ver.2</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">
        <thead class="thead-strong">
        <tr>
            <th>게시글번호</th>
            <th>제목</th>
            <th>작성자</th>
            <th>최종수정일</th>
        </tr>
        </thead>
        <tbody id="tbody">
        {{#posts}}
            <tr>
                <td>{{id}}</td>
                <td>{{title}}</td>
                <td>{{author}}</td>
                <td>{{modifiedDate}}</td>
            </tr>
        {{/posts}}
        </tbody>
    </table>
</div>

{{>layout/footer}}

{{#posts}}

posts 라는 List를 순회합니다.

{{id}} 등의 {{변수명}}

List에서 뽑아낸 객체의 필드를 사용합니다.

jsp의 EL문법 ${변수명}과 같다고 생각을 했습니다. jsp에서는 List를 순회하려면 jstl의 foreach를 사용해 출력을 했었는데 그런 과정없이 사용할 수 있어 편리한 것 같습니다.


PostRepository - 쿼리 추가

쿼리를 추가합니다.

@Query("SELECT p FROM Posts p ORDER BY p.id DESC")
List<Posts> findAllDesc();

SpringDataJpa에서 제공하지 않는 메서드는 위처럼 쿼리로 작성할 수 있습니다.

PostsService - finaAllDesc 메서드 추가

...
@Transactional(readOnly = true)
    public List<PostsListResponseDto> findAllDesc() {
        return postsRepository.findAllDesc().stream()
                .map(PostsListResponseDto::new)
                .collect(Collectors.toList());
    }    

@Transactional(readOnly = true)

@Transactional에 readOnly=true 옵션을 추가하면 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도가 개선되기 때문에 등록, 수정, 삭제 기능이 없는 서비스 메서드에 사용하는것을 추천합니다.

PostsListResponseDto

@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 - index 메서드 수정

@GetMapping("/")
public String index(Model model) {
    model.addAttribute("posts", postsService.findAllDesc());
    return "index";
}

만들어놓았던 index 메서드를 위처럼 바꿔줍니다.

결과 확인


게시글 수정

PostsApiController 에 만들어놓은 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="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>
    </div>
</div>

{{>layout/footer}}

{{post.id}}

머스테치는 객체의 필드 접근 시 점으로 구분합니다.

index.js - update function 추가

var main = {
    init : function () {
    	var _this = this;
        ...
        $('#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,
            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();

type'PUT'

REST에서 CRUD는 다음과 같이 HTTP Method에 매핑됩니다.

  • 생성(create) : POST
  • 읽기(read) : GET
  • 수정(update) : PUT
  • 삭제(delete) : DELETE

지금은 update 기능을 구현하기 때문에 PUT을 사용해야 하며 PostsApiController에도 이미 @PUTMapping으로 선언해놓았습니다.

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>
    {{/posts}}
</tbody>

IndexController - update 메서드 추가

...

@GetMapping("/posts/update/{id}")
    public String postsUpdate(@PathVariable Long id, Model model) {
        PostsResponseDto dto = postsService.findById(id);
        model.addAttribute("post", dto);

        return "posts-update";
    }

수정 확인

글 제목을 클릭해 수정 페이지로 이동합니다.

수정할 내용을 입력 후 수정완료 버튼을 클릭합니다.

수정 완료된 것을 확인할 수 있습니다.


게시글 삭제

수정 기능이 구현되었으니, 삭제 기능도 구현하겠습니다.

post-update.mustache - 삭제 버튼 추가

...
</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>

index.js - 삭제 기능 추가

var main = {
    init : function () {
    	var _this = this;
        ...
        $('#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));
        });
    }
}
main.init();

PostsService - delete 메서드 추가

...

@Transactional
    public void delete(Long id) {
        Posts posts = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id = " + id));

        postsRepository.delete(posts);
    }

postsRepository.delete(posts)

  • 엔티티를 파라미터로 삭제할 수도 있고, deleteById 메서드를 이용하면 id로 삭제할 수도 있습니다.
  • 존재하는 Posts인지 확인을 위해 엔티티 조회 후 삭제합니다.

PostsApiController - URL매핑

...

@DeleteMapping("/api/v1/posts/{id}")
    public Long delete(@PathVariable Long id) {
        postsService.delete(id);
        return id;
    }

삭제 확인

게시글 수정 페이지로 이동 후 삭제 버튼을 클릭해 게시물을 삭제합니다.

이번 장에서는 머스테치를 사용해서 CRUD 화면을 구성했습니다. API는 구현이 되어있으니 나중에 Thymeleaf로 리팩토링 해보는 것도 좋을 것 같습니다.

profile
공부 목적의 블로그 입니다. 부족한 점이 많으니 잘못된 정보가 있다면 지적부탁드려요!

0개의 댓글