Skip to content

Latest commit

 

History

History
1064 lines (894 loc) · 37 KB

04 머스테치로 화면 구성하기.md

File metadata and controls

1064 lines (894 loc) · 37 KB

머스테치로 화면 구성하기

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

템플릿 엔진 : 지정된 템플릿 양식과 데이터가 합쳐져 HTML문서를 출력하는 소프트웨어
과거에는 JSP와 같이 HTML과 JAVA가 합쳐져 HTML에 데이터를 넣어서 동적으로 활용할 수 있는 것
최근에는 REACT, VUE 같은 것들이 등장했다.

과거 현재 모두 지정된 템플릿과 데이터를 이용하여 HTML을 생성하는 템플릿 엔진이지만
전자는(과거는) 서버 템플릿 엔진이라 불리며,
후자는(현재는) 클라이언트 템플릿 엔진이라 불린다.
그렇다면 둘의 차이점은 무엇일까?

아래 코드를 먼저 살펴보자

<script type="text/javascript">

$(document).ready(function(){
    if( a == "1"){
      <% 
        System.out.println("test");   
      %>    
    }
})  

위 코드의 문제점은 바로 if문과 상관없이 System.out.println("test");이 실행된다는 점이다.
이유는 javascript 와 jsp가 작동하는 영역이 다르기 때문이다.
javascript는 프론트 영역에서 jsp는 서버 영역에서 구동된다.

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

그렇기에 위 코드는 HTML로 만드는 과정에서 System.out.println("test"); 명령어를 실행할 뿐이고
이때 자바스크립트는 실행되지 않는 단순한 문자열일 뿐이다.

자바스크립트는 기본적으로 브라우저 위에서 작동한다.
즉, 서버가 아닌 브라우저에서 작동되므로 서버 템플릿 엔진의 손을 벗어나게 되어 서버에서 제어를 할 수가 없다.
React, Vue 에서 서버는 json 혹은 xml형식의 데이터만 전달하고 클라이언트에서 조립한다.

1.1. 머스테치

머스테치는 현존하는 대부분의 언어를 지원하는 가장 심플한 템플릿 엔진이다.(JSP,REACT 같은)
자바에서 사용할 때는 서버 템플릿 엔진으로,
자바스크립트에서 사용될 때는 클라이언트 템플릿 엔진으로 모두 사용할 수 있다.

장점

  • 문법이 다른 템플릿 엔진보다 심플하다.
  • 로직 코드를 사용할 수 없어 View의 역할과 서버의 역할을 명확하게 분리한다.
  • Mustache.js 와 Mustache.java 2가지가 다 있어, 하나의 문법으로 클라이언트/서버 템플릿을 모두 사용가능
개인적으로 템플릿 엔진은 '화면' 역할에만 충실해야 한다고 생각이든다.  
너무 많은 기능을 제공하면 API와 템플릿 엔진, 자바스크립트가 서로 로직을 나눠 갖게 되어 유지보수하기가 굉장히 어렵다.  

1.1.1. 머스테치 플러그인 설치

  1. ctrl + shift + A 입력 후 plugins 검색 및 클릭
  2. mustache 입력 후 Handlebars/Mustache 찾아서 설치 및 재시작

1.2. 기본 페이지 만들기

1.2.1. index.mustahce 및 IndexController 작성

머스테치는 스프링 부트에서 공식 지원하는 템플릿 엔진이다.
그러므로 우선 스프링 부트 프로젝트에서 머스테치를 편하게 사용할 수 있도록
머스테치 스타터 의존성을 build.gradle에 등록하자

build.gradle

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

머스테치 파일의 기본 위치는 src/main/resources/templates이다.
이 위치에 머스테치 파일을 두면 스프링 부트에서 자동으로 로딩한다.

첫 페이지를 담당할 index.mustache를 src/main/resources/templates에 생성하자

index.mustache

<!doctype html>
<head>
    <meta http-equiv="Content-Type" content="text/html" charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <title>스프링 부트 웹 서버</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
    <link rel="stylesheet" href="/css/app/bgimg.css">
</head>
<body>
    <h1>스프링 부트로 시작하는 웹 서비스 ver.2</h1>
</body>
</html>    

이제 이 머스테치에 접근할 Controller를 작성하고 URL 매핑을 진행해주자.

indexController

package com.jojoldu.book.springboot.web;

