Zin0_0 2020. 7. 1. 01:26
반응형

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

  • 템플릿 엔진
    • 지정된 템플릿 양식과 데이터가 합쳐서 HTML 문서를 출력하는 소프트웨어
    • 서버 템플릿 엔진 : JSP, Freemarker -> 서버에서 구동 ~> 브라우저로 전달
    • 클라이언트 템플릿 엔진 : React, Vue -> 브라우저에서 화면 생성
    • JSP, Velocity : Spring-boot에서 권장 X
    • Freemarker - 과한 기능, 자유도가 높아서 숙련도가 낮을 수록 사용하기 힘들다.
    • Thymeleaf - Spring에서 강하게 밀고있지만, 문법이 어렵다.
  • 머스테치
    • 서버 템플릿엔진, 클라이언트 템플릿 엔진으로 모두 이용 가능 (다양한 언어 지원)
    • 장점
      • 문법이 간단하다.
      • View의 역할과 서버의 역할이 명확하게 분리된다.
      • Mustache.js와 Mustache.java 2가지가 다 있어서, 하나의 문법으로 클라이언트/서버 템플릿 모두 이용 가능
    • IntelliJ 에서 설치하기
      • [ Ctrl + Shift + A ] -> plugins -> Market Place -> Mustache 검색
        • Handlebars ~ 설치

기본 페이지 만들기

  • 의존성 등록하기

    compile('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 생성하기

    package com.zin0.book.springboot.web;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.web.bind.annotation.GetMapping;
    
    @Controller
    public class IndexController {
        @GetMapping("/")
        public String index(Model model) {
            model.addAttribute("posts", postsService.findAllDesc());
            return "index";
        }
    }
    • 머스테치 스타터 덕분에 문자열을 반환할 때, 앞의 경로와 뒤의 파일 확장자는 자동으로 지정된다.
    • 위의 경우 index를 기준으로 앞에는 src/main/resources/templates/ 가 붙고, 뒤에는 .mustache가 붙게 된다.
    • 변환된 주소는 View Resolver가 처리한다.
  • test 패키지에 IndexControllerTest 클래스 생성하기

    package com.zin0.book.springboot.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 메인페이지_로딩() {
            // when
            String body = this.restTemplate.getForObject("/", String.class);
    
            // then
            assertThat(body).contains("스프링 부트로 시작하는 웹 서비스");
        }
    }

