머스테치를 통해 화면 영역을 개발해보겠습니다. 이번 기회에 서버 템플릿 엔진과 클라이언트 템플릿 엔진의 차이는 무엇인지, 이 책에서는 왜 JSP가 아닌 머스테치를 선택했는지, 머스테치를 통해 기본적인 CRUD 화면 개발 방법 등을 차례로 진행하며 배워봅시다.

 

 

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

템플릿 엔진이란, 지정된 템플릿 양식과 데이터가 합쳐져 HTML 문서를 출력하는 소프트웨어를 이야기합니다. 템플릿 엔진에는 서버 템플릿 엔진과 클라이언트 템플릿 엔진이 있습니다.

 

서버 템플릿 엔진은 서버에서 Java 코드로 문자열을 만든 뒤 이 문자열을 HTML로 변환하여 브라우저로 전달합니다.

클라이언트 템플릿 엔진은 서버에선 브라우저로 데이터만 직렬화해서 넘겨주고, 브라우저에서 HTML을 생성합니다. 따라서 소스 코드가 브라우저에서 실행됩니다.

 

 

 

머스테치란

머스테치(http://mustache.github.io/)는 수 많은 언어를 지원하는 가장 심플한 템플릿 엔진입니다. 대부분의 언어를 지원하다 보니 자바에서 사용될 때는 서버 템플릿 엔진, 자바스크립트에서 사용될 때는 클라이언트 템플릿 엔진으로 모두 사용할 수 있습니다.

 

이동욱님이 사용하는 템플릿 엔진들의 단점은 아래와 같습니다.

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

머스테치의 장점은 아래와 같습니다.

  • 문법이 다른 템플릿 엔진보다 심플합니다.
  • 로직 코드를 사용할 수 없어 View의 역할과 서버의 역할이 명확하게 분리됩니다.
  • mustache.js와 mustache.java 2가지가 다 있기 때문에 하나의 문법으로 클라이언트/서버 템플릿 모두 사용 가능합니다.

개인적으로 템플릿 엔진은 화면 역할에만 충실해야 한다고 생각합니다. 너무 많은 기능을 제공하면 API와 템플릿 엔진, 자바스크립트가 서로 로직을 나눠 갖게 되어 유지보수하기가 굉장히 어려워 질 것 입니다.

 

 

머스테치 플러그인 설치

아래와 같이 Preferences의 Marketplace에서 설치해주시면 됩니다. 

설치 한 뒤, 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를 생성합니다.

@Controller
public class IndexController {

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

}

머스테치 스타터 덕분에 컨트롤러에서 문자열을 반환할 때 앞의 경로와 뒤의 파일 확장자는 자동으로 지정됩니다. 즉, 여기선 "index"를 반환하므로, index.mustache로 전환되어 View Resolver가 처리하게 됩니다.

 

테스트 코드로 검증해 봅시다. test 패키지에 web패키지 안에 IndexControllerTest 클래스를 만든 뒤 아래의 코드를 추가해줍시다.

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

정상적으로 돌아가는 것을 볼 수 있습니다.

 

 

 

게시글 등록 화면 만들기

오픈소스인 부트스트랩을 이용하여 화면을 만들어 봅시다. 부트스트랩, 제이쿼리 등 프론트엔드 라이브러리를 사용할 수 있는 방법은 크게 2가지가 있습니다. 하나는 외부 CDN을 사용하는 것, 다른 하나는 직접 라이브러리를 받아서 사용하는 방법입니다.

 

여기선 전자은 외부 CDN을 사용합니다. 사용 방법도 HTML/JSP/Mustache에 코드만 한줄 추가하면 되어 굉장히 간단합니다.

2개의 라이브러리 부트스트랩과 제이쿼리를 index.mustache에 추가해야 합니다. 하지만, 여기서는 바로 추가하지 않고 레이아웃 방식으로 추가해보겠습니다. 레이아웃 방식이란 공통 영역을 별도의 파일로 분리하여 필요한 곳에서 가져다 쓰는 방식을 이야기합니다.

 

src/main/resources/templates 디렉토리에 layout 디렉토리를 생성한 뒤, footer.mustach, header.mustache 파일을 생성합니다.

footer.mustach

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

header.mustach

<!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>

 

코드를 보면 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}}
  • {{>layout/header}}
    • {{>}}는 현재 머스테치 파일을 기준으로 다른 파일을 가져옵니다.

 

레이아웃으로 파일을 분리했으니 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}}

여기서는 <a> 태그를 이용해 글 등록 페이지로 이동하는 글 등록 버튼이 생성되었습니다. 이동할 페이지의 주소는 /posts/save 입니다.

 

해당 주소에 해당하는 컨트롤러를 생성해줍시다. 페이지에 관련된 컨트롤러는 모두 IndexController를 사용합니다.

IndexController 메소드 추가

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

 

index.mustache와 마찬가지로 /posts/save를 호출하면 연결되는 posts-save.mustache를 만들어 줍시다. templates에 생성해주면됩니다. 코드는 아래와 같습니다.

