Server/Spring Boot

[Chapter 04] 머스테치로 화면 구성하기

Nellie29 2023. 10. 16. 00:11


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

  • 템플릿 엔진: 지정된 템플릿 양식과 데이터가 합쳐져서 HTML 문서를 출력하는 소프트웨어
  • 서버 템플릿 엔진 : JSP, Freemarker
    • 서버에서 Java 코드로 문자열을 만든 뒤 이 문자열을 HTML로 변환하여 브라우저로 전달
  • 클라이언트 템플릿 엔진 : 리액트, 뷰의 View 파일
    • 브라우저 위에서 작동. 브라우저에서 작동될 때는 서버 템플릿 엔진의 손을 벗어나기 때문에 제어가 불가능

💚 머스테치

  • 자바에서 사용할 때는 서버 템플릿 엔진으로, 자바 스크립트에서 사용할 때는 클라이언트 템플릿 엔진으로 사용 가능

💚 머스테치 플러그인 설치

  • mustache를 검색해서 해당 플러그인 설치하기

2️⃣ 기본 페이지 만들기

build.gradle (의존성 추가)

compile('org.springframework.boot:spring-boot-starter-mustache')

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를 매핑

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";
    }
}

test 패키지에 IndexControllerTest 클래스 생성

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

localhost:8080을 접속해서 확인하기

 

💥mustache 한글 깨짐 이슈 발생

해결방법

1. project > src > main > resources > application.properties

2. 파일에 다음의 설정을 추가해준다.

server.servlet.encoding.force-response=true

3. 다시 빌드해주면 성공


3️⃣ 게시글 등록 화면 만들기

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

여기서는 외부 CDN을 사용한다 (실제 서비스에서는 이 방법을 잘 사용하지 않음)

💚 layout 디렉토리를 생성해서 코드 두개 추가

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에 둔다

 

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를 머스테치 파일이 쓸 수 있도록 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에서 /로 설정됨

이후 브라우저에서 테스트해보고, h2-console을 통해 db에 데이터가 등록되었는지 확인하기


4️⃣ 전체 조회 화면 만들기

전체 조회를 위해 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}}

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

PostsService

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

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";
    }
}

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

💚 게시글 수정

머스테치 파일 생성

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

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 코드 수정

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

수정 화면을 연결할 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";
}

💚 게시글 삭제

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인지 확인하기 위해 엔티티 조회 후 삭제

PostsController에 코드 추가

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

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

브라우저 테스트하기

끝!


💻 Reference

 

머스테치로 화면 구성하기

머스테치를 이용하여 전체 게시판, 수정/삭제 화면을 만들고 API를 연결해보자

velog.io

 

머스테치로 화면 구성하기

템플릿엔진 : 지정된 템플릿 양식과 데이터가 합쳐져서 HTML 문서를 출력하는 소프트웨어서버 템플릿 엔진 : JSP, Freemarker클라이언트 템플릿 엔진 : 리액트, 뷰의 View 파일

velog.io

 

스프링부트 mustache 한글 깨짐

스프링부트 컨트롤러를 mustache view 페이지와 연결해보았다. 한?글?이??깨?진?다?? 검색해보니 스프링부트 3.7.x버전 + mustache 조합에서는 한글 깨짐이 발생한다고 한다. build.gradle 파일에서 스프링부

corin-e.tistory.com