게시글 등록 화면 만들기

  • 프론트엔드 라이브러리를 사용하는 방법
    • 외부 CDN을 사용
    • 직접 라이브러리를 받아서 사용 (npm,webpack 등도 여기에 해당)
    • 실제 서비스에서는 외부 서비스에 의존성을 줄이기 위해, 2번째 방법을 활용하지만, 지금은 토이 프로젝트가 목적이니까 외부 CDN을 이용함.
  • 부트스트랩과 제이쿼리를 레이아웃 방식으로 추가하기
    • 레이아웃 방식 - 공통 영역을 별도의 파일로 분리하여, 필요한 곳에서 가져다 쓰는 방식
    • src/main/resources/templates 디렉토리에 layout 디렉토리를 추가로 생성
    • 위의 디렉토리에 footer.mustache와 header.mustache를 생성한다. 스프링 부트 웹 서비스_Zin0
    • css를 상단에, js를 하단에 위치한 이유
      • 페이지 로딩 속도를 높이기 위해
      • head에 무거운 js 파일이 들어가면, 흰색 빈 화면이 오랫동안 보일 것임
      • 그렇다고 css도 하단에 같이 넣으면, 깨진 화면이 송출될 수 있음
  • index.mustache 변경하기
    {{>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>
        </div>
    {{>layout/footer}}
    • {{>layout/header}}
      • {{> }} - 현재 머스테치 파일을 기준으로 다른 파일을 가져온다.
  • IndexController 수정하기 (posts-save를 위한 수정)

    // import ...
    @RequiredArgsConstructor
    @Controller
    public class IndexController {
        // ...
    
        @GetMapping("/posts/save")
        public String postsSave() {
            return "posts-save"; // 앞서 말한대로 경로와 mustache가 붙어서 감
        }
    }
  • posts-save.mustache 생성

    {{>layout/header}}
    <h1>게시글 등록</h1>
    <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 herf="/" role="button" class="btn btn-secondary">취소</a>
        <button type ="button" class="btn btn-primary" id ="btn-save">등록</button>
    </div>
    
    {{>layout/footer}}
  • resources 하위에 static/js/app 디렉토리 생성 ~> 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라는 변수 속성으로 function을 추가한 이유

      • 브라우저의 스코프는 공용 공간이기 때문에, 나중에 로딩된 JS가 같은 함수명을 쓰고 있다면 덮어 쓰게 되기 때문(방지하기 위함)
      • 코드는 아까 상단에서 미리 추가를 해뒀다..
      • 스프링 부트는 기본적으로 src/main/resources/static에 위치한 JS, CSS, 이미지 등 정적 파일들은 URL에서 / (절대 경로)로 설정된다.

전체 조회 화면 만들기

  • index.mustached UI 변경
    {{>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>
            <!-- 목록 출력 Area-->
            <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}}</a></td>
                        <td>{{author}}</td>
                        <td>{{modifiedDate}}</td>
                    </tr>
                {{/posts}}
                </tbody>
            </table>
        </div>
    {{>layout/footer}}
    • {{#posts}}
      • posts라는 List를 순회한다 ~> for문이라고 생각하면 된다.
    • {{id}}
      • List에서 뽑아낸 객체의 필드를 사용 -> id, author, title 등
  • PostsRepository 인터페이스에 쿼리 추가하기

    package com.zin0.book.springboot.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> {   // JpaRepository<Entity 클래스, PK 타입> 상속 ~> CRUD 자동 생성
        @Query("SELECT p FROM Posts p ORDER BY p.id DESC")
        List<Posts> findAllDesc();
    }
    • SpringDataJpa 에서 제공하지 않는 메소드는 위처럼 쿼리로 작성해도 사용 가능하다.
    • 대규모 프로젝트에서는 조회용 프레임워크를 추가로 사용한다. -> Querydsl 추천
  • PostsService에 코드 추가하기

    // import ...
    @RequiredArgsConstructor
    @Service
    public class PostsService {
        private final PostsRepository postsRepository;
    
        //...
    
        @Transactional(readOnly = true) // readOnly에 error 뜨길래 뭐지하고 찾아보니까, import에 javax.Transactional 이 임포트 돼있었음.. springframework.transaction~ 이다..
        public List<PostsListResponseDto> findAllDesc() {
            return postsRepository.findAllDesc().stream().map(PostsListResponseDto::new).collect(Collectors.toList());
        }
    }
    • @Transactional (readOnly = true)
      • 트랜잭션 범위는 유지하되, 조회 기능만 남겨서 조회 속도가 개선되게 설정함
    • .map(PostsListResponseDto::new)
      • 람다식을 사용한 코드
      • 풀면 .map(posts -> new PostsListResponseDto(posts)) 와 같다
  • PostsListResponseDto 생성

    package com.zin0.book.springboot.web.dto;
    
    import com.zin0.book.springboot.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 변경

    • 상기에 작성한 IndexController가 이미 변경되어 있는 상태다.
    • 파라메터로 Model을 받아주었는데, 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="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 herf="/" role="button" class="btn btn-secondary">취소</a>
            <button type ="button" class="btn btn-primary" id ="btn-update">수정 완료</button>
        </div>
    </div>
    {{>layout/footer}}
    • {{post.id}}
      • 머스테치는 객체의 필드 접근 시 점(Dot)으로 구분
      • Post 클래스의 id에 대한 접근 ~> post.id로 사용
    • readonly
      • input 태그에 읽기 가능만 허용하는 속성
  • Index.js에 update function 추가하기

    var main = {
        init : function () {
            // ...
            $('#btn-update').on('click', function () {  _this.update(); });
    },
        save : function () {
            //...
        },
        update : function () {
            var data = {
                title: $('#title').val(),       // title을 읽어올 때, #을 빼주는 실수를 했었다..ㅠ
                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();
    • $('#btn-update').on('click')
      • btn-update란 id를 가진 HTML 엘리먼트에 click 이벤트가 발생할 때, update function 실행하도록 이벤트를 등록한다.
    • update : function()
      • 신규 추가 될 update function
    • url:'/api/v1/posts/'+id
      • 수정할 게시글을 URL PATH로 구분하기 위해 id를 추가
  • index.mustache 코드 수정
    • 위에서 이미 수정된 코드를 올림.
      <a href ="/posts/update/{{id}}"></a>
      • 타이틀(title)에 a 태그를 추가
      • 타이틀을 클릭하면 해당 게시글의 수정 화면으로 이동
  • IndexController에 update 추가하기

    // import ...
    
    @RequiredArgsConstructor
    @Controller
    public class 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에 추가)

    {{>layout/header}}
    <h1>게시글 수정</h1>
    <div class="col-md-12">
        <div class="col-md-4">
            <form>
             ...
            </form>
            <a herf="/" 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에 delete 추가하기

    var main = {
        init : function () {
            var _this = this;
            // ...
            $('#btn-delete').on('click', function () {  _this.delete(); });
    },
        save : function () {
            //...
        },
        update : function () {
            //...
        },
        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에 기능 추가하기

    // import ...
    @RequiredArgsConstructor
    @Service
    public class PostsService {
        // ...
    
        @Transactional
        public void delete(Long id) {
            Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id =" + id));
            postsRepository.delete(posts);
        }
    }
    • postRepository.delete(posts)
      • JpaRepositoy에서 지원하는 delete 메소드
      • 엔터티를 파라미터로 삭제할 수도 있고, deleteById 메소드를 이용하면 id로 삭제할 수도 있다.
      • 존재하는 Posts인지 확인을 위해 엔티티 조회 후 그대로 삭제
  • ApiController에 delete 추가하기

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

중간에 오타로 인해서 오류를 맞이해서 그런지, 시간이 오래걸렸다.

대신 오래 걸린 만큼 조금 더 이해하기 수월해진 것 같다.

spring-boot에 대해서도 아주 조금은 더 가까워지고 있는 것 같지만, 여전히 왜 이렇게 되는지 모르는 경우도 존재한다.

결론 : 원초적인 실수는 하지말자.. ㅠㅠ

반응형