[spring] 머스테치로 화면 구성하기(1) - 등록, 조회

Kaite.Kang·2023년 1월 3일
0
post-thumbnail

* 목표

이 프로젝트의 구조는 서버에서는 API를 제공하고, 클라이언트 템플릿 엔진으로 HTML 문서를 생성하는 구조이다.

이번 포스팅에서는 클라이언트 템플릿 엔진으로 머스테치를 사용하여 게시글 등록, 조회 화면을 구현해보자.

1. 머스테치 설정

1) 머스테치 플러그인 추가

mustache 플러그인 설치 후 인텔리제이를 재시작하여 플러그인이 작동하는 것을 확인한다.

2) build.gradle에 머스테치 플러그인 추가

//mustache
implementation('org.springframework.book:spring-boot-starter-mustache')

2. 메인 화면 구현하기

* 코드

머스테치의 파일 기본 위치는 다음과 같다. 이 위치에 머스테치 파일을 두면 스프링 부트에서 자동으로 로딩한다.

  • src/main/resources/templates

A. index.mustache: 메인 화면
“스프링 부트로 시작하는 웹 서비스”를 출력하는 페이지이다.

  • src/main/resources/templates/index.mustache
<!DOCTYPE HTML>
<html>
<head>
		<title>스프링 부트 웹서비스</title>
		<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">

</head>
<body>
		<h1>스프링부트로 시작하는 웹 서비스</h1>
</body>
</html>

B. IndexController

index.mustache에 URL을 매핑할 Controller를 생성한다.

  • src/main/java/com/spring/book/web/IndexController.java
package com.spring.book.web;

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

@Controller
public class IndexController {

    @GetMapping("/")
    public String index() {
        return "index"; //(1)
    }
}
  • (1) 컨트롤러에서 문자열 반환
    • 머스테치 스타터 덕분에 컨트롤러에서 문자열을 반환할 때 앞의 경로와 뒤의 확장자는 자동으로 지정된다.
    • 앞의 경로: src/main/resources/templates
    • 뒤의 파일 확장자: .mustache 가 붙는다.
    • 즉, 여기서 index를 반환하므로, src/main/resources/templates/index.mustache로 전환되어 View Resolver가 처리하게 된다.

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

* 테스트

실제로 URL 호출 시 페이지의 내용이 제대로 호출되는지에 대한 테스트한다. TestRestTemplate를 통해 “/”를 호출했을 때 index.mustache에 포함된 코드들이 있는지 확인할 수 있다.

  • src/test/java/com/spring/book/web/IndexControllerTest.java
package com.spring.book.web;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.test.context.junit4.SpringRunner;

import static org.assertj.core.api.Assertions.assertThat;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class IndexControllerTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    public void 메인페이지_로딩(){
        //when
        String body = this.restTemplate.getForObject("/", String.class);

        //then
        assertThat(body).contains("스프링 부트로 시작하는 웹 서비스");
    }
}
  • 테스트 결과
  • 브라우저 접속 화면

3. 게시글 등록 화면 구현하기

1) 부트스트랩 라이브러리 사용하기

화면을 구현할 때 HTML만 사용하기에는 멋이 없기 때문에 오픈소스인 부트스트랩을 이용하겠다.
부트스트랩을 사용하기 위해서는 부트스트랩과 제이쿼리를 mustache 파일에 추가해야 한다.

* 라이브러리를 사용하는 방법

프론트엔드 라이브러리(부트스트랩, 제이쿼리 등)를 사용할 수 있는 방법은 크게 두 가지가 있다.

  1. 외부 CDN을 사용하는 방법
    1. 프로젝트에 직업 내려받아 사용할 필요도 없고, 사용 방법도 HTML/JSP/Mustache에 코드만 한 줄 추가하면 되니 굉장히 간단하다.
    2. 실제 서비스에서는 이 방법을 잘 사용하지 않는다. 결국은 외부 서비스에 우리 서비스가 의존하게 돼버려서, CDN을 서비스하는 곳에 문제가 생기면 덩달아 같이 문제가 생기기 때문이다.
  2. 직접 라이브러리를 받아서 사용하는 방법

이 프로젝트에서는 외부 CDN을 사용하여 라이브러리를 이용하겠다.

