머스테치로 화면 구성하기

짱J·2022년 6월 21일
0
post-thumbnail

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

☕️ 템플릿 엔진

: 지정된 템플릿 양식과 데이터가 합쳐져 HTML 문서를 출력하는 소프트웨어
ex) JSP, Freemarker, React, Vue ...

  • JSP, Freemarker - 서버 템플릿 엔진
  • React, Vue - 클라이언트 템플릿 엔진
  • JSP를 비롯한 서버 템플릿 엔진은 서버에서 구동된다.

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

반면, 자바스크립트는 서버가 아닌, 브라우저 위에서 작동한다.
브라우저에서 작성될 때는 서버 템플릿 엔진의 손을 벗어나 제어할 수 없다.

☕️ 머스테치

: 수많은 언어를 지원하는 가장 심플한 템플릿 엔진 (http://mustache.github.io)

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

머스테치의 장점는 아래와 같다

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

기본 페이지 만들기


먼저 머스테치 스타터 의존성을 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>

이제 이 머스테치에 URL을 매핑하자.
URL 매핑은 Controller에서 진행한다.
web 패키지 안에 IndexController를 생성하자.

package com.example.demo.web;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

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

머스테치 스타터 덕분에 컨트롤러에서 문자열을 반환할 때 앞의 경로와 뒤의 파일 확장자는 자동으로 지정된다.
즉 "index"는 src/main/resources/templates/index.mustache로 전환되어 View Resolver가 처리하게 된다.

  • View Resolver - URL 요청의 결과를 전달할 타입과 값을 지정하는 관리자 격

이제 테스트 코드로 검증 고고

package com.example.demo.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 메인페이지_로딩() {
        // when
        String body = this.restTemplate.getForObject("/", String.class);
        
        // then
        assertThat(body).contains("스프링 부트로 시작하는 웹 서비스");
    }
}

실제로 URL 호출 시 페이지의 내용이 제대로 호출되는지에 대한 테스트이다.
전체 코드를 다 검증할 필요는 없으니, "스프링 부트로 시작하는 웹 서비스"라는 문자열이 포함되어 있는지만 비교한다.

Application.java의 main 메소드를 실행하고 브라우저에서 localhost:8080을 접속해서 화면으로 직접 확인할 수도 있다 ^ㅅ^


게시글 등록 화면 만들기

이번에는 오픈소스인 부트스트랩을 이용하여 게시글 등록 화면을 구현한다.

부트스트랩, 제이쿼리 등 프론트엔드 라이브러리를 사용하는 방법에는 1️⃣ 외부 CDN 사용, 2️⃣ 직접 라이브러리를 받아서 사용 두 가지 방법이 있다.

외부 CDN을 사용하면 라이브러리를 내려받을 필요도 없고, 코드만 한 줄 추가하면 되므로 외부 CDN을 사용하는 방식을 사용하자.
(실제 서비스에서는 결국 외부 서비스에 의존하게 되는 것이므로 이 방법을 잘 사용하지 않는다.)

  • 레이아웃 - 공통 영역을 별도의 파일로 분리하여 필요한 곳에서 가져다 쓰는 방식

부트스트랩과 제이쿼리는 머스테치 화면 어디서나 필요하다. 그러므로 레이아웃 파일로 만들어두자

🥸 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는 body 하단에 두어 화면이 다 그려진 뒤 호출하는 것이 좋다 !

라이브러리를 비롯해 기타 HTML 태그가 모두 레이아웃에 추가되니 이제 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}}

☕️ {{> }}

  • 현재 머스테치 파일을 기준으로 다른 파일을 가져옴

이제 IndexController에 글 등록 페이지 URL 매핑을 하고, posts-save.mustache 파일을 만들자.

@GetMapping("/posts/save")
public String postsSave() {
    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}}

이번에는 등록 버튼을 눌렀을 때 게시글 등록이 가능하도록 API를 호출해보자.

먼저 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.js에서 index라는 변수의 속성의로 function을 추가한 이유

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

