들어가기 전에
하기 포스팅은 스프링 부트와 AWS로 혼자 구현하는 웹 서비스(이동욱 저)를 공부하여 작성한 내용입니다.
템플릿 엔진
웹 개발에 있어 템플릿 엔진이란, 지정된 템플릿 양식과 데이터가 합쳐져 HTML 문서를 출력하는 소프트웨어를 의미합니다.
- 서버 템플릿 엔진 예: JSP, Freemarker
- JSP의 경우 명확한 서버 템플릿 엔진은 아니나, View의 역할만 하도록 구성할 때에는 템플릿 엔진으로써 사용이 가능합니다.
- 클라이언트 템플릿 엔진 예: React의 View, Vue의 View
서버 템플릿 엔진을 이용한 화면 생성은 서버에서 Java 코드로 문자열을 만든 뒤 이 문자열을 HTML로 변환하여 브라우저로 전달합니다. 반면 자바스크립트는 브라우저 위에서 작동합니다. 즉, 자바스크립트 코드가 실행되는 장소는 서버가 아닌 브라우저입니다.
- 브라우저에서 작동될 때에는 서버 템플릿 엔진의 손을 벗어나 제어 불가
Vue.js나 React.js를 이용한 SPA(Single Page Application)는 브라우저에서 화면을 생성합니다. 즉, 서버에서 이미 코드가 벗어난 경우입니다. 서버에서는 json 또는 xml 형식의 데이터만 전달하고 클라이언트에서 조립합니다.
- 최근 리엑트나 뷰와 같은 자바스크립트 프레임워크에서 서버 사이드 렌더링(Server Side Rendering)을 지원하기도 합니다.
- 다만, 스프링 부트를 사용하면서 자바스크립트를 서버사이드에서 렌더링하도록 구현하는 것은 많은 수고가 필요하므로 이번 포스팅에서는 다루지 않습니다.
머스테치(Mustache)
머스테치(http://mustache.github.io)는 수많은 언어를 지원하는 심플한 템플릿 엔진입니다. 루비, 자바스크립트, 파이썬, PHP, JAVA, PERL, Go, ASP 등 현존하는 대부분 언어를 지원하고 있습니다.
- 자바에서 사용될 때는 서버 템플릿 엔진으로, 자바스크립트에서 사용될 때는 클라이언트 템플릿 엔진으로 사용 가능합니다.
자바에서의 서버 템플릿 엔진들
자바에서 사용할 수 있는 서버 템플릿 엔진에는 JSP, Velocity, Freemarker, Thymeleaf 등이 있습니다. 이러한 템플릿 엔진들의 단점은 아래와 같습니다.
- JSP, Velocity: 스프링 부트에서 권장하지 않는 템플릿 엔진입니다.
- JSP는 IntelliJ Community 버전에서 지원하지 않고 유료 버전에서만 공식 지원합니다.
- Freemarker: 템플릿 엔진으로는 너무 과하게 많은 기능을 지원합니다. 숙련도가 낮으면 잘못된 방향으로 사용하게 될 수 있습니다.
- Thymeleaf: 스프링에서 자주 사용하는 템플릿 엔진이지만, 문법이 어렵습니다. HTML 태그에 속성으로 템플릿 기능을 사용하는 방식이라 어렵게 느껴질 수 있습니다.
- Vue.js를 사용해보았다면, 태그 속성 방식에 익숙해 Thymeleaf가 더 쉬울 수 있습니다.
- IntelliJ Community 버전에서 지원하지 않고 유료 버전에서만 공식 지원합니다.
반면 머스테치의 장점은 아래와 같습니다.
- 문법이 다른 템플릿 엔진보다 심플합니다.
- 로직 코드를 사용할 수 없어, View의 역할과 서버의 역할이 명확하게 구분됩니다.
- Mustache.js와 Mustache.java 모두를 가지고 있어 하나의 문법으로 클라이언트/서버 템플릿을 모두 사용할 수 있습니다.
- IntelliJ Community 버전에서 플러그인으로 사용이 가능합니다.
- 해당 플러그인을 이용하면 머스테치의 문법 체크, HTML 문법 지원, 자동완성 등이 지원되어 개발할 때 편리합니다.
머스테치 플러그인 설치
IntelliJ IDEA에서 command + shift + A를 눌러 action 탭에서 plugins를 검색하여 mustache를 설치합니다. 설치가 다 되면 인텔리제이를 재시작하여 플러그인 작동을 확인할 수 있습니다.
머스테치로 화면 구성하기
기본 페이지 만들기
먼저, 스프링 부트 프로젝트에서 머스테치를 사용할 수 있도록 머스테치 스타터 의존성을 build.gradle에 등록합니다. 머스테치는 스프링 부트에서 공식 지원하는 템플릿 엔진이므로 의존성 하나만 추가하면 다른 스타터 패키지와 마찬가지로 추가 설정이 필요하지 않습니다.
- 버전 역시 따로 개발자가 신경쓰지 않아도 된다는 장점이 있습니다.
compile('org.springframework.boot:spring-boot-starter-mustache')
머스테치의 파일 위치는 기본적으로 src/main/resources/templates입니다. 이 위치에 머스테치 파일을 두면 스프링 부트에서 자동으로 로딩합니다.
먼저 첫 페이지를 담당할 index.mustache를 src/main/resources/templates에 생성하고 하기와 같이 코드를 작성합니다.
<!DOCTYPE HTML>
<html>
<head>
<title>스프링 부트 웹서비스</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<h1>스프링 부트로 시작하는 웹 서비스</h1>
</body>
</html>
index.mustache 코드는 "스프링 부트로 시작하는 웹 서비스"를 출력하는 페이지입니다. 이 머스테치를 URL에 매핑하기 위해 web 패키지 내에 IndexController를 생성하여 하기와 같이 코드를 작성합니다.
package com.earth.study.springboot.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 패키지 내 web 패키지에 IndexControllerTest 클래스를 생성합니다.
package com.earth.study.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.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에 포함된 코드들이 있는지 확인하면 됩니다.
작성된 테스트 코드를 수행하면 하기와 같은 결과가 나오고, 실제 스프링 부트 어플리케이션을 키고 localhost:8080에 접속하면 정상적으로 화면이 노출되는 것을 확인할 수 있습니다.
게시글 등록 화면 만들기
앞선 포스팅에서 PostsApiController로 API를 구현하였기 때문에 여기서는 바로 화면을 개발합니다. 여기서는 오픈소스인 Bootstrap을 사용하여 화면을 만들어보도록 하겠습니다.
부트스트랩, 제이쿼리 등 프론트엔드 라이브러리를 사용할 수 있는 방법은 크게 2가지 있습니다. 하나는 외부 CDN을 사용하는 것이고, 다른 하나는 직접 라이브러리를 받아서 사용하는 방법입니다.
여기서는 외부 CDN을 사용하여 프론트엔드 라이브러리를 사용하겠습니다.
- 프로젝트에서 직접 내려받아 사용할 필요가 없고, 사용방법도 HTML/JSP/Mustache에 코드만 한 줄 추가하면 되므로 간단합니다.
- 실제 서비스에서는 이 방법을 자주 사용하지 않습니다. 그 이유는 외부 CDN을 사용하면 외부 서비스에 의존도가 생겨 CDN 이슈가 발생했을 때 함께 문제가 생기기 때문입니다.
2개의 라이브러리 부트스트랩과 제이쿼리를 index.mustache에 추가해보겠습니다. 이때, 바로 추가하지 않고 레이아웃 방식으로 넣어보도록 하겠습니다.
레이아웃 방식이란?
공통 영역을 별도의 파일로 분리하여 필요한 곳에서 가져다 쓰는 방식입니다.
- 이번에 추가하는 부트스트랩과 제이쿼리 라이브러리는 머스테치 화면 어디에서나 필요하기 때문에 매번 해당 라이브러리를 머스테치 파일마다 추가하는 일을 줄이기 위해 레이아웃 파일을 만들어 추가하는 것입니다.
먼저 src/main/resources/templates 디렉토리에 layout 디렉토리를 추가로 생성합니다. 그리고 footer.mustache, header.mustache 파일을 생성하여 각각 코드를 넣어줍니다.
<!-- header.mustache -->
<!DOCTYPE HTML>
<html>
<header>
<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">
</header>
<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와 js의 위치가 다른 것을 확인할 수 있습니다. 페이지 로딩속도를 높이기 위해 css는 header에, js는 footer에 두었습니다. HTML은 위에서부터 코드가 실행되므로 head가 모두 실행되고 나서 body가 실행됩니다.
- head가 다 불러지지 않으면 사용자 쪽에서는 백지 화면만 노출됩니다.
- 일반적으로 js 용량이 크면 body 부분의 실행이 늦어지므로, js는 body 하단에 두어 화면이 다 그려진 뒤 호출하는 것이 좋습니다.
- css는 화면을 그리는 역할이므로 head에서 불러야 css가 적용되지 않은 깨진 화면을 사용자가 보지 않을 수 있습니다.
jquery를 bootstrap.js 호출하기 전에 부르는 이유는, bootstrap.js는 제이쿼리가 반드시 있어야하기 때문입니다.
- bootstrap.js가 제이쿼리에 의존한다라고 보면 됩니다.
라이브러리를 비롯한 기타 HTML 태그들이 모두 레이아웃에 추가되었으므로, index.mustache에는 필요한 코드만 남게 됩니다. index.mustache의 코드를 아래와 같이 변경합니다.
{{>layout/header}}
<h1>스프링 부트로 시작하는 웹 서비스</h1>
{{>layout/footer}}
- {{>layout/header}}
- {{>}}는 현재 머스테치 파일을 기준으로 다른 파일을 가져옵니다.
레이아웃으로 파일을 분리했으므로 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}}
- <a> 태그를 이용해 글 등록 페이지로 이동하는 글 등록 버튼이 생성되었습니다.
- 이동할 페이지 주소는 /posts/save입니다.
index.mustache에서 글 등록 버튼 클릭 시 이동할 /posts/save에 대한 컨트롤러를 생성해보겠습니다. 페이지와 관련된 컨트롤러는 모두 IndexController를 사용하겠습니다.
@Controller
public class IndexController {
...
@GetMapping("/posts/save")
public String postSave() {
return "posts-save";
}
}
- 처음 /를 호출했을 때 "index"를 리턴하여 /src/resources/templates/index.mustache를 호출했던 것처럼, /posts/save를 호출하면 /src/resources/templates/posts-save.mustache를 부를 수 있도록 작성하였습니다.
컨트롤러 코드가 생성되었으니 posts-save.mustache 파일을 /src/resources/templates/ 아래에 생성하여 하기와 같이 코드를 작성합니다.
{{>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}}
게시글 등록 UI가 완성되었으니 스프링부트 어플리케이션을 재실행하고 브라우저에서 http://localhost:8080으로 접근해보겠습니다. 보이는 화면에서 '글 등록' 버튼을 클릭하면 글 등록 화면으로 이동할 수 있습니다.
현재까지 작성한 내용에서는 등록 버튼을 클릭했을 때, 동작하지 않습니다. 이는 현재 따로 등록 API를 호출하는 기능을 넣지 않았기 때문입니다.
이번에는 등록 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()
};
$.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();
- window.location.href = '/'
- 글 등록이 성공하면 메인페이지(/)로 이동합니다.
var main = {...} 코드 선언한 이유는?
index.js의 첫 문장에 var main = {...} 코드를 통해 index라는 변수의 속성으로 function을 추가한 이유에 대해 알아보겠습니다.
예를 들어, 아래와 같이 index.js에 function이 작성되어 있다고 가정하겠습니다. index.mustache에 a.js가 추가되어 a.js만의 init과 save function이 있다면 어떻게 될까요?
var init = function () {
...
};
var save = function () {
...
};
init();
브라우저의 스코프(scope)는 공용 공간으로 쓰이기 때문에 나중에 로딩된 js의 init, save가 먼저 로딩된 js의 function을 덮어쓰게 됩니다.
여러 사람이 함께하는 프로젝트에서는 중복된 함수 이름이 자주 발생할 수 있습니다. 모든 function 이름을 확인하면서 만들수는 없기 때문에, 이런 문제를 피하고자 index.js 만의 유효범위를 만들어 사용합니다.
- index.js만의 유효범위 만드는 방법은, var index란 객체(여기서는 main)를 만들어 해당 객체에 필요한 모든 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 추가(API 호출) -->
<script src="/js/app/index.js"></script>
</body>
</html>
- index.js 호출 코드를 보면 절대 경로(/)로 바로 시작합니다.
- 스프링부트는 기본적으로 src/main/resources/static에 위치한 자바스크립트, CSS, 이미지 등 정적 파일들을 / 위치에 있다고 판단하기 때문에 src에 /js/app/index.js로 작성합니다.
정적 파일 호출 경로
아래와 같이 파일이 위치하면 (호출방법)에 따라 호출이 가능합니다.
- src/main/resources/static/js/··· (http://도메인/js/···)
- src/main/resources/static/css/··· (http://도메인/css/···)
- src/main/resources/static/image/··· (http://도메인/image/···)
등록 API 연결 코드가 작성되었으니, 스프링부트 어플리케이션을 실행시켜 보겠습니다.
게시글 등록이 제대로 되었는지 확인하기 위해서는 localhost:8080/h2-console에 접속하여 DB 데이터를 select해보면 됩니다.
게시글 등록 기능이 정상적으로 작동하는 것을 확인하였으니, 이번에는 게시글 전체 조회 화면을 만들어보겠습니다.
게시글 전체 조회 화면 만들기
게시글 전체 조회를 위해 index.mustache의 UI를 아래와 같이 수정합니다.
{{>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>
<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에서 뽑아낸 객체의 필드를 사용합니다.
UI에 맞추어 Controller, Service, Repository 코드를 작성해보겠습니다.
PostsRepository
package com.earth.study.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();
}
- Spring Data JPA에서 제공하지 않는 메소드는 위와 같이 쿼리로 작성하여 @Query 어노테이션을 사용하면 됩니다.
- 사실 위 코드는 Spring Data JPA에서 제공하는 기본 메소드로 사용 가능하지만, 가독성을 위해 @Query 어노테이션을 사용했습니다.
참고
규모가 있는 프로젝트에서의 데이터 조회는 FK의 조인, 복잡한 조건 등으로 인해 Entity 클래스만으로 처리하기 어려워 조회용 프레임워크를 추가로 사용합니다. 대표적 예로 querydsl, joop, MyBatis 등이 있습니다. 조회는 조회용 프레임워크를 따로 사용하고 등록/수정/삭제 등은 Spring Data JPA를 통해 진행합니다.
- 그 중 querydsl을 추천합니다.
- 타입 안정성이 보장됩니다.
- 단순한 문자열로 쿼리를 생성하지 않고 메소드 기반으로 쿼리를 생성합니다.
- 따라서, 오타나 존재하지 않는 컬럼명을 명시하면 IDE에서 자동으로 검출됩니다.
- 많은 회사에서 사용중입니다(예: 쿠팡, 배민).
- 레퍼런스가 많습니다.
- 타입 안정성이 보장됩니다.
PostsService
...
import java.util.List;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
...
@Transactional(readOnly = true)
public List<PostsListResponseDto> findAllDesc() {
return postsRepository.findAllDesc().stream()
.map(PostsListResponseDto::new)
.collect(Collectors.toList());
}
}
- findAllDesc() 메소드의 트랜잭션 어노테이션(@Transactional)에는 옵션이 하나 들어가 있습니다.
- readOnly = true를 주면, 트랜잭션 범위는 유지하되 조희 기능만 남겨두어 조회 속도가 개선됩니다(등록/수정/삭제 기능이 없는 서비스 메소드에서 사용하는 것을 추천).
- .map(PostsListResponseDto::new)
- .map(posts -> new PostsListResponseDto(posts))와 동일한 코드라고 보면 됩니다.
- postsRepository 결과로 넘어온 Posts의 Stream을 map을 통해 PostsListResponseDto 변환 -> List로 반환하는 메소드입니다.
PostsListResponseDto
PostsListResponseDto 클래스는 아직 없기 때문에, 해당 클래스를 web.dto 내에 생성하고 아래와 같이 코드를 적어줍니다.
package com.earth.study.springboot.web.dto;
import com.earth.study.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.earth.study.springboot.web;
import com.earth.study.springboot.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 postSave() {
return "posts-save";
}
}
- 기존 GetMapping("/") 내용에서 model 부분이 추가되었습니다.
- Model
- 서버 템플릿 엔진에서 사용할 수 있는 객체를 저장할 수 있습니다.
- postsService.findAllDesc()로 가져온 결과를 posts로 index.mustache에 전달합니다.
여기까지 작성한 뒤 스프링부트 어플리케이션을 기동하여 아래와 같이 게시글을 하나 등록하면, 메인 화면에서 추가한 게시글이 조회되는 것을 확인할 수 있습니다.
게시글 수정 화면 만들기
마지막으로 게시글 수정, 삭제 화면을 만들어보겠습니다. 게시글 수정 API는 이전 포스팅에서 진행했으므로 해당 API로 요청하는 화면을 만들어보겠습니다.
먼저 게시글 수정 화면 머스테치 파일을 생성합니다. src/main/resources/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}}
- {{post.id}}
- 머스테치는 객체의 필드 접근 시 점(dot)으로 구분합니다.
- readonly
- Input 태그에 읽기 가능만 허용하는 속성입니다.
- id와 author는 수정할 수 없도록 읽기만 허용한 상태로 만들어줍니다.
btn-update id를 가진 버튼 클릭 시, update 기능을 호출할 수 있도록 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();
});
},
...
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에서 CRUD는 아래와 같은 HTTP Method에 매핑됩니다.
- 생성(Create) - POST
- 읽기(Read) - GET
- 수정(Update) - PUT
- 삭제(Delete) - DELETE
- url: '/api/v1/posts/'+id
- 어느 게시글을 수정할지 URL Path로 구분하기 위해 Path에 id를 추가합니다.
마지막으로, 전체 목록에서 수정 페이지로 이동할 수 있도록 페이지 이동 기능을 추가해보겠습니다. 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>
...
- <a href="/posts/update/{{id}}">{{title}}</a></td>
- title에 a 태그를 추가하여, 제목 클릭 시 해당 게시글의 수정 화면으로 이동할 수 있게 합니다.
이제는 수정 화면을 연결할 Controller 코드를 작성하겠습니다. IndexController에 아래와 같이 메소드를 추가합니다.
@RequiredArgsConstructor
@Controller
public class IndexController {
...
@GetMapping("/posts/update/{id}")
public String postsUpdate(@PathVariable Long id, Model model) {
PostsResponseDto dto = postsService.findById(id);
model.addAttribute("post", dto);
return "posts-update";
}
}
수정 기능을 사용해보도록 하겠습니다. 스프링부트 어플리케이션을 재시작하여 localhost:8080에 접속하고, 게시글을 하나 생성한 뒤 수정해보겠습니다. 게시글 수정은 게시글 제목을 클릭한 뒤 제목과 내용을 수정할 수 있습니다.
게시글 삭제 기능 구현하기
수정 기능에 이어 삭제 기능을 구현해보겠습니다. 삭제 버튼은 게시글 수정 페이지에서 볼 수 있어야 하므로, posts-update.mustache에 삭제 버튼을 추가해보겠습니다.
{{>layout/header}}
<h1>게시글 수정</h1>
<div class="col-md-12">
<div class="col-md-4">
...
<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를 id로 갖는 버튼을 클릭하면, JS에서 이벤트를 수신하여 해당 게시글을 삭제할 것입니다.
index.js에 삭제 이벤트를 진행할 코드를 추가합니다.
var main = {
init : function () {
...
$('#btn-delete').on('click', function () {
_this.delete();
});
},
...
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();
- HTTP Method Type이 DELETE인 것을 제외하고는 update function과 큰 차이가 없습니다.
이제 삭제 API를 만들어보겠습니다.
PostsService
@RequiredArgsConstructor
@Service
public class PostsService {
...
@Transactional
public void delete (Long id) {
Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id="+ id));
postsRepository.delete(posts);
}
}
- postsRepository.delete(posts)
- JPA Repository에서 delete 메소드를 지원하고 있으므로 이를 활용하면 됩니다.
- Entity를 파라미터로 삭제할 수도 있고, deleteById 메소드를 이용해 id로 삭제할 수도 있습니다.
- 존재하는 Posts인지 확인하기 위해 엔티티 조회 후 삭제합니다.
서비스에 만든 delete 메소드를 컨트롤러가 사용하도록 코드를 추가합니다.
PostsApiController
@RequiredArgsConstructor
@RestController
public class PostsApiController {
...
@DeleteMapping("/api/v1/posts/{id}")
public Long delete(@PathVariable Long id) {
postsService.delete(id);
return id;
}
}
여기까지 수정한 후 스프링부트 어플리케이션을 다시 재시작하고, 게시글 생성 후 삭제 버튼을 클릭하면 아래와 같이 삭제 성공 메시지를 확인할 수 있습니다. 여기서 확인 버튼을 누르면 게시판 메인 페이지에 접근하게 되어 기존에 있던 게시글이 삭제되었음을 확인할 수 있습니다.
'FRAMEWORK > Spring' 카테고리의 다른 글
[Springboot, JWT] JWT를 이용한 소셜 로그인 구현하기 - Kakao/Google (0) | 2021.10.11 |
---|---|
[Springboot, IntelliJ] Re-run Spring Boot Configuration Annotation Processor to update generated metadata (0) | 2021.10.04 |
[SpringBoot] 게시판 만들기 v2.1 (등록/수정/조회 API 만들기) (0) | 2021.04.25 |
[SpringBoot] JPA로 데이터베이스 다루기 (0) | 2021.04.24 |
[SpringBoot] springboot에서 테스트 코드 사용하기 (0) | 2021.04.20 |