2) 레이아웃 방식으로 분리

부트스트랩과 제이쿼리 라이브러리는 index.mustache에 추가해야 한다. 바로 추가할 수도 있지만 레이아웃 방식으로 추가할 수도 있다.

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

* 코드

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

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

C. index.mustache

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

공통 HTML, 라이브러리 코드를 레이아웃으로 분리하였기 때문에 index.mustache는 위와 같이 간결해진다.

  • {{>layout/header}}: {{> }}는 현재 머스테치 파일(index.mustache)을 기준으로 다른 파일은 가져온다.

  • 메인 화면을 구성하는 코드는 3개의 파일로 분리하였다. css와 js 코드를 따로 분리하였는데 css는 header에, js는 footer에 위치시켜 css → index.mustache → js순서로 실행된다.

* 레이아웃 방식의 장점

이렇게 코드를 분리하면 다음과 같은 장점이 있다.

  1. head가 다 실행되고서 body가 실행되기 때문에 페이지의 로딩 속도를 높히는 효과가 있다.
    • head가 다 불러지지 않으면 백지 화면만 노출되고, js 용량이 클수록 body 부분의 실행이 늦어지기 때문에 js는 body는 하단에 두어 화면이 다 그려진 뒤 호출하는 것이 좋다.
    • css는 화면이 다 그리는 역할이므로 head에서 불러오는 것이 좋다. 그렇지 않으면 css가 적용되지 않은 깨진 화면을 사용자가 볼 수 있기 때문이다.
    • bootstrap.js가 제이쿼리에 의존하기 때문에 부트스트랩보다 먼저 호출되어야 한다.
  2. 중복된 코드를 줄일 수 있다.
    • 부트스트랩과 제이쿼리 라이브러리는 머스테치화면 어디서나 필요하기 때문에 매번 해당 라이브러리를 매번 머스테치 파일에 추가하기 보다는 레이아웃 파일을 만들어 추가하는 것이 효율적이다.

3) 글 등록 버튼 추가하기

* 코드

A. (화면-메인) index.mustache

/posts/save로 페이지 주소로 이동하도록 코드를 추가해주었다.

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

{{>layout/footer}}
  • html <a> 태그는 하이퍼링크를 걸어주는 태그이다.

B. (컨트롤러) IndexController.java

"/posts/save" 경로에 대한 요청을 받는 컨트롤러이다. 컨트롤러에 /posts/save를 호출하면 posts-save.mustache를 호출하는 메소드 추가하였다.

@RequiredArgsConstructor
@Controller
public class IndexController {
...

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

C. (화면-게시글 등록) posts-save.mustache

  • resources/templates/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}}
  • 게시글 등록 화면 테스트

프로젝트를 실행하고 브라우저에서 화면을 확인한다. 컨트롤러가 /posts/save 요청을 받아 posts-save.mustache 화면을 잘 출력하였다.

D. index.js

게시글 등록 화면에 등록 버튼 기능 추가하고 JS를 통해 API를 호출한다.

  • resources/static/js/app/index.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();

* 자바스크립트에서 function은 객체 안에서 만들어쓰기

  • main 변수 속성으로 function(init, save)을 추가한 이유?
    • 브라우저의 스코프(scope)는 공용 공간으로 쓰이기 때문에 나중에 로딩된 js의 init, save가 먼저 로딩된 js의 function을 덮어쓰게 된다. 이를 피하기 위해 index.js만의 유효공간을 만들어서 사용한다.

여러 사람이 참여하는 프로젝트에서는 중복된 함수 이름이 자주 발생할 수 있다. 모든 function 이름을 확인하면서 만들 수 없기 때문에 index.js 만의 유효범위를 만들어 사용해야 한다.
방법은 var index이란 객체를 만들어 해당 객체에서 필요한 모든 function을 선언하면 index 객체 안에서만 function이 유효하기 때문에 다른 JS와 겹칠 위험이 사라진다.

E. footer.mustache에서 index.js를 사용할 수 있게 추가

<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에서 /로 설정된다.

4) 테스트

  • 게시글 등록 화면 확인 테스트
  • 등록 데이터베이스 확인 테스트

