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

[스프링 부트와 AWS로 혼자 구현하는 웹 서비스 4장] - 머스테치로 화면 구성하기

dev_rosieposie 2023. 3. 16. 16:17

Goal 

  1.  서버 템플릿 엔진 엔진과 클라이언트 템플릿 엔진의 차이
  2.  머스테치의 기본 사용방법
  3.  스프링 부트에서 화면 처리 방식
  4.  js/css 선언 위치를 다르게 해서 웹 사이트의 로딩 속도 향상하는 방법
  5.  js 객체를 이용하여 브라우저의 전역 변수 충돌 문제를 회피하는 방법

 

템플릿 엔진이란?

지정된 템플릿 양식과 데이터가 합쳐져 HTML문서를 출력하는 소프트웨어

구분 서버 템플릿 엔진 클라이 언트 템플릿 엔진
종류 JSP, Freemarker, Velocity, Mustache, Thymeleaf 리액트, 뷰
특징 서버에서 Java 코드로 문자열을 만든 뒤 이 문자열을 HTML로 변환하여 브라우저로 전달
-> 이때, 자바스크립트 코드는 단순한 문자열
브라우저에서 화면 생성
즉, 서버에서 이미 코드가 벗어난 경우

 

머스테치란?

수많은 언어를 지원하는 가장 심플한 템플릿 엔진

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

 

예제1. 기본 페이지 만들기

1.1 build.gradle에 dependency  추가하기

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

1.2 src/main/resources 경로에 template 폴더 추가후 index.mustache 생성

<!DOCTYPE HTML>
<html>
<head>
	<title>스프링 부트 웹 서비스 </title>
    <meta http-equiv="Content-Type" content="text/html;charset=UTF-8"/>
</head>
<body>
	<h1>스프링부트로 시작하는 웹 서비스 Ver.2</h1> 
</body>
</html>

1.3 index.mustache에 URL 매핑해줄 IndexController 생성

package com.jojoldu.book.springboot.web;
@RequiredArgsConstructor
@Controller
public class IndexController {

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

1.4 테스트 코드 작성 (IndexControllerTest)

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

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    public void mainpage_loading(){
        // when
        String body = this.restTemplate.getForObject("/", String.class);

        // then
        assertThat(body).contains("스프링부트로 시작하는 웹 서비스 Ver.2");
    }
}

테스트 통과
주의!! 비교하는 문자열과 index.mustache에 작성된 문자열이 맞는지 확인할 것

 

 

예제2. 게시글 등록 화면 만들기

2.1 공통 영역을 별도 파일로 분리하고 외부 라이브러리(부트스트랩, 제이쿼리) 추가하기

2.1.1 src/main/resources/template 에 layout 디렉토리를 생성하고 header.mustache, footer.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>
<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가 실행된다.

 

2.2 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>
            {{#userName}}
                Logged in as: <span id="user">{{userName}}</span>
                <a href="/logout" class="btn btn-info active" role="button">Logout</a>
            {{/userName}}
            {{^userName}}
                <a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
                <a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
            {{/userName}}
        </div>
    </div>
    <br>
</div>

{{>layout/footer}}
  • 필요한 코드만 남게 된다.
  • {{>layout/header}}
    • {{>}}는 현재 머스테치 파일(index.mustache)을 기준으로 다른 파일을 가져온다.
    • {{# /}}는 userName이 있을 경우 / 반복의 개념도 있음
    • {{^ /}}는 userName이 없 경우

2.3 이동할 페이지 주소에 해당하는 컨트롤러인 IndexController에 등록페이지 메소드 생성

@RequiredArgsConstructor
@Controller
public class IndexController {

    @GetMapping("/posts/save")
    public String postsSave(){
        return "posts-save";
    }
}
  • index.mustache와 마찬가지로 /posts/save를 호출하면 posts-save.mustache를 호출하는 메소드가 추가됨

2.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}}
  • index.mustache와 같은 위치

2.4 localhost:8080/ 에서 등록 클릭시 다음과 같은 화면 확인

화면 호출까지만 성공! 등록 api는 만들지 않았기 때문에 기능은 동작하지 않는다.

2.5 등록 API를 호출할 JS 생성

2.5.1 src/main/resources에 static/js/app 디렉토리를 생성하고 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));
        });
    }
};

