BackEnd/Spring

Spring Study 1주차 ~ 4장

Bubbles 2022. 3. 31. 17:03

"머스테치로 화면 구성하기"

- 서버 템플릿 엔진과 클라이언트 템플릿 엔진의 차이는 무엇인가?

- 왜 JSP말고 머스테치?

- 머스테치를 이용한 CRUD화면 개발 방법


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

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

서버 템플릿 엔진 : JSP, Freemaker 등

클라이언트 템플릿 엔진 : React, Vue의 view파일 등

 

✅ 서버 템플릿 엔진을 이용한 화면생성은 서버에서 JAVA코드로 문자열을 만든뒤, 이 문자열을 HTML로 변환하여 브라우저로 전달. 클라이언트 템플릿 엔진은 브라우저 위에서 화면을 생성하기에 이미 서버에서 코드가 벗어난 경우임. 

 

✅ 머스테치란?

다양한 언어를 지원하는 템플릿 엔진, 문법이 다른 엔진에 비해 심플하고 로직 코드를 사용할 수 없기에 서버와 View가 완전히 분리. (화면 역할에만 충실할 수 있음)

 


4.2 기본 페이지 만들기

우선 머스테치 플러그인을 설치하고, 의존성을 build.gradle에 등록한다. 

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

- web/IndexController : mustache는 앞의 경로와 뒤의 파일 확장자를 자동으로 지정해준다. 

@Controller
public class IndexController {
    @GetMapping("/")
    public String index(Model model) {
        return "index"; // 문자열을 반환할 때 앞의 경로, 뒤 확장자는 자동지정됨.
        // 즉, src/main/resources/templates 까지는 자동, return 문의 index, 그 뒤 다시 자동으로 .mustache 확장자
    }
}

즉 위 코드는, src/main/resources/templates가 지정이 되어 있고, return되는 index가 경로에 붙은다음 확장자 .mustache는 다시 자동으로 붙는다. 이 경로를 View Resolver가 처리하게 된다. 

 

- IndexControllerTest

package firstSpring.practice.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;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = RANDOM_PORT)
public class IndexControllerTest {
    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    public void 메인페이지_로딩() {
        String body = this.restTemplate.getForObject("/", String.class);
        assertThat(body).contains("스프링 부트로 시작하는 웹 서비스");
    }
}

메인 메소드를 실행하고 localhost:8080 접속하면 확인해볼 수 있다.

 

 


4.3 게시글 등록 화면 만들기

 

부트스트랩을 레이아웃 방식으로 추가하여 사용. 

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

 

이런 디렉토리 구조

- 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>
</body>
</html>

 

✅ 페이지 로딩 속도를 높이기 위해 css는 header, js는 footer에 둔다. HTML은 head가 다 실행된 후에 body가 실행되기 때문이다. 

 

이렇게 레이아웃 틀을 만들어두었기 때문에 index.mustache코드는 간결해진다. 

<!DOCTYPE HTML>
<html>
{{>layout/header}}
<h1> 스프링 부트로 시작하는 웹 서비스</h1>
{{>layout/footer}}

그리고 글을 등록하기 위한 버튼도 추가해보자. <h1>태그와 footer사이에 추가. 

<div class="row">
        <div class="col-md-6">
            <a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
        </div>
    </div>

<a> 태그를 이용해서 글 등록 버튼을 누르면 등록 페이지로 이동할 수 있다. 페이지에 관련된 작업들은 모두 IndexController를 사용한다. 

...
    @GetMapping("/posts/save")
    public String postsSave() {
        return "posts-save"; // posts/save 호출하면 posts-save.mustache 호출하는 메소드 추가
    }
...

✅ /posts/save호출하면 posts-save.mustache호출하는 메소드 추가. posts-save.mustache파일도 index.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 type="text" 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}}

여기까지 작성하고 다시 프로젝트를 실행해보면 아래처럼 글 등록 화면도 완성된다. 

이제 등록 버튼에 실제로 등록할 수 있게끔 API를 호출하는 코드를 작성한다. 

 

- 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 = '/'; : 성공시 메인 페이지(/)로 돌아간다

