Super Kawaii Cute Cat Kaoani
본문 바로가기
⚙️ Back-end/Spring

[Spring Boot] 2주차 스터디 : 4장 머스테치로 화면 구성하기

by wonee1 2025. 1. 15.
728x90

 

✏️ 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 책을 보면서 스터디 한 내용을 정리하였습니다 

https://www.yes24.com/product/goods/83849117

 

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

가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링

www.yes24.com

 

 

 

 

4장 머스테치로 화면 구성하기

 

 

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


💠 템플릿 엔진이란?

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

💠 서버 템플릿 엔진

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

 

💠 클라이언트 템플릿 엔진

  • 자바스크립트는 브라우저 위에서 작동한다
  • 자바스크립트 코드가 실행되는 장소는 서버 X 브라우저 O
  • Vue.js 나 React.js를 이용한 SPA는 브라우저에서 화면을 생성한다
  • → 즉 서버에서 이미 코드가 벗어난 경우

서버에서는 Json 혹은 Xml 형식의 데이터만 전달하고 클라이언트에서 조립한다

 

 

 

💠머스테치란?

  • 수많은 언어를 지원하는 가장 심플한 템플릿 엔진
  • 루비, 자바스크립트, 파이썬, PHP, 자바 ,펄 GO,ASP 등 현종하는 대부분의 언어 지원

💠머스테치의 장점

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

 

💜머스테치 플러그인 설치


 

 

  • 머스테치 플러그인을 이용하면 머스테치의 문법 체크, HTML 문법 지원, 자동완성 등이 지원된다

 

 

💜기본 페이지 만들기


 

 

  1. 머스테치 스타터 의존성을 build.gradle에 등록한다
implementation 'org.springframework.boot:spring-boot-starter-mustache'

 

 

   2. src/main/resoureces/templates 파일에 머스테치 파일 생성

 

파일 구조

 

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

간단한 html 코드 작성 후 머스테치에 URL을 매핑 → URL 매핑은 Controller에서 진행

 

 

3. web 패키지 안에 IndexController를 생성

 

파일 구조

 

 

IndexController

package com.jojoIdu.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";
    }

}

  • 머스테치 스타터 덕분에 컨트롤러에서 문자열을 반활할 때 앞의 경로와 뒤의 파일 확장자는 자동으로 지정된다
  • View Resolver : URL 요청의 결과를 전달할 타입과 값을 지정하는 관리자 격

 

 

 

4. test 패키지에 IndexControllerTest 클래스 생성

 

 

IndexControllerTest

