스프링 부트 시작(개발부터 빌드까지) - 5

PM/iOS Developer KimKU·2022년 8월 20일
0
post-thumbnail

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

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

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

먼저 템플릿 엔진이란 무엇인지 소개하겠다. 일반적으로 웹 개발에 있어 템플릿 엔진이란, 지정된 템플릿 양식과 데이터가 합쳐저 HTML 문서를 출력하는 소프트웨어를 이야기한다. 예전에 스프링이나 서블릿을 사용해본 사람들은 아마도 JSP, Freemarker 등을 떠올릴 것이고, 요즘 개발한 사람들은 리액트, 뷰의 View 파일들을 떠올릴 것이다.

둘 모두 결과적으로 지정된 템플릿과 데이터를 이용하여 HTML을 생성하는 템플릿 엔진이다.

다만, 조금의 차이가 있다. 전자는 서버 템플릿 엔진이라 불리며, 후자는 클라이언트 템플릿 엔진이라고 불린다. 개발을 시작하는 많은 개발자들이 이 둘 간에 많은 오해를 한다.

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

반면에 자바스크립트는 브라우저 위에서 작동한다. 앞에서 작성된 자바스크립트 코드가 실행되는 장소는 서버가 아닌 브라우저다. 즉, 브라우저에서 작동될 때는 서버 템플릿 엔진의 손을 벗어나 제어할 수 없다.

흔히 이야기하는 Vue.js 나 React.js 를 이용한 SPA 는 브라우저에서 화면을 생성한다. 즉, 서버에서 이미 코드가 벗어난 경우이다. 그래서 다음과 같이 서버에서는 Json 혹은 Xml 형식의 데이터만 전달하고 클라이언트에서 조립힌다.
ㅇ

물론 최근엔 리액트나 뷰와 같은 자바스크립트 프레임워크에서 서버 사이드 랜더링을 지원하는 모습을 볼 수 있다. 간단하게 설명하자면, 자바스크립트 프레임워크의 화면 생성 방식을 서버에서 실행하는 것을 이야기한다. 이는 V8 엔진 라이브러리들이 지원하기 때문이며, 스프링 부트에서 사용할 수 있는 대표적인 기술들로는 Nashorn, J2V8 이 있다.

다만 스프링 부트를 사용하면서 자바스크립트를 서버사이드에서 랜더링하도록 구현하는 것은 많은 수고가 필요하므로 시작하는 단계에서 추천하지는 않는다. 스프링 부트에 대한 이해도와 자바스크립트 프레임워크 양쪽에 대한 이해도가 높아졌을 때 시도해 보면 좋겠다.

5.2 머스테치란

머스테치는 수많은 언어를 지원하는 가장 심플한 템플릿 엔진이다.
ㄴ

루비, 자바스크립트, 파이썬, PHP, 자바, 펄, Go, ASP 등 현존하는 대부분 언어를 지원하고 있다. 그러다 보니 자바에서 사용될 때는 서버 템플릿 엔진으로, 자바스크리브에서 사용될 때는 클라이언트 템플릿 엔진으로 모두 사용할 수 있다.

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

하지만 머스테치의 장점때문에 사용하는데, 장점은 다음과 같다.

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

앞에서 언급한 장점 외에 머스테치를 사용할 때 장점이 하나 더 있다. 그건 바로 인텔리제이 커뮤니티 버전을 사용해도 플러그인을 사용할 수 있다는 것이다.

thymemleaf 나 JSP 등은 커뮤니티 버전에서 지원하지 않고 인텔리제이 얼티메이트 버전(유료)에서만 공식 지원한다. 머스테치는 이와 달리 커뮤니티 버전에서도 설치 가능한 플러그인이 있다. 그래서 커뮤니티 버전을 사용하는 개발자들은 특히나 머스테치를 사용하는 것이 좋다. 이 플러그인을 이용하면 머스테치의 문법체크, HTML 문법 지원, 자동완성 등이 지원되니 개발할 때 큰 도움을 받을 수 있다.

다음과 같이 'mustache' 를 검색해서 해당 플러그인을 설치하면 된다.
d

5.3 기본 페이지 만들기

가장 먼저 스프링 부트 프로젝트에서 머스테치를 편하게 사용할 수 있도록 머스테치 스타터 의존성을 build.gradle에 등록한다.

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

보는 것처럼 머스테치는 스프링 부트에서 공식 지원하는 템플릿 엔진이다. 의존성 하나만 추가하면 다른 스타터 패키지와 마찬가지로 추가 설정없이 설치가 끝이다. 별도로 스프링 부트 버전을 개발자가 신경 쓰지 않아도 되는 장점도 있다.

