스프링 부트와 AWS로 혼자 구현하는 웹 서비스 를 공부하고 정리한 내용입니다.
템플릿 엔진 : 지정된 템플릿 양식과 데이터가 합쳐서 HTML 문서를 출력하는 소프트웨어
서버 템플릿 엔진 : JSP, Freemarker
클라이언트 템플릿 엔진 : React, Vue
✔ 서버 템플릿 엔진
서버 템플릿 엔진을 이용한 화면 생성은 서버에서 Java 코드로 문자열을 만든 뒤 이 문자열을 HTML로 변환하여 브라우저로 전달한다.
서버 템플릿 엔진은 서버에서 구동된다.
✔ 클라이언트 템플릿 엔진
반면, 자바스크립트는 브라우저 위에서 작동한다.
브라우저에서 작동될 때는 서버 템플릿 엔진의 손을 벗어나 제어할 수가 없다.
Vue.js나 React.js를 이용한 SPA는 브라우저에서 화면을 생성한다.
서버에서 이미 코드가 벗어난 경우이다.
서버에서는 Json 혹은 Xml 형식의 데이터만 전달하고 클라이언트에서 조립한다.
💡 참고
- 스프링 부트를 사용하면서 자바스크립트를 서버사이드에서 렌더링하도록 구현하는 것은 많은 수고가 필요하다.
- 스프링 부트에 대한 이해도와 자바스크립트 프레임워크 양쪽에 대한 이해도가 높아졌을 때 시도해 보면 좋다.
머스테치(
http://mustache.github.io/
)는 수많은 언어를 지원하는 가장 심플한 템플릿 엔진
- JSP와 같이 HTML을 만들어 주는 템플릿 엔진
- 현존하는 대부분 언어를 지원한다.
🎁 템플릿 엔진들의 단점
- JSP, Velocity : 스프링부트에서는 권잔항지 않는 템플릿 엔진
- Freemarker : 템플릿 엔진으로는 너무 과하게 많은 기능을 지원한다.
- Thymeleaf : 스프링 진영에서 적극적으로 밀고 있지만 문법이 어렵다. HTML 태그에 속성으로 템플릿 기능을 사용하는 방식이 기존 개발자들에게 높은 허들로 느껴지는 경우가 많다.
🔔 머스테치의 장점
- 문법이 다른 템플릿 엔진보다 심플하다.
- 로직 코드를 사용할 수 없어 View의 역할과 서버의 역할이 명확하게 분리된다.
- Mustache.js와 Mustache.java 2가지가 다 있어, 하나의 문법으로 클라이언트/서버 템플릿을 모두 사용 가능하다.
템플릿 엔진은 화면 역할에만 충실해야 한다.
💡 참고
템플릿이 너무 많은 기능을 제공하면 API와 템플릿 엔진, 자바스크립트가 서로 로직을 나눠 갖게 되어 유지보수하기가 굉장히 어렵다!
머스테치는 인텔리제이 커뮤니티 버전을 사용해도 플러그인을 사용할 수 있다.
- 플러그인을 이용하면 머스테치의 문법 체크, HTML 문법 지원, 자동 완성 등이 지원된다.
Thymeleaf나 JSP 등은 커뮤니티 버전에서 지원하지 않고, 인텔리제이 얼티메이트 버전(유료)에서만 공식 지원한다.
플러그인 설치
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-mustache'
build.gradle
에 등록하자!머스테치는 스프링 부트에서 공식 지원하는 템플릿 엔진이다.
의존성 하나만 추가하면 다른 스타터 패키지와 마찬가지로 추가 설정 없이 설치를 완료할 수 있다.
✔ 머스테치의 파일 만들기
머스테치의 파일 기본 위치 :
src/main/resources/templates
- 이 위치에 머스테치 파일을 두면 스프링 부트에서 자동으로 로딩한다.
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>
IndexController - URL 매핑은 Controller
package springbootawsbook.springawsbook.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/main/resources/templates/index.mustache
로 전환 되어 View Resolver가 처리하게 된다.
💡 참고
ViewResolver : URL 요청의 결과를 전달할 타입과 값을 지정하는 관리자 격으로 볼 수 있다.
IndexControllerTest - 테스트 코드
package springbootawsbook.springawsbook.web;
import org.assertj.core.api.Assertions;
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.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
@SpringBootTest(webEnvironment = RANDOM_PORT)
class IndexControllerTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
public void 메인페이지_로딩() {
// when
String body = this.restTemplate.getForObject("/", String.class);
// then
assertThat(body).contains("스프링 부트로 시작하는 웹 서비스");
}
}
TestRestTemplate
를 통해 /
를 호출했을 때 index.mustache
에 포함된 코드들이 있는 확인하면 된다.
실행 결과
기본적인 화면 생성이 완성되었다!
이전 Chapter 03에서 PostsApiController로 API를 구현하였다.
여기서는 화면만 개발한다.
📢 프론트엔드 라이브러리를 사용할 수 있는 방법 2가지
(1) 외부 CDN을 사용하는 것 (여기서는 이걸 사용)
(2) 직접 라이브러리를 받아서 사용하는 것
💡 참고
실제 서비스에서는 (1) 외부 CDN을 잘 사용하지 않는다. 결국은 외부 서비스에 사용하는 우리 서비스가 의존하게 돼버려서, CDN을 서비스하는 곳에 문제가 생기면 덩달아 같이 문제가 생긴다.
✏ 레이아웃 방식이란?
공통 영역을 별도의 파일로 분리하여 필요 한 곳에서 가져다 쓰는 방식
레이아웃 방식을 통해 2개의 라이브러리 부트스트랩과 제이쿼리를 추가할 것이다.
src/main/resources/templates
디렉터리에 layout 디렉토리를 추가로 생성
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>
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>
<!--index.js 추가-->
<script src="/js/app/index.js"></script>
</body>
</html>
코드를 보면 css와 js의 위치가 서로 다르다. 페이징 로딩속도를 높이기 위해 css는 header에, js는 footer에 두었다.
HTML은 위에서 아래 방향으로 코드가 실행되기 때문에, head가 다 실행되고서야 body가 실행된다.
head가 다 불러지지 않으면 사용자 쪽에서 백지 화면만 노출된다.
🎁 js vs css
- js의 용량이 크면 클수록 body 부분의 실행이 늦어지기 때문에 js는 body 하단에 두어 화면이 다 그려진 뒤에 호출하는 것이 좋다.
- css는 화면을 그리는 역할이므로 head에서 불러오는 것이 좋다. (그렇지 않으면 css가 적용되지 않은 깨진 화면을 사용자가 볼 수 있다.)
💡 참고
bootstrap.js
의 경우 제이쿼리가 꼭 있어야만 하기 때문에, 부트스트랩보다 먼저 호출되도록 코드를 작성했다!- 이와 같은 상황을
bootstarp.js
가 제이쿼리에 의존한다고 한다.
index.mustache - 코드 변경
라이브러리를 비롯해 기타 HTML 태그들이 모두 레이아웃에 추가된 상태에서
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
이다.
IndexController
/posts/save
페이지에 해당하는 컨트롤러를 생성해보자- 페이지에 관련된 컨트롤러
package springbootawsbook.springawsbook.web;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@RequiredArgsConstructor
@Controller
public class IndexController {
@GetMapping("/")
public String index() {
return "index";
}
@GetMapping("/posts/save")
public String postsSave() {
return "posts-save";
}
}
/posts/save
를 호출하면 posts-save.mustache
를 호출하는 메서드가 추가되었다.
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가 없는 상태이다.
src/main/resources/static/js/app
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 init = function (){
....
};
var save = function (){
...
};
init();
✏ index.js만의 유효범위를 만들자
- var index이란 객체를 만들어 해당 객체에서 필요한 모든 function을 선언하는 것이다.
- 이렇게 하면 index 객체 안에서만 function이 유효하기 때문에 다른 JS와 겹칠 위험이 사라진다.
생성된 index.js를 머스테치 파일이 쓸 수 있게 footer.mustache에 추가해보자.
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>
<script src="/js/app/index.js">
: /
로 시작한다.src/main/resources/static
에 위치한 자바스크립트, CSS, 이미지 등 정적 파일들은 URL에서 /
로 설정된다.src/main/resources/static/js/~
: http://도메인/js/~
실행 결과
등록 기능이 정상적으로 작동한다.
전체 조회를 위해
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><a href="/posts/update/{{id}}">{{title}}</a></td>
<td>{{author}}</td>
<td>{{modifiedDate}}</td>
</tr> {{/posts}}
</tbody>
</table></div>
{{>layout/footer}}
{{#posts}}
{{id}}
등의 {{변수명}}
✔ Controller, Service, Repository 코드 작성
PostsRepository - Repository
package springbootawsbook.springawsbook.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();
}
@Query
) 작성해도 된다.@Query
는 가독성이 좋다.
💡 참고
규모가 있는 프로젝트
(1) 조회
- FK의 조인, 복잡한 조건 등으로 인해 이런 Entity 클래스만으로 처리하기 어려워 조회용 프레임워크를 추가로 사용한다.
Querydsl
을 사용한다.(2) 등록, 수정, 삭제
- SpringDataJpa를 통해 진행한다.
💡 참고
Querydsl을 추천하는 이유
(1) 타입 안정성이 보장된다.
- 단순한 문자열로 쿼리를 생성하는 것이 아니라, 메소드를 기반으로 쿼리를 생성하기 때문에 오타나 존재하지 않는 컬럼명을 명시할 경우 IDE에서 자동으로 검출된다.
(2) 국내 많은 회사에서 사용 중이다.
(3) 레퍼런스가 많다.
- 국내 자료가 많다.
- 어떤 문제가 발생했을 때 여러 커뮤니티에 질문하고 그에 대한 답변을 들을 수 있다.
PostsService - Service
package springbootawsbook.springawsbook.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import springbootawsbook.springawsbook.domain.posts.Posts;
import springbootawsbook.springawsbook.domain.posts.PostsRepository;
import springbootawsbook.springawsbook.web.dto.PostsListResponseDto;
import springbootawsbook.springawsbook.web.dto.PostsResponseDto;
import springbootawsbook.springawsbook.web.dto.PostsSaveRequestDto;
import springbootawsbook.springawsbook.web.dto.PostsUpdateRequestDto;
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());
}
}
@Transactional
: 트랜잭션 어노테이션readOnly =true
: 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도가 개선된다. 등록, 수정, 삭제 기능이 전혀 없는 서비스 메소드에서 사용하자!.map(PostsListResponseDto::new)
➡ .map(posts -> new PostsListResponseDto(posts))
PostsListResponseDto
package springbootawsbook.springawsbook.web.dto;
import lombok.Getter;
import springbootawsbook.springawsbook.domain.posts.Posts;
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 - Controller
package springbootawsbook.springawsbook.web;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import springbootawsbook.springawsbook.service.PostsService;
@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
에 전달한다.
실행 결과
게시글 수정 API
public class PostApiController {
...
@PutMapping("/api/v1/posts/{id}")
public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto){
return postsService.update(id, requestDto);
}
...
}
✔ 게시글 수정
게시글 수정 화면 머스테치 파일을 생성
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
로 사용할 수 있다. readonly
index.js
btn-update 버튼을 클릭하면 update 기능을 호출할 수 있게 코드 추가
var main = {
init : function () {
var _this = this;
$('#btn-save').on('click', function () {
_this.save();
});
$('#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')
update : function()
type: 'PUT'
@PutMapping
으로 선언했기 때문에 PUT을 사용해야 한다. REST 규약에 맞게 설정된 것이다.url:'/api/v1/posts/'+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
수정 화면을 연결할 Controller 코드 추가
@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";
}
}
실행 결과
삭제 버튼은 본문을 확인하고 진행해야 하므로, 수정 화면에 추가
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>
btn-delete
index.js
삭제 이벤트를 진행할 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();
DELETE
를 제외하고는 update function과 크게 차이 나지 않는다.
PostsService
삭제 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)
deleteById 메소드
를 이용하면 id로 삭제할 수도 있다.
PostApiController
서비스에서 만든 delete 메소드를 컨트롤러가 사용하도록 코드를 추가!
@RequiredArgsConstructor
@RestController
public class PostApiController {
...
@DeleteMapping("/api/v1/posts/{id}")
public Long delete(@PathVariable Long id) {
postsService.delete(id);
return id;
}
...
}
컨트롤러까지 생성되었다.
실행 결과
이번 Chapter을 통해 배운 화면 개발뿐만 아니라, 웹 요청에서의 테스트 코드 작성 방법을 필수로 익혀놓자!
💡 참고
기본적인 게시판 기능이 완성되었다.