모든 코드는 Github에 올라와 있습니다.
프로젝트 생성

WebRestController
- 응답을 정상적으로 하는지 간단하게 확인


PostsSaveRequest
- setter를 사용하는 이유
- Controller에서
@RequestBody로 외부에서 데이터를 받는 경우엔 기본생성자 + set메소드를 통해서만 값이 할당된다.
- Controller에서
Entity 클래스로 Request/Response를 하지 않는 이유
- Entity 클래스는 매우 중요한 역할을 하는 클래스이다.
- 다른 많은 클래스들이 Entity 클래스를 중심으로 동작을 하기 때문에 변경이 있어서는 안된다.
- Request나 Response용 DTO를 따로 만들어서 view에 맞는 스펙으로 운영해야한다.
- view Layer와 DB Layer를 역할 분리를 하는 것이 좋다.
properties를 사용하지 않고 yml을 쓰는 이유
- 상대적으로 유연한 구조를 가졌기 때문이다.
- 계층 구조를 한눈에 쉽게 알아볼 수 있다.
처음에는 아무것도 없는 상태이다.

PostsRequest.http 파일로 POST 요청을 보낸다.

다시 localhost:8080/h2-console을 확인하면 데이터가 정상적으로 입력된 것을 확인할 수 있다.

Entity는 유지보수를 위해 생성, 수정 시간 같은 데이터를 가지는데,
이를 개발자가 매번 업데이트 시키기에는 너무 많은 리소스가 들기 떄문에 JPA에서 자동으로 관리하도록 하겠다.
BaseTimeEntity 생성
src/main/java/study/soowan/webservice/domain에 BaseTimeEntity 클래스를 생성
모든 Entity들의 상위 클래스가 되어 Entity들의 createdDate, modifiedDate를 자동으로 관리하는 역할
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
@CreatedDate
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime modifiedDate;
}
@MappedSuperClass- JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우 필드들(
createdDate,modifiedDate)도 컬럼으로 인식하도록 한다.
- JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우 필드들(
@EntityListeners(AuditingEntityListener.class)- BaseTimeEntity 클래스에 Auditing 기능을 포함시킨다.
@CreatedDate- Entity가 생성되어 자장될 때 시간이 자동 저장된다.
@LastModifiedDate- 조회한 Entity의 값을 변경할 때 시간이 자동 저장된다.
Posts 클래스가 BaseTimeEntity를 상속받도록 변경
...
public class Posts extends BaseTimeEntity {
...
}
이제 마지막으로 JPA Auditing 어노테이션들을 모두 활성화 시킬수 있도록 Application 클래스에 활성화 어노테이션을 추가한다.
@EnableJpaAuditing // JPA Auditing 활성화
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
JPA Auditing 테스트 코드 작성하기
@Test
package study.soowan.webservice.web.domain.posts;
import org.aspectj.lang.annotation.After;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class PostsRepositoryTest {
@Autowired
private PostsRepository postsRepository;
@After("")
public void cleanup() {
postsRepository.deleteAll();
}
@Test
void 게시글저장_불러오기() {
...
}
@Test
void BaseTimeEntity_등록() {
// given
LocalDateTime now = LocalDateTime.now();
postsRepository.save(Posts.builder()
.title("테스트 게시글")
.content("테스트 본문")
.title("test@gmail.com")
.build());
// when
List<Posts> postsList = postsRepository.findAll();
// then
Posts posts = postsList.get(0);
assertThat(posts.getCreatedDate()).isAfter(now);
assertThat(posts.getModifiedDate()).isAfter(now);
}
}
테스트 코드를 실행시켜보면?
성공적으로 통과한다.

PostRequest.http를 통해서 POST요청을 해보자
POST http://localhost:8080/posts
Content-Type: application/json
{
"title": "테스트 타이틀",
"content": "테스트 본문",
"title": "테스터"
}
h2 console을 통해서 확인해보면

createdDate와 modifiedDate가 추가된 것을 확인할 수 있다.
앞으로 생성하는 Entity도 BaseTimeEntity를 상속받아서 생성, 수정시간을 자동으로 관리하게 할 수 있다.
뷰 템플릿엔진으로 Thymeleaf 사용
Spring에서 정식으로 지원하는 뷰 템플릿엔진으로 안정적인 서비스 지원을 받을 수 있고 Spring과 관련된 여러가지 기능들을 편리하게 사용가능하다.
네추럴 템플릿: HTML 파일을 기반으로 작동한다.표현식 문법: 다양한 표현식을 사용하여 동적으로 값을 삽입하거나 조건부 로직을 구현할 수 있다.Spring 통합: spring MVC와 쉽게 통합된다. 컨트롤러에서 전달된 모델 데이터를 템플릿에서 간편하게 사용가능하다.
의존성 추가

메인 페이지 생성
src/main/resources/templates에 main.html 파일을 생성한다.
<!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>
메인페이지를 반환하는 controller를 만든다.
web패키지 안에 WebController를 만든다.
@Controller
@AllArgsConstructor
public class WebController {
@GetMapping("/")
public String main() {
return "main";
}
}
prefix, sufix를 설정하지 않아도 Spring Boot의 자동 설정 기능 덕분에 정상적으로 html파일 리턴이 가능하다.
메인 페이지 테스트 코드
src/test/java/com/jojoldu/webservice/web에 WebControllerTest 클래스 생성
@SpringBootTest(webEnvironment = RANDOM_PORT)
class WebControllerTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void 메인페이지_로딩() {
// when
String body = this.restTemplate.getForObject("/", String.class);
// then
assertThat(body).contains("스프링부트로 시작하는 웹 서비스");
}
}
테스트가 무사히 통과되었다.
실제로 body가 잘 나오는지 확인해보겠다.

