머스테치로 화면 구성 - 루타블의 개발일기

김주영·2022년 7월 7일
0

[본 글은 프로젝트 과정을 기록할 목적으로 작성되었으며 아래 교재에 기반하여 작성됨]

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


일반적으로 웹 개발에 있어 템플릿 엔진이란, 지정된 템플릿 양식과 데이터가 합쳐져 HTML 문서를 출력하는 소프트웨어를 말한다.

🌿 서버 템플릿 엔진 vs 클라이언트 템플릿 엔진

  • 동작하는 영역에서 차이

📢 서버 템플릿 엔진

서버에서 구동되며 화면 생성은 서버에서 Java코드로 문자열을 만든 뒤 이 문자열을 HTML로 변환하여 브라우저로 전달한다. 여기서 서버 템플릿 엔진은 javascript와 같은 클라이언트 템플릿 엔진을 단순한 문자열로 취급한다.

ex) JSP, Freemarker

📢 클라이언트 템플릿 엔진

브라우저 위에서 작동하며, 브라우저에서 화면을 생성한다. 즉, 서버에서 이미 코드가 벗어난 경우다.

서버에서는 JSON 혹은 Xml 형식의 데이터만 전달하고 클라이언트에서 조립한다.

🌿 머스테치

  • 수많은 언어를 지원하는 가장 심플한 템플릿 엔진이다. JSP와 같이 HTML을 만들어 주는 템플릿 엔진이다.

  • 현존하는 대부분 언어를 지원하며, 사용되는 언어에 따라 서버 템플릿 엔진과 클라이언트 템플릿 엔진으로 모두 사용할 수 있다.

📢 장점

  1. 문법이 다른 템플릿 엔진보다 심플하다.
  2. 로직 코드를 사용할 수 없어 View의 역할과 서버의 역할이 명확하게 분리된다.
  3. Mustache.js와 Mustache.java 2가지가 다 있어, 하나의 문법으로 클라이언트/서버 템플릿을 모두 사용 가능하다.
  4. InteliiJ 커뮤니티 버전을 사용해도 플러그인을 사용할 수 있다.
    • 문법 체크, HTML 문법 지원, 자동완성 등 지원

교재 : 템플릿 엔진은 화면 역할에만 충실해야 한다고 생각한다. 너무 많은 기능을 제공하면 API와 템플릿 엔진, 자바스크립트가 서로 로직을 나눠 갖게 되어 유지보수하기가 굉장히 어렵다.

🔧 Handlebars/Mustache 플러그인을 설치

🌱 기본 페이지 생성


🌿 build.gradle

🔧 머스테치 스타터 의존성을 등록

dependencies {
    compile('org.springframework.boot:spring-boot-starter-web')
    compile('org.projectlombok:lombok')
    compile('org.springframework.boot:spring-boot-starter-data-jpa')
    compile('com.h2database:h2')
    compile('org.springframework.boot:spring-boot-starter-mustache') //추가
    testCompile('org.springframework.boot:spring-boot-starter-test')
}

머스테치는 스프링 부트에서 공식 지원하는 템플릿 엔진이다.

src/main/resources/templates : 머스테치의 기본 파일 위치

이 위치에 머스테치 파일을 두면 스프링 부트에서 자동으로 로딩한다.

🌿 index.mustache

🔧 첫 페이지를 담당할 index.mustache를 머스테치의 기본 파일 위치에 생성

<!DOCTYPE HTML>
<html>
<head>
    <title>🍁 ~Rootable's Free Board~ 🍁</title>
    <meta http-equiv="Content-Type" content="text/html"; charset="UTF-8" />

</head>
<body>
    <h1>Welcome To Rootable's Free Board</h1>
</body>
</html>

🌿 IndexController

🔧 index.mustache의 요청을 받고 처리할 IndexController를 web 패키지에 생성

package com.bbs.projects.bulletinboard.web;

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

