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

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

한 면만 쓴 종이 2022. 11. 21. 22:10

 

✏️ 서버 템플릿 엔진과 메스테치 소개

 

  • JSP를 비록한 서버 템플릿 엔진은 서버에서 구동됨
  • 서버 템플릿 엔진을 이용한 화면 생성
    • 서버에서 Java 코드로 문자열을 만든 뒤 이 문자열을 HTML로 변환하여 브라우저로 전달
  • 브라우저에서 자바스크립트가 작동될 때는 서버 템플릿 엔진의 손을 벗어나 제어할 수 없게 됨

 

📎머스테치

 

  • 장점
    • 문법이 다른 템플릿 엔지보다 간단함
    • 로직 코드를 사용할 수 없어 View 의 역할과 서버의 역할이 명확하게 분리됨
    • 현존하는 대부분의 언어를 지원하기 때문에 서버와 템플릿 엔진으로 모두 사용 가능

 

머스테치 의존성 추가

 

IndexController

@RequiredArgsConstructor
@Controller
public class IndexController {

    private final PostsService postsService;

    @GetMapping("/")
    public String index(Model 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("/")
//    public String index() {
//        return "index"; //머스테치에서 문자열 반환 시 앞의 경로와 뒤의 파일 확장자 자동으로 지정된다
//    }

    @GetMapping("/posts/save")
    public String postsSave() {
        return "posts-save";
    }
}
  • 머스테치 스타터는 컨트롤러가 문자열을 반환할 때 앞의 경로와 뒤의 파일 확장자는 자동으로 지정

 

IndexController

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

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    // 웹 요청에서 테스트 코드 작성
    public void 메인페이지_로딩() {
        //when
        String body = this.restTemplate.getForObject("/", String.class);

        // URL 호출 시 페이지 내용 제대로 호출 되었는지 테스트 -> 코드가 잘 포함되어 있는지 확인하면 된다.
        assertThat(body).contains("스프링 부트로 시작하는 웹 서비스");
    }
}
  • 실제 URL호출 시 페이지의 내용이 제대로 호출되는지에 대한 테스트
  • TestRestTemplate를 통해 "/"로 호출했을 때 index.mustache에 포함된 코드들이 있는지 확인

 

 

✏️ 게시글 등록 화면 만들기

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

  • 외부 CDN 사용 ☝️
  • 직접 라이브러리를 받아서 사용

 

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

 

 

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>

<!--index.js 추가-->
<script src="/js/app/index.js"></script>
</body>
</html>
  • header, footer 따로 만들어서 로딩 속도 향상
  • HTML은 위에서부터 코드가 실행되기 때문에 head가 다 실행되고서야 body가 실행됨
    • 따라서 body를 하단에 두어야 효율적

 

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><a href="/posts/update/{{id}}">{{title}}</td>
                <td>{{author}}</td>
                <td>{{modifiedDate}}</td>
            </tr>
        {{/posts}}
        </tbody>
    </table>
</div>


{{>layout/footer}}

 

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

 

  • $('#btn-update').on('click')
    • btn-update란 id를 가진 HTML 엘리먼트에 click 이벤트가 발생할 때 update function을 실행하도록 이벤트를 등록
  • update : function ()
    • 신규로 추가될 update function
  • type:'PUT'
    • 여러 HTTP Method 중 PUT메소드를 선택
    • REST에서 CRUD는 아래와 같이 HTTP Method에 매핑됨
      • Create - POST
      • Read - GET
      • Update - PUT
      • Delete - DELETE

 

 

상황: index.mustache에서 a.js가 추가되어 a.js도 a.js만의 init과 save function이 있다면?

  • 브라우저의 스코프는 공용공간으로 쓰임
    • => 나중에 로딩된 js의 init, save가 먼저 로딩된 js의 function을 덮어쓰게 됨
  • 해결법
    • var.index라는 객체를 만들어 해당 객체에서 필요한 모든 function을 선언
      • index 객체 안에서만 function이 유효하기 때문에 다른 JS와 겹칠 위험이 사라짐

 

머스테치 문법

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

 

PostsRepository

public interface PostsRepository extends JpaRepository<Posts,Long> {

    @Query("Select p from Posts p order by p.id DESC ")
    List<Posts> findAllDesc();
}
  • SpringDataJpa에서 제공하지 않는 메소드는 쿼리로 작성해도 됨!

 

 

PostsListResponseDto

@Getter
public class PostsListResponseDto {
    private Long id;
    private String title;
    private String content;
    private String author;
    private LocalDateTime modifiedDate;

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

 

 

IndexController

@RequiredArgsConstructor
@Controller
public class IndexController {

    private final PostsService postsService;

    @GetMapping("/")
    public String index(Model model) { //Model: 서버 템플릿 엔진에서 사용할 수 있는 객체 저장
        model.addAttribute("posts", postsService.findAllDesc());
        return "index";
    }
  • Model
    • 서버 템플릿 엔진에서 사용할 수 있는 객체를 저장할 수 있음
    • postsService.findAllDesc()로 가져온 결과를 posts로 index.mustache에 전달하는 역할

 

 

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

 

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

 

 

 

IndexController 추가

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

 

PostsApiController 추가

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