머스테치의 파일 위치는 기본적으로 src/main/resources/templates 이다. 이 위치에 머스테치 파일을 두면 스프링 부트에서 자동 로딩한다. 첫 페이지를 담당할 index.mustache 를 생성하자.
![d](https://velog.velcdn.com/images/kimku1018/post/e901b61a-07b9-4283-a53e-08492f449935/image.png

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>

간단하게 h1 크기로 "스프링 부트를 사용한 웹 서비스" 를 출력하는 페이지이다.

이 머스테치에 URL을 매핑한다. URL 매핑은 당연하게 Controller에서 진행한다. web 패키지 안에 IndexController 를 생성한다.
d

package com.example.springbootwebsite.web;

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

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

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

자 여기까지 코드가 완성되었으니 이번에도 테스트 코드로 검증해보겠다. test 패키지에 IndexControllerTest 클래스를 생성한다.

package com.example.springbootwebsite;

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("스프링 부트를 사용한 웹 서비스");
        
    }
}

이번 테스트는 실제로 URL 호출 시 페이지의 내용이 제대로 호출되는지에 대한 테스트이다.

HTML도 결국은 규칙이 있는 문자열이다. TestRestTemplate를 통해 "/"로 호출했을 때 index.mustache 에 포함된 코드들이 있는지 확인하면 된다. 전체 코드를 다 검증할 필요는 없으니. "스프링 부트를 사용한 웹 서비스" 문자열이 포함되어 있는지만 비교한다.

테스트 코드를 수행해보면 정상적으로 코드가 수행되는 것을 확인할 수 있다.
ㅇ
테스트 코드로 검증했지만, 그래도 이대로 넘어가기 아쉽다. 실제로 화면이 잘 나오는지 확이해 보겠다. Application.java 의 main 메소드를 실행하고 브라우저에서 http://localhost:8080 으로 접속해보겠다.
ㅇ

정상적으로 화면이 노출되는 것이 확인된다. 기본적인 화면 생성이 완성되었으니. 좀 더 다양한 주제로 가겠다.

5.4 게시글 등록 화면 만들기

이번에는 게시글 등록 화면을 구현해 보겠따. 앞서 PostsApiController 로 API를 구현하였으니 여기선 바로 화면을 개발한다. 아무래도 그냥 HTML만 사용하기에는 멋이 없다. 그래서 오픈소스인 부트스트랩을 이용하여 화면을 만들어 본다.

부트스트랩, 제이쿼리 등 프론트엔드 라이브러리를 사용할 수 있는 방법은 크게 2가지가 있다. 하나는 외부 CDN을 사용하는 것이고, 다른 하나는
직접 라이브러리를 받아서 사용하는 방법이다.

여기서는 전자인 외부 CDN을 사용한다. 본인의 프로젝트에서 직접 내려받아 사용할 필요도 없고, 사용 방법도 HTML/JSP/Mustache 에 코드만 한 줄 추가하면 되니 굉장히 간단하다.

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

이번에 추가할 라이브러리들인 부트스트랩과 제이쿼리는 머스테치 화면 어디서나 필요하다. 매번 해당 라이브러리를 머스테치 파일에 추가하는 것은 귀찮은 일이니, 레이아웃 파일들을 만들어 추가한다.

src/main/resources/templates 디렉토리에 layouy 디렉토리를 추가로 생성한다. 그리고 footer.mustache, header.mustache 파일을 생성한다.

  • 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>
</html>
  • 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의 위치가 서로 다르다. 페이지 로딩속도를 높이기 위해 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)을 기준으로 다른 파일을 가져온다.

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

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

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

package com.example.springbootwebsite.web;

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

@RequiredArgsConstructor
@Controller
public class IndexController {

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

index.mustache 와 마찬가지로 /posts/save를 호출하면 posts-save.mustache를 호출하는 메소드가 추가되었다. 컨트롤러 코드가 생성되었따면 posts-save.mustache 파일을 생성한다. 파일의 위치는 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="from-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}}

UI가 완성되었으니 다시 프로젝트를 실행하고 브라우저에서 http://localhost:8080/ 로 접근해 보겠다. '글 등록'이라고 되어있는 버튼을 클릭하면 글 등록 화면으로 이동한다.

d

하지만, 아직 게시글 등록 화면에 등록 버튼은 기능이 없다. API를 호출하는 JS가 전혀 없기 때문이다. 그래서 src/main/resourcesstatic/js/app 디렉토리를 생성한다.
d

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',
            data: JSON.stringify(data)
        }).done(function (){
            alert('글이 등록되었습니다.');
            window.location.href = '/';
        }).fail(function (error){
            alert(JSON.stringify(error));
        });
    }
};

