'스프링 부트와 AWS로 혼자 구현하는 웹 서비스'를 읽고 정리한 글입니다.
- 머스테치(Mustache)를 통해 화면 영역을 개발해보자.
- 서버 템플릿 엔진과 클라이언트 템플릿 엔진의 차이
- 머스테치를 통해 기본적인 CRUD 화면 개발
4.1 서버 템플릿 엔진과 머스테치 소개
템플릿 엔진이란, 지정된 템플릿 양식과 데이터가 합쳐져 HTML 문서를 출력하는 소프트웨어를 뜻한다.
- 서버 템플릿 엔진: JSP, Freemarker, Thymeleaf ...
- 클라이언트 템플릿 엔진: React, Vuew ...
서버 템플릿 엔진을 이용한 화면 생성은 서버에서 Java 코드로 문자열을 만든 뒤 이 문자열을 HTML로 변환하여 브라우저로 전달한다.
Vue.js나 React.js를 이용한 SPA(Single Page Application)는 브라우저에서 화면을 생성한다. 즉, 서버에서 이미 코드가 벗어난 경우로 서버에서는 Json 혹은 Xml 형식의 데이터만 전달하고 클라이언트에서 조립한다.
최근엔 리액트나 뷰와 같은 자바스크립트 프레임워크에서 SSR(Server Side Rendering)을 지원한다.
스프링 부트를 사용하면서 자바스크립트를 서버사이드에서 렌더링하도록 구현하는 것은 많은 수고가 필요하므로 시작하는 단계에선 템플릿 엔진을 활용하여 화면을 개발해보자. 스프링 부트와 자바스크립트 프레임워크에 대한 이해도가 높아졌을 때 스프링 부트를 사용하면서 자바스크립트를 서버사이드에서 렌더링해보자.
Mustache 머스테치란
머스테치는 수많은 언어를 지원하는 가장 심플한 템플릿 엔진이다.
현존하는 대부분 언어를 지원하고 있다 보니 자바에서 사용될 때는 서버 템플릿 엔진으로, 자바스크립트에서 사용될 때는 클라이언트 템플릿 엔진으로 모두 사용할 수 있다.
자바에서 사용하는 서버 템플릿 엔진은 JSP, Velocity, Freemarker, Thymeleaf 등이 있다.
- JSP, Velocity
- 스프링 부트에서는 권장하지 않는 템플릿 엔진이다.
- Freemarker
- 템플릿 엔진으로는 과하게 많은 기능을 지원한다.
- 높은 자유도로 인해 숙련도가 낮을수록 Freemarker 안에 비즈니스 로직이 추가될 확률이 높다.
- Thymeleaf
- 스프링이 적극 지원하지만 문법이 어렵다.
- HTML 태그에 속성으로 템플릿 기능을 사용하는 방식이 개발자에게 어려운 경우가 많다.
머스테치의 장점
- 문법이 다른 템플릿 엔진보다 심플하다.
- 로직 코드를 사용할 수 없어 View의 역할과 서버의 역할이 명확하게 분리된다.
- Mustache.js와 Mustache.java를 지원하여 하나의 문법으로 클라이언트/서버 템플릿 모두 사용 가능하다.
템플릿 엔진이 너무 많은 기능을 제공하면 API와 템플릿 엔진, JS가 서로 로직을 나눠 갖게 되어 유지보수하기가 어렵다.
머스테치 플러그인 설치
머스테치는 인텔리제이 커뮤니티 버전을 사용해도 플러그인을 사용할 수 있다. Thymeleaf나 JSP 등은 인텔리제이 얼티메이트 버전에서만 공식 지원한다. 머스테치는 이와 달리 커뮤니티 버전에서도 설치 가능한 플러그인이 있다.
이 플러그인을 이용하면 머스테치의 문법 체크, HTML 문법 지원, 자동완성 등이 지원된다.
Marketplace에서 mustache를 검색해서 플러그인을 설치하자.
4.2 기본 페이지 만들기
implementation('org.springframework.boot:spring-boot-starter-mustache')
- build.gradle에 머스체티 스타터 의존성을 등록
- 머스테치의 파일 위치: src/main/resources/templates다.
- 이 위치에 머스테치 파일을 두면 스프링 부트에서 자동으로 로딩한다.
resources/templates 폴더를 직접 생성하자.
index.mustache
<!doctype html>
<html>
<head>
<title>스프링 부트 웹서비스</title>
<meta http-equiv="X-UA-Compatible" content="text/html"; charset="UTF-8">
</head>
<body>
<h1>스프링 부트로 시작하는 웹 서비스</h1>
</body>
</html>
- Controller에서 URL 매핑을 하자.
IndexController
- web 패키지에 IndexController 생성
@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 클래스 생성
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = 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 호출 시 페이지의 내용이 제대로 호출되는지에 대한 테스트
- TestRestTemplate를 통해 "/"로 호출했을 때 index.mustache에 포함된 코드들이 있는지 확인한다.
4.3 게시글 등록 화면 만들기
외부 CDN을 사용하여 부트스트랩과 제이쿼리를 이용하여 화면을 만들어 보자.
(실제 서비스에서는 잘 사용하지 않는다. CDN에 문제가 생기면 프로젝트도 문제가 생기기 때문이다.)
부트스트랩과 제이쿼리 라이브러리를 바로 추가하지 않고 레이아웃 방식으로 추가해보자.
레이아웃 방식이란 공통 영역을 별도의 파일로 분리하여 필요한 곳에서 가져다 쓰는 방식이다.
부트스트랩과 제이쿼리는 머스테치 화면 어디서나 필요하기 때문에 매번 해당 라이브러리를 머스테치 파일에 추가하는 것은 귀찮은 일이니, 레이아웃 파일들을 만들어 추가하자.
- src/main/resources/templates에 layout 디렉토리 생성
- 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>
<body>
header.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>
- 페이지 로딩속도를 높이기 위해 css는 header에, js는 footer에 두었따.
- HTML은 위에서부터 코드가 실행되기 때문에 head가 다 실행되고 나서야 body가 실행된다.
- js는 body 하단에 두어 화면이 다 그려진 뒤에 호출하는 것이 좋다.
- css는 화면을 그리는 역할이므로 head에서 불러오는 것이 좋다.
그렇지 않으면 css가 적용하지 않은 깨진 화면을 사용자가 볼 수 있기 때문이다. - bootstrap.js의 경우 제이쿼가 꼭 있어야만 하기 때문에 부트스트랩보다 먼저 호출되도록 한다.
변경된 index.mustache
{{>latout/header}}
<h1>스프링 부트로 시작하는 웹 서비스</h1>
{{>latout/footer}}
{{>latout/header}}
- {{>}}는 현재 머스테치 파일(index.mustache)을 기준으로 다른 파일을 가져온다.
레이아웃으로 분리했으니 index.mustache에 글 등록 버튼을 하나 추가해보자.
{{>latout/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>
{{>latout/footer}}
- 글 등록 페이지로 이동하는 버튼 생성
이동할 페이지의 주소: /posts/save
해당 주소를 컨트롤러에 매핑하자
IndexController
@RequiredArgsConstructor
@Controller
public class IndexController {
@GetMapping("/")
public String index() {
return "index";
}
@GetMapping("/posts/save")
public String postsSave() {
return "posts-save";
}
}
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}}
다시 프로젝트를 실행해보자.
API를 호출하는 JS가 없기 때문에 아직 등록 버튼은 기능이 없다.
API를 호출하는 JS - index.js 작성
- 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 ='/'
글 등록이 성공하면 메인페이지로 이동한다. - 브라우저의 스코프는 공용 공간으로 쓰이기 때문에 나중에 로딩된 js의 init, save가 먼저 로딩된 js의 function을 덮어쓰게 된다.
- 함수의 이름이 중복될 수 있는 상황이 자주 발생할 수 있으니 모든 function 이름을 확인하면서 만들 수 없다.
- 이런 문제를 피하기 위해 index.js의 유효범위(scope)를 만들어 사용한다.
- index란 객체를 만들어 객체에서 필요한 모든 function을 선언하면 index 객체 안에서만 function이 유효하기 때문에 다른 JS와 겹칠 위험이 사라진다.
(ES6을 비롯한 Angular, React, Vue 등은 이미 이런 기능을 프레임워크 레벨에서 지원하고 있다.)
foouter.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>
<script src="/js/app/index.js"></script>
- index.js 호출 코드는 절대 경로로 바로 시작한다.
- 스프링 부트는 기본적으로 ../resources/static에 위치한 JS, CSS, 이미지 등 정적 파일들은 URL에서 /로 설정된다.
- 그래서 다음과 같이 파일이 위치하면 위치에 맞게 호출이 가능하다.
- src/main/resources/static/js/ ... (http://도메인/js/...)
- src/main/resources/static/css/ ... (http://도메인/css/...)
- src/main/resources/static/image/ ... (http://도메인/image/...)
등록 기능 직접 테스트
- 프로젝트를 실행하여 localhost:8080/posts/save 에서 직접 테스트
정상적으로 동작한다.
localhost:8080/h2-console에 접속해서 실제로 DB에 데이터가 등록되었는지도 확인한다.
4.4 전체 조회 화면 만들기
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}}</a></td>
<td>{{author}}</td>
<td>{{modifiedDate}}</td>
</tr>
{{/posts}}
</tbody>
</table>
</div>
{{>layout/footer}}
{{$posts}}
- posts라는 List를 순회한다.
- Java의 for문과 동일하게 생각하면 된다.
{{id}} 등의 {{변수명}}
- List에서 뽑아낸 객체의 필드를 사용한다.
이제 Controller, Service, Repository 코드를 작성하자.
PostsRepository
public interface PostsRepository extends JpaRepository<Posts, Long> {
@Query("SELECT p FROM Posts p ORDER BY p.id DESC")
List<Posts> findAllDesc();
}
@Query를 사용하면 SpringDataJpa에서 제공하지 않는 메서드는 위처럼 쿼리로 작성해도 된다.
SpringDataJpa에서 제공하는 기본 메서드만으로 해결할 수 있지만 @Query를 이용하여 가독성이 좋게 만들었다.
규모가 있는 프로젝트에서의 데이터 조회는 FK의 조인, 복잡한 조건 등으로 인해 이런 Entity 클래스만으로 처리하기 어려워 조회용 프레임워크를 추가로 사용한다.
대표적으로 Querydsl, jooq, MyBatis 등이 있다. 조회는 이런 프레임워크 중 하나를 통해 조회하고, 등록/수정/삭제 등은 SpringDataJpa를 통해 진행한다.
Querydsl을 추천하는 이유는 다음과 같다.
1. 타입 안정성이 보장된다.
단순한 문자열로 쿼리를 생성하는 것이 아니라, 메서드를 기반으로 쿼리를 생성하기 때문에 오타나 존재하지 않는 컬럼명을 명시할 경우 IDE에서 자동으로 검출된다. 이 장점은 Jooq에서도 지원하지만, Mybatis에서는 지원하지 않는다.
2. 국내 많은 회사에서 사용 중이다.
쿠팡, 배민 등 JPA를 적극적으로 사용하는 회사에서는 Querydsl를 적극적으로 사용한다.
3. 레퍼런스가 많다.
많은 회사와 개발자들이 사용하다보니 국내 자료가 많다.
PostsService
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
/*
@Transactional 어노테이션 => 트랜잭션 처리 지원
트랜잭션의 개념
모든 작업들이 성공적으로 완료되어야 작업 묶음의 결과를 적용하고, 어떤 작업에서 오류가 발생했을 때는
이전에 있던 모든 작업들이 성공적이었더라도 없었던 일처럼 완전히 되돌리는 것
데이터베이스를 다룰 때 트랜잭션을 적용하면 데이터 추가, 갱신, 삭제 등으로 이루어진 작업을 처리하던 중
오류가 발생했을 때 모든 작업들을 원상태로 되돌릴 수 있다.
일련의 작업들을 묶어서 하나의 단위로 처리하고 싶을 때 사용
*/
...
@Transactional(readOnly = true)
public List<PostsListResponseDto> findAllDesc() {
return postsRepository.findAllDesc().stream()
.map(PostsListResponseDto::new)
.collect(Collectors.toList());
}
}
- @Transactional(readOnly = true)를 주면 트랜잭션의 범위는 유지하되 조회 기능만 남겨두어 조회 속도가 개선된다.
- 등록, 수정, 삭제 기능이 전혀 없는 서비스 메서드에서 사용하는 것을 추천한다.
.map(PostsListResponseDto::new) 는 .map(posts -> new PostsListResponseDto(posts)) 와 같다.
postsRepository 결과로 넘어온 Posts의 Stream을 map을 통해 PostsListRepsponseDto 변환 -> List로 반환하는 메서드다.
PostsListResponseDto
@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
@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에 전달한다.
테스트
4.5 게시글 수정 화면 만들기
@PutMapping("/api/v1/posts/{id}") // 수정
public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) {
return postsService.update(id, requestDto);
}
이전에 개발한 수정 API로 요청하는 화면을 개발하자.
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}}
{{posts.id}}
- 머스테치는 객체의 필드 접근 시 점(Dot)으로 구분한다.
- 즉, Post 클래스의 id에 대한 접근은 post.id로 사용할 수 있다.
readonly
- Input 태그에 읽기 가능만 허용하는 속성이다.
- id와 author는 수정할 수 없도록 읽기만 허용하도록 추가한다.
index.js
btn-update 버튼을 클릭하면 update 기능을 호출할 수 있게 index.js 파일에도 update funtion을 추가한다.
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을 실행하도록 이벤트를 등록한다.
update : function()
- 신규로 추가될 update function이다.
type: 'PUT'
- 여러 HTTP 메서드 중 PUT 메서드를 선택한다.
- PostsApiController에 있는 API에서 이미 @PutMapping으로 선언했기 때문에 PUT을 사용해야 한다.
- REST에서 CRUD는 각각 다음과 같이 매핑된다.
- 생성(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}}"></a>
- 타이틀에 a tag를 추가한다.
- 타이틀을 클릭하면 해당 게시글의 수정 화면으로 이동한다.
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";
}
- Update 메서드 추가
테스트
4.5 게시글 삭제 만들기
삭제 버튼은 본문을 확인하고 진행해야 하므로 수정 화면에 추가하도록 하자.
posts-update.mustache 수정
<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>
index.js 수정
var main = {
init : function () {
var _this = this;
...
$('#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();
type이 DELETE인 것을 제외하고 update와 크게 다른게 없다.
이제 삭제 API를 만들어보자.
PostsService 수정
@Transactional
public void delete(Long id) {
Posts posts = postsRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id = " + id));
postsRepository.delete(posts);
}
- delete 메서드 추가
postsRepository.delete(posts)
- JpaRepository에서 이미 delete 메서드를 지원하고 있으니 활용한다.
- Entity를 Parameter로 삭제할 수도 있고, deleteById 메서드를 이용하면 id를 삭제할 수도 있다.
- 존재하는 Posts인지 확인을 위해 Entity 조회 후 그대로 삭제한다.
delete 메서드를 컨트롤러가 사용하도록 코드를 추가하자.
PostsApiController
@DeleteMapping("/api/v1/posts/{id}")
public Long delete(@PathVariable Long id) {
postsService.delete(id);
return id;
}
- postsService의 delete 메서드 활용
테스트
기본적인 게시판 기능이 완성되었으니 이제 로그인 기능을 만들어 보자.