정상적으로 화면이 노출되는게 확인된다.
게시글 등록
화면에서 게시글 등록 기능을 구현한다.
service 메소드 구현
src/main/java/study/soowan/webservice/ 아래에 service 패키지를 생성후, PostsService 클래스를 생성한다.
@AllArgsConstructor
@Service
public class PostsService {
private PostsRepository postsRepository;
@Transactional
public Long save(PostsSaveRequestDto dto) {
return postsRepository.save(dto.toEntity()).getId();
}
}
호출한쪽에서 게시글의 id를 알수있도록 리턴 타입을 Long으로 두고, getId()를 반환값으로 한다.
Service에서는 Entity 대신 Save용 DTO인 PostsSaveRequestDto를 받아서 저장한다.
DB 데이터를 등록/수정/삭제 하는 Service 메소드는 @Transaction을 필수적으로 가져간다.
만약 메소드에서 Exception이 발생하면 해당 메소드에서 이루어진 모든 DB작업을 초기화시킨다.
예) 만약 5개의 데이터를 등록해야하는데 3번째에서 Exception이 발생하면 앞의 2개의 데이터도 전부 롤백시킨다.
이제 Service 메소드를 테스트해보자!
src/test/java/study/soowan/webservice/service 패키지 생성후, PostServiceTest 클래스를 생성
package study.soowan.webservice.service;
import org.aspectj.lang.annotation.After;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import study.soowan.webservice.web.domain.posts.Posts;
import study.soowan.webservice.web.domain.posts.PostsRepository;
import study.soowan.webservice.web.dto.PostsSaveRequestDto;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class PostsServiceTest {
@Autowired
private PostsService postsService;
@Autowired
private PostsRepository postsRepository;
@After("")
void cleanup() {
postsRepository.deleteAll();
}
@Test
void Dto데이터가_posts테이블에_저장된다() {
// given
PostsSaveRequestDto dto = PostsSaveRequestDto.builder()
.title("테스트 타이틀")
.content("테스트")
.title("test@gmail.com")
.build();
// when
postsService.save(dto);
// then
Posts posts = postsRepository.findAll().get(0);
assertThat(posts.gettitle()).isEqualTo(dto.gettitle());
assertThat(posts.getContent()).isEqualTo(dto.getContent());
assertThat(posts.getTitle()).isEqualTo(dto.getTitle());
}
}
DTO가 service.save 메소드에 전다로디면, DB에 잘 저장되었는지 검증
테스트를 성공적으로 돌리기위해 PostsSaveRequestDto에 Builer를 추가
...
public class PostsSaveRequestDto {
private String title;
...
@Builder
public PostsSaveRequestDto(String title, String content, String author) {
this.title = title;
this.content = content;
this.author = author;
}
public Posts toEntity() {
...
}
}

