[스프링 부트와 AWS로 혼자 구현하는 웹 서비스]무작정 따라하기 4일차

민지킴·2021년 3월 31일
0
post-thumbnail

*모든 내용은 책에 있는 내용을 기반으로 작성하였습니다.

4장

4.1 서버 템플릿 엔진과 머스테치 소개

4.1.1 템플릿 엔진이란?

웹 개발에 있어 템플릿 엔진이란, 지정된 템플릿 양식과 테이터가 합쳐져 HTML 문서를 출력하는 소프트웨어를 말한다.
ex) JSP, Freemarker, React와 Vue의 view 파일

쉽게 말해 웹 사이트의 화면을 어떤 형태로 만들지 도와주는 양식

템플릿 엔진은 서버 템플릿 엔진, 클라이언트 템플릿 엔진으로 나뉘게 된다

4.1.1.1 서버 템플릿 엔진

JSP를 비롯한 서버 템플릿 엔진을 이용한 화면 생성은 서버에서 Java 코드로 문자열을 만든 뒤 이 문자열을 HTML로 변환하여 브라우저로 전달한다.

4.1.1.2 클라이언트 템플릿 엔진

자바스크립트는 브라우저 위에서 작동한다.
Vue.js, React.js를 이용한 SPA는 브라우저에서 화면을 생성한다. 즉, 서버에서 이미 코드가 벗어난 경우이며 서버에서는 Json, Xml 형식의 데이터만 전달하고 클라이언트에서 조힙한다.

하지만 최근에는 Vue.js, React,js도 서버사이드 렌더링을 지원하는 모습을 볼수도 있다.

4.1.2 머스테치란?

수 많은 언어를 지원하는 가장 심플한 템플릿 엔진이다.
현존하는 대부분의 언어를 지원하고 있다.

서버 템플릿 엔진으로는 : JSP, Velocity, Freemaker, Thymeleaf등 다양한 서버템플릿 엔진이 존재한다.

머스테치의 장점
1) 다른 템플릿 엔진들 중에서는 문법이 심플하다.
2) 로직 코드를 사용할 수 없어 View의 역할과 서버의 역할이 명확하게 분리된다.
3) Mustache.js, Mustache.java 2가지가 다 있어, 하나의 문법으로 클라이언트/서버 템플릿 모두 사용이 가능하다.

4.2 기본 페이지 만들기

4.2.1 Build.gradle

dependencies {
    compile('org.springframework.boot:spring-boot-starter-mustache')
}

추가한다.

머스터치 파일 위치는 기본적으로
src/main/resource/templates 에 위치하며

templates 안에 .mustache 파일을 생성한다.
ex) index.mustache

Controller를 이용하여 매핑한다.
머스터치 스타터로 인해 문자열을 반환할때 앞의 경로와 뒤의 확장자는 자동으로 지정된다.
앞의 경로는 : src/main/resources/templates
뒤의 확장자는 : .mustache
가 붙는다.

4.2.2 IndexController

package com.jojoldu.book.springboot.web;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class IndexController {

    @GetMapping("/")
    public String index(){
        return "index";
    }
}

에서 "index"를 반환 하므로 src/main/resources/template/index.mustache 로 전환되어 View Resolver가 처리한다.

View Resolver : URL 요청의 결과를 전달할 타입과 값을 지정하는 관리자 격으로 볼 수 있다.

4.3 게시글 등록 화면 만들기

레이아웃 방식 : 공통 영역을 별도의 파일로 분리하여 필요한 곳에서 가져다 쓰는 방식.

4.3.1 footer.mustache, header.mustache

4.3.1.1 header.mustache

<!DOCTYPE HTML>
<html>
<head>
    <title>스프링부트 웹서비스</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />

    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
</head>
<body>

4.3.1.2 footer.mustache

<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>

<!--index.js 추가-->
<script src="/js/app/index.js"></script>
</body>
</html>

파일에 따라서 css와 js의 위치가 서로 다를수 있다.
페이지의 로딩속도를 높이기 위함이다.
HTML은 위에서부터 코드가 실행되기 때문에 head가 다 실행되고서야 body가 실행된다.
head가 다 불러지지 않으면 body 부분의 실행이 늦어지기 때문에 js는 footer 쪽에 css는 header쪽에서 불러오는 것이 좋다.

bootstrap.js의 경우 제이쿼리가 꼭 있어야만 하기 때문에 jQuery를 먼저 호출해야하며, 이것을 bootstrap.js가 제이쿼리에 의존한다고 한다.

4.3.2 index.mustache

{{>layout/header}} //(1)
    <h1>스프링 부트로 시작하는 웹 서비스</h1>
{{>layout/footer}}

4.3.2.1 {{>layout/header}}

{{>}}는 현재 머스테치 파일(index.mustache)를 기준으로 다른 파일을 가져온다는 뜻이다.

4.3.3 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 = '/'; //(1)
        }).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(); 

4.3.3.1 window.location.href = '/';

글이 성공적으로 등록되면 메인 페이지로 이동한다.

브라우저의 scope는 공용 공간으로 쓰이기 때문에 나중에 로딩된 js의 init, save가 먼저 로딩된 js의 function을 덮어쓰게 된다.

여러 사람이 참여하는 프로젝트에서는 중복된 함수 이름은 자주 발생할 수 있다. 모든 function의 이름을 확인하면서 만들수 없으므로 index.js만의 scope(유효범위)를 만들어서 사용한다.

var index란 객체를 만들어 해당 객체에서 필요한 모든 function을 선언한다. 이러헥 하면 index 안에서만 function이 유효하기 때문에 다른 js와 겹칠 위험이 사라진다.

