[스프링 부트] Chapter 04

두두·2023년 2월 3일
0
post-thumbnail

이 글은 이동욱 님의 '스프링 부트와 AWS로 혼자 구현하는 웹 서비스' 책 내용을 정리한 것입니다.

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


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

템플릿 엔진

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

서버 템플릿 엔진 = JSP, Freemarker
클라이언트 템플릿 엔진 = React, Vue
자바스크립트가 작동하는 영역과 JSP가 작동하는 영역이 다르다. JSP를 비롯한 서버 템플릿 엔진은 서버에서 구동된다!

서버 템플릿 엔진을 이용한 화면 생성
➡️ 서버에서 Java 코드로 문자열 을 만든 뒤 HTML로 변환하여 브라우저로 전달

자바스크립트
➡️ 브라우저 위에서 작동
자바스크립트 코드가 실행되는 장소 = 서버X 브라우저O


머스테치란?

  • 수많은 언어를 지원하는 가장 심플한 템플릿 엔진

루비,자바스크립트,파이썬,PHP,자바,펄,Go,ASp등 현존하는 대부분 언어를 지원하고 있다.
자바에서 사용될 때는 서버템플릿 엔진으로 자바스크립트에서 사용될때는 클라이언트 템플릿 엔진으로 모두 사용할 수 있다.

자바 진영에는 JSP,Velocity,Freemarker,Thymleaf등 다양한 서버 템플릿 엔진이 존재한다.

다른 템플릿 엔진들의 단점
JSP,Velocity : 스프링 부트에서는 권장하지 않음
Freemarker : 너무 과하게 많은 기능 지원, 숙련도 낮을 수록 비지니스 로직 추가 확률 높음
Thymleaf : 스프링 진영에서 적극적으로 밀고 있지만 문법이 어려움. HTML 태그에 속성으로 템플릿 기능을 사용하는 방식을 사용


머스테치의 장점

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

머스테치 플러그인 설치



4.2. 기본 페이지 만들기

의존성 등록

build.gradle에 머스테치 스타터 의존성을 등록

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

머스테치의 파일 위치는 기본적으로 src/main/resources/templates !
이 위치에 머스테치 파일을 두면 스프링 부트에서 자동으로 로딩한다.


index.mustache

간단하게 h1 크기로 "스프링 부트로 시작하는 웹서비스" 를 출력하는 페이지

<!DOCTYPE HTML>
<html>
<head>
    <title> Spring Boot Web Service </title>
    <meta http-equiv="Content-Type" content="text/html;charset=UTF-8"/>


</head>
<body>
    <h1> Web Service starting from spring boot</h1>
</body>

</html>

IndexController.java

이 머스테치에 URL을 매핑
URL 매핑은 Controller에서 진행함!

@Controller
public class IndexController {

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

머스테치 스타터 덕분에 컨트롤러에서 문자열을 반환할 때 앞의 경로와 뒤의 파일 확장자는 자동으로 지정된다.
여기서는 "index"를 반환하므로 src/main/resources/templates/index.mustache 로 전환되어 View Resolver가 처리하게 된다.

IndexControllerTest.java (테스트)

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

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = RANDOM_PORT)
public class IndexControllerTest extends TestCase {
    @Autowired //의존성을 주입
    private TestRestTemplate restTemplate;

    @Test
    public void main_page_loading(){
        //when
        String body = this.restTemplate.getForObject("/", String.class);

        //then
        assertThat(body).contains("Web Service starting from spring boot");
    }
}
  • TestRestTemplate을 통해 "/"로 호출했을 때 index.mustache에 포함된 코드들이 있는지 확인
    전체 코드를 다 검증할 필요는 없으니 해당 문자열이 포함되어 있는지만 비교한다.

실행화면

http://localhost:9090/



4.3. 게시글 등록 화면 만들기

부트스트랩을 이용하여 화면을 만들 것!

프론트 엔드 라이버러리를 사용할 수 있는 방법

  • 외부 CDN 사용 ✅ 우리가 사용할 것! (매우 간단)
  • 직접 라이브러리를 받아서 사용

2개의 라이브러리를 레이아웃 방식으로 index.mustache에 추가


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>

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

페이지 로딩속도를 높이기 위해 css는 header에,js는 footer에 둠
HTML은 위에서부터 코드가 실행되기 때문에 header가 다 실행되고서야 body가 실행됨.

즉, header가 다 불러지지 않으면 사용자 쪽에서는 백지 화면만 노출됨. 특히 js의 용량이 크면 클수록 body 부분의 실행이 늦어지기 때문에 js는 body 하단에 두어 화면이 다 그려진 뒤에 호출하는 것이 좋음!

반면 css는 화면을 그리는 역할이므로 head에서 불러오는 것이 좋음.
그렇지 않으면 css가 적용되지 않은 깨진 화면을 사용자가 볼 수 있기 때문!
추가로, bootstrap.js는 제이쿼리가 꼭 있어야만 하기 때문에 부트스트랩보다 먼저 호출되도록 코드를 작성한다.


