본문 바로가기

스프링부트와 AWS로 구현하는 웹서비스

[스프링부트/AWS] 4장 게시글 등록 화면 만들기 2

앞에서 PostAPiController로 API를 구현하였으니 바로 화면을 개발합니다. 그냥 HTML을 사용하지 않고 오픈소스인 부트스트랩을 이용하여 화면을 만들어 봅니다.

부트스트랩, 제이쿼리 등 프론트엔드 라이브러리를 사용할 수 있는 방법은 크게 2가지가 있습니다. 하나는 외부 CDN을 사용하는 것이고, 다른 하나는 직접 라이브러리를 받아서 상요하는 방법니다.

여기서는 전자인 외부 CDN을 사용합니다. 본인의 프로젝트에 직접 내려받아 사용할 필요도 없고, 사용할 방법도 HTML/JSP/Mustache에 코드만 한 줄 추가하면 되니 굉장히 간단합니다.

실제 서비스에서는 이 방법을 잘 사용하지 않습니다. 결국은 외부 서비스에 우리 서비스가 의존하게 돼버려서, CDN을 서비스하는 곳에 문제가 생기면 덩달아 같이 문제가 생기기 때문이라고 합니다.

- 공통영역으로 분리

2개의 라이브러리(부트스트랩, 제이쿼리)를 index.mustache에 추가해야 합니다. 하지만, 여기서는 바로 추가하지 않고 레이아웃 방식으로 추가해 보겠습니다. 라이브러리 들은 머스테치 화면 어디서나 필요합니다. 매번 해당 라이브러리를 머스테치 파일에 추가하는 것은 귀찮고 관리가 되지 않습니다. 그러니 공통 영역을 별도의 파일로 분리하여 필요한 곳에서 가져다 쓰는 방식을 사용합니다.

 

공통 코드 추가

화면영역은 코드가 굉장히 많으니 저자의 깃허브의 코드를 가져다 썻습니다.

깃허브 주소 - github.com/jojoldu/freelec-springboot2-webservice

 

jojoldu/freelec-springboot2-webservice

Contribute to jojoldu/freelec-springboot2-webservice development by creating an account on GitHub.

github.com

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>

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>

</body>
</html>

코드를 보며 css와 js의 위차가 서로 다릅니다. 페이지 로딩속도를 높이기 위해 css는 header에, js는 footer에 두었습니다. HTML은 위에서부터 코드가 실행되기 때문에 head가 다 실행되고서야 body가 실행됩니다.

즉, head가 다 불러지지 않으면 사용자 쪽에선 백지 화면만 노출됩니다. 특히 js의 용량이 클수록 body 부분의 실행이 늦어지기 때문에 js는 body 하단에 두어 화면이 다 그려진 뒤에 호출하는 것이 좋습니다.

반면, css는 화면을 그리는 역할이므로 head에서 불러오는 것이 좋습니다. 그렇지 않으면 css가 적용되지 않은 깨진 화면을 사용자가 볼 수 있기 때문입니다. 

bootstrap.js 의 경우 제이쿼리가 꼭 있어야만 하기 때문에 부트스트랩보다 먼저 호출되도록 코드를 작성했습니다. 이런 상황을 bootstrap.js가 제이쿼리에 의존한다고 합니다.

라이브러리를 비롯해 기타 HTML 태그들이 모두 레이아웃에 추가되니 이제 index.mustache에는 필요한 코드만 남게 됩니다. index.mustache의 코드는 다음과 같이 변경됩니다.

 

3. index.mustache

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

1) {{>layout/header}}

  • {{>}}는 현재 머스테치 파일(index.mustache)을 기준으로 다른 파일을 가져옵니다.

 

- index.mustache에 글 등록 버튼 추가

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>
    </div>
{{>layout/footer}}

여기서는 <a>태그를 이용해 글 등록 페이지로 이동하는 글 등록 버튼을 생성하였습니다. 이동할 페이지릐 주소는 /posts/save 입니다.

 

2. 컨트롤러 추가(IndexController.java) 

@RequiredArgsConstructor
@Controller
public class IndexController {

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

 

3. 등록화면 추가(posts-save.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="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>
        <a href="/" role="button" class="btn btn-secondary">취소</a>
        <button type="button" class="btn btn-primary" id="btn-save">등록</button>
    </div>
</div>

{{>layout/footer}}

 

4. 등록 페이지 이동 및 UI 확인

UI가 완성되었으니 다시 프로젝트를 실행하고 브라우저에서 http://localhost:8080/으로 접근해 보겠습니다.

 

글 등록 화면

 

 

- index.js 파일 생성(src/main/resources에 static/js/app)

1. API를 호출하는 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 : '/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));
        });
    }
};

main.init();

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

중복된 함수이름이 있는지 모든 function 이름을 확인하면서 만들 수는 없습니다. 이런 문제를 피하려고 index.js만의 유효범위(scope)를 만들어서 사용합니다.

방법은 var index란 객체를 만들어 해당 객체에서 필요한 모든 function을 선언하는 것입니다. 이렇게 하면 index객체 안에서만 function이 유효하기 때문에 다른 JS와 겹칠 위험이 사라집니다.

 

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>

index.js 호출코드를 보면 절대경로(/)로 바로 시작합니다. 스프링 부트는 기본적으로 src/main/resources/static에 위치한 자바스크립트, css, 이미지 등 정적 파일들은 URL에서 /로 설정됩니다.

 

- 등록기능 브라우저 테스트

1. 등록화면에서 텍스트 입력 후 등록버튼 클릭

 

2. 등록 데이터에비스 확인(localhost:8080/h2-console에 접속)

 

