https://jojoldu.tistory.com/255?category=635883
본 내용은 위의 출처를 바탕으로 합니다.
이번 시간은 Spring Boot & Handlebars 로 간단한 화면을 만듭니다.
Handlebars는 Freemarker, Velocity와 같은 서버 템플릿 엔진입니다.JSP는 서버 템플릿 역할만 하지 않기 때문에 JSP와 완전히 똑같은 역할을 한다고 볼순 없지만, 순수하게 JSP를 View용으로만 사용할 때는 Handlebars가 이와 똑같은 역할을 한다고 보면됩니다.
즉, URL 요청 시 파라미터와 상태에 맞춰 적절한 HTML 화면을 생성해 전달하는 역할을 수행합니다.
JSP, Freemarker, Velocity 가 몇년동안 업데이트가 되지 않아 사실상 SpringBoot에선 권장하지 않는 템플릿 엔진입니다. 현재까지도 꾸준하게 업데이트 되고 있는 템플릿 엔진은 Thymeleaf, Handlebars 이며 둘 중 하나를 선택하면 됩니다.
이 중에서 Handlebars를 추천하는 이유는
(1) 문법이 다른 템플릿엔진보다 간단하며
(2) 로직 코드를 사용할 수 없어 View의 역할과 서버의 역할을 명확하게 제한 할 수 있으며
(3) Handlebars.js와 Handlebars.java 2가지가 있어 하나의 문법으로 클라이언트 템플릿/서버 템플릿 모두를 사용할 수 있습니다.
View 템플릿 엔진은 View의 역할에만 충실하는 것이 좋습니다.
너무 많은 기능을 제공하면 API와 View 템플릿 엔진, JS가 서로 로직을 나눠갖게 되어 유지보수 하기가 굉장히 어렵습니다.
작성일자 2022.08.05 ~
0. 템플릿 엔진이란?
템플릿 양식과 데이터가 합쳐져 HTML 문서를 출력하는 소프트웨어
즉, 웹사이트 화면을 어떤 형태로 만들지 도와주는 양식입니다.
웹 템플릿 엔진은 view code(HTML)와 data logic code(DB connection)를 분리해주는 기능을 합니다.
즉, 웹 문서가 출력되는 템플릿 엔진를 말합니다. = 지정된 템플릿 양식 + 데이터 → HTML 문서를 출력하는 소프트웨어
템플릿 엔진은 서버 사이드 / 클라이언트 사이드 템플릿 엔진으로 나눌 수 있습니다.
템플릿 엔진이 필요한 이유는
(1) 많은 코드를 줄일 수 있다.
- 대부분의 템플릿 엔진은 기존의 HTML에 비해 간단한 문법을 사용합니다.
(2) 재사용성이 높다.
- 똑같은 디자인의 페이지에 보이는 데이터만 바뀌는 경우가 많습니다.
(3) 유지보수에 용이하다.
- 하나의 템플릿을 만들어 여러 페이지를 렌더링할 수 있습니다.
- 서버 사이드 템플릿 엔진(Server Side Template Engine)
- 서버에서 DB 나 API에서 가져온 데이터를 미리 정의된 Template에 넣어 HTML을 그리고 그 결과를 클라이언트에 전달해주는 역할
- HTML 코드에서 고정적으로 사용되는 부분은 템플릿으로 만들어두고 동적으로 생성되는 부분만 템플릿 특정 장소에 끼워 넣는 방식으로 동작할 수 있도록 한다.
- 서버에서 최종 HTML 결과를 만들어서 브라우저에 전달하기 때문에 주로 화면 이동이 많은 곳에서 사용된다.
- 대표적으로는 JSP, Thymeleaf, Freemarker, Velocity가 있다.
JSP이란?
자바 언어를 기반으로 하는 서버 사이드 스크립트 언어이다.
HTML 코드에 java 코드를 넣어 동적인 웹 페이지를 생성할 수 있으며 서블릿과 비슷한 역할을 한다.
* 서블릿이란? https://bestsu.tistory.com/39
서블릿은 Java코드에 HTML 코드가 들어있는 형태라면
JSP는 HTML 코드에 Java 코드가 들어가는 구조이다.
[JSP의 특징]
1. WAS가 이미 만들어 놓은 객체를 사용
2. 사용자 정의 태그를 사용하여 보다 효율적으로 웹사이트 구성
3. HTML 코드 안에 Java 코드가 있으므로 HTML 코드 작성이 쉬움
4. 서블릿과 달리 JSP는 수정된 경우 재배포할 필요없이 WAS가 알아서 처리
Thymeleaf란?
서버 사이드 템플릿 엔진의 한 종류이며 Spring boot에서 공식적으로 지원하고 권장하는 템플릿 엔진이다.
Thyemeleaf는 MVC 기반 웹 애플리케이션의 뷰 레이어에서 XHTML/HTML5를 서비스 하는데에 적합하다.
스프링 프레임워크와 완전한 통합 기능을 제공한다.
브라우저에서 직접 열 수 있고 웹페이지로 올바르게 표시되는 템플릿 파일인 Natural Template이라는 개념을 구현한다.
[Thymeleaf의 특징]
1. HTML5용 자바 템플릿 엔진
2. 웹 및 오프라인 환경 모두 작동 가능
3. 서블릿 API에 대한 하드 종속성이 없음
4. 방언(사투리/dialects)이라 불리는 모듈형 형상 집합에 기초
5. 전체 국제화를 지원
- 서버 사이드 템플릿 엔진 동작 과정
- 1. 클라이언트 요청 받기
- 2. 필요한 데이터를 DB나 API에서 가져온다
- 3. 미리 정의된 Template에 해당 데이터를 배치한다
- 4. 서버에서 데이터가 반영된 Template를 기반으로 HTML을 그린다
- 5. 해당 HTML을 클라이언트에 전달한다.
- 클라이언트 사이드 템플릿 엔진(Client Side Template Engine)
- HTML형태로 코드를 작성할 수 있으며 동적으로 DOM을 그리게 해주는 역할
- 데이터를 받아서 DOM 객체에 동적으로 그려주는 프로세스를 담당
- URL이 바뀌어도 HTML을 다시 내려받지 않고 클라이언트에서 알아서 그리기 때문에 주로 단일 화면에서 화면이 자주 변경되는 경우에 사용
- 예를 들어, 웹 페이지 내에 여러 카테고리 중 하나를 선택할 때마다 같은 형식의 프레임에 내용만 변경되는 경우
- 매번 템플릿을 입력, 바꾸기보다는 Script 타입을 템플릿으로 미리 만들어 사용하며 안의 내용을 replace하는 방식으로 동작한다.
- 대표적으론 Mustache, Squirrelly, Handlebars가 있다.
DOM이란?
DOM은 Document Object Model의 약자. = 문서 객체 모델
넓은 의미로는 웹 브라우저가 HTML 페이지를 인식하는 방식
좁은 의미로는 document 객체와 관련된 객체의 집합을 의미
- 클라이언트 사이드 템플릿 엔진 동작 과정
- 1. 클라이언트에서 공통적인 프레임을 미리 Template로 만든다.
- 2. 서버에서 필요한 데이터를 받는다.
- 3. 데이터를 Template에 배치하고 DOM 객체에 동적으로 그린다.
참조
- https://velog.io/@hi_potato/Template-Engine-Template-Engine
- https://code-lab1.tistory.com/211
1. Handlebars 연동
의존성 추가
compile 'pl.allegro.tech.boot:handlebars-spring-boot-starter:0.2.15'
build.gradle에 다음과 같이 의존성을 추가해줍니다. 이후 sync
의존성을 추가하는데 이러한 오류가 납니다.
Could not find method compile() for arguments [pl.allegro.tech.boot:handlebars-spring-boot-starter:0.2.15] on object of type org.gradle.api.internal.artifacts.dsl.dependencies.DefaultDependencyHandler.
implementation 'pl.allegro.tech.boot:handlebars-spring-boot-starter:0.3.2'
위 댓글처럼 위와 같이 수정하여 의존성을 추가해주니까 잘 적용됩니다!
감사합니다!
의존성만 추가하면 다른 서버 템플릿 스타터 패키지와 마찬가지로 추가 설정없이 설치가 끝이납니다.
다른 서버 템플릿 스타터 패키지와 마찬가지로 Handlebars도 기본 경로는 src/main/resources/templates 가 됩니다.
추가로 handlebars 플러그인까지 설치해주었습니다.
설치 후 IntelliJ를 재실행하면 사용 가능합니다!
스프링 부트는 디폴트 설정이 굉장히 많습니다.
기존의 스프링처럼 개인이 하나하나 설정 코드를 작성할 필요가 없습니다.
스프링 부트를 사용하면 많은 설정을 생략할 수 있습니다.
IntelliJ를 사용중이라면 Handlebars 플러그인을 설치하여 문법체크 등과 같은 많은 지원을 받을 수 있습니다.
메인 페이지 생성
src > main > resources > templates > main.hbs 파일을 생성해줍니다.
<!DOCTYPE HTML>
<html>
<head>
<title>스프링부트 웹서비스</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<h1>스프링부트로 시작하는 웹 서비스</h1>
</body>
</html>
main.hbs
다음으로는 위에서 만들어진 메인 페이지를 URL 요청시 호출되도록 Controller를 생성합니다.
package com.jojoldu.webservice.web;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
@AllArgsConstructor
public class WebController {
@GetMapping("/") // @RequestMapping(value="/", method = RequestMethod.GET) 과 동일
public String main(){
return "main"; // handlebars-spring-boot-starter 덕분에 컨트롤러에서 문자열을 반환할 때 앞의 path와 뒤의 파일 확장자는 자동으로 지정된다.
// 예: path = src/main/resources/templates, 확장자 = .hbs
// src/main/resources/templates 경로의 main.hbs로 전환되어 view Resolver가 처리한다.
}
}
WebController.java
@GetMapping("/")는 @RequestMapping(value="/", method=RequestMethod.GET) 과 동일합니다.
main 함수가 "main" 문자열을 반환하므로 src/main/resources/templates/main.hbs 로 handlebars-spirng-boot-starter에 의해 경로와 파일 확장자가 자동으로 지정되어 반환됩니다.
main.hbs로 전환되므로 View Resolver가 처리하게 되는데
여기서 View Resolver는 URL 요청의 결과를 전달할 타입과 값을 지정하는 관리자 격입니다.
메인 페이지 테스트 코드,
다음으로는 위에서 완성한 내용을 테스트 코드를 통해 검증해보도록 하겠습니다!
src > test > java > com > 패키지명 > webservice > web 경로에 WebControllerTest 클래스를 생성해줍시다.
테스트 내용은 실제로 URL을 호출 시 제대로 페이지가 호출되는지에 대한 것입니다.
TestRestTemplate를 통해 "/"로 호출했을 때 main.hbs에 포함된 코드들이 있는지 확인하면 됩니다.
implementation 'pl.allegro.tech.boot:handlebars-spring-boot-starter:0.3.4'
2022년 8월 11일 기준으로 handlebars 버전을 0.3.4로 업데이트 시켜주어야 테스트 코드가 오류나지 않습니다!
package com.jojoldu.webservice.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.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
import static org.assertj.core.api.Assertions.assertThat;
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = RANDOM_PORT)
public class WebControllerTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
public void 메인페이지_로딩() {
//given (테스트 기반 환경 구축)
//when (테스트 하고자 하는 행위)
String body = this.restTemplate.getForObject("/",String.class);
//then (테스트 결과 검증)
assertThat(body).contains("스프링부트로 시작하는 웹 서비스"); // 전체 코드를 다 찾지 않고 해당 문자열이 포함되어 있는지만 비교
}
}
WebControllerTest.java
테스트가 통과하면 실제로도 화면이 잘 나오는지 확인 해보도록 하겠습니다
Application을 실행하고 localhost로 접속합니다.
아주 자알 나오는군요
2. 게시글 등록
화면에 게시글 등록 기능을 구현합니다.
service 메소드 구현
src > main > java > com > 패키지 > webservice 위치에 service 패키지를 생성 후 PostsServic 클래스를 생성합니다.
package com.jojoldu.webservice.service;
import com.jojoldu.webservice.domain.posts.PostsRepository;
import com.jojoldu.webservice.dto.PostsSaveRequestDto;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@AllArgsConstructor
@Service
public class PostsService {
private PostsRepository postsRepository; // DTO
@Transactional
public Long save(PostsSaveRequestDto dto){
return postsRepository.save(dto.toEntity()).getId(); // 저장한 게시글의 id를 리턴
}
}
PostsService.java
Controller에서 Dto.toEntity를 통해서 바로 전달해도 되는데 굳이 Service에서 Dto를 받는 이유는?
=> Controller와 Service의 역할을 분리하기 위해서다.
Service는 비지니스 로직 & 트랜잭션 관리를 담당하고
Controller는 View와 연동되는 부분을 관리하도록 한다.
트랜잭션이란? (http://springmvc.egloos.com/495798)
일반적으로 DB 데이터를 등록/수정/삭제 하는 Service 메소드는 @Transaction 을 필수적으로 가진다.
이 어노테이션은 메소드 내에서 Exception이 발생하면 해당 메소드에서 이루어진 모든 DB작업을 초기화 한다.
즉, save 메소드를 통해서 10개를 등록해야 하는데 5번째에서 Exception이 발생하면 앞에 저장된 4개의 데이터를 롤백시킨다. => 모든 처리가 정상적으로 처리됐을때만 DB에 커밋한다.
다음으로는 작성한 Service 메소드가 잘 작동하는지 테스트 코드를 작성합니다.
src > test > java > com > 패키지명 > webservice > service 로 생성해준 다음 해당 경로에 PostServiceTest.java를 생성합니다.
package com.jojoldu.webservice.service;
import com.jojoldu.webservice.domain.posts.Posts;
import com.jojoldu.webservice.domain.posts.PostsRepository;
import com.jojoldu.webservice.dto.PostsSaveRequestDto;
import org.junit.After;
import org.junit.Test;
import org.junit.jupiter.api.AfterEach;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
@RunWith(SpringRunner.class)
@SpringBootTest
public class PostServiceTest {
@Autowired
private PostsService postsService;
@Autowired
private PostsRepository postsRepository;
@After
public void cleanup(){
postsRepository.deleteAll();
}
@Test
public void Dto데이터가_posts테이블에_저장된다 (){
//given
PostsSaveRequestDto dto = PostsSaveRequestDto.builder() // PostsSaveRquestDto에 Builder를 추가해주어야 builder() 사용가능
.author("jojoldu@gmail.com")
.content("테스트")
.title("테스트 타이틀")
.build();
//when
postsService.save(dto);
//then
Posts posts = postsRepository.findAll().get(0);
assertThat(posts.getAuthor()).isEqualTo(dto.getAuthor());
assertThat(posts.getContent()).isEqualTo(dto.getContent());
assertThat(posts.getTitle()).isEqualTo(dto.getTitle());
}
}
PostServiceTest.java
package com.jojoldu.webservice.dto;
import com.jojoldu.webservice.domain.posts.Posts;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
public class PostsSaveRequestDto {
private String title;
private String content;
private String author;
@Builder
public PostsSaveRequestDto(String title, String content, String author){
this.author = author;
this.title = title;
this.content = content;
}
public Posts toEntity(){
return Posts.builder()
.title(title)
.content(content)
.author(author)
.build();
}
}
PostsSaveRequestDto.java
=> Builder 추가
테스트가 통과하면 WebRestController의 save 메소드를 수정해줍니다.
void 타입에서 Long 타입으로
WebRestController의 멤버변수로 PostsService 객체를 추가해주고
PostsService 객체의 save 메소드의 결과값으로 return 하도록 수정합니다.
public class WebRestController {
private PostsRepository postsRepository;
private PostsService postsService; // 추가
@GetMapping("/hello")
public String hello() {
return "HelloWorld";
}
@PostMapping("/posts")
public Long savePosts(@RequestBody PostsSaveRequestDto dto){
// postsRepository.save(dto.toEntity());
return postsService.save(dto);
}
}
여기까지 Java 코드를 완성한 것이고 다음으론 Handlebars를 통해 입력 화면을 생성합니다.
입력화면
CSS를 전부 구성하기엔 무리가 있으므로 오픈소스 부트스트랩을 활용합니다.
부트스트랩, JQuery등 프론트엔드 라이브러리를 사용하는 방법은 2가지가 있습니다.
1. 외부 CDN 사용
=> 실제 서비스에서는 잘 사용하지 X , 외부 서비스에 서비스가 의존하게 되므로 CDN을 서비스하는 곳에 문제가 생기면 같이 문제가 생기기 때문이다.
2. 직접 라이브러리 받아서 사용 (채택)
다운 받은 부트스트랩 파일을 압축해제 후 dist 폴더 아래에 있는 각각의 css/js 폴더에서 bootstrap.min.css와 bootstrap.min.js 파일을 찾아서 해당 사진과 같이 위치시켜줍니다.
마찬가지로 JQuery도 다운받아줍니다.
Download the compressed, production JQuery 3.6.0을 눌러줍니다.
그럼 홈페이지가 뜰탠데 당황하지 않고 우클릭 -> 다른 이름으로 저장을 통해 저장해줍니다.
다운받은 파일의 이름을 jqeury.min.js로 변경해줍니다.
변경한 파일을 src/main/resources/static/js/lib에 위치시켜줍니다!
그럼 최종적으로 이렇게 됩니다! 그리고 main.hbs에 가셔서 해당 라이브러리를 추가해주면 됩니다!
<!DOCTYPE HTML>
<html>
<head>
<title>스프링부트 웹서비스</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/css/lib/bootstrap.min.css">
</head>
<body>
<h1>스프링부트로 시작하는 웹 서비스</h1>
<script src="/js/lib/jquery.min.js"></script>
<script src="/js/lib/bootstrap.min.js"></script>
</body>
</html>
main.hbs
스프링 부트에서 기본적으로 / 은 src/main/resources/static으로 지정됩니다.
여기서 css와 js의 위치가 다릅니다.
css는 head에 위치하며
js는 body의 최하단에 위치합니다.
이렇게 하는 이유는 페이지 로딩속도를 높이기 위함입니다.
HTML은 최상단에서부터 코드가 실행되기 때문에 head가 전부 실행되고 나서야 body가 실행됩니다.
즉, head가 다 불러지지 않으면 사용자 쪽에선 백지 화면만 노출됩니다.
특히 js의 용량이 클수록 body 부분의 실행이 늦어지기에 js는 body 하단에 두어 화면이 다 그려진 뒤에 호출되도록 합니다.
반면 css는 화면을 그리는 역할을 하므로 head에서 먼저 불러오는 것이 좋습니다.
추가로 bootstrap.js의 경우 jquery가 꼭 있어야 하기 때문에 bootstarp보다 jquery가 먼저 호출되도록 작성합니다.
=> bootstrap.js가 jqeury에 의존하는 상황입니다.
<!DOCTYPE HTML>
<html>
<head>
<title>스프링부트 웹서비스</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/css/lib/bootstrap.min.css">
</head>
<body>
<h1>스프링부트로 시작하는 웹 서비스</h1>
<div class="col-md-12">
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#savePostsModal">글 등록</button>
</div>
<div class="modal fade" id="savePostsModal" tabindex="-1" role="dialog" aria-labelledby="savePostsLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="savePostsLabel">게시글 등록</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<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>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">취소</button>
<button type="button" class="btn btn-primary" id="btn-save">등록</button>
</div>
</div>
</div>
</div>
<script src="/js/lib/jquery.min.js"></script>
<script src="/js/lib/bootstrap.min.js"></script>
</body>
</html>
main.hbs
라이브러리를 전부 추가했으므로 main.hbs에 UI부분을 추가해주었습니다.
어플리케이션을 실행시키고 UI를 확인해봅니다.
글등록이라는 버튼이 생성되었습니다.
근데 왜 모달창이 안뜰까요....?? 그대로 적용했는데 . . . .
=> 수업자료 github에 들어가서 lib 파일들을 그대로 복사붙여넣기 해왔습니다!
아무래도 버전차이 때문에 발생하는 오류인 것 같습니다.
lib 파일들을 수정해주니깐 모달창이 뜹니다.
단! 아직은 등록버튼에 아무 기능도 없는 상태입니다.
이제 JS를 작성해서 등록버튼에 기능을 만들어줍니다.
static/js 아래에 app 디렉토리를 생성하고 main.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: '/posts',
dataType: 'json',
contentType:'application/json; charset=utf-8',
data: JSON.stringify(data)
}).done(function() {
alert('글이 등록되었습니다.');
location.reload();
}).fail(function (error) {
alert(error);
});
}
};
main.init();
main.js
JS의 첫문장에 var main = { ... }이라는 변수의 속성으로 fucntion을 추가한 이유는?
만약 main.js가 아래와 같이 함수를 작성한 상황일 경우
var init = function () {
....
};
var save = function () {
...
};
init();
main.hbs에 a.js가 추가된다고 가정합니다.
여기서 a.js가 init ,save 함수가 없을땐 괜찮은데 a.js도 a.js만의 init , save 함수를 가진다면 어떻게 될까요?
브라우저의 scope는 공용으로 쓰이기 때문에 나중에 불려진 js의 init, save가 먼저 불려진 js의 함수를 덮어씁니다!
이러한 문제를 피하기위해 main.js만의 변수, function 영역으로 var main이란 객체 안에서 function을 선언합니다.
이렇게 하면 main 객체 안에서만 이름이 유효하기에 다른 js와 겹칠 위험이 사라집니다.
이제 작성한 main.js를 main.hbs에 추가해줍니다.
main.hbs에 main.js를 적용하고 난 후 모달창을 통해 글을 등록해줍니다.
h2-console로 접속하면 모달창을 통해 등록한 글이 h2 DB에 저장되어 있는 것을 확인할 수 있습니다.
3. 게시글 목록
목록 출력
코드를 작성하기 전에
현재 사용중인 로컬DB는 H2입니다.
H2는 메모리 DB입니다.
따라서 프로젝트를 실행할때마다 스키마가 새로 생성되어 테이블 구조 변경시 일일이 alter table과 같이 수정 할 필요가 없습니다.
또한, 항상 테이블을 초기화하기 때문에 깨끗한 상태로 로컬 개발을 진행할 수 있다는 장점도 있습니다.
하지만 이로 인해, 프로젝트 코드를 수정하고 다시 실행시키면 이전에 저장해놓은 데이터가 초기화됩니다.
게시글 목록의 UI가 잘 나오는지 확인/수정하는 과정에서 수동 데이터 입력 과정을 제외하기 위한 설정 작업을 먼저 하도록 합니다!
resource 폴더 아래에 data-h2.sql 파일을 생성합니다.
insert into posts(title,author, content, created_data, modified_data) values ('테스트1', 'test1@gmail.com', '테스트1의 본문',now(), now());
insert into posts(title,author, content, created_data, modified_data) values ('테스트2', 'test2@gmail.com', '테스트2의 본문',now(), now());
data-h2.sql
다음으론 위 insert sql 파일을 프로젝트 실행시에 자동으로 수행되도록 설정을 추가합니다.
spring:
profiles:
active: local # 기본 환경 선택
# local 환경
---
spring:
profiles: local
datasource:
data: classpath:data-h2.sql # 시작할때 실행시킬 script
jpa:
show-sql: true
hibernate:
ddl-auto: create-drop
h2:
console:
enabled: true
application.yml
spring.profiles 옵션은 어플리케이션 실행시 특별히 파라미터로 넘어온게 없으면 active 값을 보게됩니다.
운영환경에선 real 혹은 production 등과 같은 profile을 보도록 jar 실행시점에 파라미터를 변경합니다.
local profile에선 data-h2.sql을 초기 데이터 실행 스크립트로 지정합니다.
그외 환경에선 해당 스크립트가 실행되지 않기 위해 local에 등록한 것입니다.
---를 기준으로 상단은 공통영역, 하단은 각 profile의 설정 영역입니다.
공통영역의 값은 각 profile환경에 동일한 설정이 있으면 무시되고 없으면 공통 영역의 설정값이 사용됩니다.
따라서 공통영역에 설정값을 넣는것에 굉장히 주의가 필요합니다.
만약 공통영역에 jpa.hibernate.ddl-auto: create-drop이 있고 운영 profile에 해당 설정값이 없다면?
운영환경에서 배포시 모든 테이블이 drop -> create 됩니다.
이때문에 datasource, table등과 같은 옵션들은 공통영역에 두지 않고 각 profile 마다 별도로 두는 것을 추천합니다.
설정을 완료한 후 프로젝트를 다시 실행시키고 h2-console을 통해 해당 로컬 데이터가 들어갔는지 확인합니다.
로컬데이터가 안들어가.. 왜 !?
로컬데이터가 안들어가는 부분 수정하기..
도저히 못찾겠다..
이틀내내 삽질했는데도 계속해서 에러가 난다...
처음엔 applicaiton이 실행이라도 됐는데 어디서부터 문제인지 오류가 나서 돌아가지도 않는다 ㅜㅜ
하란대로 다했다 근데 왜..?!!!!
Caused by: org.springframework.jdbc.datasource.init.ScriptStatementFailedException: Failed to execute SQL script statement #1 of class path resource [data-h2.sql]: insert into posts (title, author, content, created_date, modified_date) values ('테스트1', 'test1@gmail.com', '테스트1의 본문', now(), now()); nested exception is org.h2.jdbc.JdbcSQLSyntaxErrorException: Syntax error in SQL statement "INSERT[*] INTO POSTS (TITLE, AUTHOR, CONTENT, CREATED_DATE, MODIFIED_DATE) VALUES ('테스트1', 'test1@gmail.com', '테스트1의 본문', NOW(), NOW())"; SQL statement:
Caused by: org.h2.jdbc.JdbcSQLSyntaxErrorException: Syntax error in SQL statement "INSERT[*] INTO POSTS (TITLE, AUTHOR, CONTENT, CREATED_DATE, MODIFIED_DATE) VALUES ('테스트1', 'test1@gmail.com', '테스트1의 본문', NOW(), NOW())"; SQL statement:
대체 뭐가 문제인건데..
spring:
profiles:
active: local # 기본 환경 선택
# local 환경
---
spring:
profiles: local
datasource:
data: classpath:data-h2.sql # 시작할때 실행시킬 script
mode: MySQL
url: jdbc:h2:mem:testdb
jpa:
database-platform: org.hibernate.dialect.H2Dialect
defer-datasource-initialization: true
properties:
hibernate:
show-sql: true
hibernate:
ddl-auto: create-drop
h2:
console:
enabled: true
sql:
init:
mode: always
하란대로 다했찌 않느냐고 ..
넘넘 짜증난다 ㅠ
로컬 데이터 없이 하려고해도 어디서부터 오류났는지도 모르겠고 ..
웃긴건 application.yml에 data에 data-h2.sql 지정해주는 부분 빼면 잘 돌아간다 ㅋㅋㅋㅋ
뭔데 . ..?
그래서 그냥 .. 로컬데이터 없이 진행하기로했다
이틀내내 잡았으니 더 잡으면 진도가 진행이 안될것 같다.
이러다가 추가로 발견한 수정내용인데
h2-console을 접속할 때 마다 h2의 key를 어플리케이션에서 찾아서 접속해야 하는게 정말 귀찮았는데.. 해결법을 찾았다
url을 jdbc:h2:mem:testdb로 설정해주면 h2 console에서 url을 변경없이 jdbc:h2:mem:testdb로 접속할 수 있다!
귀찮음 해결
<div class="col-md-12">
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#savePostsModal">글 등록</button>
<br/>
<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">
{{#each posts}}
<tr>
<td>{{id}}</td>
<td>{{title}}</td>
<td>{{author}}</td>
<td>{{modifiedDate}}</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
main.hbs에 게시글 글 목록이 뜨도록 UI를 변경해줍니다.
여기서 handlebars 문법이 사용됩니다.
{{#each post}} : posts 라는 리스트를 순회하여 하나씩 꺼내 각각의 필드값을 채워서 테이블에 출력시킵니다.
~{{/each}}
public interface PostsRepository extends JpaRepository<Posts,Long> {
@Query("SELECT p " +
"FROM Posts p " +
"ORDER BY p.id DESC")
Stream<Posts> findAllDesc();
}
다음으로 PostsRepository 인터페이스에 쿼리를 추가해줍니다.
위 코드는 StringDataJpa에서 제공되는 기본 메소드로 해결할 수 있으나 굳이 @Query를 사용한 이유는
바로 SpringDataJpa에서 제공하지 않는 메소드는 위처럼 쿼리로 작성해도 되는 것을 보여주기 위함입니다!
규모가 있는 프로젝트에서 데이터 조회는 FK의 조인, 복잡한 조건등으로 인해 이런 Entity 클래스만으로 처리하기가 어렵습니다.
따라서 조회용 프레임워크를 추가로 사용합니다!
대표적인 조회용 프레임워크로는 querydsl, jooq, MyBatis 등이 있습니다.
조회는 위 3가지 프레임워크중 하나를 통해 조회하고, 등록/수정/삭제 등은 SpringDataJpa를 통해 진행합니다!
@Transactional(readOnly = true) // 조회기능만 남겨두어 조회 속도가 개선됨 따라서 등록/수정/삭제 기능이 없는 메소드에선 사용하는것이 좋음
public List<PostsMainResponseDto> findAllDesc(){
return postsRepository.findAllDesc()
.map(PostsMainResponseDto::new) // .map(posts -> new PostsMainResponseDto(posts))와 같다.
.collect(Collectors.toList());
}
다음으로는 PostsService 코드에 위 메소드를 추가했습니다.
findAllDesc 메소드에 트랜잭션 어노테이션이 추가되었습니다.
트랜잭션 어노테이션에 옵션 (readOnly = true)를 주면 트랜잭션 범위는 유지하며너 조회기능만 남겨두기 떄문에
조회 속도가 개선됩니다. 따라서 특별히 등록/수정/삭제 기능이 없는 메소드라면 사용하는 것이 좋습니다!
메소드 내부는 람다식으로 구성되었습니다.
.map(PostsMainResponseDto::new) 는 .map(posts -> new PostsMainResponseDto(posts)) 와 같습니다.
즉, repository 결과로 넘어온 Posts의 stream을 map을 통해 PostsMainResponseDto로 변환하고 List로 반환합니다.
위의 코드를 작성하기 위해 dto 패키지에 PostsMainResponseDto.java를 생성해줍니다.
package com.jojoldu.webservice.dto;
import com.jojoldu.webservice.domain.posts.Posts;
import lombok.Getter;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.Optional;
@Getter
public class PostsMainResponseDto {
private Long id;
private String title;
private String author;
private String modifiedDate; // View영역에선 LocalDateTime 타입을 모르기 때문에 인식할 수 있도록 문자열로 날짜형식을 변경하여 등록한다.
public PostsMainResponseDto(Posts entity){
id = entity.getId();
title = entity.getTitle();
author = entity.getAuthor();
modifiedDate = toStringDateTime(entity.getModifiedDate());
}
/* java 8 버전*/
private String toStringDateTime(LocalDateTime localDateTime){
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
return Optional.ofNullable(localDateTime)
.map(formatter::format)
.orElse("");
}
/* java 7 버전 */
private String toStringDateTimeByJava7(LocalDateTime localDateTime){
if(localDateTime == null){
return "";
}
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
return formatter.format(localDateTime);
}
}
PostsMainResponseDto.java
modifiedDate는 String을 사용하였습니다.
왜냐하면 View 영역에선 LocalDateTime 타입을 모르기 때문에 인식할 수 있도록 StringDateTime 메소드를 통해
문자열로 날짜형식을 변경해서 등록해주는 것입니다.
Entity가 toDto와 같은 메소드로 dto를 반환해서는 절대 안됩니다!
DTO는 Entity를 사용해도 되지만,
Entity는 DTO에 대해 전혀 모르게 코드를 구성해야 합니다.
Entity는 가장 core한 클래스인 반면, DTO는 View 혹은 외부 요청에 관련있는 클래스입니다.
Entity가 DTO를 사용하게 되면 View/외부요청에 따라 DTO 뿐만 아니라 Entity까지 변경이 필요하게 됩니다.
또한, 다른 DTO도 필요하다고 하면 다시 Entity에 toDto2 와 같은 메소드가 추가되는데
이렇게 되면 모든 변화에 맞춰 Entity 변경이 필요하게 됩니다.
프로젝트 규모가 커져 프로젝트를 분리해야 할때도 Entity가 DTO를 의존하고 있으면
분리하기가 굉장히 어렵기 때문에 DTO가 Entity에 의존하는 코드로 무조건 작성하시길 바랍니다.
@Controller
@AllArgsConstructor
public class WebController {
private PostsService postsService;
@GetMapping("/")
public String main(Model model){ // 인자 Model 객체 추가
model.addAttribute("posts",postsService.findAllDesc()); // 추가
return "main";
}
}
다음으로 WebController를 변경해줍니다.
마지막으로 페이지를 접속해서 수동으로 글등록을하면 글목록이 출력됩니다.
저는 로컬데이터가 추가되지 않아서 수동으로 등록해주었습니다;;
간단한 게시판 기능을 완료했습니다.
다음으론 AWS 서버 구축, 배포환경 구축, 도메인 등록을 진행합니다.
저 망할 로컬데이터 때문에 이틀이나 잡아먹었습니다. 진짜 짜증나네요.
'Web > Spring' 카테고리의 다른 글
[Spring Security] @AuthenticationPrincipal에 null값 들어오는 문제 해결 방법 (2) | 2023.11.16 |
---|---|
[인프런] 스프링부트 개념정리(이론) - 1 (1) | 2022.10.05 |
[JUnit4] 테스트 라이브러리 기본 사용법 (0) | 2022.08.05 |
[2] 스프링부트로 웹 서비스 출시하기 - 2. SpringBoot & JPA로 간단 API 만들기 (0) | 2022.08.01 |
[1] 스프링부트로 웹 서비스 출시하기 - 1. SpringBoot & Gradle & Github 프로젝트 생성하기 (0) | 2022.07.21 |