개발자의 서재

Chapter.04 : 머스테치로 게시판 화면 구성하기 본문

SpringBootProject/SpringBoot_ Oauth_AWS

Chapter.04 : 머스테치로 게시판 화면 구성하기

ironmask431 2022. 2. 11. 23:00

"스프링부트와 AWS로 혼자 구현하는 웹 서비스" 라는 책을 바탕으로 학습목적의 프로젝트를 진행하고 있습니다. 

 

소스 : https://github.com/ironmask431/springboot_aws_01

Chapter 04. 머스테치(mustache)로 화면구성하기

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

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

ex) jsp, Thymeleaf (서버템플릿엔진)  리액트, 뷰 (클라이언트 템플릿엔진)

 

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

반면, 자바스크립트는 브라우저위에서 작동함.  vue나 react를 이용한 SPA는 브라우저에서 화면을 생성함.

 

머스테치란?

jsp, Thymeleaf 와 같은 템플릿엔진.

 

머스테치의 장점

1. 문법이 다른 템플릿엔진보다 심플함. 

2. 로직코드를 사용할수없어 view와 서버의 역할이 명확하게 분리됨

3. Mustache.js 와 Mustache.java 두가지가 다 있어 클라이언트/서버템플릿 모두 사용가능

 

머스테치 플러그인 설치 > 완료 

4.2 기본페이지 만들기 

build.gradle 에 머스테치 스타터 라이브러리 추가 (머스테치는 스프링부트 공식 지원 템플릿)

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

머스테치의 파일 위치는 기본적으로 아래 와같다. 

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>
    <h4>스프링부트로 시작하는 웹서비스</h4>
</body>
</html>

 

IndexController.java 생성

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

머스테치 스타터로 인해 url의 앞의 경로와  뒤의 파일 확장자는 자동으로 지정됨. 

(앞의 경로 :  src/main/resources/templates)

(뒤의 파일확장자 : .mustache)

index.mustache 로 전환되어 viewResolver가 처리하게됨.

 

IndexControllerTest.java 파일 생성

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

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    public void 메인페이지_로딩(){
        //when
        String body = restTemplate.getForObject("/",String.class);
        //then
        //html 전체를 확인할 필요는 없으니 일부내용만 있는지 확인
        assertThat(body).contains("스프링부트로 시작하는 웹서비스");
    }
}

==> test passed 정상확인

 

Application.java에서 main 구동 후 localhost:8080 에서 확인 

==> 정상 확인

4.3 게시글 등록화면 만들기

header.mustache 생성 (부트스트랩 stylesheet 참조)

<!DOCTYPE HTML>
<html>
<head>
    <title>스프링부트 웹서비스</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <link rel="stylesheet" href="http://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="http://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
</body>
</html>

** css stylesheet는 header에,  js는 footer에 넣은 이유는 페이지 로딩속도를 높이기 위해서임.

html은 위에서부터 코드가 실행되기 때문에 head가 다 실행되고 나서 body가 실행된다.

즉, head가 다 불러지지않으면 사용자 화면은 백지만 노출된다. 

특히 head에서 조회하는 js 의 용량이 크면 클수록 body 실행이 늦어지므로, js는 하단에 두는 게 좋다. 

반면 css는 head에서 먼저불러오지 않으면, 사용자가 css가 미적용된 깨진화면을 볼수있으므로 상단에 둔다. 

 

index.mustache 변경 + 글 등록페이지 버튼 추가 

{{>layout/header}}
    <h4>스프링부트로 시작하는 웹서비스</h4>   
    <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  에 글 저장 매핑url 생성

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

posts-save.mustache 생성

{{>layout/header}}
    <h4>게시글 등록</h4>
    <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>
                    <input type="text" class="form-control" id="content" placeholder="내용을 입력하세요.">
                </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}}

/src/main/resources/static/js/app 디렉토리생성

index.js 파일 생성

var index = {
    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));
        })
    }
} //index

main.init();

* 별도로 var index = { } 을 선언하고 내부에 init()와 save() function 을 속성으로 둔 이유

브라우저의 스코프(scope)는 공용공간으로 쓰이기 때문에 나중에 로딩된 다른 js에도 init(), save()가 있다면

먼저로딩된 js의 function들을 덮어쓰게됨.  여러사람이 참여하는 프로젝트는 중복된 함수명이 발생할 수 있음.

모든 함수명을 일일히 확인할 수 없으므로, 이렇게 index.js만의 유효범위 var index = { } 을 만들어 사용하는것. 

 

footer.mustache 에 index.js 링크

<script src="/js/app/index.js"></script>

스프링부트는 기본적으로 /src/main/resources/static 에 위치한 자바스크립트, css, 이미지등 정적파일들

url 에서 "/"로 설정된다. 

 

localhost:8080/ 에서 게시글등록화면, 게시글 등록 테스트 

==> 정상 확인

게시글 등록 화면
등록 완료

localhost:8080/h2-console 에 접속해서 실제 DB에 데이터가 등록되는지 확인함.