main.init();

코드 설명하겠다.

window.location.href='/'

  • 글 등록이 성공하면 메인페이지(/)로 이동한다.

index.js의 첫 문장에 var main = {..}라는 코드를 선언했다. 굳이 index라는 변수의 속성으로 function을 추가한 이유는 무엇일까? 예를 들어 설명하겠다. index.js가 다음과 같이 function을 작성한 상황이라고 가정하겠다.

var init = function() {
  	....
	};
	
	var save = function () {
      	...
    };
      
    init();

index.mustache에서 a.js가 추가되어 a.js도 a.js만의 init과 save function이 있다면 어떻게 될까?

브라우저의 스코프는 공용 공간으로 쓰이기 때문에 나중에 로딩된 js의 init, save가 먼저 로딩된 js의 function을 덮어쓰게 된다.

여러 사람이 참여하는 프로젝트에서는 중복된 함수 이름은 자주 발생할 수 있다. 모든 function 이름을 확인하면서 만들 수는 없다. 그러다 보니 이런 문제를 피하려고 index.js만의 유효범위를 만들어 사용한다.

방법은 var.index이란 객체를 만들어 해당 객체에서 필요한 모든 function을 선언하는 것이다. 이렇게 하면 index 객체 안에서만 function이 유효하기 때문에 다른 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>

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

그래서 다음과 같이 파일이 위치하면 위치에 맞게 호출이 가능하다.

모든 코드가 완성되었다. 등록 기능을 브라우저에서 직접 테스트하겠다.
ㅇ
등록 버튼을 클릭하면 다음과 같이 "글이 등록되었습니다"라는 Alett이 노출된다.

5.5 전체 조회 화면 만들기

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

머스테치의 문법이 처음으로 사용된다.

코드 설명하겠다.

{{#posts}}

  • posts 라는 List를 순회한다.
  • Java의 for문과 동일하게 생각하면 된다.

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

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

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

기존에 있던 PostRepository 인터페이스에 쿼리가 추가된다.

package com.example.springbootwebsite.domain.posts;

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

import java.util.List;

public interface PostRepository extends JpaRepository<Posts, Long> {
    
    @Query("SELECT p FROM Posts p ORDER BY p.id DESC")
    List<Posts> findAllDesc();
}

SpringDataJpa에서 제공하지 않는 메소드는 위처럼 쿼리로 작성해도 되는 것을 보여주고자 @Query를 사용했다.

실제로 앞의 코드는 SpringDataJpa에서 제공하는 기본 메소드만으로 해결할 수 있다. 다만 @Query가 훨씬 가독성이 좋으니 선택해서 사용하면 된다.

Repository 다음으로 PostsService에 코드를 추가하겠다.

package com.example.springbootwebsite.service.posts;

import com.example.springbootwebsite.domain.posts.PostRepository;
import com.example.springbootwebsite.domain.posts.Posts;
import com.example.springbootwebsite.web.dto.PostsListResponseDto;
import com.example.springbootwebsite.web.dto.PostsResponseDto;
import com.example.springbootwebsite.web.dto.PostsSaveRequestDto;
import com.example.springbootwebsite.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 PostRepository postRepository;

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

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

        posts.update(requestDto.getTitle(), requestDto.getContent());

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

    public PostsResponseDto findByID (Long id){
        Posts entity = postRepository.findById(id).orElseThrow(()-> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));

        return new PostsResponseDto(entity);
    }
}

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

아직 PostsListResponseDto 클래스가 없기 때문에 이 클래스 역시 생성한다.

package com.example.springbootwebsite.web.dto;

import com.example.springbootwebsite.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();
    }
}

마지막으로 Controller를 변경하겠다.

package com.example.springbootwebsite.web;

import com.example.springbootwebsite.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에 전달된다.

Controller까지 모두 완성하였다. http://localhost:8080/ 로 접속한 뒤 등록 화면을 이용해 하나의 데이터를 등록해 본다. 그럼 다음과 같이 목록 기능이 정상적으로 자동하는 것을 확인할 수 있다.

ㅇ

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

마지막으로 게시글 수정, 삭제 화면을 만들어 보겠다. 게시글 수정 API는 이미 전에 만들어 두었다.

//PostsApiController

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

해당 API로 요청하는 화면을 개발하겠다.

게시글 수정

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

{{>layout/header}}
<h1>게시글 수정</h1>

<div class="col-md-12">
   <div class="col-md-4">
       <from>
           <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" ind="content">{{post.content}}</textarea>
           </div>
       </from>
       <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}}

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