index.mustache 수정

라이브러리를 비롯해 기타 HTML 태그들이 모두 레이아웃에 추가되므로 다음과 같이 index.mustache에는 필요한 코드만 남게 됨

//{{>layout/header}} : {{>}}는 현재 머스테치 파일을 기준으로 다른 파일을 가져옴

{{>layout/header}}

<h1> 스프링 부트로 시작하는 웹 서비스</h1>

{{>layout/footer}} 

레이아웃으로 파일을 분리하였으니
✅글 등록 버튼 을 하나 추가

{{>layout/header}}

<h1> 스프링 부트로 시작하는 웹 서비스</h1>
<div class = "col-md-12">
    <div class = "row">
        <a href="/posts/save" role = "button" class = "btn btn-primary">글 등록</a>
    </div>
</div>
{{>layout/footer}}

글 등록 버튼을 누르면 /posts/save 로 이동함
이 주소로 이동할 컨트롤러를 생성하기!

IndexController.java 수정

/posts/save 에 해당하는 컨트롤러를 생성
페이지에 관련된 컨트롤러는 모두 IndexController를 사용

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

/posts/save를 호출하면 posts-save.mustache를 호출함


posts-save.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 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}}

실행화면

여기까지 하면 게시글 등록 화면은 다음과 같이 완성된다
(http://localhost:9090/posts/save)


하지만 등록 버튼의 기능이 없는 상태!
➡️ API를 호출하는 JS가 전혀 없음!
➡️ src/main/resource에 static/js/app 디렉토리를 생성하고
아래의 js파일 생성하기


index.js

src/main/resource/static/js/app

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();
  • window.location.href='/' : 글 등록이 성공하면 메인페이지(/)로 이동


footer.mustache 수정

생성된 index.js를 머스테치 파일이 쓸수있게 footer.mustach에 추가

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

스프링 부트는 기본적으로 src/main/resources/static에 위치한 자바스크리브,CSS, 이미지 등 정적파일들은 URL에서 /로 설정됨


실행화면



4.4 전체 조회 화면 만들기


<!-- 목록 출력 영역 -->

<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}}</td>
            <td>{{author}}</td>
            <td>{{modifiedDate}}</td>
        </tr>
    {{/posts}}
    </tbody>
</table>
  • {{#posts}}
    posts라는 List를 순회한다. for문과 동일

  • {{#id}}등의 변수명
    List에서 뽑아낸 객체의 필드를 사용


Controller,Service,Repository 코드 작성하기

PostRepostiroy 인터페이스

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

PostService.java

	@Transactional(readOnly = true)
    public List<PostsListResponseDto> findAllDesc(){
        return postsRepository.findAllDesc().stream()
                .map(PostsListResponseDto::new)
                .collect(Collectors.toList());
    }
  • @Transactional(readOnly=true)
    readOnly를 true로 주면 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도가 개선되기 때문에 추천

  • .map(PostsListResponseDto::new)
    실제로 .map(posts->new PostsListResponseDto(posts))와 같음
    postRepository로 넘어온 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.author = entity.getAuthor();
        this.title = entity.getTitle();
        this.modifiedDate = entity.getModifiedDate();
    }
}

IndexController.java

    @GetMapping("/")
    public String index(Model model){
        model.addAttribute("posts",postsService.findAllDesc());
        return "index";
    }
  • Model
    서버 템플릿 엔진에서 사용할 수 있는 객체를 저장
    여기서는 postsService().findAllDesc()에서 가져온 결과를 posts로 index.mustache에 전달


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


게시글 수정

API

    @PutMapping("/api/v1/posts/{id}")
    public Long update(@PathVariable Long id,@RequestBody PostsUpdateRequestDto requestDto){
        return postsService.update(id,requestDto);
    }

posts-update.mustache

{{>layout/header}}
<h1>게시글 수정</h1>

<div class = "col-md-14">
    <div class = "col-md-4">
        <form>
            <div class="form-group">
                <label for = "id">글 번호</label>
                <input type="text" class = "form-control" id = "id" value = "{{posts.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}}

index.js 수정

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

게시글 삭제

posts-update.mustache

<div class="col-md-12">
    <div class="col-md-4">
        ...
        <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>

index.js

var main = {
    init : function () {
        ...
 
        $('#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));
        });
    }
};
 

삭제 API

@RequiredArgsConstructor
@Service
public class 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인지 확인을 위해 엔티티 조회 후 그대로 삭제

PostApiController.java

@RequiredArgsConstructor
@RestController
public class PostApiController {
    ...
 
    @DeleteMapping("/api/v1/posts/{id}")
    public Long delete(@PathVariable Long id) {
        postsService.delete(id);
        return id;
    }
}
profile
멋쟁이가 될테야

0개의 댓글