[From Hello To QueryDSL] Simple Board (2/12)

By | 2020년 3월 11일
Table of Content

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>

댓글 남기기