- 전체 조회 화면 만들기

1. index.mustache의 UI 변경(목록 출력을 위하여)

<!--<!DOCTYPE HTML>
<html>
<head>
    <title>스프링 부트 웹 서비스</title>
    <meta http-equiv="content-type" content="text/html;charset=UTF-8"/>
</head>
<body>-->
{{>layout/header}}
    <h1>스프링 부트로 시작하는 웹 서비스 Ver.2</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>
    <br>
    <!--목록 출력 영역-->
    <table class="table table-horizontal table-bordered">
        <thead class="thead-string">
        <tr>
            <th>게시글번호</th>
            <th>제목</th>
            <th>작성자</th>
            <th>최초작성일</th>
            <th>최종수정일</th>
        </tr>
        </thead>
        <tbody id="tbody">
        {{#posts}} <!-- posts 라는 List를 순회합니다., Java의 for문과 동일합니다.-->
            <tr>
                <td>{{id}}</td> <!-- List에서 뽑아낸 객체의 필드를 사용합니다.-->
                <td>{{title}}</td>
                <td>{{author}}</td>
                <td>{{createdDate}}</td>
                <td>{{modifiedDate}}</td>
            </tr>
        {{/posts}}
        </tbody>
    </table>
{{>layout/footer}}
<!--</body>
</html>-->

 

Cotroller, Service, Repository 코드를 작성하겠습니다.

2. PostsRepository에 조회쿼리 추가

package com.jordy.books.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에서 제공하지 않는 메소드는 위처럼 쿼리로 작성해도 됩니다.

 

규모가 있는 프로젝트에서의 데이터 조회는 FK의 조인, 복잡한 조건 등으로 인해 이런 Entity 클래스만으로 처리하기 어려워 조회용 프레임워크를 추가로 사용합니다. 대표적 예로 querydsl, jooq, MyBatis 등이 있습니다. 조회는 위 3가지 프레임워크 중 하나를 통해 조회하고, 등록/수정/삭제 등은 SpringDataJpa를 통해 진행합니다.

 

이 저자는 Querydsl을 추천합니다. 추천 이유는 다음과 같습니다.

1. 타입 안정성 보장

단순 문자열로 쿼리를 생성하는게 아닌 메소드 기반으로 쿼리를 생성합니다. 그래서 오타나 존재하지 않는 컬럼명을 명시할 경우 IDE에서 자동으로 검출됩니다.(Jooq 지원, MyBatis 미지원)

 

2. 국내 많은 회사에서 사용 중입니다.

쿠팡, 배민 등 JPA를 적극적으로 사용하는 회사에서는 Querydsl를 적극적으로 사용중 이라고 합니다.

 

3. 레퍼런스가 많습니다.

앞 2번의 장점에서 이어집니다. 

 

3. PostsListResponseDto 클래스 생성

package com.jordy.books.springboot.web.dto;

import com.jordy.books.springboot.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 createdDate;
    private LocalDateTime modifiedDate;

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

 

4. PostsService에 코드 추가

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

findAllDesc 메소드의 트랜잭션 어노테이션(@Transactoinal)에 옯션이 하나 추가되었습니다.(readOnly = true)를 주면 트랜잭션 범위는 유지하되, 조회기능만 남겨두어 조회 속도가 개선되기 때문에 등록, 수정, 삭제 기능이 전혀 없는 서비스 메소드에 사용하는 것을 추천합니다.

* 람다식 코드 설명

.map(PostsListResponseDto::new)
와
.map(posts -> new PostsListResponseDto(posts))
는 같습니다.

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

 

* readOnly = true 옵션이 안될경우(import 확인)

import org.springframework.transaction.annotation.Transactional; // Optional 허용
import javax.transaction.Transactional; // Optional 미허용

stackoverflow.com/questions/32087469/the-attribute-readonly-is-undefined-for-the-annotation-type-transactional

 

The attribute readOnly is undefined for the annotation type Transactional

I am getting this error when I am putting this piece of code on a service method @Transactional(readOnly =true) I am writing this code to make a transaction read only. Can you please tell me Wh...

stackoverflow.com

5. indexController에 메소드 추가

package com.jordy.books.springboot.web;

import com.jordy.books.springboot.service.posts.PostsService;
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.PostMapping;

@RequiredArgsConstructor
@Controller
public class IndexController {

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

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

    private final PostsService postsService;

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

}

1) Model

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

6. 페이지 접속 리스트 조회 확인(하나의 데이터를 등록해야 조회가 된다.-> h2는 인메모리 db)

ko.wikipedia.org/wiki/%EC%9D%B8%EB%A9%94%EB%AA%A8%EB%A6%AC_%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B2%A0%EC%9D%B4%EC%8A%A4

 

인메모리 데이터베이스 - 위키백과, 우리 모두의 백과사전

위키백과, 우리 모두의 백과사전. 인메모리 데이터베이스(In-memory Database)는 데이터 스토리지의 메인 메모리에 설치되어 운영되는 방식의 데이터베이스 관리 시스템이다. 인메모리 데이터베이스

ko.wikipedia.org

 

 

출처 : 

jojoldu.tistory.com/463

 

[스프링 부트와 AWS로 혼자 구현하는 웹 서비스] 출간 후기

(출판사: 프리렉, 쪽수: 416, 정가: 22,000원) 서적 링크 오프라인 서점에는 2019.12.04 (수) 부터 올라갈 예정입니다. 강남 교보문고나 광화문 교보문고는 주말에도 올라올 순 있겠지만, 혹시 모르니

jojoldu.tistory.com