티스토리 뷰

이동욱 님의 '스프링부트와 AWS로 혼자 구현하는 웹 서비스' 책 내용을 정리한 것입니다.

 

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

서버 템플릿 엔진: JSP, Freemarker

클라이언트 템플릿 엔진: React, Vue

 

머스테치

다양한 언어를 지원하는 템플릿 엔진

 

(장점)

- 다른 템플릿 엔진보다 문법이 심플

- View의 역할과 서버의 역할이 명확하게 분리

- 하나의 문법으로 클라이언트와 서버 모두 사용 가능

 

플러그인에서 머스테치 설치

 

index.mustache - 파일 위치는 src/main/resources/templates

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>스프링부트 웹 서비스</title>
</head>
<body>
    <h1>스프링부트로 시작하는 웹 서비스</h1>
</body>
</html>

 

➡️ 이 머스테치에 URL 매핑 (Controller에서 진행)

 

IndexController.java

package com.example.project.web;

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

@Controller
public class IndexController {

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

문자열 반환 시 앞의 경로와 뒤의 파일 확장자는 자동으로 지정

 

IndexControllerTest.java

package com.example.project.web;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
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.junit.jupiter.SpringExtension;

@ExtendWith(SpringExtension.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
        Assertions.assertThat(body).contains("스프링부트로 시작하는 웹 서비스");
    }
}

실행결과 성공!

 

localhost 8080 실행 시 화면

 

❗️mustache 한글 깨짐 해결 방법 → application.properties에 코드 추가

server.servlet.encoding.force=true

 

프론트엔드 라이브러리 사용 방법

1. 외부 CDN을 사용 (✔️)

2. 직접 라이브러리를 받아서 사용

 

➡️ 2개의 라이브러리 부트스트랩과 제이쿼리 레이아웃 방식으로 추가

 

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

 

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>

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>

 

▶ 페이지 로딩속도를 높이기 위해 CSS는 Header에, JS는 Footer에 위치

▶ 부트스트랩의 경우 제이쿼리가 필요하기 때문에 제이쿼리 먼저 호출

 

index.mustache

{{>layout/header}} // 현재 머스테치 파일 기준으로 다른 파일 가져옴

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

{{>layout/footer}}

 

 

index.mustache - a 태그 이용하여 글 등록 버튼 추가

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

 

실행 시 화면

 

IndexController.java

package com.example.project.web;

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

@Controller
public class IndexController {

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

    // 게시글 등록 화면 연결
    @GetMapping("/posts/save")
    public String postsSave() {
        return "posts-save";
    }
}

 

posts-save.mustache - 게시글 등록 화면 UI

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

index.js만의 유효범위를 만들어서 사용 (해당 객체를 만들어서 객체에 필요한 모든 function을 선언)

 

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>

실행시 화면

 

DB 확인

 

index.mustache - 게시글 전체 조회를 위한 화면 UI

{{>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}} <!-- posts라는 List를 순회 -->
                <tr>
                    <td>{{id}}</td> <!-- List에서 뽑아낸 객체 필드 사용 -->
                    <td>{{title}}</td>
                    <td>{{author}}</td>
                    <td>{{modifiedDate}}</td>
                </tr>
            {{/posts}}
            </tbody>
        </table>
    </div>

{{>layout/footer}}

 

PostsRepository.java

package com.example.project.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();
}

 

PostsService.java

package com.example.project.service.posts;

import com.example.project.domain.posts.Posts;
import com.example.project.domain.posts.PostsRepository;
import com.example.project.web.dto.PostsResponseDto;
import com.example.project.web.dto.PostsSaveRequestDto;
import com.example.project.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.stream.Collectors;

@RequiredArgsConstructor
@Service
public class PostsService {

    private final PostsRepository postsRepository;

    // ~~ 게시글 등록, 수정, 조회 메서드 ~~
    
    // 게시글 전체 목록 조회
    @Transactional(readOnly = true) // 트랜잭션 범위는 유지 but 조회 기능만 남겨둠
    public List<PostsListResponseDto> findAllDesc() {
        return postsRepository.findAllDesc().stream()
                .map(PostsListResponseDto::new)
                .collect(Collectors.toList());
    }
}

 

