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

김진욱·2022년 2월 14일
0
post-thumbnail

머스테치로 화면 구성하기

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

템플릿 엔진이란, 지정된 템플릿 양식과 데이터가 합쳐져 HTML문서를 출력하는 소프트웨어를 이야기한다.

  • 서버 템플릿 엔진: JSP, Freemarker, Thymeleaf 등
  • 클라이언트 템플릿 엔진: 리액트, 뷰 등

머스테치란
수많은 언어를 지원하는 가장 심플한 템플릿 엔진이다.

템플릿 엔진들의 단점

  • JSP, Velocity: 스프링 부트에서는 권장하지 않는 템플릿 엔진이다.
  • Freemarker: 템플릿 엔진으로는 너무 과하게 많은 기능을 지원한다. 높은 자유도로 인해 숙련도가 낮을수록 Freemarker 안에 비즈니스 로직이 추가될 확률이 높다.
  • Thymeleaf: 스프링 진영에서 적극적으로 밀고 있지만 문법이 어렵다.

머스테치의 장점

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

머스테치를 사용하기 위해서는 플러그인을 설치해야 한다.


4.2 기본페이지 만들기

build.gradle에 의존성을 등록하자

dependencies {
    implementation('org.springframework.boot:spring-boot-starter-mustache')
}

머스테치의 파일 위치는 기본적으로 src/main/resources/templates 이다.

src/main/resources/templates에 index.mustache를 생성하자
코드는 생략한다.