여러 사람이 참여하는 프로젝트에서는 중복된 함수 이름은 자주 발생할 수 있다.
그런 상황의 문제를 피하기 위해 index.js만의 유효 범위(scope)를 만들어 사용한다.

index라는 객체를 만들어 해당 객체에서 필요한 모든 function을 선언하면 해당 함수는 index 객체 안에서만 유효하기 때문에 다른 JS와 겹칠 위험이 사라진다.

요즘 ES6를 비롯한 최신 자바스크립트 버전이나 앵귤러, 리액트 뷰 등은 이런 기능을 프레임워크 레벨에서 지원한다.

이제 index.js를 머스테치 파일이 쓸 수 있도록 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>

호출 코드를 보면 절대 경로(/)로 바로 시작한다.
스프링 부트는 기본적으로 src/main/resources/static에 위치한 정적 파일들은 URL에서 /로 설정된다.

브라우저에서 테스트 시 등록 기능이 잘 작동하는 것을 알 수 있다! 실제로 DB에 데이터가 등록되었는지도 h2-console을 통해 확인해보자.

굿 ~


전체 조회 화면 만들기

전체 조회를 위해 index.mustache의 UI를 변경해보자.

{{>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">
            <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를 순회
  • Java의 for문과 동일하게 생각하면 됨

☕️ {{변수명}}

  • Lists에서 뽑아낸 객체의 필드를 사용

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

🤓 PostsRepository

package com.example.demo.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를 사용하여 해결할 수 있다.

🐹 참고

규모가 있는 프로젝트에서의 데이터 조회는 FK의 조인, 복잡한 조건 등으로 Entity 클래스만으로 처리하기 어려워 조회용 프레임워크를 추가로 사용한다.

대표적인 예로 querydsl, jooq, MyBatis 등이 있다.
조회는 위 3가지 중 하나를 사용하고, 등록/수정/삭제 등은 SpringDataJpa를 통해 진행한다.

저자는 그 중 Querydsl을 추천한다.
- 타입 안정성이 보장됨
: 메소드를 기반으로 쿼리를 생성하므로 오타나 존재하지 않는 컬럼명을 명시할 경우 IDE에서 자동으로 검출된다.
- 국내 많은 회사에서 사용 중
- 많은 레퍼런스

다음으로 PostsService에 코드를 추가하자.
<@Transactional(readOnly = true)
public List<PostsListResDto> findAllDesc() {
    return postsRepository.findAllDesc().stream()
            .map(PostsListResDto::new)
            .collect(Collectors.toList());
}
}

(원본 코드에는 맨 앞에 '<'가 없는데 벨로그 오류인지 계속 앞에 '<'가 붙는다 ㅠ)

@Transactional에 (readOnly = true) 옵션을 추가하여 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도가 개선된다. → 💥 등록, 수정, 삭제 기능이 전혀 없는 서비스 메소드에서 사용하는 것을 추천! 💥

반환 값에 람다식이 들어가 생소하게 느껴질 수 있는데 프로세스는 아래와 같다

postsRepository 결과로 Postsdml Stream이 넘어옴 → map을 통해 PostsListResDto 변환 → List로 변환

아직 PostsListResDto 클래스가 없으니까 만들어야 한다 !

package com.example.demo.web.dto;

import com.example.demo.domain.posts.Posts;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Getter;
import java.time.LocalDateTime;

@Getter
public class PostsListResDto {
    private Long id;
    private String title;
    private String author;
    private LocalDateTime modifiedDate;
    
    public PostsListResDto(Posts entity) {
        this.id = entity.getId();
        this.title = entity.getTitle();
        this.author = entity.getAuthor();
        this.modifiedDate = entity.getModifiedDate();
    }
}

마지막으로 IndexController를 아래처럼 수정하자.

package com.example.demo.web;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

import com.example.demo.service.posts.PostsService;

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

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

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

☕️ Model

  • 서버 템플릿 엔진에서 사용할 수 있는 객체를 저장
  • 해당 코드에서는 postsService.findAllDesc() 로 가져온 결과를 posts로 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="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}}

☕️ {{post.id}}

  • 객체의 필드 접근 시 점(Dot)으로 구분
  • 해당 코드에서는 Post 클래스의 id에 접근한다는 의미

☕️ readonly

  • Input 태그에 읽기 가능만 허용하눈 속성
  • 해당 코드에서 id와 author은 수정할 수 없도록 함

그 다음, btn-update 버튼을 클릭하면 수정 기능을 호출할 수 있도록 index.js 파일에 update function을 추가한다.

var main = {
    init : function () {
        var _this = this;
        $('#btn-save').on('click', function () {
            _this.save();
        });
        $('#btn-update').on('click', function () {
            _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)
      }).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));
        });
    }
};