@Controller
public class IndexController {
    
    //첫 페이지 호출 시, index 뷰를 호출
    @GetMapping("/")
    public String index() {
        return "index";
    }
    
}

@Controller로 지정했기 때문에 컴포넌트 스캔 대상으로 자동 빈 등록되고, 루트 페이지("/") 조회 시 index 뷰로 매핑된다.

머스테치 스타터 덕분에 컨트롤러에서 문자열을 반환할 때 앞의 경로와 뒤의 파일 확장자는 자동으로 지정된다.

앞의 경로 : src/main/resources/templates
뒤의 확장자 : .mustache
최종 URL : src/main/resources/templates/index.mustache

최종 물리적 URL을 조립하여 반환하는 것은 ViewResolver의 역할이다.

🌿 IndexControllerTest

package com.bbs.projects.bulletinboard.web;

import org.assertj.core.api.Assertions;
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.*;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.*;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = RANDOM_PORT)
public class IndexControllerTest {

    //필드 주입
    @Autowired
    private TestRestTemplate restTemplate;

    //index 페이지 호출 테스트
    @Test
    public void load_main() throws Exception{
        //when
        //루트 페이지에 Get 요청 결과를 문자열 객체로 받음
        String body = this.restTemplate.getForObject("/", String.class);

        //then
        //루트 페이지에 해당 문자열이 있는지 검증
        assertThat(body).contains("Welcome To Rootable's Free Board");
    }

}

index 페이지 호출을 테스트한다. 마찬가지로 HTTP API 환경에서 JPA와 함께 통합 테스트를 하기 위해 @SpringBootTest와 TestRestTemplate를 함께 사용했다. 또한, 루트 페이지를 조회한 결과 String 객체 내용에 작성했던 내용이 있는지 검증한다.

📢 getForObject

주어진 URL 주소로 HTTP GET 메소드로 요청하여 객체로 결과를 받는다.

🌱 게시글 등록 화면


화면에 HTML/CSS/JS 등을 입히기 위해 부트스트랩을 사용한다. 방법은 외부 CDN을 사용하거나 직접 라이브러리를 받아서 사용하는 방법이 있는데 여기서는 외부 CDN을 사용한다. 또한, 바로 추가하지 않고 공통 영역을 별도의 파일로 분리하여 필요한 곳에서 가져다 쓰는 레이아웃 방식으로 추가한다.

2개의 라이브러리 부트스트랩과 제이쿼리를 index.mustache에 추가해야 한다. 이들은 머스테치 화면 어디서나 필요하다. 매번 해당 라이브러리를 머스테치 파일에 추가하는 것은 번거럽기 때문에 레이아웃 파일들을 만들어 추가한다.

🔧 src/main/resources/templates 디렉토리에 layout 디렉토리를 추가로 생성

🔧 layout 디렉토리에 footer.mustache, header.mustache 파일을 생성

🌿 header.mustache

<!DOCTYPE HTML>
<html>
<head>
    <title>🍁 ~Rootable's Free Board~ 🍁</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는 header에 두었다. css는 화면을 그리는 역할이므로 head에서 불러오는 것이 좋다. 그렇지 않으면 css가 적용되지 않은 깨진 화면을 사용자가 볼 수 있기 때문이다.

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

js는 footer에 두었다. HTML은 위에서부터 코드가 실행되기 때문에 head가 다 실행되고서야 body가 실행된다. 즉, head가 다 불러지지 않으면 사용자 쪽에선 백지 화면만 노출된다. 특히 js의 용량이 크면 클수록 body 부분의 실행이 늦어지기 때문에 js는 body 하단에 두어 화면이 다 그려진 뒤에 호출하는 것이 좋다.

추가로, bootstrap.js의 경우 제이쿼리가 꼭 있어야만 하기 때문에 부트스트랩보다 먼저 호출되도록 코드를 작성했다. 보통 앞선 상황을 bootstrap.js가 제이쿼리에 의존한다고 한다.