이 머스테치를 URL 매핑을 하기 위해 web 패키지 안에 IndexController를 생성하자

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public calss IndexController {

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

머스테치 스타터 덕분에 컨트롤러에 문자열을 반환할 때 앞의 경로와 뒤의 파일 확자자는 자동으로 지정된다. 앞의 경로는 src/main/resources/templates로, 뒤의 파일 확장자는 .mustache가 붙는다.

IndexControllerTest를 생성하자

package com.jojoldu.book.springboot.web;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.test.context.junit.jupiter.SpringExtension;

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class IndexControllerTest {

	@Autowired
	private TestRestTemplate restTemplate;

	@Test
	void 메인페이지_로딩() {
		//when
		String body = this.restTemplate.getForObject("/", String.class);

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

이 테스트는 실제로 URL 호출 시 페이지의 내용이 제대로 호출되는지에 대한 테스트다.


4.3 게시글 등록 화면 만들기

header.mustachefooter.mustachesrc/main/resources/templates/layout 경로에 생성한다.

참고
페이지 로딩 속도를 높이기 위해 css는 header에, js는 footer에 위치한다. HTML은 위에서부터 코드가 실행되기 때문에 head가 다 실행되고서야 body가 실행된다.
js는 body 하단에 두어 화면이 다 그려진 뒤에 호출하는 것이 좋다.

header와 footer가 있기 때문에 index.mustache의 코드가 변경된다.

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

-> 레이아웃으로 파일을 분리했으니 index.mustache에 글 등록 버튼을 추가하자.

코드생략

글 등록 버튼을 누르면 이동하는 페이지 주소는 /posts/save 이다.
이 주소에 해당하는 코드를 IndexController에 추가하자.

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

posts-save.mustache를 추가해야한다. 파일의 위치는 index.mustache와 동일하다.

코드생략

현재 게시글 등록 화면에 등록 버튼은 기능이 없다.
src/main/resources에 static/js/app 디렉토리를 생성하고 index.js를 생성하자.

코드생략

index.js를 머스테치 파일이 사용할 수 있게 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>

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

index.js 호출 코드를 보면 절대경로(/)로 바로 시작한다. 스프링 부트는 기본적으로 src/main/resources/static에 위치한 자바스크립트, CSS, 이미지 등 정적 파일들을 URL에서 /로 설정한다.


4.4 전체 조회 화면 만들기

전체 조회를 위해 index.mustache의 UI를 변경한다.

{{>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>
    </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}}
      <tr>
        <td>{{id}}</td>
        <td>{{title}}</a></td>
        <td>{{author}}</td>
        <td>{{modifiedDate}}</td>
      </tr>
    {{/posts}}
    </tbody>
  </table>
</div>

{{>layout/footer}}

머스테치의 문법

{{#posts}}

  • posts라는 List를 순회한다.
  • Java의 for문과 동일하게 생각하면 된다.

{{id}}등의 {{변수명}}

  • List에서 뽑아낸 객체의 필드를 사용한다.

기존에 있던 PostsRepository 인터페이스에 쿼리가 추가된다.

package com.jojoldu.book.springboot.domain.posts;

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface PostsRepository extends JpaRepository<Posts, Long> {

	@Query("select p from Posts p order by p.id desc")
	List<Posts> findAllDesc();
}

참고
Querydsl을 추천하는 이유

  • 타입 안정성이 보장된다.
    : 단순한 문자열로 쿼리를 생성하는 것이 아니라, 메소드를 기반으로 쿼리를 생성하기 때문에 오타나 존재하지 않는 컬럼명을 명시할 경우 IDE에서 자동으로 검출해준다.
  • 래퍼런스가 많다.

PostsService에 코드를 추가하자


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

@Transactional(readOnly = true)

  • 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도가 개선되기 때문에 등록, 수정, 삭제 기능이 전혀 없는 서비스 메서드에서 사용하는 것이 좋다.

PostsListResponseDto 를 추가하자.

package com.jojoldu.book.springboot.web.dto;

import com.jojoldu.book.springboot.domain.posts.Posts;
import java.time.LocalDateTime;
import lombok.Getter;

@Getter
public class PostsListResponseDto {

	private Long id;
	private String title;
	private String author;
	private LocalDateTime modifiedDate;

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

마지막으로 IndexController를 수정하자

package com.jojoldu.book.springboot.web;

import com.jojoldu.book.springboot.config.auth.LoginUser;
import com.jojoldu.book.springboot.config.auth.dto.SessionUser;
import com.jojoldu.book.springboot.service.posts.PostsService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
@RequiredArgsConstructor
public class IndexController {

	private final PostsService postsService;

	@GetMapping("/")
	public String index(Model model, @LoginUser SessionUser user) {
		model.addAttribute("posts", postsService.findAllDesc());

		return "index";
	}
}

Model

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

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

게시글 수정

posts-update.mustache 를 생성하자
경로는 src/main/resources/templates이다.

코드생략

{{post.id}}

  • 머스테치는 객체의 필드 접근 시 점(dot)으로 구분한다.

readonly

  • Input 태그에 읽기 기능만 허용하는 속성이다.

btn-update 버튼을 클릭하면 update 기능을 호출할 수 있게 index.js 파일에 update function을 추가하자.

코드생략

$('#btn-update').on('click')

  • btn-update란 id를 가진 HTML 엘리먼트에 click 이벤트가 발생할 때 update function을 실행하도록 이벤트를 등록한다.

전체 목록에서 수정페이지로 이동할 수 있게 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>
     </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}}
      <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}}

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

  • 타이틀(title)에 a tag를 추가한다.

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을 수정하자

코드생략


삭제 이벤트를 진행하기 위해 index.js를 수정하자

코드생략


PostsService 코드 추가

@Transactional
	public void delete(Long id) {
		Posts posts = postsRepository.findById(id)
			.orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));

		postsRepository.delete(posts);
	}
   

postsRepository.delete(posts)

  • JpaRepository에서 이미 delete 메서드를 지원하니 이를 사용하자.
  • 엔티티를 파라미터로 삭제할 수도 있고, deleteById 메서드를 이용하면 id로 삭제할 수도 있다.
  • 존재하는 Posts인지 확인을 위해 엔티티 조회 후 그대로 삭제한다.

PostsApiController 수정


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

이로써 수정/삭제 기능까지 완성되었다.

1개의 댓글

comment-user-thumbnail
2022년 6월 8일

전 이번장에서 버그가 제대로 걸려서 더이상 진행이 안되네요..
깃헙을 끼고살긴 했지만 혹시나해서 해당블로그 글을 처음부터 해당부분까지 코드비교해가며 다 읽어보고 해도 안되는거보면 여기까지인가 싶네요...후.....ㅠ

답글 달기

관련 채용 정보