Simple Board
게시물을 입력/수정/삭제/리스트표시 하는 게시판을 생성합니다.
검색기능이나 페이징 기능은 다음 글에서 추가하기에 여기서는 제외됩니다.
개발환경
- Spring Boot 2.1.x
- Gradle 4.10.2
프로젝트는 이전 글에서 작성된 프로젝트에 파일을 추가 또는 수정하는 방식으로 진행됩니다. 이전 글을 따라 하지 않은 경우, 먼저 이전 글대로 프로젝트를 구성하시기 바랍니다.
Lombok 이 처음이신 분은 여기 를 먼저 확인하시기 바랍니다.
파일 수정 순서
먼저 build.gradle
을 수정합니다. build.gradle
에 의존성을 추가해서 기능을 확장합니다.
다음은 config
, domain
, service
, web
을 추가합니다.
config
레이어에는 각종 설정파일들이 배치됩니다. Spring Boot 에서는 클래스 파일에도 설정을 추가할 수 있습니다.
domain
레이어에는 도메인 영역의 Entity
, Repository
파일들이 추가됩니다. Entity
는 데이타베이스 테이블과 1:1 로 대응합니다. Repository
로 테이블의 CRUD 를 수행합니다.
service
레이어는 domain
, web
두 레이어가 섞이지 않도록 중간단계 역활을 합니다.
web
레이어는 브라우저에 노출되는 html 과 브라우저에서 전달받는 파라미터를 처리하는 역활을 합니다.
build.gradle 수정
build.gradle
에 다음 의존성을 추가합니다.
h2
는 데이타베이스로 기본적으로 메모리에 데이타를 저장하므로 톰캣을 재실행할 때마다 데이타가 사라집니다. 개발용으로는 쓰기 편해서 사용합니다.
dependencies {
// ......
compile('org.springframework.boot:spring-boot-starter-data-jdbc')
compile('org.springframework.boot:spring-boot-starter-data-jpa')
compile('com.h2database:h2')
// ......
}
config 레이어
Spring Boot
는 설정파일에 설정을 추가하는 방식 뿐만 아니라, 클래스 파일을 생성해서 설정을 추가할 수 있습니다. @Configuration
을 추가함으로 해서 이 클래스는 설정을 위한 클래스임을 알려줍니다. @EnableJpaAuditing
은 JPA 를 활성화 함을 표시합니다.
JPA 는 Raw 쿼리 기반의 JDBC 를 훨씬 멋있게 코딩할 수 있게 바꿔줍니다. 어떻게 바뀌는지는 아래에서 설명합니다.
src/main/java/kr/co/episode/example/config/JpaConfig.java
package kr.co.episode.example.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}
domain 레이어
데이타마다 createdDate/modifiedDate 를 매번 입력하고 업데이트 해야 하던걸 한번에 해결해주는 클래스입니다. 일단, 왜 이렇게 되는지는 나중에 알아보고 정말 자동으로 업데이트 되는지 보도록 합니다.
src/main/java/kr/co/episode/example/domain/BaseTimeEntity.java
package kr.co.episode.example.domain;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseTimeEntity {
@CreatedDate
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime modifiedDate;
}
@Entity
어노테이션을 붙여 줌으로 해서 이 클래스는 데이타베이스 상의 테이블과 1:1 대응함을 표시합니다. 내부변수 하나 하나가 DB 테이블의 필드에 해당합니다. @Id
어노테이션은 PK 를 의미합니다.
분명 내부변수에 createdDate/modifiedDate 가 없음에도 자동으로 테이블에는 createdDate/modifiedDate 가 추가될것입니다.
src/main/java/kr/co/episode/example/domain/posts/Posts.java
package kr.co.episode.example.domain.posts;
import kr.co.episode.example.domain.BaseTimeEntity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@Getter
@NoArgsConstructor
@Entity
public class Posts extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Column(length = 500, nullable = false)
private String title;
@Column(columnDefinition = "TEXT", nullable = false)
private String content;
private String author;
@Builder
public Posts(String title, String content, String author) {
this.title = title;
this.content = content;
this.author = author;
}
public void update(String title, String content) {
this.title = title;
this.content = content;
}
}
src/main/java/kr/co/episode/example/domain/posts/PostsRepository.java
package kr.co.episode.example.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(value = "SELECT p FROM Posts p ORDER BY p.id DESC")
List<Posts> findAllDesc();
}
짜잔!! CRUD 가 끝났습니다. JPA 가 이런 겁니다.
사실 마법은 없는게… insert/update/delete 모두 개발자가 기계적으로 코딩하고 있던걸 자동화 한것에 불과합니다.
select 는 한건 조회는 역시 기계적으로 코딩하는 부분이라 역시 자동화가 됩니다.
list 가져오는 기능이 복잡하고 JPA 에서도 커버 못하는 부분인데… 일단 위에서는 가장 단순하게 검색조건/페이징도 없이 전체내역 가져오도록 코딩했습니다.
위에 표시된 @Query
는 우리가 아는 일반적인 쿼리가 아니고, JPQL 이라고 하는 새로운 쿼리입니다. 그리고 JPQL 은 대소문자를 구분합니다.
service 레이어
service 레이어는 domain 레이어와 web 레이어가 섞이는 레이어라 web 레이어의 일부 클래스를 먼저 생성해주어야 합니다.
DTO 클래스 생성
Entity
는 DB 테이블과 1:1 대응하는 객체이고, 그와 달리 Web 에 대응하는 객체가 필요한데 DTO(Data Transfer Object) 가 그 기능을 합니다.
이번 프로젝트는 단순한 프로젝트이기에 Entity 객체와 DTO 객체가 거의 유사하지만, 실무에서는 매우 다른 경우가 많습니다.
아래에 이번 프로젝트에 쓰일 4개의 DTO 객체를 생성합니다.
src/main/java/kr/co/episode/example/web/dto/PostsListResponseDto.java
package kr.co.episode.example.web.dto;
import kr.co.episode.example.domain.posts.Posts;
import lombok.Getter;
import java.io.Serializable;
import java.time.LocalDateTime;
@Getter
public class PostsListResponseDto implements Serializable {
private Long id;
private String title;
private String author;
private LocalDateTime modifiedDate;
public String getLink() {
return "/posts/update/" + id;
}
public PostsListResponseDto(Posts entity) {
this.id = entity.getId();
this.title = entity.getTitle();
this.author = entity.getAuthor();
this.modifiedDate = entity.getModifiedDate();
}
}
src/main/java/kr/co/episode/example/web/dto/PostsResponseDto.java
package kr.co.episode.example.web.dto;
import kr.co.episode.example.domain.posts.Posts;
import lombok.Getter;
import java.io.Serializable;
@Getter
public class PostsResponseDto implements Serializable {
private Long id;
private String title;
private String content;
private String author;
public PostsResponseDto(Posts entity) {
this.id = entity.getId();
this.title = entity.getTitle();
this.content = entity.getContent();
this.author = entity.getAuthor();
}
}
src/main/java/kr/co/episode/example/web/dto/PostsSaveRequestDto.java
package kr.co.episode.example.web.dto;
import kr.co.episode.example.domain.posts.Posts;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
public class PostsSaveRequestDto {
private String title;
private String content;
private String author;
@Builder
public PostsSaveRequestDto(String title, String content, String author) {
this.title = title;
this.content = content;
this.author = author;
}
public Posts toEntity() {
return Posts.builder()
.title(title)
.content(content)
.author(author)
.build();
}
}
src/main/java/kr/co/episode/example/web/dto/PostsUpdateRequestDto.java
package kr.co.episode.example.web.dto;
import lombok.Builder;
import lombok.Getter;
@Getter
@NoArgsConstructor
public class PostsUpdateRequestDto {
private String title;
private String content;
@Builder
public PostsUpdateRequestDto(String title, String content) {
this.title = title;
this.content = content;
}
}
service 클래스 생성
src/main/java/kr/co/episode/example/service/posts/PostsService.java
package kr.co.episode.example.service.posts;
import kr.co.episode.example.domain.posts.Posts;
import kr.co.episode.example.domain.posts.PostsRepository;
import kr.co.episode.example.web.dto.PostsListResponseDto;
import kr.co.episode.example.web.dto.PostsResponseDto;
import kr.co.episode.example.web.dto.PostsSaveRequestDto;
import kr.co.episode.example.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
@Transactional
public Long save(PostsSaveRequestDto postsSaveRequestDto) {
return postsRepository.save(postsSaveRequestDto.toEntity()).getId();
}
@Transactional
public Long update(Long id, PostsUpdateRequestDto postsUpdateRequestDto) {
Posts posts = getOne(id);
posts.update(postsUpdateRequestDto.getTitle(), postsUpdateRequestDto.getContent());
return id;
}
public PostsResponseDto findById(Long id) {
Posts posts = getOne(id);
return new PostsResponseDto(posts);
}
@Transactional(readOnly = true)
public List<PostsListResponseDto> findAllDesc() {
return postsRepository.findAllDesc().stream()
.map(PostsListResponseDto::new)
.collect(Collectors.toList());
}
@Transactional
public void delete(Long id) {
Posts posts = getOne(id);
postsRepository.delete(posts);
}
private Posts getOne(Long id) throws IllegalArgumentException {
return postsRepository.findById(id).orElseThrow(() ->
new IllegalArgumentException("해당 게시물이 없습니다.[id=" + id + "]"));
}
}
@Service
어노테이션을 붙여주어서 이 클래스가 서비스 클래스임을 스프링 부트에 인식시켜 줍니다.
PostsRepository
를 이용하고 있는데… 이 놈은 언제 누가 생성할까? Spring Boot 는 톰캣실행시 @Service
어노테이션이 붙어있는 클래스도 자동생성합니다.
그런데 @RequiredArgsConstructor
어노테이션이 붙어있고, 그러면 final
이 붙어있는 내부변수를 초기화 하는 생성자가 자동생성되고, Spring Boot 는 그것을 초기화 할 수 있는 클래스도 자동생성해 줍니다.
이렇게 PostsRepository
도 자동생성되고, PostsService
도 자동생성됩니다.
postsRepository.save()
, posts.update()
는 첨보는데 이건 모냐?
PostsRepository
가 상속하는 JpaRepository
에 이미 구현(?)되어 있기 때문에 실행이 가능해 집니다. 뭐… 정확히는 정의만 되어 있지 구현된건 아닌데… 너무 깊게 들어가면 힘들어지므로 넘어갑시다.(정 궁금하시면 JpaRepository 를 소스보기 하시면 됩니다.)
web 레이어
RestController 는 json 을 반환하는 컨트롤러이고, Controller 는 html 을 반환하는 컨트롤러입니다.
RestController 생성
src/main/java/kr/co/episode/example/web/PostsApiController.java
package kr.co.episode.example.web;
import kr.co.episode.example.service.posts.PostsService;
import kr.co.episode.example.web.dto.PostsResponseDto;
import kr.co.episode.example.web.dto.PostsSaveRequestDto;
import kr.co.episode.example.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
@RestController
public class PostsApiController {
private final PostsService postsService;
@PostMapping("/api/v1/posts")
public Long save(@RequestBody PostsSaveRequestDto postsSaveRequestDto) {
return postsService.save(postsSaveRequestDto);
}
@PutMapping("/api/v1/posts/{id}")
public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto postsUpdateRequestDto) {
return postsService.update(id, postsUpdateRequestDto);
}
@GetMapping("/api/v1/posts/{id}")
public PostsResponseDto findById(@PathVariable Long id) {
return postsService.findById(id);
}
@DeleteMapping("/api/v1/posts/{id}")
public Long delete(@PathVariable Long id) {
postsService.delete(id);
return id;
}
}
@PathVariable
을 이용해 url 에서 파라미터를 추출해 전달받는 방법도 보여집니다.
Controller 생성
src/main/java/kr/co/episode/example/web/IndexController.java
package kr.co.episode.example.web;
import kr.co.episode.example.service.posts.PostsService;
import kr.co.episode.example.web.dto.PostsResponseDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@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";
}
@GetMapping("/posts/update/{id}")
public String postsUpdate(@PathVariable Long id, Model model) {
PostsResponseDto dto = postsService.findById(id);
model.addAttribute("post", dto);
return "posts-update";
}
}
public String index(Model model)
에서 Model 파라미터를 전달받습니다. 이 파라미터에 index.html 템플릿에 전달할 데이타를 설정하게 됩니다.
html, js 생성
src/main/resources/static/js/app/index.js
var main = {
init : function() {
var _this = this;
$('#btn-save').on('click', function() {
_this.save();
});
$('#btn-update').on('click', function() {
_this.update();
});
$('#btn-delete').on('click', function() {
_this.delete();
});
$('#btn-search').on('click', function() {
document.frm.submit();
});
},
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));
if (error.status == 403) {
alert('권한이 없습니다.');
} else {
alert('알 수 없는 오류입니다.' + error.status);
}
});
},
update : function() {
var id = $('#id').val();
var data = {
title : $('#title').val(),
content : $('#content').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));
});
},
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();
IndexController 에서 입력한 posts
속성이 아래 템플릿 html 에서 사용되고 있습니다.
src/main/resources/templates/index.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>게시판</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
<style>
.container p { display: inline }
</style>
</head>
<body class="container">
<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>
<div style="height: 80px;">
</div>
<table class="table">
<tr>
<th>글 번호</th>
<th>글쓴이</th>
<th>글 제목</th>
<th>최종수정</th>
</tr>
<tr th:each="posts: ${posts}">
<td th:text="${posts.id}"></td>
<td>
<a th:href="${posts.link}" th:text="${posts.author}" />
</td>
<td>
<a th:href="${posts.link}" th:text="${posts.title}" />
</td>
<td th:text="${posts.modifiedDate}"></td>
</tr>
</table>
<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>
<script src="/js/app/index.js"></script>
</body>
</html>
src/main/resources/templates/posts-save.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>게시판</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
<style>
.container p { display: inline }
</style>
</head>
<body class="container">
<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>
<button type="button" class="btn btn-primary" id="btn-save">등록</button>
<a href="/" role="button" class="btn btn-secondary">취소</a>
</div>
</div>
<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>
<script src="/js/app/index.js"></script>
</body>
</html>
역시, IndexController 에서 입력받은 post
속성이 사용되고 있습니다.
src/main/resources/templates/posts-update.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>게시판</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
<style>
.container p { display: inline }
</style>
</head>
<body class="container">
<h1>게시물 수정</h1>
<div class="col-md-12">
<div class="col-md-4">
<form>
<div class="form-group">
<label for="id">글번호</label>
<input type="text" class="form-control" id="id" name="id" th:value="${post.id}" readonly />
</div>
<div class="form-group">
<label for="title">제목</label>
<input type="text" class="form-control" id="title" name="title" th:value="${post.title}" />
</div>
<div class="form-group">
<label for="author">작성자</label>
<input type="text" class="form-control" id="author" name="author" th:value="${post.author}" readonly />
</div>
<div class="form-group">
<label for="content">내용</label>
<textarea class="form-control" id="content" name="content" th:text="${post.content}"></textarea>
</div>
</form>
<button type="button" class="btn btn-primary" id="btn-update">수정</button>
<a href="/" role="button" class="btn btn-secondary">취소</a>
<button type="button" class="btn btn-danger" id="btn-delete">삭제</button>
</div>
</div>
<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>
<script src="/js/app/index.js"></script>
</body>
</html>