테스트 코드가 잘 통과하였습니다.
WebRestController의 save 메소드도 service의 save로 교체한다.
@RestController
@AllArgsConstructor
public class WebRestController {
private PostsService postsService;
...
@PostMapping("/posts")
public void savePosts(@RequestBody PostsSaveRequestDto dto) {
postsService.save(dto);
}
}
입력화면
CSS는 부트스트랩의 도움을 받겠습니다.
부트스트랩, jQuery 등 프론트엔드 라이브러리를 사용할 수 있는 방법은 크게 2가지가 있다.
외부 CDN을 사용하는 것과, 직접 라이브러리를 다운받아서 사용하는 방법이다.
이 프로젝트는 CDN을 사용한다.
- 본인이 직업 다운받을 필요가없다. 사용법도 HTML/JPS/Thymeleaf에 코드만 한줄 추가하면 되서 간단하다.
- 하지만 실제 서비스에서는 잘 사용하지 않는다.
- 외부 서비스에 의존하는 경우이기 때문에 서비스를 제공하는 쪽에서 문제가 생기면 CDN을 사용하는 서비스도 문제가 생긴다.
- bootstrap
- Download 현재 버전에서 최신버전을 받는다.
- dist 폴더 아래에 있는 css폴더에서 bootstrap.min.css를 src/main/resources/static/css/lib로 복사한다.
- js파일도 마찬가지로 src/main/resources/static/js/lib로 복사한다.
- jQuery
Download the compressed, production jQuery 3.x.x을 클릭 복사합니다.- bootstrap.min.js와 같은 폴더에 jquery.min.js 파일을 생성하고 붙여넣기 합니다.

설정을 끝냈다면 아래와 같이 라이브러리를 추가합니다.
<!DOCTYPE HTML>
<html>
<head>
<title>스프링부트 웹서비스</title>
...
<!-- 부트스트랩 css 추가 -->
<link rel="stylesheet" href="/css/lib/bootstrap.min.css"/>
</head>
<body>
...
<!-- 부트스트랩 js, jquery 추가 -->
<script src="/js/lib/jquery.min.js"></script>
<script src="/js/lib/bootstrap.min.js"></script>
</body>
</html>
css와 js 호출주소가 /로 시작하는데 이는 Spring Boot는 기본적으로 src/main/resource/static은 URL에서 /로 지정됩니다.
또한 css와 js의 위치가 서로 다릅니다.
css는 <head>에 js는 <body> 최하단에 두었습니다.
이유는 페이지 로딩속도를 높이기 위함입니다.
HTML은 최상단에서부터 코드가 실행되기 떄문에 head가 다 실행되고 나서 body가 실행됩니다.
즉, head가 다 불러지지 않으면 사용자 쪽에선 백지 화면만 노출됩니다.
추가로, bootstrap.js는 jquery가 꼭 있어야만 하기 때문에 bootstrap보다 먼저 호출되도록 코드를 작성했습니다.
글 등록을 클릭하면? 게시글 등록 모달이 정상적으로 출력됩니다.

