이 글은 이동욱 님의 '스프링 부트와 AWS로 혼자 구현하는 웹 서비스' 책 내용을 정리한 것입니다.
http://www.yes24.com/Product/Goods/83849117
1. 템플릿 엔진과 머스테치
템플릿 엔진이란?
지정된 템플릿과 데이터가 합쳐져 HTML 문서를 출력하는 소프트웨어
서버 템플릿 엔진 vs 클라이언트 템플릿 엔진
- 공통점 : 지정된 템플릿과 데이터를 이용하여 HTML을 생성
- 차이점
- 서버 템플릿 엔진을 이용한 화면 생성은 서버에서 Java 코드로 문자열을 만든 뒤 이 문자열을 HTML로 변환하여 브라우저로 전달.
- 자바스크립트는 브라우저 위에서 작동
⇒ 자바 스크립트 코드가 실행되는 장소는 서버가 아닌 브라우저 !
⇒ 브라우저에서 화면을 생성. 즉, 서버에서 이미 코드가 벗어난 경우!
머스테치란?
- 수많은 언어를 지원하는 가장 심플한 템플릿 엔진으로 JSP와 같이 HTML을 만들어 주는 템플릿 엔진이다.
- 자바에서 사용될 때는 서버 템플릿 엔진으로, 자바스크립트에서 사용될 때는 클라이언트 템플릿 엔진으로 모두 사용할 수 있다.
- 장점
- 문법이 다른 템플릿 엔진보다 심플하다.
- 로직 코드를 사용할 수 없어 View의 역할과 서버의 역할이 명확하게 분리된다.
- Mustache.js와 Mustache.java 2가지가 다 있어, 하나의 문법으로 클라이언트/서버 템플릿을 모두 사용 가능하다.
2. 기본 페이지 만들기
머스테치 파일 위치는 기본적으로 src/main/resources/templates 이다.
src/main/resources/templates/index.mustache
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" >
<meta http-equiv="Content-Type" content="text/html;" charset="UTF-8" />
<title>스프링 부트 웹서비스</title>
</head>
<body>
<h1>스프링 부트로 시작하는 웹서비스</h1>
</body>
</html>
간단하게 h1 크기로 "스프링 부트로 시작하는 웹 서비스"를 출력하는 페이지이다.
이 머스테치에 URL 매핑을 해보자. 매핑을 당연하게 Controller에서 진행한다.
web 패키지 안에 IndexController를 생성하자.
src/main/java/com/jojoldu/book/springboot/web/IndexController.java
package com.jojoldu.book.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
이제 테스트 코드로 검증해보자.
src/test/java/com/jojoldu/book/springboot/web/IndexControllerTest.java
package com.jojoldu.book.springboot.web;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class IndexControllerTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void 메인페이지_로딩() {
//when
String body = this.restTemplate.getForObject("/", String.class);
//then
assertThat(body).contains("스프링 부트로 시작하는 웹 서비스");
}
}
이 테스트는 실제로 URL 호출 시 페이지의 내용이 제대로 호출되는지에 대한 테스트이다.
TestRestTemplate
: Spring Boot에서 컨트롤러를 테스트하기 위해서 사용.
❓기존의 MockMvc도 컨트롤러를 테스트하는 라이브러리인데 무슨 차이가 있을까?
- 서블릿 컨테이너의 실행 여부
MockMvc
: 컨테이너를 실행하지 않는다.TestRestTemplate
: 컨테이너를 직접 실행시킨다.
- 테스트의 관점의 차이
MockMvc
: 서버의 입장에서 구현한 API를 통해 비지니스 로직에 문제가 없는지 테스트TestRestTemplate
: 클라이언트 입장에서 사용할 때 문제가 없는지 테스트
이제 실제로 화면이 잘 나오는지 확인해보자.
잘 나오는 것을 확인할 수 있다. 이제 게시글 등록 화면을 만들어보자.
3. 게시글 등록 화면 만들기
부트스트랩, 제이쿼리 등 프론트엔드 라이브러리를 사용할 수 있는 방법은 2가지가 있다.
- 외부 CDN을 사용하는 방법
- 직접 라이브러리를 다운로드하는 방법
여기서는 CDN을 사용하겠다. 이번에 추가할 라이브러리들은 머스테치 화면 어디서나 필요하다. 매번 해당 라이브러리를 머스테치 파일에 추가하는 방법은 귀찮은 일이므로 레이아웃 파일을 만들어서 추가해주자.
src/main/resources/templates/layout/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>
<body>
src/main/resources/templates/layout/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가 실행된다.
즉, head가 다 불러지지 않으면 body 부분의 실행이 늦어지기 때문에 js는 body 하단에 두어 호출하는 것이 좋다.
이제 index.mustache에 코드를 추가하고, 글 등록 버튼을 하나 추가해보자.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" >
<meta http-equiv="Content-Type" content="text/html;" charset="UTF-8" />
<title>스프링 부트 웹서비스</title>
</head>
<body>
{{>layout/header}}
<h1>스프링 부트로 시작하는 웹 서비스 Ver.2</h1>
<div class="col-md-12">
<div class="col-md-6">
<a href="/posts/save" role="button"
class="btn btn-primary">글 등록</a>
</div>
</div>
{{>layout/footer}}
</body>
</html>
{{> }}
: 현재 머스테치 파일을 기준으로 다른 파일을 가져옴.<a>
태그를 이용해 글 등록 버튼 생성- 이동할 페이지 주소 : /posts/save
이제 컨트롤러를 생성해보자. 페이지에 관련된 모든 컨트롤러는 모두 IndexController를 사용한다.
@RequiredArgsConstructor
@Controller
public class IndexController {
private final PostsService postsService;
...
@GetMapping("/posts/save")
public String postSave() {
return "posts-save";
}
}
컨트롤러 코드가 생성되었다면, posts-save.mustache 파일을 생성한다.
src/main/resources/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}}
UI가 완성되었으니, 프로젝트를 실행하고 브라우저에서 http://localhost:8080을 들어가 보자. 글 등록 화면으로 이동하면 화면이 보이지만, 아직 등록버튼은 기능이 없다. API를 호출하는 JS가 전혀 없기 때문이다. 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 = '/';
: 글 등록이 성공하면 "/"로 이동
❓index.js
에서 var main={}
이라는 코드를 쓰는 이유?
- 여러 사람이 참여하는 프로젝트에서는 중복된 함수 이름이 자주 발생할 수 있는데, 이런 문제를 피하려고 유효 범위(scope)를 만들어 사용
- 이렇게 하면 index 객체 안에서만 function이 유효하기 때문에 다른 js와 겹칠 위험이 사라진다.
이제 생성된 index.js를 footer.mustache에 추가해보자.
src/main/resources/templates/layout/footer.mustache
...
<!--index.js 추가-->
<script src="/js/app/index.js"></script>
</body>
</html>
코드를 보면 절대 경로로 바로 시작한다. 스프링 부트는 기본적으로 src/main/resourse/static에 위치한 자바스크립트. CSS, 이미지 등 정적 파일은 URL에서 /로 설정된다.
이제 등록 기능을 직접 테스트해보자.
등록 버튼을 클릭하면 다음과 같이 "글이 등록되었습니다. "라는 Alert이 노출된다.
localhost:8080/h2-console에 접속해서 실제로 DB에 데이터가 등록되었는지도 확인하자.
4. 전체 조회 화면 만들기
전체 조회를 위해 index.mustache의 UI를 변경해보자.
...
{{>layout/header}}
<h1>스프링 부트로 시작하는 웹 서비스 Ver.2</h1>
<div class="col-md-12">
...
</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>
{{>layout/footer}}
</body>
</html>
- {{#posts}}
- posts라는 List를 순회
- Java의 for문과 동일하게 생각
- {{id}} 등의 {{변수명}}
- List에서 뽑아낸 객체의 필드를 사용
기존에 있던 PostsRepository 인터페이스에 쿼리를 추가해보자.
src/main/java/com/jojoldu/book/springboot/domain/posts/PostsRepository.java
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();
}
SprigDataJpa에서 제공하지 않는 메소드는 위처럼 쿼리로 작성해도 된다.
이제 PostsService에 코드를 추가해보자.
src/main/java/com/jojoldu/book/springboot/service/PostsService.java
...
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());
}
}
readOnly
- 트랜잭션 어노테이션(
@Transactional
)의 옵션 - 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도가 개선됨
- 등록, 수정, 삭제 기능이 없는 메소드에 추천
- 트랜잭션 어노테이션(
.map(PostsListResponseDto::new)
- =
.map(posts -> new PostsListResponseDto(posts))
- postRepository 결과로 넘어온 Posts의 Stream을 map을 통해 PostsListResponseDto로 변환 → List로 반환하는 메소드
- =
아직 PostsListResponseDto 클래스가 없기 때문에 만들어주자.
src/main/java/com/jojoldu/book/springboot/web/dto/PostsListResponseDto.java
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();
}
}
마지막으로 Controlelr를 변경하자.
...
import org.springframework.ui.Model;
...
@RequiredArgsConstructor
@Controller
public class IndexController {
private final PostsService postsService;
@GetMapping("/")
public String index(Model model){
model.addAttribute("posts", postsService.findAllDesc());
return "index";
}
}
Model
- 서버 템플릿 엔진에서 사용할 수 있는 객체를 저장
- 여기서는 postsService.findAllDesc()로 가져온 결과를 posts로 index.mustache에 전달
이제 실행시켜 하나의 데이터를 등록해보자. 그럼 다음과 같이 목록 기능이 정상적으로 작동하는 것을 확인할 수 있다.
5. 게시글 수정, 삭제 화면 만들기
게시글 수정 API는 이미 3장에서 만들었다.
public class PostApiController {
private final PostsService postsService;
...
@PutMapping("/api/v1/posts/{id}")
public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) {
return postsService.update(id, requestDto);
}
}
해당 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="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}}
{{post.id}}
- 객체의 필드 접근 시 점으로 구분
- Post 클래스의 id에 대한 접근은 post.id로 사용
readonly
- input 태그에 읽기 기능만 허용하는 속성
- id와 author는 수정할 수 없도록 읽기만 허용하도록 추가
index.js 파일에도 update function을 하나 추가해주자.
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()
};
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을 실행하도록 이벤트를 등록
이제 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에 다음과 같이 메소드를 추가하자.
...
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";
}
}
이제 수정 기능을 사용해보자.
게시글 삭제
src/main/resources/templates/posts-update.mustache
...
<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}}
src/main/resources/static/js/app/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();
이제 삭제 API를 만들어보자.
...
@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);
- JpaRepository에서 이미 delete 메소드를 지원하고 있음
- 엔티티를 파라미터로 삭제할 수도 있고, deleteById 메소드를 이용하면 id로 삭제도 가능
- 존재하는 Posts인지 확인을 위해 엔티티 조회 후 그대로 삭제
src/main/java/com/jojoldu/book/springboot/web/PostApiController.java
...
@RequiredArgsConstructor
@RestController
public class PostApiController {
...
@DeleteMapping("/api/v1/posts/{id}")
public Long delete(@PathVariable Long id) {
postsService.delete(id);
return id;
}
}
이제 다음장에서 로그인 기능을 만들어보자.