ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 4장 ) 머스테치로 화면 구성하기
    Java & Spring/스프링 부트와 AWS로 혼자 구현하는 웹 서비스 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 .another_category { border: 1px solid #E5E5E5; padding: 10px 10px 5px; margin: 10px 0; clear: both; } .another_category h4 { font-size: 12px !important; margin: 0 !important; border-bottom: 1px solid #E5E5E5 !important; padding: 2px 0 6px !important; } .another_category h4 a { font-weight: bold !important; } .another_category table { table-layout: fixed; border-collapse: collapse; width: 100% !important; margin-top: 10px !important; } * html .another_category table { width: auto !important; } *:first-child + html .another_category table { width: auto !important; } .another_category th, .another_category td { padding: 0 0 4px !important; } .another_category th { text-align: left; font-size: 12px !important; font-weight: normal; word-break: break-all; overflow: hidden; line-height: 1.5; } .another_category td { text-align: right; width: 80px; font-size: 11px; } .another_category th a { font-weight: normal; text-decoration: none; border: none !important; } .another_category th a.current { font-weight: bold; text-decoration: none !important; border-bottom: 1px solid !important; } .another_category th span { font-weight: normal; text-decoration: none; font: 10px Tahoma, Sans-serif; border: none !important; } .another_category_color_gray, .another_category_color_gray h4 { border-color: #E5E5E5 !important; } .another_category_color_gray * { color: #909090 !important; } .another_category_color_gray th a.current { border-color: #909090 !important; } .another_category_color_gray h4, .another_category_color_gray h4 a { color: #737373 !important; } .another_category_color_red, .another_category_color_red h4 { border-color: #F6D4D3 !important; } .another_category_color_red * { color: #E86869 !important; } .another_category_color_red th a.current { border-color: #E86869 !important; } .another_category_color_red h4, .another_category_color_red h4 a { color: #ED0908 !important; } .another_category_color_green, .another_category_color_green h4 { border-color: #CCE7C8 !important; } .another_category_color_green * { color: #64C05B !important; } .another_category_color_green th a.current { border-color: #64C05B !important; } .another_category_color_green h4, .another_category_color_green h4 a { color: #3EA731 !important; } .another_category_color_blue, .another_category_color_blue h4 { border-color: #C8DAF2 !important; } .another_category_color_blue * { color: #477FD6 !important; } .another_category_color_blue th a.current { border-color: #477FD6 !important; } .another_category_color_blue h4, .another_category_color_blue h4 a { color: #1960CA !important; } .another_category_color_violet, .another_category_color_violet h4 { border-color: #E1CEEC !important; } .another_category_color_violet * { color: #9D64C5 !important; } .another_category_color_violet th a.current { border-color: #9D64C5 !important; } .another_category_color_violet h4, .another_category_color_violet h4 a { color: #7E2CB5 !important; }
      • 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에 대해서도 아주 조금은 더 가까워지고 있는 것 같지만, 여전히 왜 이렇게 되는지 모르는 경우도 존재한다.

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

    반응형

    댓글

Designed by Tistory.