🌿 변경된 index.mustache

{{>layout/header}}

<h1>Welcome To Rootable's Free Board</h1>

{{>layout/footer}}

📢 {{>}}

현재 머스테치 파일(index.mustache)을 기준으로 다른 파일을 가져온다.

🌿 index.mustache에 글 등록 버튼 추가

{{>layout/header}}

<h1>Welcome To Rootable's Free Board</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}}

bootstrap에 등록된 class를 입혀 화면에 css 속성이 추가되었다. "새 글 등록"이라는 버튼을 클릭하면 글 등록 페이지로 이동하며, 이동할 페이지의 주소는 "/posts/save"이다.

🌿 IndexController에 API 추가

package com.bbs.projects.bulletinboard.web;

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

@Controller
public class IndexController {

    //첫 페이지 호출 시, index 뷰를 호출
    @GetMapping("/")
    public String index() {
        return "index";
    }

	//글 등록 화면으로 이동하기 위해 posts-save 뷰를 호출
    @GetMapping("/posts/save")
    public String postsSave() {
        return "posts-save";
    }
}

"/posts/save" URL 요청(새 글 등록 버튼 클릭) 시, "posts-save"라는 이름의 뷰를 호출하도록 한다.

🌿 posts-save.mustache

🔧 templates 패키지 아래 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}}

🌿 index.js

게시글 등록 화면의 등록 버튼에 기능을 부여한다. 이를 위해서는 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()
        };

      	//컨트롤러로 api 요청
        $.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();

📢 function()

브라우저의 스코프(Scope)는 공용 공간으로 쓰이기 때문에 중복된 함수 이름이 발생할 경우 먼저 로딩된 js의 function을 덮어쓰게 된다. 따라서, index.js만의 유효범위를 만들어 사용해야 한다.

📢 main.init()

글 등록 버튼을 클릭하면 this(main)의 save 함수가 호출되도록 했다.

📢 main.save()

data 변수로 title, author, content 값을 받아온다. 그 후에 $.ajax를 통해 서버로 HTTP 요청을 수행한다. POST 요청이고, 요청하는 Controller는 url에 따라 PostsApiController의 save()이다. data 변수는 단순 문자열 객체이므로 HTTP API 통신에서 JSON으로 변환하여 message body에 태워야 한다.

요청이 성공(.done)하면 등록 알람을 주고 index 페이지로 이동한다. 반면, 요청이 실패(.fail)하면 오류 메시지를 출력한다.

🌿 footer.mustache에 index.js 추가

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

절대 경로(/)로 바로 시작하는 이유는 스프링 부트는 기본적으로 src/main/resources/static에 위치한 JS, CSS, 이미지 등 정적 파일들을 URL에서 /로 설정되기 때문이다.

🌱 전체 조회 화면 만들기


🌿 index.mustache에 목록 출력 영역 추가

{{>layout/header}}

<h1>Welcome To Rootable's Free Board</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를 순회한다. 자바의 for문과 동일하게 생각하면 된다. 만약, posts가 List나 배열과 같은 iterator 속성이 없다면 조건문이 된다.

🔎 뒷 부분에 보면 IndexController에서 Model 객체를 통해 posts라는 이름으로 postsService.findAllDesc() 값을 넣어주었다. 이것은 결과가 List이다. Model 객체는 객체를 지정한 이름으로 서버 템플릿 엔진에 전달할 수 있다.

📢 {{변수명}}

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

🌿 PostsRepository에 쿼리문 추가

package com.bbs.projects.bulletinboard.domain.posts;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;

//DAO + 자동 Bean 등록
public interface PostsRepository extends JpaRepository<Posts, Long> {

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

}