4.3 전체 조회화면 만들기

index.mustache 에 목록 영역 추가 

<!-- 글 목록 -->
<table class="table table-horizontal table-bordered">
    <thead class="thead-strong">
        <tr>
            <th>게시글번호</th>
            <th>제목</th>
            <th>작성자</th>
            <th>최종수정일1</th>
        </tr>
    </thead>
    <tbody id="tbody">
    {{#posts}}
        <tr>
            <td>{{id}}</td>
            <td>{{title}}</td>
            <td>{{content}}</td>
            <td>{{modifiedDate}}</td>
        </tr>
    {{/posts}}
    </tbody>
</table>

* {{posts}} ~ {{/posts}} : posts 라는 List를 for문 순회함

* {{id}} - List for문 순회중 내부 객체의 필드값 출력 

 

PostsListResponseDto.java 생성

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

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

Repository.java에 findAllDesc() 코드 추가 

//findAll()은 jpa기본제공이지만, SpringDataJpa 에서 제공하지 않는 메소드는
//아래처럼 @Query로 실제쿼리로 작성하는예시를 보여줌.
//구동시 Validation failed for query for method public abstract java.util.List .. 에러
//nativeQuery = true 를 추가해줘야함.
@Query(value = "SELECT * FROM Posts p ORDER BY p.ID DESC", nativeQuery = true)
List<Posts> findAllDesc();

* 규모가 큰 프로젝트의 데이터조회는 조인, 복잡한 조건등으로 entity클래스만으로 조회하기는 어려워서

조회용 프레임워크를 씀. 대표적인 예시로 querydsl , jooq, Mybatis 등이 있음. 조회는 프레임워크를 쓰고,

등록/수정/삭제는 SpringDataJpa를 통해 진행한다.  (쿠팡,배민등 jpa를 적극적으로 쓰는 회사는querydsl  사용)

 

PostsService.java 에 findAllDesc() 코드추가

//@Transactional(readOnly = true) : readOnly = true를 추가하면 트랜잭션 범위는 추가하되,
//조회기능만 남겨두어 조회속도가 개선됨. 등록,수정,삭제가 없는 서비스메소드에 사용하는것을 추천
@Transactional(readOnly = true)
public List<PostsListResponseDto> findAllDesc(){
    return postsRepository.findAllDesc().stream()
        .map(posts -> {return new PostsListResponseDto(posts);}).collect(Collectors.toList());
    //postsRepository의 결과 List<posts>의 stream 을 map을 통해 
    //PostsListResponseDto 변환 -> List로 변환
    //.findAllDesc() 대신 .findAll() 를 사용해도 정상동작 (findAll()은 jpa기본 메소드)
}

 IndexController.java 에 index() 수정

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

브라우저에서 글 작성 후 글 목록 보이는 것 확인

4.4 게시글 수정/삭제 화면 만들기

posts-update.mustache 생성

{{>layout/header}}
<h4>게시글 수정</h4>
<div class="col-md-12">
    <div class="col-md-4">
        <form>
            <div class="form-group">
                <label for="id">번호</label>
                <input type="text" class="form-control" id="id" value="{{posts.id}}" readonly>
            </div>
            <div class="form-group">
                <label for="title">제목</label>
                <input type="text" class="form-control" id="title" value="{{posts.title}}">
            </div>
            <div class="form-group">
                <label for="author">작성자</label>
                <input type="text" class="form-control" id="author" value="{{posts.author}}" readonly>
            </div>
            <div class="form-group">
                <label for="content">내용</label>
                <textarea class="form-control" id="content">{{posts.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, delete function 추가

init : function(){
    var _this = this;
    .... 
    $('#btn-update').on('click',function(){
        _this.update();
    });
    $('#btn-delete').on('click',function(){
            _this.delete();
    });
},

....
update : function(){
    var id  = $('#id').val();
    var data = {
        title:$('#title').val(),
        content:$('#content').val()
    };
    $.ajax({
        type:'PUT'
        ,url:'/api/v1/posts/'+id
        ,dataType:'json'
        ,contentType:'application/json; charset=utf-8'
        ,data:JSON.stringify(data)
    })
    .done(function(){
        alert('수정되었습니다.');
        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('삭제되었습니다.');
        location.href='/';
    })
    .fail(function(error){
        alert(JSON.stringify(error));
    })
}

index.mustache 수정 (목록에서 제목 클릭 시 수정페이지 이동)

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

IndexController.java 에 수정페이지 url mapping 생성

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

브라우저에서 수정 확인 

PostsService.java 에 delete() 추가

@Transactional
public void delete(Long id){
    Posts posts = postsRepository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id="+id));
    postsRepository.delete(posts);
    //.deleteById(id) 로 id로도 삭제할 수 있음.
    //존재하는 Posts인지 먼저확인하기위해 findById()로 조회후 delete() 실행
}

PostsApiController.java 에 delete() 추가

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

브라우저 삭제 테스트 실행  정상. 

Comments