readonly

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

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

vvar 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',
            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 코드를 다음과 같이 '살짝' 수정하겠다.

{{>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><a href="/posts/update/{{id}}">{{title}}</a> </td>
                    <td>{{author}}</td>
                    <td>{{modifiedDate}}</td>
                </tr>
            {{/posts}}
            </tbody>
        </table>
    </div>

{{>layout/footer}}

코드 설명하겠다.

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

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

화면쪽 작업이 다 끝났으니 수정 화면을 연결할 Controller 코드를 작업하겠다.

IndexController에 다음과 같이 메소드를 추가한다.

package com.example.springbootwebsite.web;

import com.example.springbootwebsite.service.posts.PostsService;
import com.example.springbootwebsite.web.dto.PostsResponseDto;
import com.example.springbootwebsite.web.dto.PostsUpdateRequestDto;
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";
    }
}

등록 화면과 조회 화면을 개발하면서 익숙해진 코드들이라 수정 코드는 크게 어려울 것이 없다.

그럼 수정 기능을 사용해 보겠다. 메인 화면으로 이동하면 타이틀 항목에 링크 표시가 된 것을 확인할 수 있다.
ㅇ

해당 링크를 클릭하면 수정 페이지로 이동한다. 글 번호와 작성자가 읽기전용 상태인 것을 확인한 뒤, 제목과 내용을 수정해 본다.
ㅇ
수정 완료 버튼을 클릭하면 수정 완료 메시지가 나타나며 제목은 '테스트'에서 '태스트2'로 변경된 것을 확인할 수 있다.
ㅇ
ㅇ

게시글 삭제

수정 기능이 정상적으로 구현되었으니, 삭제 기능도 구현해 본다. 삭제 버튼은 본문을 확인하고 진행해야 하므로, 수정 화면에 추가하겠다.

{{>layout/header}}
<h1>게시글 수정</h1>

<div class="col-md-12">
    <div class="col-md-4">
        <from>
            <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>
        </from>
        <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}}

코드 설명하겠다.

btn-delete

  • 삭제 버튼을 수정 완료 버튼 옆에 추가
  • 해당 버튼 클릭 시 JS에서 이벤트를 수신할 예정

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

        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를 만들어 보겠다. 먼저 서비스 메소드이다.

package com.example.springbootwebsite.service.posts;

import com.example.springbootwebsite.domain.posts.PostRepository;
import com.example.springbootwebsite.domain.posts.Posts;
import com.example.springbootwebsite.web.dto.PostsListResponseDto;
import com.example.springbootwebsite.web.dto.PostsResponseDto;
import com.example.springbootwebsite.web.dto.PostsSaveRequestDto;
import com.example.springbootwebsite.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 PostRepository postRepository;

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

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

        posts.update(requestDto.getTitle(), requestDto.getContent());

        return id;
    }

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

    @Transactional
    public void delete (Long id){
        Posts posts = postRepository.findById(id).orElseThrow(()->new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
        
        postRepository.delete(posts);
    }


    public PostsResponseDto findByID (Long id){
        Posts entity = postRepository.findById(id).orElseThrow(()-> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));

        return new PostsResponseDto(entity);
    }
}

postRepository.delete(posts)

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

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

package com.example.springbootwebsite.web;

import com.example.springbootwebsite.service.posts.PostsService;
import com.example.springbootwebsite.web.dto.PostsResponseDto;
import com.example.springbootwebsite.web.dto.PostsSaveRequestDto;
import com.example.springbootwebsite.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RequiredArgsConstructor
@RestController
public class PostsApiController {

    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 findByIf(@PathVariable Long id){
        return postsService.findByID(id);
    }
    
    @DeleteMapping("/api/v1/posts/{id}")
    public Long delete(@PathVariable Long id){
        postsService.delete(id);
        return id;
    }
}

컨트롤러까지 생성되었으니 한번 테스트를 해본다. 좀 전에 수정한 테스트2 게시글의 수정 화면에서 삭제 버튼을 클릭한다.
ㅇ
다음과 같이 삭제 성공 메시지를 확인한다.
ㅇ

자동으로 메인 페이지로 이동하면, 기존 게시글이 삭제되었는지 확인한다.
ㅇ
수정/삭제 기능까지 완성되었다.

이번 장에서 진행한 화면 개발뿐만 아니라 웹 요청에서의 테스트 코드 작성 방법은 필수로 익혀가길 바란다. 기본적인 게시판 기능이 완성되었으니 다음으로 로그인 기능을 만들어 보겠다.

profile
With passion and honesty

0개의 댓글