{{>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를 호출하는 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();
  • window.location.href = '/'
    • 글 등록이 성고하면 메인페이지(/)로 이동합니다.
  • type : 'POST'
    • http 메소드 중 하나인 POST를 선택합니다.
    • REST에서 CRUD는 다음과 같이 HTTP 메소드에 매핑됩니다.
      • 생성 (Create) : POST
      • 읽기 (Read) : GET
      • 수정 (Update) : PUT
      • 삭제 (Delete) : DELETE

자 그럼 생성된 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>

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

index.js 호출 코드를 보면 절대 경로(/)로 바로 시작합니다. 스프링 부트는 기본적으로 src/main/resources/static에 위치한 자바스크립트, CSS, 이미지 등 정적 파일들은 URL에서 /로 설정됩니다.

 

실제 프로그램을 돌려 글 등록을 해보시면 글 등록이 되었다는 문구와 함께 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를 순회합니다.
    • java의 for문과 동일하게 생각하면 됩니다.
  • {{id}} 등의 {{변수명}}
    • List에서 뽑아낸 객체의 필드를 사용합니다.

 

그럼 Controller, Service, Repository 코드를 작성합시다. 먼저 Repository부터 시작합니다. 

기존에 있던 PostsRepository 인터페이스에 쿼리가 추가

public interface PostsRepository extends JpaRepository<Posts, Long> {

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

SpringDataJap에서 제공하지 않는 메소드는 위처럼 쿼리로 작성해도 되는 것을 보여드리고자 @Query를 사용했습니다. 실제로 앞의 코드는 SpringDataJpa에서 제공하는 기본 메소드만으로 해결할 수 있습니다. 다만 @Query가 훨씬 가독성이 좋으니 선택해서 사용합시다.

 

PostsService 코드 추가

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

(readOnly = true)를 주면 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도가 개선되기 때문에 CUD 기능이 없을 때 사용하면 좋습니다.

메소드 내부의 코드에서 람다식을 모르시면 조금 생소한 코드가 있을 수 있습니다.

.map(PostsListResponseDto::new)  -> .map(posts -> new PostsListResponseDto(posts))와 같습니다. postsRepository 결과로 넘어온 Posts의 Stream을 map을 통해 PostsListResponseDto 반환 -> List로 변환하는 메소드입니다.

 

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

 

마지막으로 Controller를 수정해줍시다.

IndexController 수정

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

    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("posts", postsService.findAllDesc());
        
        return "index";
    }
}
  • Model
    • 서버 템플릿 엔진에서 사용할 수 있는 객체를 저장할 수 있습니다.
    • 여기서는 postsService.findAllDesc()로 가져온 결과를 posts로 index.mustache에 전달합니다.

 

Controller까지 모두 완성이 됐습니다. 실행한 뒤 localhosts:8080에 접속한 뒤 등록화면을 이용해 데이터를 등록해보면 홈화면에 데이터가 추가된 것을 확인할 수 있을 것 입니다.

 

 

 

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

게시글 수정 API는 이미 만들었습니다. 해당 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>
        <button type="button" class="btn btn-danger" id="btn-delete">삭제</button>
    </div>
</div>

{{>layout/footer}}
  • {{post.id}}
    • 머스테치는 객체의 필드 접근시 점(Dot)으로 구분합니다.
  • readonly
    • Input 태그에 읽기 가능만 허용하는 속성입니다.
    • id와 author는 수정할 수 없도록 읽기만 허용하도록 추가합니다. (실제 웹에서 수정이 불가합니다.)

 

그리고 btn-update 버튼을 클릭하면 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();

 

마지막으로 수정 페이지로 이동할 수 있게 페이지 이동 기능을 추가해줍시다. index.mustache 코드를 아래와 같이 살짝만 수정해줍시다.

            {{#posts}}
                <tr>
                    <td>{{id}}</td>
                    <td><a href="/posts/update/{{id}}">{{title}}</a></td>
                    <td>{{author}}</td>
                    <td>{{modifiedDate}}</td>
                </tr>
            {{/posts}}
  • <a href="/posts/update/{{id}}"></a>
    • 타이틀(title)에 a tag를 추가합니다.
    • 타이틀을 클릭하면 해당 게시글의 수정 화면으로 이동합니다.

 

수정 화면을 연결할 Controller 작업을 해줍시다.

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에 삭제 버튼도 추가했습니다. 기능을 구현해봅시다. 삭제 이벤트를 진행할 js코드를 추가합시다.

 

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

 

이제 삭제 API를 만들어 봅시다. 먼저 서비스 메소드입니다.

 

PostsService 메소드 추가

    @Transactional
    public void delete (Long id) {
        Posts posts = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 사용자가 없습니다. id=" + id));

        postsRepository.delete(posts);
    }
  • postsRepository.delete(posts)
    • JpaRepository에서 이미 delete 메소드를 지원하고 있으니 이를 활용합니다.
    • 엔티티를 파라미터로 삭제할 수도 있고, deleteById 메소드를 이용하면 id로 삭제할 수도 있습니다.
    • 존재하는 Posts인지 확인을 위해 엔티티 조회 후 그대로 삭제합니다.

 

서비스에서 만든 delete 메소드를 컨트롤러가 사용하도록 코드를 추가합시다.

 

PostsApiController 메소드 추가

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

 

컨트롤러까지 생성되었으니 한번 테스트 해봅시다. 실제로 테스트해보면 정상적으로 돌아가는 것을 확인할 수 있습니다.

 

 

 

 

 

출처

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - YES24

가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링

www.yes24.com

 

 

+ Recent posts