import com.jojoldu.book.springboot.config.auth.LoginUser;
import com.jojoldu.book.springboot.config.auth.dto.SessionUser;
import com.jojoldu.book.springboot.service.posts.PostsService;
import com.jojoldu.book.springboot.web.dto.PostsResponseDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@RequiredArgsConstructor
@Controller
public class IndexController {

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

머스테치 스타터 덕분에 컨트롤러에서 문자열을 반환할 때 앞의 경로와 뒤의 파일 확장자는 자동으로 지정된다.(View Resolver) 앞에는 src/main/resources/templates 뒤에는 .mustache가 붙는 것이다.

1.2.2. IndexController 테스트

테스트 코드를 작성하여 검증을 진행하도록 하자

IndexControllerTest

package com.jojoldu.book.springboot.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.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
import static org.assertj.core.api.Assertions.assertThat;

@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("스프링 부트로 시작하는 웹 서비스");
        // 포함된 내용이 없으 False 리턴
    }
}

테스트 메소들를 실행하여 결과를 확인하고
테스트만으로는 아쉬우니 직접 브라우저에 접속해서 확인해보자

  1. Application 클래스의 main 메소드를 실행
  2. http://localhost:8080 접속
  3. 매핑 url 이 /이므로 index.mustache 가 출력 될 것이다.

1.3. 게시글 등록 화면 만들기

1.3.1. 레이아웃 만들기

이번 머스테치를 작성할 때는 부트스트랩을 이용해서 개발할 것이다.
부트스트랩 사용방법은 크게 2가지이다.

  1. 외부 CDN을 이용
  2. 직접 라이브러리를 받아서 사용하는 것

실제 서비스가 아니므로 머스테치 파일에 코드 한줄만 작성하는 CDN 방식을 사용할 것인데
우리는 각각에 파일에 한줄씩 넣는것보다 레이아웃 방식으로 추가해서 사용하는 방법으로 할 것이다.
레이아웃 방식이란 공통 영역을 별도의 파일로 분리하여 필요한 곳에서 가져다 쓰는 방식을 말한다.
이렇게 사용하는 이유는 반복되는 코드 작성 시간을 줄이고 변경시 유지보수에 편하기 때문이다.

  1. src/main/resources/templateslayout폴더 생성
  2. src/main/resources/templates/layout에 header.mustache 생성
  3. src/main/resources/templates/layout에 footer.mustache 생성

header.mustache

<!doctype html>
<head>
    <meta http-equiv="Content-Type" content="text/html" charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <title>스프링 부트 웹 서버</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
    <link rel="stylesheet" href="/css/app/bgimg.css">
</head>

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와 js의 위치가 서로 다른데 이는 페이지 로딩속도를 높이기 위해서이다.
html 에서는 head가 다 실행되고서야 body가 실행된다.
js를 head에 둘 경우 로딩하는 시간이 길어지므로 클라이언트 입장에서는 로딩되는 것으로 보인다.
그렇기에 html 과 css 를 우선으로 로딩하여 로딩이 빠르게 되는 것처럼 보여주게 하는 것이 좋다.

추가로 bootstrap은 제이쿼리에 의존적이기에 꼭 제이쿼리도 같이 사용해주어야 한다.

1.3.2. index.mustache 수정하기

레이아웃으로 파일을 분리햇으니 index.mustache에 글 등록 버튼을 하나 추가하자

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

위 코드에서 작성된 주소 /posts/save를 처리해주는 Controller를 작성해주자

IndexController

package com.jojoldu.book.springboot.web;