모달창에 데이터를 입력하고 등록을 누르면? 등록 버튼의 기능을 아직 만들지 않아 아무 동작도 하지 않습니다.
src/main/resource/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();
이제 등록버튼을 누르면 데이터가 정상적으로 입력되는 것을 확인할 수 있습니다.

게시글 목록
게시글 목록 출력 기능을 만들겠습니다.
목록 출력
코드 작성 전에 한가지 해야할 일!
현재 사용중인 로컬 DB는 H2입니다. H2는 메모리 위에서 작동하기 때문에 프로젝트를 실행할 때 마다 자동으로 데이터가 삭제되기 때문에 alter table을 하지 않아도 되는 장점이 있습니다.
하지만 프로젝트 실행 시 항상 테이블이 깨끗한 상태이기 때문에 매번 데이터를 넣어줘야하는 번거로움이 있습니다.
이를 개선해보겠습니다.
게시글 목록의 UI가 잘 나오는지 확인/수정하는 과정에서 수동 데이터 입력 과정을 제외하기 위한 설정 작업을 진행합니다.
resources 아래에 아래와 같이 data-h2.sql 파일을 생성합니다.
insert into posts (id, title, author, content, created_date, modified_date) values (1, '테스트1', 'test1@gmail.com', '테스트1의 본문', now(), now());
insert into posts (id, title, author, content, created_date, modified_date) values (2, '테스트2', 'test2@gmail.com', '테스트2의 본문', now(), now());
위 insert sql 파일을 프로젝트 실행시에 자동으로 수행되도록 설정을 추가하겠습니다.
application.yml 코드를 아래와 같이 변경합니다.Spring Boot 3.x.x대로 넘어오면서 profile 설정, sql.init등 이 달라졌으니 참고 부탁드립니다.
spring:
profiles:
active: local
#local 환경
---
spring:
config:
activate:
on-profile: local
h2:
console:
enabled: true
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:testdb
username: sa
password:
application:
name: spring-webservice
jpa:
show-sql: true
hibernate:
ddl-auto: create-drop
defer-datasource-initialization: true
sql:
init:
data-locations: classpath*:/data-h2.sql
특별히 어플리케이션 실행시 파라미터로 넘어온게 없으면 active 값을 보게됩니다.
운영 환경에선 real 혹은 production 등과 같은 profile을 보도록 jar 실행시점에 파라미터를 변경합니다.
local profile에서는 data-h2.sql이 실행되도록 지정하였습니다.
Tip)
application.yml에서 ---를 기준으로 상단은 공통 영역이며, 하단이 각 profile의 설정 영역입니다.
공통영역의 값은 각 profile환경에 동일한 설정이 있으면 무시되고, 없으면 공통영역의 설정값이 사용됩니다.
만약 공통영역에jpa.hibernate.ddl-auto:create-drop가 있고 운영 profile에 해당 설정값이 없다면 운영환경에서 배포시 모든 테이블이 drop -> create됩니다.
이때문에 datasource, table 등과 같은 옵션들은 공통영역이 아닌 각 profile마다 별도로 두는것을 추천합니다.
설정이 끝났다면 h2-consol에서 초기 데이터가 들어온 것을 확인할 수 있습니다.

