(4) 머스테치로 화면 구성하기

Yunes·2023년 5월 18일
0

Spring Boot

목록 보기
1/7

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

템플릿 엔진 : 지정된 템플릿 양식과 데이터가 합쳐져 HTML 문서를 출력하는 소프트웨어

서버 템플릿 엔진

  • JSP(명확하게는 서버 템플릿 엔진은 아니며 Viwe 의 역할만 하도록 구성시 템플릿 엔진으로써 사용가능하다), Freemarker
  • 서버에서 구동된다.
  • 서버에서 Java 코드로 문자열을 만든뒤 문자열을 HTML로 변환하여 브라우저로 전달한다.

    -> 이 경우 System.out.println("test");를 실행할 뿐 자바스크립트 코드는 단순한 문자열이라 if 문은 동작하지 않는다.
  • 자바스크립트는 브라우저 위에서 작동한다. 따라서 브라우저에서 작동될 때는 서버 템플릿 엔진의 손을 벗어나 제어할 수 없다.

클라이언트 템플릿 엔진

  • 리액트, 뷰의 view 파일
  • Vue.js, React.js를 이용한 SPA(Single Page Application)은 브라우저에서 화면을 생성한다. 즉 서버에서 이미 코드가 벗어난 경우다.
  • 서버에서는 Json, Xml 형식의 데이터만 전달하고 클라이언트에서 조립한다.
  • 최근엔 리액트나 뷰같은 자바스크립트 프레임워크에서 서버사이드 렌더링을 지원하기도 한다.

머스테치

수많은 언얼르 지원하는 가장 심플한 템플릿 엔진. JSP와 같이 HTML 을 만들어주는 템플릿 엔진이다.
루비, 자바스크립트, 파이썬, PHP, 자바, 펄, Go, ASP등 대부분의 언어를 지원한다.
그래서 자바스크립트에서 사용시 클라이언트 템플릿 엔진으로, 자바에서 사용시 서버 템플릿 엔진으로 사용할 수 있다.

  • 자바에는 JSP, Velocity, Freemarker, Thymeleaf 등 다양한 서버 템플릿 엔진이 존재한다.

템플릿 엔진의 단점

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

머스테치(IntelliJ Community도 지원)의 장점

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

mustache 파일

보통 src/main/resources/templates 에 위치한다.

IndexController

package com.springboot.book.springbootwebservice.web;

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

@Controller
public class IndexController {

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

mustach 스타터 덕분에 컨트롤러에서 문자열을 반환시 앞의 경로와 뒤의 파일 확장자는 자동으로 지정된다. 앞의 경로는 src/main/resources/templates 로, 뒤의 파일 확장자는 .mustache가 붙기에 여기서는 index를 반환하니 src/main/resources/templates/index.mustache로 전환되어 View Resolver가 처리하게 된다.

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

index.mustache 의 한글이 깨지는 문제 발생
applicatio.properties 에 server.servlet.encoding.force-response=true 를 추가해보라는 글을 따라했으나 작동이 되지 않아서 build.gradle 에서 springboot 를 2.6.7로 낮추었다. 이에 따라 sideeffect 가 발생하여 jakarta 에서 javax 로 변경해주어야 했다.

부스트 트랩을 이용하여 게시글 등록 화면 만들기

프론트엔드 라이브러리 사용

  • 외부 CDN 사용
  • 직접 라이브러리를 받아서 사용

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

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

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

footer.mustache

페이지 로딩속도를 높이기 위해 css 는 헤더에, js는 footer에 위치
bootstrap.js 의 경우 제이쿼리가 꼭 있어야 하기에 부트스트랩보다 먼저 호출되도록 코드 작성 (bootstrap.js 가 제이쿼리에 의존)

글 등록 페이지

페이지와 관련된 컨트롤러는 모두 IndexController 사용

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 을 덮어쓰게 되니 index.js 만의 유효범위를 만들어 사용한다.var index 란 객체를 만들어 해당 객체에서 필요한 모든 function 을 선언하면 index 객체 안에서만 function 이 유효하기에 다른 js 와 겹칠 위험이 사라진다.

src 의 경로를 보면 절대경로 / 로 바로 시작한다.
스프링부트는 기본적으로 src/main/resources/static에 위치한 js, css, image 등 정적파일들은 url 에서 /로 설정된다.


전체 조회 화면

<!-- 목록 출력 영역 -->
    <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><a href="/posts/update/{{id}}">{{title}}</a></td>
                <td>{{author}}</td>
                <td>{{modifiedDate}}</td>
            </tr>
        {{/posts}}
        </tbody>
    </table>

{{#posts}}

  • posts 라는 List를 순회한다.
  • Java의 for문과 동일하다

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

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

PostsService

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

@Transactional 에 readOnly = true 를 주면 트랜잭션 범위를 유지하되, 조회 기능만 남겨두어 조회 속도가 개선되므로 등록, 수정, 삭제 기능이 전혀 없는 서비스 메소드에서 사용을 권장한다.

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

PostsListResponseDto

import com.jojoldu.book.springboot.domain.posts.Posts;
import lombok.Getter;

import java.time.LocalDateTime;

@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

package com.springboot.book.springbootwebservice.web;

import com.springboot.book.springbootwebservice.service.posts.PostsService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

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

{{post.id}}

  • 머스테치는 객체의 필드 접근 시 점으로 구분한다.
  • 즉, Post 클래스의 id에 대한 접근은 post.id로 사용할 수 있다.

readonly

  • Input 태그에 읽기 가능만 허용하는 속성
  • id와 author는 수정할 수 없도록 읽기만 허용하도록 추가
var main = {
  init : function () {
    var _this = this;

    $('#btn-update').on('click', function () {
      _this.update();
    });

  },
  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을 실행하도록 이벤트 등록

update : function ()

  • 신규로 추가될 update function

type: 'PUT'

  • 여러 HTTP Method 중 PUT 메소드 선택
  • PostsApiController에 있는 API에서 이미 @PutMapping 으로 선언했기 때문에 PUT을 사용해야 한다. 참고로 이는 REST 규약에 맞게 설정된 것이다.

url: '/api/v1/posts/'+id,

  • 어느 게시글을 수정할지 URL Path로 구분하기 위해 Path에 id를 추가한다.
<!-- 목록 출력 영역 -->
    <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><a href="/posts/update/{{id}}">{{title}}</a></td>
                <td>{{author}}</td>
                <td>{{modifiedDate}}</td>
            </tr>
        {{/posts}}
        </tbody>
    </table>

a href="/posts/update/{{id}}"

  • 타이틀에 a 태그를 추가
  • 타이틀을 클릭시 해당 게시글의 수정 화면으로 이동한다.

삭제

  @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인지 확인을 위해 엔티티 조회후 그대로 삭제한다.
profile
미래의 나를 만들어나가는 한 개발자의 블로그입니다.

0개의 댓글

관련 채용 정보