.map(PostsListResponseDto::new)

⬇️ 똑같은 의미

.map(posts -> new PostsListResponseDto(posts))

 

PostsListResponseDto.java

package com.example.project.web.dto;

import com.example.project.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.getAuthor();
        this.modifiedDate = entity.getModifiedDate();
    }
}

 

IndexController.java

package com.example.project.web;

import com.example.project.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 postsSave() {
        return "posts-save";
    }
}

실행 시 화면

 

posts-update.mustache - 게시글 수정, 삭제 화면 UI

{{>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> <!-- Post 클래스의 id 접근 -->
                </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}}

 

index.js - update function 추가

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

main.init();

 

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}} <!-- posts라는 List를 순회 -->
                <tr>
                    <td>{{id}}</td> <!-- List에서 뽑아낸 객체 필드 사용 -->
                    <td><a href="/posts/update/{{id}}">{{title}}</a></td> <!-- 타이틀 클릭 시 게시글 수정 화면으로 이동 -->
                    <td>{{author}}</td>
                    <td>{{modifiedDate}}</td>
                </tr>
            {{/posts}}
            </tbody>
        </table>
    </div>

{{>layout/footer}}

 

IndexController.java

package com.example.project.web;

import com.example.project.service.posts.PostsService;
import com.example.project.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/save")
    public String postsSave() {
        return "posts-save";
    }
    
    // 게시글 수정 화면 연결
    @GetMapping("/posts/update/{id}")
    public String postsUpdate(@PathVariable Long id, Model model) {
        PostsResponseDto dto = postsService.findById(id);
        model.addAttribute("post", dto);
        
        return "posts-update";
    }
}

실행 시 화면

 

제목 클릭 시 게시글 수정 화면으로 이동

 

게시글 수정 시 화면

 

수정된거 확인!

 

index.js - delete function 추가

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

};

main.init();

 

PostsService.java

package com.example.project.service.posts;

import com.example.project.domain.posts.Posts;
import com.example.project.domain.posts.PostsRepository;
import com.example.project.web.dto.PostsListResponseDto;
import com.example.project.web.dto.PostsResponseDto;
import com.example.project.web.dto.PostsSaveRequestDto;
import com.example.project.web.dto.PostsUpdateRequestDto;
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
    public Long save(PostsSaveRequestDto requestDto) {
        return postsRepository.save(requestDto.toEntity()).getId();
    }

    // 게시글 수정
    @Transactional
    public Long update(Long id, PostsUpdateRequestDto requestDto) {
        Posts posts = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id = " + id));

        posts.update(requestDto.getTitle(), requestDto.getContent());

        return id;
    }

    // 게시글 조회
    public PostsResponseDto findById(Long id) {
        Posts entity = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id = " + id));

        return new PostsResponseDto(entity);
    }

    // 게시글 전체 목록 조회
    @Transactional(readOnly = true) // 트랜잭션 범위는 유지 but 조회 기능만 남겨둠
    public List<PostsListResponseDto> findAllDesc() {
        return postsRepository.findAllDesc().stream()
                .map(PostsListResponseDto::new)
                .collect(Collectors.toList());
    }

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

        postsRepository.delete(posts);
    }
}

 

PostsApiController.java

package com.example.project.web;

import com.example.project.service.posts.PostsService;
import com.example.project.web.dto.PostsResponseDto;
import com.example.project.web.dto.PostsSaveRequestDto;
import com.example.project.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RequiredArgsConstructor
@RestController
public class PostsApiController {

    private final PostsService postsService;

    // 게시글 등록
    @PostMapping("/api/v1/posts")
    public Long save(@RequestBody PostsSaveRequestDto requestDto) {
        return postsService.save(requestDto);
    }

    // 게시글 수정
    @PutMapping("/api/v1/posts/{id}")
    public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) {
        return postsService.update(id, requestDto);
    }

    // 게시글 조회
    @GetMapping("/api/v1/posts/{id}")
    public PostsResponseDto findById(@PathVariable Long id) {
        return postsService.findById(id);
    }

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

실행 시 화면

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
글 보관함