Spring/Spring Boot

[Spring Boot] 게시물 화면 구성 (2)

ozofweird 2020. 9. 17. 00:54

1. 조회 화면

1) index.mustache

전체 조회를 위한 UI를 변경한다.

어노테이션 및 코드 설명
{{#posts}} posts라는 List를 순회한다. (for 문)
{{변수명}} List에서 뽑아낸 객체의 필드를 사용한다.
{{>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>
    <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">
        {{#posts}}
            <tr>
                <td>{{id}}</td>
                <td>{{title}}</td>
                <td>{{author}}</td>
                <td>{{modifiedDate}}</td>
            </tr>
        {{/posts}}
        </tbody>
    </table>
</div>

{{>layout/footer}}

2) Repository

Spring Data JPA에서 제공하지 않는 메서드는 @Query 어노테이션을 통해 쿼리를 작성할 수 있다.

package com.springbook.biz.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();
}

※ 규모가 있는 프로젝트에서는 등록, 수정, 삭제 등은 Spring Data JPA를 통해 진행하지만, 복잡한 조회의 경우에는 조회용 프레임워크(querydsl, jooq, MyBatis 등)를 추가하여 사용한다. 그 중, 국내 많은 회사에서는 querydsl을 사용중이다. 문자열로 쿼리를 생성하는 것이 아닌 메서드 기반으로 쿼리를 생성하며 레퍼런스가 많은 장점이 있다.

3) Service

어노테이션 및 코드 설명
@Transactional(readOnly=true) 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도가 개선되도록 readOnly 속성을 준다.
.map(PostsListResponseDto::new) '.map(posts -> new PostsListResponseDto(posts)' 와 동일한 코드로 postsRepository의 결과인 Posts의 Stream을 map을 통해 변환하는 메서드이다.
package com.springbook.biz.service.posts;

import com.springbook.biz.domain.posts.Posts;
import com.springbook.biz.domain.posts.PostsRepository;
import com.springbook.biz.web.dto.PostsResponseDto;
import com.springbook.biz.web.dto.PostsSaveRequestDto;
import com.springbook.biz.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.stream.Collectors;

@RequiredArgsConstructor
@Service
public class PostsService {

    ... // 추가될 내용
    
    @Transactional(readOnly = true)
    public List<PostsListResponseDto> findAllDesc() {
        return postsRepository.findAllDesc().stream()
                .map(PostsListResponseDto::new)
                .collect(Collectors.toList());
    }
}

4) PostListResponseDto

게시글 목록을 전달해주는 PostListResponseDto 객체를 생성한다.

package com.springbook.biz.web.dto;

import com.springbook.biz.domain.posts.Posts;
import lombok.Getter;

import java.time.LocalDateTime;

@Getter
public class PostsListResponseDto {

    private Long id;
    private String title;
    private String author;
    private LocalDateTime modifiedDate;

    public PostsListResponseDto(Posts entity) {
        this.id = entity.getId();
        this.title = entity.getTitle();
        this.author = entity.getAuthor();
        this.modifiedDate = entity.getModifiedDate();
    }
}

5) Controller

Model 객체를 이용하여 서버 템플릿 엔진에서 사용할 수 있는 객체를 저장한다. 즉, index.mustache의 {{#posts}}에 결과물을 전달해준다.

package com.springbook.biz.web;

import com.springbook.biz.service.posts.PostsService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@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";
    }
}

 

 

 

2. 수정 화면

1) 수정 화면

수정 화면을 위한 posts-update.mustache 파일을 생성한다. 

어노테이션 및 코드 설명
{{posts.필드}} 객체의 필드 접근할 때 사용한다.
{{>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}}

2) index.js

btn-update 버튼을 선택할 경우, 동작할 기능에 대한 function을 추가한다.

var main = {
    init : function () {
        var _this = this;
        $('#btn-save').on('click', function () {
            _this.save();
        });

        $('#btn-update').on('click', function () {
            _this.update();
        });
    },
    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));
        });
    },
    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();

3) index.mustache

전체 목록에서 수정 화면으로 이동하도록 한다.

{{#posts}}
    <tr>
        <td>{{id}}</td>
        <td><a href="/posts/update/{{id}}">{{title}}</a></td>
        <td>{{author}}</td>
        <td>{{modifiedDate}}</td>
    </tr>
{{/posts}}

4) Controller

수정 화면을 연결한다.

package com.springbook.biz.web;

import com.springbook.biz.service.posts.PostsService;
import com.springbook.biz.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 {

    ... // 추가될 내용
    
    @GetMapping("/posts/update/{id}")
    public String postsUpdate(@PathVariable Long id, Model model) {
        PostsResponseDto dto = postsService.findById(id);
        model.addAttribute("post", dto);
        return "posts-update";
    }
}

 

 

 

3. 삭제 화면

1) posts-update.mustache

수정 화면에서 삭제할 수 있도록한다.

{{>layout/header}}

... <!-- 추가될 내용 -->
        <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}}

2) 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();
        });
    },
    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));
        });
    },
    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));
        });
    },
    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();

3) Service

삭제 API를 제작한다.

package com.springbook.biz.service.posts;

import com.springbook.biz.domain.posts.Posts;
import com.springbook.biz.domain.posts.PostsRepository;
import com.springbook.biz.web.dto.PostsListResponseDto;
import com.springbook.biz.web.dto.PostsResponseDto;
import com.springbook.biz.web.dto.PostsSaveRequestDto;
import com.springbook.biz.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 {

    ... // 추가될 내용
    
    @Transactional
    public void delete(Long id) {
        Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
        postsRepository.delete(posts);
    }
}

4) Controller

화면에 대한 연결은 IndexController에서 처리하며, 순수 API를 처리하는 장소는 PostsApiController임을 조심해야한다.

package com.springbook.biz.web;

import com.springbook.biz.service.posts.PostsService;
import com.springbook.biz.web.dto.PostsResponseDto;
import com.springbook.biz.web.dto.PostsSaveRequestDto;
import com.springbook.biz.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RequiredArgsConstructor
@RestController
public class PostsApiController {

    ... // 추가될 내용
    
    @DeleteMapping("/api/v1/posts/{id}")
    public Long delete(@PathVariable Long id) {
        postsService.delete(id);
        return id;
    }
}

5) 구현 화면

구현 화면


[참고] 스프링 부트와 AWS로 혼자 구현하는 웹 서비스

728x90