main.init();

☕️ $('#btn-update').on('click')

  • btn-update란 id를 가진 HTML 엘리먼트에 click 이벤트가 발생할 때 update function을 실행하도록 이벤트 등록

☕️ url: 'api/v1/posts'+id

  • 어느 게시글을 수정할지 URL Path로 구분하기 위해 Path에 id를 추가

마지막으로 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>

title에 a 태그를 추가하여 제목을 클릭하면 해당 게시글의 수정 화면으로 이동하도록 하였다.

화면 쪽 작업이 다 끝났으니 수정 화면을 연결할 Controller 코드를 만들어보자.

🤓 IndexController

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

    return "posts-update";
}

이제 브라우저에서 확인해보자 !

수정되었다는 팝업창이 뜨고, 내용이 수정된 것을 확인할 수 있다.

🚨 코드가 분명 맞는데 오류가 날 때

수정 완료 기능을 처음 테스트할 때는 위와 같은 오류가 생겼었다 ㅠ.ㅠ
이는 내가 index.js에서 경로를 잘못 적어 발생한 문제이다.
하지만 index.js를 수정하고 계속 다시 빌드/다시 실행을 해보아도, 수정한 내용이 반영이 안되었다 ... 왜지 ?!

https://github.com/jojoldu/freelec-springboot2-webservice/issues/270

위 링크에서 원인을 찾을 수 있었다.
나는 코드를 수정하였지만, 브라우저에 남아있는 캐시 파일로 인해 갱신이 되지 않는 것이었다.

이럴 때는 캐시를 지우거나, 개발자 도구로 캐시 파일을 확인하여 footer.mustache의 script 태그를 수정해보자.

수정 기능 완성 !

게시글 삭제

이번에는 삭제 기능도 구현해보자.
삭제 버튼은 본문을 확인하고 진행해야 하기 때문에, 수정 화면에 추가하여야 한다.

post.update.mustache에서 수정 완료 버튼 태그 아래 삭제 버튼 태그를 추가해주자.

<button type="button" class="btn btn-danger" id="btn-delete">삭제</button>

이제 index.js에 삭제 이벤트를 진행할 delete 메서드를 만들자.

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 () {
            alert(JSON.stringify(error));
        });
    }
};

main.init();

그 다음에 Service 메서드를 만들자.

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

☕️ postsRepository.delete(post)

  • JpaRepository에서 지원하는 delete 메서드
  • 엔티티를 파라미터로 삭제할 수 있움
  • deleteById 메서드를 이용하면 id로 삭제도 가능
  • 존재하는 Posts인지 확인하기 위해 엔티티 조회 후 삭제

서비스에서 만든 delete 메서드를 컨트롤러가 사용할 수 있도록 PostsController에 코드를 추가하자.

@DeleteMapping("/api/v1/posts/{id}")

public Long delete(@PathVariable Long id) {
    postsService.delete(id);
    return id;
}

브라우저로 테스트했을 때, 게시글이 삭제가 잘 된 것을 확인할 수 있다 굿 !!

이렇게 CRUD API와 화면까지 모두 완성하였다 !!!

profile
[~2023.04] 블로그 이전했습니다 ㅎㅎ https://leeeeeyeon-dev.tistory.com/

0개의 댓글