위의 footer.mustache에 에서 index.js의 호출 코드를 보면 절대 경로(/)로 시작한다. 스프링 부트는 기본적으로 src/main/resources/static에 위치한 js, css, image등 정적 파일들은 url에서 /로 설정한다.

ex) src/main/resources/static/js/....

4.4 전체 조회 화면 만들기

4.4.1 index.mustache

{{>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}} //(1)
                <tr>
                    <td>{{id}}</td> //(2)
                    <td>{{title}}</td>
                    <td>{{author}}</td>
                    <td>{{modifiedDate}}</td>
                </tr>
            {{/posts}}
            </tbody>
        </table>
    </div>
{{>layout/footer}}

{{#posts}}

  • posts라는 List를 순회한다.
  • Java의 for문과 동일하게 생각하면 된다.

4.4.1.1 {{id}} 등의 {{변수명}}

  • List에서 뽑아낸 객체의 필드를 사용한다.

4.4.2 PostRespository.java

package com.jojoldu.book.springboot.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();

}

SpringDataJpa에서 제공하는 기본메소드만으로도 가능하지만
가독성은 Query가 좋다.

참고
규모가 큰 프로젝트에서 데이터 조회는 FK의 조인, 복잡한 조건 등으로 인해 이런 Entity 클래스만으로 처리하기 어려워 조회용 프레임 워크를 추가로 사용한다.
조회만 프레임 워크를 사용하고, 등록/수정/삭제는 SpringDataJpa를 통해 진행된다.

이런 프레임 워크로는 Querydsl, myBatis, jooq이 있다.
그중에서 Querydsl이 많이 사용된다.

  1. 타입의 안정성이 보장된다.
    단순한 문자열이 아니라 메소드를 기반으로 쿼리를 생성하기 때문에 오타나 존재하지 않는 컬럼명을 명시할경우 ide에서 자동으로 검출된다. 이런 장점은 jooq에도 지원하지만 myBatis에는 없다.

  2. 많은 기업에서 사용중이다.

  3. 레퍼런스가 많다.

4.4.3 PostService.java


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

부분 추가

readOnly = true를 사용하게 되면 트랜잭션 범위는 유지하되 조회 기능만 남겨두어 조회 속도가 개선되기 때문에 조회만 하는 서비스 메소드에서 사용하면 좋다.

.map(PostsListResponseDtd::new)
이 코드는 실제로
.map(posts -> new PostsListResponseDto(posts)) 와 같다.

postsRepository 결과로 넘어온 Posts의 Stream을 map을 통해 PostsListResponseDto로 변환 -> List로 변환하는 메소드이다.

4.4.4 IndexController.java

package com.jojoldu.book.springboot.web;

import com.jojoldu.book.springboot.service.posts.PostsService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
@RequiredArgsConstructor
public class IndexController {

    private final PostsService postsService;

    @GetMapping("/")
    public String index(Model model){ //(1)
        model.addAttribute("posts",postsService.findAllDesc());
        return "index";
    }

    @GetMapping("/posts/save")
    public String postsSave(){
        return "posts-save";
    }
}

4.4.4.1 Model

  • 서버 템플릿 엔진에서 사용할 수 있는 객체를 저장할 수 있다.
  • 여기서는 postsService.findAllDesc()로 가저온 결과를 posts로 index.mustache에 전달한다.

4.5 게시글 수정, 삭제화면 만들기

4.5.1 posts-update.mustache

{{>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> //(1)
            </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> //(2)
            </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>
        <button type="button" class="btn btn-danger" id="btn-delete">삭제</button>
    </div>
</div>

{{>layout/footer}}

4.5.1.1 {{post.id}}

  • 머스테치 객체의 필드 접근 시 점(Dot)으로 구분한다.
  • 즉, Post클래스의 id에 대한 접근은 post.id로 사용할 수 있다.

4.5.1.2 readonly

  • Input 태그에 읽기 가능만 허용하는 속성
  • id와 author는 수정할수 없고 읽기만 허용하도록

4.5.2 index.js

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

        $('#btn-update').on('click', function () { //(1)
            _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 () { //(2)
        var data = {
            title: $('#title').val(),
            content: $('#content').val()
        };

        var id = $('#id').val();

        $.ajax({
            type: 'PUT', //(3)
            url: '/api/v1/posts/'+id, //(4)
            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();

4.5.2.1 $('#btn-update').on('click', function ()

  • btn-update란 id를 가진 html 엘리멘트에 click 이란 이벤트 발생할때 update의 function이 실행되도록 이벤트 등록

4.5.2.2 update : function()

  • 신규로 추가될 update function()

4.5.2.3 type:'PUT'

  • 여러 HTTP Method 중에서 put method를 선택한다.
  • PostsApiController에 있는 API에서 이미 @PutMapping 으로 선언했기 때문에 PUT을 사용해야한다. 참고록 이는 REST 규약에 맞게 설정된것이다.
  • REST에서 CRUD는 다음과 같이 HTTP Method에 매핑된다.
    생성(Create) - Post
    읽기(Read) - Get
    수정(Update) - Put
    삭제(Delete) - Delete

4.5.2.4 url:'/api/v1/posts/'+id

  • 어느 게시글을 수정할지 url path로 구준하기 위해 path에 id를 추가한다.

4.5.3 PostsService.java

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

        postsRepository.delete(posts); //(1)
    }

부분 추가

4.5.3.1 postsRepository.delete(posts)

  • JpaRepository에서 이미 delete method를 지원하고 있기에 활용가능
  • 엔티티를 파라미터로 삭제할수 있고, deleteById 메소드를 이용하여 id로 삭제할 수 있다.
  • 해당 Posts가 존재하는지 확인하고 삭제한다.
profile
하루하루는 성실하게 인생 전체는 되는대로

0개의 댓글