[4. Spring boot] 머스테치로 화면 구성하기

박현우·2021년 3월 16일
0

Spring

목록 보기
4/11

보통 자바에서 웹 개발시 JSP를 이용해 진행을 합니다. 하지만 JSP는 Java문과 HTML문이 혼용되어 사용되므로 가독성도 떨어지고 코드가 복잡해집니다. 뿐만아니라, 개발시 차후 유지보수하기도 어렵고 시간도 오래걸립니다.
이러한 문제점을 해결하기 위해 사용하는 것이 템플릿 엔진입니다.


템플릿 엔진

템플릿 엔진이란?

템플릿 양식과 특정 데이터 모델에 따른 입력 자료를 합성하여 HTML등의 결과 문서를 출력하는 소프트웨어입니다.
템플릿 엔진의 사용으로 다음과 같은 이점이 있습니다.

  • HTML에 비해 간단한 문법으로 코드량을 줄일 수 있습니다.
  • 유지 보수에 용이하고 데이터만 바꾸는 경우가 많아 코드의 재사용성을 높입니다.

서버 사이드 템플릿 엔진 VS 클라이언트 사이드 템플릿 엔진

템플릿 엔진은 다시 서버, 클라이언트로 나뉘어 집니다.

서버사이드 템플릿 엔진

  • 서버에서 DB나 API에서 가져온 데이터를 정해진 Template에 넣고 HTML을 그려 클라이언트에게 던집니다.
  • 서버에서 구동되기 때문에 브라우저에서 작동하는 코드(JS 등)와 구별이 필요합니다.
  • Freemarker, Thymeleaf, JSP 등이 있습니다.

클라이언트 사이드 템플릿 엔진

  • HTML형태의 코드를 가지며, 동적으로 DOM을 그리게 해줍니다.
  • 브라우저에서 구동되기 때문에 서버에서는 데이터만 던지고 클라이언트에서 이것을 혼합해 화면을 구성합니다.
  • React, Vue 등이 있습니다.

머스테치

머스테치(Mustache)란?

수많은 언어를 지원하는 가장 심플한 템플릿 엔진입니다.
자바에서 사용되면 서버 템플릿 엔진, JS에서 사용되면 클라이언트 템플릿으로 사용할 수 있습니다.

다음은 머스테치의 장점입니다.

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

머스테치를 플러그인에서 설치하고 의존성을 등록합니다.

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>

다음과 같은 간단한 html을 작성하고 이 머스테치에 URL을 매핑합니다. URL 매핑은 Controller에서 합니다.

main/java/web/IndexController.java

@Controller
public class IndexController {

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

머스테치 스타터 덕분에 Controller에서 문자열을 반환할 때 앞의 경로와 확장자 명을 자동으로 붙여줍니다. 그래서 "index"만 반환해도 자동 전환되어 View Resolver가 처리하게 됩니다.


이제 테스트 코드를 작성해서 검증해 보겠습니다.

test/java/web/IndexControllerTest.java

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

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

TestRestTemplate를 통해 "/"로 호출했을 때 index.mustache에 포함된 코드들이 있는지 확인 합니다.


게시글 등록 화면 만들기

이번에는 화면을 만들겠습니다. HTML만을 이용해서 화면을 만들게 되면 정말 맛없습니다.
그래서 bootstrap, JQuery등의 프론트엔드 라이브러리를 외부 CDN을 이용하거나 직접 다운을 받아 사용합니다.

매번 프론트엔드 라이브러리를 머스테치 파일에 추가할 수 없으니 레이아웃 방식으로 추가합니다.

src/main/resources/templates/layout에 footer, header 머스테치 추가

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는 위에, js는 아래에 놓는게 좋습니다. 그리고 bootstrap의 경우 JQuery가 있어야 하기 때문에 먼저 호출되어야 하는 것은 JQuery입니다.

이렇게 header와 footer를 분리하면 index.mustache(혹은 .html)에는 필요한 코드만 남습니다.

index.mustache

{{>layout/header}} // 1.
<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}}
  1. {{>layout/header}}
    • 현재 파일을 기준으로 다른 파일을 가져옵니다. import라고 생각하면 될 것 같습니다.

직관적이고 코드도 확 줄어들었습니다. 등록 버튼을 하나 만들었고 이동할 페이지를 작성하기 위해 컨트롤러를 생성합니다.


IndexController.mustache

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

아까 만들었던 Controller에 메소드를 추가했습니다.
다음과 같이 HTTP에서 /posts/save 라는 요청이 들어오면 String을 리턴해주는 메소드입니다.
위에도 말씀드렸듯이, mustache의 스타터가 앞뒤로 경로와 확장자를 채워주기 때문에 posts-save.mastache 파일이 실행됩니다.

파일이 없으므로 index와 같은 위치에 파일을 만듭니다.


posts-save.mastache

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

모양이 나옵니다. 하지만 등록버튼을 눌러도 아무런 동작도 하지 않습니다.
API를 호출하는 JS가 없기 때문이죠.

src/main/resources/static/js/app 디렉토리에 index.js를 생성합니다.


src/main/resources/static/js/app/index.js

var main = { // 2.
    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 = '/'; // 1.
        }).fail(function (error) {
            alert(JSON.stringify(error));
        });
    }
}
  1. window.location.href = '/'
    • ''안의 주소로 이동합니다.(root)

