머스테치를 이용한 화면 기능 구현

YoungG209·2021년 11월 12일
0

본 포스팅은 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 책을 보고 작성하였음

1. 머스테치(Mustache)

1.1 머스테치

  • 수많은 언어를 지원하는 가장 심플한 템플릿 엔진
  • 장점
    • 문법이 다른 템플릿 엔진보다 심플
    • 로직 코드를 사용할 수 없어 View의 역할과 서버의 역할을 명확하게 분리
    • Mustache.js와 Mustache.java 2가지가 다있어, 하나의 문법으로 클라이언트/서버 템플릿을 모두 사용 가능

2. 머스테치 세팅 및 기본 페이지 만들기

2.1 플러그인 설치

2.2 의존성 추가

2.3 페이지 만들기

  • 기본 파일 위치 : 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>

2.4 URL 매핑

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

@Controller
public class IndexController {

    @GetMapping("/")
    public String index() {
        return "index";
    }
}
  • 의존성 추가로 컨트롤러에서 문자열을 반한할 때 앞의 경로와 뒤의 파일 확장자는 자동으로 지정됨

2.5 테스트

import org.junit.jupiter.api.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;
import static org.junit.jupiter.api.Assertions.*;

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

    @Autowired
    private TestRestTemplate restTemplate;
    
    @Test
    void indexLoding() {
        //when
        String body = this.restTemplate.getForObject("/", String.class);
        
        //then
        assertThat(body).contains("스프링 부트로 시작하는 웹 서비스");
    }
}

결과


3. 게시글 등록 화면 만들기

3.1 레이아웃

  • 공통 영역을 별도파일로 분리햐여 필요한 곳에서 가져다 쓰는 방식
  • 페이지 로딩속도를 높이기 위해 css는 header, js는 footer
  • header
<!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>
  • footer
<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>

3.2 글 등록

index 변경

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

posts-save 추가

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

index.js 변경 (등록 기능 추가)

var index = {
    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));
        });
    }
};
  • index라는 변수의 속성으로 function을 추가한 이유는?
    • 유효 범위(scope)를 만들기 위함
      • 브라우저의 스코프(scope)는 공용 공간으로 쓰이기 때문에 나중에 로딩된 js의 function을 덮어쓰게 되어 문제가 발생할 수도 있음
      • 여러 사람이 참여하는 프로젝트에서는 중복된 함수(function)은 자주 발샐 할 수 있음

결과


3.3 조회

index 변경

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

//조회
<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><a href="/posts/update/{{id}}">{{title}}</a></td>
            <td>{{author}}</td>
            <td>{{modifiedDate}}</td>
        </tr>
    {{/posts}}
    </tbody>
</table>
{{>layout/footer}}
  • 머스테치 문법
    • ⓐ {{#posts}}
      • posts 라는 List를 순회함
      • Java의 for문과 동일하게 생각하면됨
    • ⓑ {{id}} 등의 {{변수명}}
      • List에서 뽑아낸 객체의 필드를 사용함

PostsRepository 변경

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();
}
  • FK의 조인, 복잡한 조건 등으로 인해 Entity 클래스만으로 처리하기 어려워 조회용 프레임워크를 추가로 사용함
  • 대표적으로 querydsl, jooq, Mybatis 등이 있음, 저자의 추천으로는 querydsl가 있음
  • querydsl 추천 이유
    • 국내 많이느 회사에서 사용한다 함
    • 래퍼런스가 많다고 함
    • 타입 안전성이 보장됨
      • 단순한 문자열로 쿼리를 생성하는 것이 아ㅣ라, 메소드를 기반으로 쿼리를 생성하므로 오타나 존재하지 않는 컬럼명을 명시할 경우 검출됨
        • Jooq에서도 지원됨 but, Mybatis는 지원하지 않음

PostsService 변경

import com.study.aws.studyspringbootaws.domain.posts.PostsRepository;
import com.study.aws.studyspringbootaws.web.dto.PostsListResponseDto;
import com.study.aws.studyspringbootaws.web.dto.PostsResponseDto;
import com.study.aws.studyspringbootaws.web.dto.PostsSaveRequestDto;
import com.study.aws.studyspringbootaws.web.dto.PostsUpdateRequestDto;
import com.study.aws.studyspringbootaws.domain.posts.Posts;
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(readOnly = true) ⓐ
    public List<PostsListResponseDto> findAllDesc() {
        return postsRepository.findAllDesc().stream()
                .map(PostsListResponseDto::new)
                .collect(Collectors.toList());
    }
}
  • ⓐ @Transactional(readOnly = true)
    • 트랜잭션 범위는 유지하되 조회 기능만 남겨두어 조회속도가 개선되기 때문에 등록, 수정, 삭제 기능이 전혀 없는 서비스 메소드에서 사용하는 것을 추처남

PostsListResponseDto 추가

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

Controller 변경

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";
    }
}
  • ⓐ Model
    • 서버 템플릿 엔진에서 사용할 수 있는 객체를 저장할 수 있음
    • 여기서는 postsService.findAllDesc()로 가져온 결과를 posts로 index에 전달함

결과

3.4 게시글 수정, 삭제 화면

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

{{>layout/footer}}
  • 머스테치 문법
    • ⓐ {{post.title}}
      • 객체 필드 접근 시 상기 처럼 구분

index.js 수정

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

PostsService (삭제 로직 추가)

    @Transactional
    public void delete(Long id) {
        Posts posts = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 사용자가 없습니다. id=" + id));

        postsRepository.delete(posts);
    }

PostsApiController (삭제 로직 추가)

    @DeleteMapping("/api/v1/posts/{id}")
    public Long delete(@PathVariable Long id) {
        postsService.delete(id);

        return id;
    }

PostsApiControllerTest (삭제 로직 테스트)

    @Test
    public void deletePosts() throws Exception {
        //given
        Posts savedPosts = postsRepository.save(Posts.builder()
                .title("title")
                .content("content")
                .author("author")
                .build()
        );

        Long deleteId = savedPosts.getId();

        String url = "http://localhost:" + port + "/api/v1/posts/"+deleteId;
        HttpHeaders headers = new HttpHeaders();
        HttpEntity httpEntity = new HttpEntity(headers);

        //when
        ResponseEntity<Long> responseEntity = restTemplate.exchange(url, HttpMethod.DELETE, httpEntity, Long.class);

        //then
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(responseEntity.getBody()).isGreaterThan(0L);
    }

결과



profile
고우고우~

0개의 댓글

관련 채용 정보