main.init();
  • window.location.href = '/'
    • 글 등록 성공시 메인페이지 /로 이동
  • var main = {}을 선언한 이유
    • 나중에 로딩된 js의 init, save가 먼저 로딩된 js의 function을 덮어쓰게 되고, 중복된 함수 이름 발생의 가능성
    •  var index란 객체를 만들어 해당 객체에서 필요한 모든 function을 선언 -> index객체 안에서만 function 유효

2.6 index.js를 mustache 파일이 쓸 수 있도록 footer.mustache에 추가

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

2.7 등록 기능 테스트

등록 버튼 클릭시 등록 성공 알럿 출력 확인

 

예제3. 전체 조회 화면 만들기

3.1 전체 조회를 위한 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>
            {{#userName}}
                Logged in as: <span id="user">{{userName}}</span>
                <a href="/logout" class="btn btn-info active" role="button">Logout</a>
            {{/userName}}
            {{^userName}}
                <a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
                <a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
            {{/userName}}
        </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><a href="/posts/update/{{id}}">{{title}}</a></td>
                <td>{{author}}</td>
                <td>{{modifiedDate}}</td>
            </tr>
        {{/posts}}
        </tbody>
    </table>
</div>

{{>layout/footer}}
  • {{#posts}}
    • posts라는 List를 순회한다
    • Java의 For문과 동일
  • {{id}}등의 {{변수명}}
    • List에서 뽑아낸 객체의 필드를 사용

3.2 PostsRepository 인터페이스에 쿼리 추가

public interface PostsRepository extends JpaRepository<Posts, Long> {
    @Query(value = "select p.* from posts p order by p.id desc", nativeQuery = true)
    List<Posts> findAllDesc();
}
  • SpringDataJpa에서 제공하지 않는 메소드는 @Query를 사용하여 쿼리처럼 작성 가능
  • 가독성이 좋은 장점
  •  nativeQuery = true 작성해줘야 쿼리 오류가 나지 않는다.

3.3 PostService에 findAllDesc 메소드 추가

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

(readOnly = true)를 주면 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도가 개선

-> 실제로 이후 delete메소드 추가하고 삭제가 안되서 왜 안되지? 하고 있었는데 readOnly=true 자동생성되서 안됨.

3.4 PostsListResponseDto 클래스 생성

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

3.4 IndexController 변경

@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.mustache에 전달

3.4 localhost:8080/ 에서 다음과 같은 화면 확인

 

예제4. 게시글 수정 화면 만들기

4.1 수정 API는 이전 글에서 생성

https://dev-rosiepoise.tistory.com/42 의  2.5 PostsApiController에 수정/조회 API추가 참고

4.2 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.id}}
    • 머스테치는 객체의 필드 접근 시 점(Dot)으로 구분
    • 즉, Post 클래스의 id에 대한 접근은 post.id로 사용 가능

4.2 index.js에 upate function 추가

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

4.3 index.mustache 수정

<td><a href="/posts/update/{{id}}">{{title}}</a></td>
  • title에 a 태그 추가

4.3 indexController에 update페이지 이동 메소드 추가

@GetMapping("/posts/update/{id}")
public String postsUpdate(@PathVariable Long id, Model model) {

    PostsResponseDto dto = postsService.findById(id);
    model.addAttribute("post", dto);

    return "posts-update";
}

4.4 localhost:8080/ 에서 수정 기능 확인

제목에 링크 표시 됨을 확인

 

수정페이지 이동 후 수정 기능 성공 확인

예제5. 게시글 삭제 화면 만들기

5.1 이미 4.1의 posts-update.mustache 삭제 버튼 생성됨

5.2 index.js에 delete function 추가

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

5.3 PostsService클래스에서 삭제 API 생성 

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

(readOnly = true)가 자동 생성 되었는지 꼭 확인할 것

-> 조회 기능만 유지

  • postsRespository.delete(posts)
    • JpaRepository에서 이미 delete메소드를 지원
    • 엔티티를 파라미터로 삭제 또는 deleteById 메소드를 이용하면 id로 삭제 가능
    • 존재하는 Posts인지 확인 위해 엔티티 조회 후 그대로 삭제

5.3 PostsApiController에 삭제 API 추가

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

5.4 localhost:8080/ 에서 삭제 기능 확인

삭제 기능 성공 확인
조회 목록에 데이터 없음