5) 게시글 등록 동작 정리

  1. 메인화면에서 "글 등록" 버튼을 누르면 컨트롤러가 요청을 받아서 posts-save.mustache 화면으로 연결한다.
  2. posts-save.mustache 에서 title, author, content 데이터를 입력할 수 있다.
  3. "등록" 버튼을 누르면 데이터를 json 형태로 변경하여 컨트롤러에게 전달한다.
  4. 컨트롤러에서는 @RequestBody를 통해 json 형태의 데이터를 객체로 변환한다.
  5. 컨트롤러는 서비스로 객체로 변환된 데이터를 전달한다.
  6. 서비스에서는 도메인 엔티티의 필드를 변경하여 최종적으로 변경된 데이터를 DB에 반영한다.

3. 게시글 조회 화면 구현하기

* 코드

A. index.mustache

메인 화면인 index.mustache에 조회 화면을 구현한다. 여기서 머스테치 문법이 처음으로 사용된다.

{{>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>
    <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}}
  • {{#post}}
    • post 라는 List를 순회한다.
    • 자바의 for문과 동일한 기능을 한다.
  • {{id}}등의 {{변수명}}
    • Lists에서 뽑아낸 객체의 필드를 사용한다.

이제 컨트롤러, 서비스, 레포지토리 코드를 추가해보자.

B. (레포지토리) PostsRepository

package com.spring.book.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’을 사용하여 쿼리로 작성해도 된다.
실제로 SpringDataJpa에서 제공하는 기본 메소드만으로 해결할 수 있다.
다만, ‘@Query’가 훨씬 가독성은 좋으니 선택해서 사용해도 된다.

C. (서비스) PostsService

package com.spring.book.service.posts;
...

import java.util.List;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@Service
public class PostsService {
    private final PostsRepository postsRepository;

		...

    @Transactional(readOnly = true)
    public List<PostsListResponseDto> findAllDesc() {
        return postsRepository.findAllDesc().stream()
                .map(PostsListResponseDto::new)
                .collect(Collectors.toList());
    }
}
  • 트랜젝션 어노테이션(@Transactional)의 (readOnlu=true) 옵션
    • 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도가 개선되기 때문에 등록, 수정, 삭제 기능이 전혀 없는 서비스 메소드에서 사용하는 것을 추천한다.
  • findAllDesc() 메소드 내부 코드
    • 람다식으로 아래와 같이 풀어쓸 수 있다.
    • .map(PostsListResponseDto::new) = .map(posts -> new PostsListResponseDto)
    • findAllDesc() 메소드의 동작을 설명하자면 postsRepository 결과로 넘어온 Posts의 Stream을 map을 통해 PostsListReponseDto 변환 → Lists로 반환한다.

D. (DTO) PostsListResponseDto

  • com/spring/book/web/dto/PostsListResponseDto.java
package com.spring.book.web.dto;

import com.spring.book.domain.posts.Posts;

import java.time.LocalDateTime;

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

E. (컨트롤러) IndexController

package com.spring.book.web;

import com.spring.book.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; //@RequiredArgsConstructor 생성자로 초기화함.

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

    ...
}
  • 컨트롤러에서 Model 객체의 역할
    • 서버 템플릿 엔진에서 사용할 수 있는 개체를 저장할 수 있다.
    • 여기서는 postsService.findAllDesc()로 가져온 결과를 posts로 index.mustache에 전달한다.

* 테스트

http://localhost:8080/ 로 접속한 뒤 등록 화면을 이용해 데이터를 등록한 후, 조회하였다.

1) 게시글 조회 동작 정리

  1. 컨트롤러는 "/" URL 에 대한 요청을 받는다.
  2. 서비스는 리포지토리에서 PostsListResponseDto map를 가져와서 리스트 형태로 변환해서 컨트롤러에게 전달한다.
  3. 컨트롤러는 PostsListResponseDto 리스트를 index.mustache 화면에 전달한다.
  4. index.mustache는 PostsListResponseDto 리스트를 목록으로 출력한다.
    -> 즉, 게시글 등록하면 메인 화면에서 작성한 전체 게시글을 확인 할 수 있다.

참고

도서 - 스프링 부트와 AWS로 혼자 구현하는 웹 서비스

0개의 댓글