SpringDataJpa에서 제공하지 않는 메소드는 위처럼 쿼리로 작성해도 된다. 사실, 위 코드는 SpringDataJpa에서 제공하는 기본 메소드만으로 해결할 수 있지만 @Query가 훨씬 가독성이 좋아 이렇게 작성했다.

📢 @Query

정적 쿼리를 작성하여 해당 메소드가 해당 쿼리 기능을 수행하도록 함

📝 (참고) Querydsl 프레임워크

규모가 있는 프로젝트에서의 데이터 조회는 FK의 조인, 복잡한 조건 등으로 인해 이런 Entity 클래스만으로 처리하기 어려워 조회용 프레임워크를 추가로 사용한다. 등록/수정/삭제 등은 SpringDataJpa를 통해 진행한다.

ex) querydsl, jooq, MyBatis

  • Querydsl 추천 이유
  1. 타입 안정성이 보장된다.

    • 단순한 문자열로 쿼리를 생성하는 것이 아니라, 메소드를 기반으로 쿼리를 생성하기 때문에 오타나 존재하지 않는 컬럼명을 명시할 경우 IDE에서 자동으로 검출된다. 이것은 jooq도 지원하지만 MyBatis는 지원하지 않는다.
  2. 국내 많은 회사에서 사용 중이다.

  3. 레퍼런스가 많다.

🌿 PostsService에 findAllDesc() 구현

package com.bbs.projects.bulletinboard.service.posts;

import com.bbs.projects.bulletinboard.domain.posts.Posts;
import com.bbs.projects.bulletinboard.domain.posts.PostsRepository;
import com.bbs.projects.bulletinboard.web.dto.PostsResponseDto;
import com.bbs.projects.bulletinboard.web.dto.PostsSaveRequestDto;
import com.bbs.projects.bulletinboard.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 {

    //생성 시점에 PostsRepository 의존성을 받음
    private final PostsRepository postsRepository;

    ...
    
    //해당 DTO를 받아 가공 후 List로 반환
    @Transactional(readOnly = true)
    public List<PostsListResponseDto> findAllDesc() {
        return postsRepository.findAllDesc().stream()
                .map(PostsListResponseDto::new)
                .collect(Collectors.toList());
    }
    
}

content와 createdDate를 제외한 컬럼값들을 DB에서 조회해서 받을 것이므로 새로운 DTO가 필요하다. map을 통해 조회한 레코드를 PostsResponseDto에 맞게 new(생성)하고, 그것을 collect를 통해 List로 변환하여 반환한다.

📢 @Transactional(readOnly = true)

트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도가 개선되기 때문에 등록, 수정, 삭제 기능이 전혀 없는 서비스 메소드에서 사용하는 것을 추천한다.

📢 map()

요소들을 특정 조건에 해당하는 값으로 변환해 준다.

📢 collect()

map, filter, sorted 등으로 가공된 데이터를 리턴해줄 결과를 만들어 준다.

🌿 PostsListResponseDto

🔧 web.dto 아래 PostsListResponseDto를 추가한다.

package com.bbs.projects.bulletinboard.web.dto;

import com.bbs.projects.bulletinboard.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;

	//index 화면 렌더링용 dto
    public PostsListResponseDto(Posts entity) {
        this.id = entity.getId();
        this.title = entity.getTitle();
        this.author = entity.getAuthor();
        this.modifiedDate = entity.getModifiedDate();
    }
}

🌿 IndexController 수정

package com.bbs.projects.bulletinboard.web;

import com.bbs.projects.bulletinboard.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;

    //첫 페이지 호출 시, index 뷰를 호출
    @GetMapping("/")
    public String index(Model model) {
    	//Model 객체에 findAllDesc 결과를 담아 index 뷰에 전달
        model.addAttribute("posts", postsService.findAllDesc());
        return "index";
    }

	//글 등록 화면으로 이동하기 위해 posts-save 뷰를 호출
    @GetMapping("/posts/save")
    public String postsSave() {
        return "posts-save";
    }
}

