일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
- jpa
- Flexbox
- 스프링부트와 AWS로 혼자 구현하는 웹서비스
- 스프링부트
- AWS EC2 구현
- 기술면접
- 그래도일단
- 운영체제
- 스프링부트 테스트코드
- 어찌저찌해냄
- 내가해냄
- 오늘도
- 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 2장
- 개발자기술면접
- 트랜지스터
- 스프링부트와 AWS로 혼자 구현하는 웹 서비스
- 자바스크립트
- 스프링 부트와 AWS로 혼자 구현하는 웹 서비스
- CS
- 테스트코드
- Today
- Total
개발 공부
스프링 부트와 AWS로 혼자 구현하는 웹 서비스 4장 - Mustache를 통한 화면 영역 개발 본문
3장 정리글
https://paradiseiswhereiam.tistory.com/134
스프링 부트와 AWS로 혼자 구현하는 웹 서비스 3장 - JPA
2장 정리글 https://paradiseiswhereiam.tistory.com/132 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 2장 - 테스트 코드 1장 정리글 https://paradiseiswhereiam.tistory.com/131 스프링 부트와 AWS로 혼자 구..
paradiseiswhereiam.tistory.com
❓웹 개발에 있어 템플릿 엔진이란
지정된 템플릿 양식과 데이터가 합쳐져 HTML 문서를 출력하는 소프트웨어. (ex. JSP 또는 React, Vue의 View 파일)
- 서버 템플릿 엔진 : JSP, Freemarker
- 클라이언트 템플릿 엔진 : React, Vue의 View
➡️ 화면 생성을 서버에서 Java 코드로 문자열을 만든 뒤, 이 문자열을 HTML로 변환하여 브라우저로 전달
자바스크립트
➡️ 자바스크립트 코드가 실행되는 장소는 서버가 아닌 브라우저.
Vue.js나 React.js를 이용한 SPA(Single Page Application)은 브라우저에서 화면을 생성한다.
즉, 서버에서 이미 코드가 벗어난 상태.
서버에서는 Json 혹은 Xml 형식의 데이터만 전달하고 HTML은 클라이언트에서 조립
머스테치
수많은 언어를 지원하는 가장 심플한 템플릿 엔진.
해당 책에서는 머스테치를 이용한다.
Thymeleaf라는 템플릿 엔진을 스프링과 많이 쓰는데 책에서 다루지 않는 이유는
태그에 속성으로 템플릿 기능을 사용하는 방식을 쓰는데 이 문법이 어렵다는 이유가 가장 크다.
(단 Vue.js를 사용해본 경험이 있다면 비교적 수월하게 배울 수 있다고 함)
머스테치의 장점
- 문법이 다른 템플릿 엔진보다 심플하다.
- 로직 코드를 사용할 수 없어 View의 역할과 서버의 역할이 명확하게 분리된다.
- Mustache.js와 Mustache.java 2가지가 다 있어, 하나의 문법으로 클라이언트/서버/탬플릿을 모두 사용 가능하다.
- 인텔리제이 커뮤니티 버전에서 쉽게 이용 가능
플러그인 사용을 위해 내려 받기
그리고 build.gradle에 의존성 주입하기
개편되었기 때문에 책처럼 complie은 쓰면 안 된다.
implementation('org.springframework.boot:spring-boot-starter-mustache')
그리고 이제 파일을 만들어준다
위치는 보통 src/main/resources/templates이고
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>
url 매핑은 Controller에서 진행한다.
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/resourcex/templates, 뒤의 파일 확장자는 .mustache가 붙는다.
위의 코드에서는 "index"를 반환하므로,
src/main/resourcex/templates/index.mustache로 전환되어 View Resolver가 처리하게 된다.
다음은 테스트 코드로 검증하기.
IndexControllerTest
package com.jojoldu.book.springboot.web;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
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.junit.jupiter.SpringExtension;
import static org.assertj.core.api.Assertions.assertThat;
@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("스프링 부트로 시작하는 웹 서비스");
}
}
no tests were found 뜨길래 깜짝 놀랐네... @Test 어노테이션을 깜빡하고 달지 않아 생긴 에러였다.
다시 적고 실행하면 잘 된다.
화면으로 잘 나오는지 확인하기 위해
Applicattion.java의 메인메소드를 실행 시킨 후
http://localhost:8080으로 접속하면
이와 같은 화면이 뜬다!
게시글 등록 화면 만들기
프론트엔드 라이브러리를 사용하는 방법
1. 외부 CDN 사용 (책에서는 이 방법을 사용
2. 직접 라이브러리 받아서 사용
제이쿼리와 부트스트랩을 index.mustache에 바로 추가하는 게 아니라
레이아웃 방식으로 추가한다.
❓레이아웃 방식
공통 영역을 별도의 파일로 분리하여 필요한 곳에서 가져다 쓰는 방식을 이야기함
templates/layout 디렉토리를 만들어서 header.mustache와 footer.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>
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는 header에, js는 footer에 둠.
HTML은 위에서부터 코드가 실행되기 때문에 head가 다 실행된 후에 body가 실행된다. 그렇기 때문에 우선 css로 화면을 다 그린 후에 js를 호출한다.
그리고 bootstrap.js의 경우 제이쿼리가 꼭 있어야 하므로 부트스트랩보다 먼저 호출함. (학원에서도 이 순서를 체크해야 한다고 했었다~~!)
라이브러리를 통해 기타 HTML 태그들이 모두 레이아웃에 추가되므로 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}}
위에 /posts/save 라는 페이지를 이동할 수 있는 a 태그를 만들었으므로, 이 주소에 해당하는 컨트롤러를 생성해준다.
페이지 이동에 관한 컨트롤러는 모두 IndexController를 쓴다.
IndexController.java
...
@Controller
public class IndexController {
...
@GetMapping("/posts/save")
public String postsSave() {
return "posts-save";
}
}
이렇게 /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="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}}
여기까지 작성했으면 Application의 메인메소드를 실행한 후 다시 http://localhost:8080으로 접속한다
그럼 다음과 같은 화면을 만날 수 있을 것이다!
글 등록 버튼을 누르면
이렇게 글 등록 페이지도 볼 수 있다~
그러나 아직 화면 제일 아래 등록 버튼은 그림만 그려졌을 뿐, 기능은 없는 상태이다.
API를 호출하는 JS가 전혀 없기 때문이다. 그래서 지금부터 만들어준다.
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{...}라는 코드를 선언한 이유
:
다시 메인메소드를 실행해서 홈페이지를 확인하면, 글 등록 버튼이 잘 기능하는 것을 볼 수 있다.
정말 등록됐는지
http://localhost:8080/h2-console
에 가서 확인을 해 보면
조회가 되는 것을 볼 수 있다.
전체 조회 화면 만들기
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="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}}
~머스테치 문법~
➡️{{#posts}}
: posts라는 List를 순회한다. Java의 for문과 동일하게 생각하면 됨
➡️{{id}} 등의 {{변수명}}
: List에서 뽑아낸 객체의 필드를 사용한다.
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();
}
SpringDataJpa에서 제공하지 않는 메소드는 위처럼 쿼리로 작성할 수 있다.
@Query를 쓰면 가독성도 좋음
IndexController.java 변경
package com.jojoldu.book.springboot.web;
import com.jojoldu.book.springboot.service.PostsService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
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";
}
@GetMapping("/posts/save")
public String postsSave() {
return "posts-save";
}
}
➡️Model
: 서버 템플릿 엔진에서 사용할 수 있는 객체를 저장할 수 있음
여기서는 postsService.findAllDesc()로 가져온 결과를 posts로 index.mustache에 전달
다시 메인 메소드 실행 후 http://localhost:8080/
게시글 수정 화면 만들기
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}}
➡️{{posts.id}}
머스테치는 객체의 필드 접근 시 . 으로 구분한다.
= Post 클래스의 id에 대한 접근을 posts.id로 할 수 있다는 뜻
index.js 추가
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();
index.mustache 수정
{{#posts}}
<tr>
<td>{{id}}</td>
<td><a href="/posts/update/{{id}}">{{title}}</a></td>
<td>{{author}}</td>
<td>{{modifiedDate}}</td>
</tr>
{{/posts}}
여기까진 됐는데 테스트 눌렀을 때 에러 떠서 정말 식겁했다
오타 조심하자...!!!!!!
어디서 오류가 났는지 보려면 whitelabel error 페이지 나왔을 때 status 번호를 확인하자...!!
나는 500 에러가 떠서 어딘가에 오타가 있을 거라고 짐작할 수 있었고,
더 구체적으로 찾기 위해 디버깅 콘솔 창을 확인했다................
post.content 데이터가 안 들어갔다길래 뭐지? 싶어서 찾아보니, PostsResponseDto에 오타가 있었다... 한줄을 빼먹고 안 적었더라 😂😂
아무튼, a 태그로 인해 링크가 걸린 테스트라는 제목을 누르면
이렇게 수정 창이 뜨고 (회색 칸은 readonly로 인해 수정이 불가능한 부분이다)
테스트2로 제목을 수정한 후 수정완료 버튼을 누르면 수정됐다는 alret창이 뜨면서
확인을 누르면 다시 목록 페이지로 돌아온다. 제목이 수정되어 있다!
게시글 삭제
posts-update.mustache
...
<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}}
삭제 이벤트를 위한 버튼을 만들어 준다.
이제 삭제 이벤트를 진행할 JS 코드도 추가한다.
index.js
var main = {
init : function () {
var _this = this;
...
$('#btn-delete').on('click', function () {
_this.delete();
});
},
...
update : function () {
...
},
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();
PostsService
public clss PostsService {
...
@Transactional
public void delete (Long id) {
Posts posts = postsRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("해당 사용자가 없습니다. id=" + id));
postsRepository.delete(posts);
}
...
}
PostsApiController
...
public class PostsApiController {
...
@DeleteMapping("/api/v1/posts/{id}")
public long delete(@PathVariable long id) {
postsService.delete(id);
return id;
}
...
}
그럼 삭제 기능이 수월하게 작동한다.