✅ 함수의 이름이 중복되는 경우가 자주 발생할 수 있으므로, index라는 변수의 속성으로 function을 추가함으로써 유효범위를 만들어 사용할 수 있다. 

✅ 머스테치 파일이 index.js를 사용할 수 있게끔, footer.mustache에도 추가해준다. 

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

 

 

테스트를 해보면 위처럼 글이 등록되었다는 alert가 뜨고, database에도 저장이 되어 있다. 

 


4.4 전체 조회 화면 만들기

- index.mustache를 다시 수정해보자

<!DOCTYPE HTML>
<html>
{{>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">
        <thread class="thread-strong">
            <tr>
                <th>게시글 번호</th>
                <th>제목</th>
                <th>작성자</th>
                <th>최종수정일</th>
            </tr>
        </thread>
        <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}}
</html>

✅ {{#posts}} : posts라는 List를 순회한다. for문의 기능을 한다고 생각하면 된다. 

✅ {{id}} : List에서 뽑아낸 객체의 필드. 

 

- PostsRepository

package firstSpring.practice.practice.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에서 제공하지 않는 메소드는 쿼리로 작성가능. Entity만으로 해결하기 어려운 복잡한 쿼리의 경우 직접 쿼리문을 작성하는 방안이 있다.

 

- PostsService에 추가

@Transactional(readOnly = true) // 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 속도 향상. 등록, 수정, 삭제 전혀 없는 메소드에서 사용 추천.
public List<PostsListResponseDto> findAllDesc() {
	return postsRepository.findAllDesc().stream()
    	.map(PostsListResponseDto::new) // posts -> new PostsListResponseDto(posts))의 람다식.
        .collect(Collectors.toList());
}

- PostsResponseDto

package firstSpring.practice.practice.web.dto;
import firstSpring.practice.practice.domain.posts.Posts;
import lombok.Getter;

@Getter
public class PostsResponseDto {
    private Long id;
    private String title;
    private String content;
    private String author;

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

- IndexController

@RequiredArgsConstructor
@Controller
public class IndexController {

    private final PostsService postsService;
    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("posts", postsService.findAllDesc());
        return "index"; // 문자열을 반환할 때 앞의 경로, 뒤 확장자는 자동지정됨.
        // 즉, src/main/resources/templates 까지는 자동, return 문의 index, 그 뒤 다시 자동으로 .mustache 확장자
    }
}

여기까지 코드를 완성하면 글 작성, 글 목록 2가지 기능이 구현되어 있다. 

 


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

마지막으로, 만들어 두었던 게시글 수정 API를 위한 화면 개발, 삭제 화면 및 삭제 API를 작성하면 기본 기능 구현이 끝난다. 

 

- 수정을 위한 posts-update.mustache

{{>layout/header}}
<h1>게시글 수정</h1>
<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="{{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}}

✅ readonly : Input태그에서 값을 수정하지 못하도록 만들려면 readonly를 추가하면 된다. id와 author은 수정할 수 없도록 readonly 속성을 부여

 

- index.js에 update function 추가

$('#btn-update').on('click', function() {
	_this.update();
});

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함수 실행

✅ 수정을 위해 PUT 메소드 이용, url에는 게시물 구분을 위해 경로에 id가 붙는다. 

 

- 수정 페이지로 이동하기 위한 index.mustache코드

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

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

 

아래 화면처럼 확인할 수 있다. 

 

 


마지막으로 게시글 삭제! 

- posts-update.mustache : 삭제 버튼 추가

<button type="button" class="btn btn-danger" id="btn-delete">삭제</button>

- index.js에 삭제 이벤트 추가

 $('#btn-delete').on('click', function() {
	_this.delete();
});

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); // deleteById(id)로 바로 삭제도 가능. 엔티티를 파라미터로 주는것도 가능.
}

- 마지막으로 PostsApiController에 삭제 API를 구현해주면 끝난다! 

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

 

- 삭제 버튼이 추가된 것을 확인할 수 있다. 

 


참고 책 : http://www.yes24.com/Product/Goods/83849117

 

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

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

www.yes24.com