import com.jojoldu.book.springboot.config.auth.LoginUser;
import com.jojoldu.book.springboot.config.auth.dto.SessionUser;
import com.jojoldu.book.springboot.service.posts.PostsService;
import com.jojoldu.book.springboot.web.dto.PostsResponseDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@RequiredArgsConstructor
@Controller
public class IndexController {

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

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

/posts/save를 호출하면 posts-save.mustache를 호출하는 메소드를 추가해줬다.
컨트롤러 코드가 생성되었다면 호출될 posts-save.mustache 파일도 생성해주자

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

1.3.3. index.js 생성하기

UI를 작성했지만 아직 게시글 등록 화면에 등록 버튼에 대한 기능을 정의해주지 않았다.
그렇기에 src/main/resourcesstatic/js/app디렉토리를 생성해주고 js 코드를 작성해주자

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

여기서 한가지 팁이 있는데 var index = { 함수이름 : function(){}}이런식으로 만들었을까?
이유는 간단하다 우선 아래 코드를 살펴보자

a.js

var init = function(){

}

var save = function(){

}

단순히 이러한 코드만 보면 문제가 없어보인다.
하지만 a.js와 같은 식별자를 사용하는 코드가 존재한다면 어떻게 될까?

b.js

var init = function(){

}

var save = function(){

}

이름이 같기 때문에 먼저 적용된 코드는 묻히고 뒤에 작성된 코드가 사용될 것이다.

var a = {
     init : function(){}
     save : function(){}
}

var b = {
     init : function(){}
     save : function(){}
}

그렇기에 이를 막고자 JavaScript의 객체의 특성을 이용하여 위와 같이 코드를 작성했으며
a.init() / a.save()b.init() / b.save() 이렇게 겹치지 않게 사용할 수 있게해준다.
이를 네임스페이스 기법이라고 부르기도 한다.

이제 작성된 index.js를 적용시키기 위해 footer.mustache에 추가해주자

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>

등록 기능까지 끝냈으므로 실제로 한번 구동시켜서 테스트 해보자

1.4. 전체 조회 화면 만들기

조회 UI를 위해 index.mustache를 조금 수정해보자

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>
    <br>
    <!-- 목록 출력 영역 -->
    <table class="table table-horizontal table-bordered">
        <thead class="thead-string">
            <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>
{{>layout/footer}}

코드 설명

{{#posts}}

     * posts라는 List를 순회한다.  
     * Java의 for문과 동일하게 생각하면 된다.  
_____________________________________________________________________________
{{#id}}등의 {{변수명}}

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

이제 조회를 위해서 기존에 있던 Controller, Service, Repository 에 코드를 추가해주자

PostsRepository

package com.jojoldu.book.springboot.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에서 제공해주는 CRUD 외에도 추가로 쿼리를 작성을 해도 된다.
그럴때는 @Query로 사용해주면 된다.

PostsService

package com.jojoldu.book.springboot.service.posts;

import com.jojoldu.book.springboot.domain.posts.Posts;
import com.jojoldu.book.springboot.domain.posts.PostsRepository;
import com.jojoldu.book.springboot.web.dto.PostsListResponseDto;
import com.jojoldu.book.springboot.web.dto.PostsResponseDto;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
import com.jojoldu.book.springboot.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;


@RequiredArgsConstructor
@Service
public class PostsService {
    private final PostsRepository postsRepository;

    @Transactional
    public Long save(PostsSaveRequestDto requestDto){
        return postsRepository.save(requestDto.toEntity()).getId();
    }

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

        return id;
    }

    @Transactional
    public PostsResponseDto findById(Long id){
        Posts entity = postsRepository.findById(id).orElseThrow(() -> new
                IllegalArgumentException("헤당 게시글이 없습니다. id="+id));
        return new PostsResponseDto(entity);
    }

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

}

findAllDesc 메소드의 트랜잭션 어노테이션@Transactional에 옵션이 하나 추가되었다.
바로 (readOnly=true)인데 이 옵션을 추가해주면 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도가 개선되기 때문에 등록, 수정, 삭제 기능이 없는 서비스 메소드에 사용하는 것이 좋다.

위 코드에서는 람다식이 사용되었는데 모르는 사람들을 위해 해석을 진행해보면 이렇다.

findAllDesc() -> List<Posts> 반환
.stream() -> List에 저장된 각각의 원소들에 대하여 stream 진행 -> 전체가 아니라 Posts 하나씩 로직 처리하게   
.map(PostsListResponseDto::new) -> PostsListResponseDto 생성자 호출 
                                -> 사실 .map(posts -> new PostsListResponseDto(posts))  같은 의미  
                                -> 이는 stream으로 인해 하나씩 분리된 posts를 인자로 넘겨 PostsListResponseDto 생성자의 매개변수로 넣는다.
                                -> 아직 PostsListResponseDto는 만들지 않았지만 만들면 생성자의 매개변수로 Posts 변수로 만들어야 한다

즉, PostsRepository 결과로 넘어온 List에 저장된 각각의 Posts 를 PostsListResponseDto 만들고 다시 리스트에 넣는다.
그리고 PostsListResponseDto 를 아직 생성하지 않았으니 이 클래스 역시 패키지/web/dto에 생성해주자

PostsListResponseDto

package com.jojoldu.book.springboot.web.dto;

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

위에서 언급했듯이 .map(PostsListResponseDto::new) 를 수행하기 위해서 생성자 매개변수 타입은 Posts 이다.

이제 마지막으로 Controller를 수정한다

IndexController

package com.jojoldu.book.springboot.web;

import com.jojoldu.book.springboot.config.auth.LoginUser;
import com.jojoldu.book.springboot.config.auth.dto.SessionUser;
import com.jojoldu.book.springboot.service.posts.PostsService;
import com.jojoldu.book.springboot.web.dto.PostsResponseDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

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

     * 서버 탬플릿 엔진에서 사용할  있는 객체를 저장할  있다.  
     * 여기서 postsService.findAllDesc() 가져온 결과를 posts로 index.mustache에 전달한다.  

Controller 까지 모두 완성되었으니 데이터를 등록해보고 조회 목록에 나오는지 확인해보자!!

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

마지막으로 게시글 수정, 삭제 화면을 만들어보자
게시글 수정 API는 이미 만들어 두었으니 삭제 API를 만들어주고 수정 삭제 화면을 만들면 된다.

1.5.1. 게시글 수정

게시글 수정 화면 머스테치 파일을 생성한다.

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

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

     * Input 태그에 읽기 가능만 허용하는 속성이다.  
     * id와 author는 수정할 수 없도록 읽기만 허용하도록 추가한다.  

그리고 btn-update 버튼을 클릭하면 update 기능을 호출할 수 있게
index.js 파일에도 update function을 하나 추가하자

index.js

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을 실행하도록 이벤트를 등록한다. 
___________________________________________________________________________________________________________________
update : function(){}

     * 신규로 추가될 update function 입니다.   
___________________________________________________________________________________________________________________
type : 'PUT'

     * 여러 HTTP Method  PUT 메소드를 선택합니다.  
     * PostsApiController에 있는 API에서 이미 @PutMapping으로 선언했기 때문에 PUT을 사용해야 한다.  
     참고로 이는 REST 규약에 맞게 설정된 것이다.  
     * REST에서 CRUD는 다음과 같이 HTTP Method에 매핑된다.  
          생성(create) - POST
          읽기(read) - GET
          수정(update) - PUT
          삭제(delete) - DELETE
___________________________________________________________________________________________________________________
url: '/api/v1/posts/'+id

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

마지막으로 전체 목록에서 수정 페이지로 이동할 수 있게 index.mustache 에 페이지 이동 기능을 추가해주자

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>
    <br>
    <!-- 목록 출력 영역 -->
    <table class="table table-horizontal table-bordered">
        <thead class="thead-string">
            <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>
{{>layout/footer}}

코드 설명

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

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

화면 작업이 끝났으므로 Controller 코드를 수정해주자

indexController

package com.jojoldu.book.springboot.web;

import com.jojoldu.book.springboot.config.auth.LoginUser;
import com.jojoldu.book.springboot.config.auth.dto.SessionUser;
import com.jojoldu.book.springboot.service.posts.PostsService;
import com.jojoldu.book.springboot.web.dto.PostsResponseDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

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

    @GetMapping("/posts/update/{id}")
    public String postsUpdate(@PathVariable Long id, Model model){

        PostsResponseDto dto = postsService.findById(id);
        model.addAttribute("post",dto);
        return "posts-update";
    }
}

이제 앞서 작성한 코드들을 토대로 실제로 동작시켜보자

1.5.2. 게시글 삭제

삭제 버튼은 본문을 확인하고 진행해야 하므로 수정 화면에 추가해주자

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

코드 설명

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

     * 삭제 버튼을 수정 완료 버튼 옆에 추가합니다.  
     * 해당 버튼 클릭시 JS에서 이벤트를 수신할 예정입니다. 

삭제 이벤트를 진행할 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()

type은 DELETE를 제외하고는 update function과 크게 차이 나진 않습니다.
이제 삭제 API를 만들어볼 차례입니다.

PostsService

package com.jojoldu.book.springboot.service.posts;

import com.jojoldu.book.springboot.domain.posts.Posts;
import com.jojoldu.book.springboot.domain.posts.PostsRepository;
import com.jojoldu.book.springboot.web.dto.PostsListResponseDto;
import com.jojoldu.book.springboot.web.dto.PostsResponseDto;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
import com.jojoldu.book.springboot.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;


@RequiredArgsConstructor
@Service
public class PostsService {
    private final PostsRepository postsRepository;

    @Transactional
    public Long save(PostsSaveRequestDto requestDto){
        return postsRepository.save(requestDto.toEntity()).getId();
    }

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

        return id;
    }

    @Transactional
    public PostsResponseDto findById(Long id){
        Posts entity = postsRepository.findById(id).orElseThrow(() -> new
                IllegalArgumentException("헤당 게시글이 없습니다. id="+id));
        return new PostsResponseDto(entity);
    }

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

    @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

package com.jojoldu.book.springboot.web;

import com.jojoldu.book.springboot.service.posts.PostsService;
import com.jojoldu.book.springboot.web.dto.PostsResponseDto;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
import com.jojoldu.book.springboot.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RequiredArgsConstructor
@RestController
public class 
{
    private final PostsService postsService; // 생성자로 주입

    @PostMapping("/api/v1/posts")
    public Long save(@RequestBody PostsSaveRequestDto requestDto){
        return postsService.save(requestDto);
    }

    @PutMapping("/api/v1/posts/{id}")
    public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto){
        return postsService.update(id, requestDto);
    }

    @GetMapping("/api/v1/posts/{id}")
    public PostsResponseDto findById (@PathVariable Long id) {
        return postsService.findById(id);
    }

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

}

컨트롤러까지 생성되었으니 실제로 한번 테스트를 진행해보자