var main = { ..

여러 사람이 프로젝트를 같이 하면 중복된 함수명이 생성될 수 있습니다. 파일 안의 JS와 공용 JS 파일을 혼용해서 쓰는 경우 특히 자주 발생 합니다.
중복 함수를 방지하기 위해 함수선언식이 아닌 일반 변수 선언식으로 사용합니다. 그리고 var index라는 객체를 만들어 해당 객체에서 메소드로 접근하는 방식을 사용합니다. 이렇게하면 JS의 허용 범위를 명확하게 판별할 수 있습니다.


js파일은 footer에 선언되어야 한다고 말씀드렸으니, footer에 방금 만든 js를 추가하겠습니다

<!--index.js 추가-->
<script src="/js/app/index.js"></script>

index.js 호출 코드는 '/' 로 시작합니다.
스프링 부트는 기본적으로 src/main/resources/static에 위치한 JS,CSS,images 등 정적 파일들은 URL에서 '/'로 설정됩니다.

/로 시작하는 경로를 더 만들고 싶으시면 ResoucesProperties.java에 classpath를 추가하면 됩니다.

private static final String[] CLASSPATH_RESOURCE_LOCATIONS = {
"classpath:/META-INF/resources/",
"classpath:/resources/",
"classpath:/static/",
"classpath:/public/" };

디렉토리 만들때 주의점

인텔리제이에서 디렉토리를 만들때 경로를 잘 적으시길 바랍니다. qwe.ad.zs 이런식으로 디렉토리를 만들고 실제로 확인하면 qwe/ad/zs가 아닌 qwe.ad.zs라는 디렉토리가 생성될 수 있습니다.



글을 등록하면 alert가 뜨고 h2에서 확인할 수 있습니다.
다음은 전체 조회 화면을 만들어 보겠습니다.


전체 조회 화면 만들기

조회 화면을 위해 index.mustache의 UI를 변경하겠습니다.

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>
            {{#userName}}
                Logged in as: <span id="user">{{userName}}</span>
                <a href="/logout" class="btn btn-info active" role="button">Logout</a>
            {{/userName}}
            {{^userName}}
                <a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
                <a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
            {{/userName}}
        </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}} // 1.
            <tr>
                <td>{{id}}</td> // 2.
                <td><a href="/posts/update/{{id}}">{{title}}</a></td>
                <td>{{author}}</td>
                <td>{{modifiedDate}}</td>
            </tr>
        {{/posts}}
        </tbody>
    </table>
</div>

{{>layout/footer}}
  1. {{#posts}}
    • posts 라는 Lists를 순회합니다.
    • for문입니다
  2. {{id}} 등의 {{변수명}}
    • List에서 뽑아낸 객체의 필드를 사용합니다.

그럼 Controller, Service, Repository 코드를 작성하겠습니다.

PostsRepository

@Query("SELECT p FROM Posts p ORDER BY p.id DESC")
    List<Posts> findAllDesc();

SpringDataJpa에서 제공하지 않는 메소드는 위처럼 쿼리로 작성해도 됩니다.


PostsService.java

@Transactional(readOnly = true)
    public List<PostsListResponseDto> findAllDesc() {
        return postsRepository.findAllDesc().stream()
                .map(PostsListResponseDto::new)
                .collect(Collectors.toList());
    }

어노테이션에 readOnly = true가 추가되었습니다. 이름에도 알 수 있듯, 트랜잭션 범위는 유지하되, 조회 기능만 남겨 조회속도를 개선시킵니다. 나머지 기능을 못하기 때문에 조회만 하는 메소드에 추가하도록 합시다.

.map(PostsListResponseDto::new)
.map(posts -> new PostsListResponseDto(posts))
두개의 코드는 같은 의미입니다.
postsRepository 결과로 넘어온 Posts의 Stream을 map을 통해 PostsListResponseDto로 변환하고 List로 반환하는 메소드입니다.


PostsListResponseDto.java

@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

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

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

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

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


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

수정 화면 mustache를 만들겠습니다.

<input type="text" class="form-control" id="id" value="{{post.id}}" readonly>
  1. {{post.id}}
    • mustache는 객체의 필드 접근 시 .으로 구분합니다.
    • Post 클래스의 id에 대한 접근입니다.
  2. readonly
    • input 태그에 읽기 가능만 허용합니다.(즉, 수정 불가)

버튼으로 요청을 받으면 기능을 호출해야 하기 때문에 js에서도 메소드를 추가합니다.

            $.ajax({
                type: 'PUT', // 1.
                url: '/api/v1/posts/'+id,
                dataType: 'json',
                contentType:'application/json; charset=utf-8',
                
  1. type: PUT
    • PUT메소드를 선택하는 이유는 PostsApiController에 있는 API에서 이미 @PutMapping으로 선언했기 때문입니다.
    • 이는 REST 규약에 맞게 설정된 것입니다.
      • C(생성) - POST
      • R(읽기) - GET
      • U(수정) - PUT
      • D(삭제) - DELETE

전체 목록에서 수정 페이지로 이동할 수 있게 페이지 이동 기능을 추가하겠습니다.

index.mustache

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

이제 Controller로 연결해줍니다.

IndexController.java

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

        return "posts-update";
    }

삭제의 추가는 update.mastache의 버튼 추가, PostsService에서의 API추가, PostsApiController에서의 메소드 추가로 update와 비슷합니다.

0개의 댓글