package com.jojoIdu.book.springboot.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 MainPage_loading() {
        //when
        String body = this.restTemplate.getForObject("/", String.class);

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

실제로 URL 호출 시 페이지의 내용이 제대로 호출되는지에 대한 테스트

 

클래스 선언부

  1. @RunWith(SpringRunner.class)
    • JUnit이 이 테스트 클래스를 실행할 때 SpringRunner를 사용하도록 설정한다.
    • SpringRunner는 스프링 테스트 환경을 제공하며, 스프링 컨텍스트를 로드하여 테스트에서 사용할 수 있도록 한다.
  2. @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
    • 스프링 부트 테스트 환경을 설정한다.
    • RANDOM_PORT를 사용하여 테스트 실행 시마다 랜덤 포트를 할당한다. 이는 여러 테스트가 동시에 실행될 때 포트 충돌을 방지한다.
  3. TestRestTemplate 의존성 주입
  • TestRestTemplate은 테스트 전용 HTTP 클라이언트로, 실제 HTTP 요청을 모방하여 서버와 통신한다.
  • 이 템플릿을 통해 테스트 중 서버에 요청을 보내고 응답을 받아올 수 있다.

 

테스트 메서드

  1. @Test
    • JUnit이 이 메서드를 테스트로 인식하도록 설정한다.
  2. this.restTemplate.getForObject("/", String.class)
    • 루트 경로 /로 GET 요청을 보낸다.
    • 응답의 본문(body)을 **문자열(String)**로 반환받는다.
    • 이 요청은 테스트 서버에서 실행되며, 실제 애플리케이션의 메인 페이지가 잘 동작하는지 확인하는 데 사용된다.
  3. assertThat(body).contains("스프링부트로 시작하는 웹 서비스")
    • 응답 본문(body)에 "스프링부트로 시작하는 웹 서비스"라는 텍스트가 포함되어 있는지 확인한다.
    • 예상 텍스트가 없으면 테스트는 실패한다.

 

 

5. Application.java의 main 메소드를 실행하고 브라우저에서 http://localhost:8080으로 접속

 

 

 

 

💜게시글 등록 화면 만들기


 

 

부트스트랩, 제이쿼리 등 프론트엔드 라이브러리를 사용할 수 있는 방법

→ 1. 외부 CDN을 사용하는것

→ 2. 직접 라이브러리를 받아서 사용하는 법

 

 

책에선 외부 CDN을 사용하는 방식을 활용함

2개의 라이브러리 부트스트랩과 제이쿼리를 index.mustache에 추가

→ 바로 추가하지 않고 레이아웃 방식으로 추가

 

 

💠레이아웃 방식이란?

  • 공통 영역을 별도의 파일로 분리하여 필요한 곳에서 가져다 쓰는 방식을 이야기 함

 

  1. src/main/resources/templates 디렉토리에 layout 디렉토리를 추가로 생성 그 다음 footer.mustache, header.mustache파일 생성

 

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>

 

 

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>

 

 

  • CSS와 JS의 위치가 서로 다름 → 페이지 로딩 속도를 높이기 위해 css는 header에 js는 footer에 두었다
  • HTML은 위에서부터 코드가 실행되기 때문에 head가 다 실행되고 나서 body가 실행된다
  • bootstrap.js의 경우 제이쿼리가 꼭 이어야만 한다 따라서 부트스트랩보다 먼저 호출되도록 코드 작성

 

 

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

 

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

 

 

 

 

     2. IndexController와 posts-save.mustache 설정

 

 

 

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

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

    @GetMapping("/posts/save")
    public String save() {
        return "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}}

 

 

 

        3. api호출 js만들기

 

 

파일 구조

 

 

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

 

 

 

 

4. 등록 후 실행결과 localhost:8080/h2-console에 접속해서 확인

 

 

 

💜전체 조회 화면 만들기


 

 

전체 조회를 위해 index.mustache의 UI 변경

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

  • {{#posts}}
    • posts라는 List를 순회한다
    • Java의 fo문과 동일하게 생각하면 된다
  • {{id}} 등의 {{변수명}}
    • List에서 뽑아낸 객체의 필드를 사용한다

 

 

Controller, Service, Repository 코드 작성

 

PostsRepository

package com.jojoIdu.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활용

규모가 있는 프로젝트에서 데이터 조회는 조회용 프레임크를 추가로 활용한다

→ querydsl, jooq, MyBatis 등

 

Querydsl 장점

  1. 타입 안정성 보장
  2. 국내 많은 회사 사용
  3. 레퍼런스가 많음

 

 

PostsService

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

  • findAllDesc 메소드의 트랜잭션 어노테이션 (@Transactional)에 옵션 추가
  • (readOnly = true) 를 주면 트랜잭션 범위는 유지하되 조회 기능만 남겨두어 조회 속도가 개선된다 !

          → 등록 수정 삭제 기능이 전혀 없는 서비스 메소드에서 사용하는 것 추천

 

.map(PostsListResponseDto::new)

-> .map(posts-> new PostsListResponseDto(posts)) 
  • postsRepository 결과로 넘어온 Posts의 Stream을 map을 통해 PostsListResponseDto 변환 → List로 반환하는 메소드

 

 

PostsListResponseDto

package com.jojoIdu.book.springboot.web.dto;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.jojoIdu.book.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 modifiedDate;

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

    }

}

 

 

IndexController

package com.jojoIdu.book.springboot.web;

import com.jojoIdu.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;

@RequiredArgsConstructor
@Controller
public class IndexController {
    private final PostsService postsService;

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

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

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

 

결과 화면

 

 

 

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


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>
            </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}}
  • {{posts.id}}
    • 머스테치는 객체의 필드 접근 시 점(Dot)으로 구분한다
    • 즉 Posts 클래스의 id에 대한 접근은 post.id로 사용할 수 있다
  • readonly
    • Input 태그에 읽기 기능만 허용하는 속성
    • id와 author는 수정할 수 없도록 읽기만 허용하도록 추가

 

 

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));
        });
    },
  • $(#btn-update').on('click')
    • btn-update란 id를 가진 HTML 엘리먼트에 click 이벤트가 발생할 때 update function을 실행하도록 이벤트를 등록.
  • update : function ()
    • 신규로 추가될 update function
  • type: 'PUT'
    • 여러 HTTP Method 중 PUT 메소드를 선택
    • PostsApiController에 있는 API에서 이미 @PutMapping으로 선언했기 때문에 PUT을 사용해야한다. 참고로 이는 REST 규약에 맞게 설정된 것
    • REST에서 CRUD는 다음과 같이 HTTP Method에 매핑 생성 (Create) - POST 읽기 (Read) -GET 수정 (Update) - PUT 삭제 (Delete) - DELETE
  • url: '/api/v1/posts/'+id
    • 어느 게시글을 수정할지 URL Path로 구분하기 위해 Path에 id를 추가한다

 

 

 

index.mustache

         {{#posts}}
                <td>{{id}}</td>
                <td><a href="/posts/update/{{id}}"> {{title}}</a></td>
                <td>{{author}}</td>
                <td>{{modifiedDate}}</td>
            {{/posts}}

전체 목록에서 수정 페이지로 이동할 수 있게 페이지 이동 기능 추가

  • <a href="/posts/update/{{id}}"></a>
    • 타이틀에 a tag를 추가
    • 타이틀을 클릭하면 해당 게시글의 수정 화면으로 이동

 

 

 

IndexController

package com.jojoIdu.book.springboot.web;

import com.jojoIdu.book.springboot.service.posts.PostsService;
import com.jojoIdu.book.springboot.web.dto.PostsResponseDto;
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.PathVariable;

@RequiredArgsConstructor
@Controller
public class IndexController {
    private final PostsService postsService;

    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("posts", postsService.findAllDesc());
        return "index";
    }
    
    @GetMapping("/posts/update/{id}")
    public String postsUpdate(@PathVariable Long id, Model model){
        PostsResponseDto dto = postsService.findById(id);
        model.addAttribute("post",dto);
        
        return "posts-update";
    }

    @GetMapping("/posts/save")
    public String save() {
        return "posts-save";
    }
}
  • 수정 화면을 연결할 controller 코드 작업
  • 메인 화면으로 이동하면 타이틀 항목에 링크 표시 확인

 

 

 

결과 화면

 

결과 화면

 

 

 

 

2. 게시글 삭제

 

posts-update.mustache

        </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>
  • btn-delete
    • 삭제 버튼을 수정 완료 버튼 옆에 추가
    • 해당 버튼 클릭 시 js 에서 이벤트를 수신할 예정

 

index.js

   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);
    }
  • postsRepository.delete(posts)
    • JpaRepository에서 이미 delete 메소드를 지원하고 있으니 이를 활용
    • 엔티티 파리미터로 삭제할 수 도 있고 deleteById 메소드를 이용하면 id로 삭제할 수도 있다
    • 존재하는 Posts인지 확인을 위해 엔티티 조회 후 그대로 삭제

 

서비스에서 만든 delete 메소드를 컨트롤러가 사용하도록 코드 추가

 

 

PostsApiController

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

결과 화면

728x90