Model 객체를 통해 posts라는 키로 서버 템플릿 엔진(index.mustache)에 값(postsService.findAllDesc)을 전달했다.

🌱 게시글 수정 구현


🌿 posts-update.mustache

🔧 templates 아래 posts-update.mustache 추가

{{>layout/header}}

<h1>게시글 수정</h1>

<div class="col-md-12">
    <div class="col-md-4">
        <form>
            <div class="form-group">
                <label for="id">글 번호</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}}

PostsApiController의 update() 메소드와 매칭될 뷰를 생성한다.

📢 {{post.id}}

뒷 부분에서 IndexController의 postsUpdate()가 Model 객체를 통해 id, title, content, author 필드들을 담은 DTO를 post라는 키에 담아 뷰에 전달했다. 따라서, Post 클래스의 해당 속성 값에 접근할 수 있다.

📢 readonly

id와 author는 수정할 수 없도록 읽기만 허용하도록 한다.

🌿 index.js에 update 이벤트 추가

var main = {
  	//각 버튼에 대한 이벤트 처리
    init : function () {
        var _this = this;
        ...
        $('#btn-update').on('click', function () {
            _this.update();
        });
    },
  	//저장
    save : function () {
            ...
        },
    //수정
    update : function () {
      		//각 속성 값 포장
            var data = {
                title: $('#title').val(),
                content: $('#content').val()
            };

      		//페이지 구분을 위한 id 값 추출
            var id = $('#id').val();

      		//컨트롤러로 api 요청
            $.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();

게시글 수정 뷰(posts-update)에서 HTTP 요청을 수행하여 컨트롤러가 수정 작업을 처리할 수 있도록 JS에 update를 추가한다.

🔎 흐름 정리(수정 PUT)

  1. main.init()
    • 클릭 이벤트 후 main.update() 호출
  2. main.update()
    • 수정된 title과 content(브라우저 상)를 data로 포장
    • 현재 id 값을 추출하여 호출할 url 완성
    • AJAX를 통해 HTTP 요청 패킷을 완성하여 요청하고 성공 시 알림, 실패 시 에러 메시지를 알리도록 함
  3. PostsApiController.update()
    • URL을 확인하고 자신과 일치하므로 응답
    • id를 매개변수로 받고, title과 content는 DTO를 통해 받음
    • 전달 받은 속성 값들을 서비스의 update()에 보냄
  4. PostsService.update()
    • findById를 통해 DB에서 해당 id를 가진 레코드(Posts) 추출
    • Posts의 update()를 통해 Posts의 title과 content값 수정(서버 상)
    • id 값 반환

📢 REST 규약

  • 생성(Create) - POST
  • 읽기(Read) - GET
  • 수정(Update) - PUT
  • 삭제(Delete) - DELETE

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

🌿 IndexController에 수정 화면 조회 API 추가

package com.bbs.projects.bulletinboard.web;

import com.bbs.projects.bulletinboard.service.posts.PostsService;
import com.bbs.projects.bulletinboard.web.dto.PostsResponseDto;
import com.sun.org.apache.xpath.internal.operations.Mod;
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("/posts/update/{id}")
    public String postsUpdate(@PathVariable Long id, Model model) {
        PostsResponseDto dto = postsService.findById(id);
        //Model 객체에 해당 DTO 속성 값을 담아 뷰에 전달
        model.addAttribute("post", dto);

        return "posts-update";
    }
}

수정 화면 url과 매칭되는 수정 화면 조회 api를 추가했다. 화면에 렌더링할 id, title, content, author 값이 필요하기 때문에 서비스의 findById()를 호출한 결과 DTO가 필요하고, 이를 Model 객체를 통해 렌더링을 담당하는 뷰로 전달했다.

제목에 링크가 생긴 것을 볼 수 있다.

글 번호와 작성자는 readonly가 적용된 것을 볼 수 있다.

🔎 흐름 정리(수정 GET)

  1. index.mustache
    • index.mustache에 title의 링크에 의해 "/posts/update/id"로 이동한다.
  2. IndexController.postsUpdate()
    • URL 요청을 받은 API는 뷰에 전달할 DTO를 Model에 담는다.
    • posts-update.mustache 호출
  3. posts-update.mustache
    • 컨트롤러로부터 받은 post로 화면 렌더링을 수행한다.

🌱 게시글 삭제 구현


🌿 posts-update.mustache 삭제 버튼 추가

🔧 삭제 버튼은 본문을 확인하고 진행해야 하므로, 수정 화면(posts-update)에 추가하겠다.

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

🌿 index.js 삭제 이벤트 추가

🔧 해당 버튼 클릭 시 이벤트가 발생하도록 삭제 이벤트를 진행할 JS 코드를 index.js에 추가한다.

var main = {
  	//각 버튼에 대한 이벤트 처리
    init : function () {
        ...
        $('#btn-delete').on('click', function () {
            _this.delete();
        });
    },
 
  	...
  
  	//삭제
    delete : function () {
      	//페이지 구분을 위한 id 값 추출
        var id = $('#id').val();
        
      	//컨트롤러로 api 요청
        $.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();

🌿 PostsService 삭제 로직 구현

package com.bbs.projects.bulletinboard.service.posts;

import com.bbs.projects.bulletinboard.domain.posts.Posts;
import com.bbs.projects.bulletinboard.domain.posts.PostsRepository;
import com.bbs.projects.bulletinboard.web.dto.PostsListResponseDto;
import com.bbs.projects.bulletinboard.web.dto.PostsResponseDto;
import com.bbs.projects.bulletinboard.web.dto.PostsSaveRequestDto;
import com.bbs.projects.bulletinboard.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 {

...

	//게시글 존재 여부 확인 후 삭제
	@Transactional
    public void delete(Long id) {
        Posts posts = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));

        postsRepository.delete(posts);
    }
    
}

findById()를 통해 존재하는 글인지 확인하고, 해당 게시글을 JpaRepository의 CRUD 인터페이스를 통해 삭제한다.

🌿 PostsApiController에서 삭제 기능 사용

🔧 서비스가 구현한 삭제 로직을 컨트롤러가 사용하도록 하자.

package com.bbs.projects.bulletinboard.web;

import com.bbs.projects.bulletinboard.service.posts.PostsService;
import com.bbs.projects.bulletinboard.web.dto.PostsResponseDto;
import com.bbs.projects.bulletinboard.web.dto.PostsSaveRequestDto;
import com.bbs.projects.bulletinboard.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RequiredArgsConstructor
@RestController
public class PostsApiController {

    ...

	//js의 삭제 요청을 받아 서비스의 삭제 기능을 호출
    @DeleteMapping("/api/v1/posts/{id}")
    public Long delete(@PathVariable Long id) {
        postsService.delete(id);
        return id;
    }
    
}

🔎 흐름 정리

  1. posts-update.mustache
    • 삭제 버튼을 클릭
  2. main.init
    • 삭제 이벤트 수신 후 main.delete 호출
  3. main.delete
    • 삭제 API를 요청하여 매핑된 컨트롤러가 처리하도록 함
  4. PostsApiController.delete
    • JS의 요청을 받아 서비스의 삭제 기능을 호출
    • 서비스에서 처리 후 id 값 반환
  5. PostsService.delete
    • 게시글 존재 여부 확인 후 삭제

삭제 버튼이 생긴 것을 볼 수 있다.

삭제 버튼을 클릭하면 삭제 alert가 발생한다.

게시글이 삭제된 것을 확인할 수 있다.

여기까지 기본적인 게시판 기능이 완성되었다. 😁😁

다음 챕터에서 로그인 기능을 만들어보도록 하겠다. ~💪

0개의 댓글