이제 실제 목록 출력에 관한 코드를 작성하겠습니다.
main.html의 UI를 변경합니다.
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>스프링부트 웹서비스</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<!--부트스트랩 css 추가-->
<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-bs-toggle="modal" data-bs-target="#savePostsModal">글 등록</button>
<br/>
<br/>
<!-- 목록 출력 영역 -->
<table class="table table-bordered">
<thead class=table>
<tr>
<th>게시글번호</th>
<th>제목</th>
<th>작성자</th>
<th>최종수정일</th>
</tr>
</thead>
<tbody id="tbody">
<tr th:each="post : ${posts}">
<td th:text="${post.id}"></td>
<td th:text="${post.title}"></td>
<td th:text="${post.author}"></td>
<td th:text="${#temporals.format(post.modifiedDate, 'yyyy-MM-dd HH:mm:ss')}"></td>
</tr>
</tbody>
</table>
</div>
<!-- Modal 영역 -->
<div class="modal fade" id="savePostsModal" tabindex="-1" aria-labelledby=savePostsModal 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-bs-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<form th:object="${postsRequest}">
<div class="form-group">
<label for="title">제목</label>
<input type="text" class="form-control" th:field="*{title}" id="title" placeholder="제목을 입력하세요">
</div>
<div class="form-group">
<label for="author">작성자</label>
<input class="form-control" th:field="*{author}" id="author" placeholder="작성자를 입력하세요">
</div>
<div class="form-group">
<label for="content">내용</label>
<input class="form-control" th:field="*{content}" id="content" placeholder="내용을 입력하세요">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
<button type="button" class="btn btn-primary" id="btn-save">등록</button>
</div>
</div>
</div>
</div>
<!--부트스트랩 js, jquery 추가-->
<script src="/js/lib/jquery.min.js"></script>
<script src="/js/lib/bootstrap.min.js"></script>
<!-- custom js 추가 -->
<script src="/js/app/main.js"></script>
</body>
</html>
타임리프를 사용하기 위해서 xmlns:th="http://www.thymeleaf.org"> 추가

th:each를 사용해서 리스트를 순회하면서 하나씩 꺼내 각각의 필드값을 채워서 테이블에 출력합니다.
기존에 있던 PostsRepository 인터페이스에 쿼리가 추가됩니다.
public interface PostsRepository extends JpaRepository<Posts, Long> {
@Query("SELECT p " +
"FROM Posts p " +
"ORDER BY p.id DESC")
Stream<Posts> findAllDesc();
}
위 코드는 SpringDataJPA가 제공하는 기본 메소드만으로도 해결이 가능하지만 굳이 @Query를 사용한 이유는 SpringDataJPA에서 제공하지 않는 메소드는 위처럼 쿼리로 작성해도 되는것을 보여주기 위함입니다.
Repository 다음으로 PostsService 코드를 변경하겠습니다.
@AllArgsConstructor
@Service
public class PostsService {
private PostsRepository postsRepository;
...
@Transactional(readOnly = true)
public List<PostsMainResponseDto> findAllDesc() {
return postsRepository.findAllDesc()
.map(PostsMainResponseDto::new)
.collect(Collectors.toList());
}
}
findAllDesc 메소드의 트랜잭션 어노테이션(@Transactional)에 옵션이 하나 추가되었습니다.
옵션(readOnly = true)을 주면 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도가 개선되기 때문에 특별히 등록/수정/삭제 기능이 없는 메소드에선 사용하시는걸 추천드립니다.
PostsMainResponseDto 클래스가 없기 때문에 생성해줍니다.
src/main/study/soowan/webservice/web/dto
@Getter
public class PostsMainResponseDto {
private Long id;
private String title;
private String author;
private LocalDateTime modifiedDate;
public PostsMainResponseDto(Posts entity) {
id = entity.getId();
title = entity.getTitle();
author = entity.getAuthor();
modifiedDate = entity.getModifiedDate();
}
}
타임 리프를 사용하면 타임리프 자체 #Temporals.format() 함수를 사용하여 간편하게 View로 넘어온 자바의 LocalDateTime을 변환할 수 있습니다.
마지막으로 Controller를 변경합니다.
기존에 생성해둔 WebController 클래스를 아래와 같이 변경합니다.
@Controller
@AllArgsConstructor
public class WebController {
private PostsService postsService;
@GetMapping("/")
public String main(Model model) {
model.addAttribute("postsRequest", new PostsSaveRequestDto());
model.addAttribute("posts", postsService.findAllDesc());
return "main";
}
}
브라우저를 통해서 확인해보겠습니다.
