Peony의 기록 창고 🌼
article thumbnail
반응형

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

http://www.yes24.com/Product/Goods/83849117

 

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

가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링

www.yes24.com

 

1. 템플릿 엔진과 머스테치

템플릿 엔진이란?

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

 

서버 템플릿 엔진 vs 클라이언트 템플릿 엔진

  • 공통점 : 지정된 템플릿과 데이터를 이용하여 HTML을 생성
  • 차이점
    • 서버 템플릿 엔진을 이용한 화면 생성은 서버에서 Java 코드로 문자열을 만든 뒤 이 문자열을 HTML로 변환하여 브라우저로 전달.
    • 자바스크립트는 브라우저 위에서 작동
      ⇒ 자바 스크립트 코드가 실행되는 장소는 서버가 아닌 브라우저 !
      ⇒ 브라우저에서 화면을 생성. 즉, 서버에서 이미 코드가 벗어난 경우!

 

머스테치란?

  • 수많은 언어를 지원하는 가장 심플한 템플릿 엔진으로 JSP와 같이 HTML을 만들어 주는 템플릿 엔진이다.
  • 자바에서 사용될 때는 서버 템플릿 엔진으로, 자바스크립트에서 사용될 때는 클라이언트 템플릿 엔진으로 모두 사용할 수 있다.
  • 장점
    • 문법이 다른 템플릿 엔진보다 심플하다.
    • 로직 코드를 사용할 수 없어 View의 역할과 서버의 역할이 명확하게 분리된다.
    • Mustache.js와 Mustache.java 2가지가 다 있어, 하나의 문법으로 클라이언트/서버 템플릿을 모두 사용 가능하다.

 

2. 기본 페이지 만들기

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

src/main/resources/templates/index.mustache

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="utf-8" >
    <meta http-equiv="Content-Type" content="text/html;" charset="UTF-8" />
    <title>스프링 부트 웹서비스</title>
</head>
<body>
<h1>스프링 부트로 시작하는 웹서비스</h1>
</body>
</html>

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

이 머스테치에 URL 매핑을 해보자. 매핑을 당연하게 Controller에서 진행한다.

 

web 패키지 안에 IndexController를 생성하자.

src/main/java/com/jojoldu/book/springboot/web/IndexController.java

package com.jojoldu.book.springboot.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class IndexController {
    @GetMapping("/")
    public String index(){
        return "index";
    }
}
  • 머스테치 스타터 덕분에 컨트롤러에서 문자열을 반환할 때 앞의 경로와 뒤의 파일 확장자는 자동으로 지정
    • 앞의 경로 : src/main/resources/templates
    • 뒤의 파일 확장자 : .mustache

 

이제 테스트 코드로 검증해보자.

src/test/java/com/jojoldu/book/springboot/web/IndexControllerTest.java

package com.jojoldu.book.springboot.web;

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

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

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

    @Autowired
    private TestRestTemplate restTemplate;

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

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

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

  • TestRestTemplate : Spring Boot에서 컨트롤러를 테스트하기 위해서 사용.

 

❓기존의 MockMvc도 컨트롤러를 테스트하는 라이브러리인데 무슨 차이가 있을까?

  • 서블릿 컨테이너의 실행 여부
    • MockMvc : 컨테이너를 실행하지 않는다.
    • TestRestTemplate : 컨테이너를 직접 실행시킨다.
  • 테스트의 관점의 차이
    • MockMvc : 서버의 입장에서 구현한 API를 통해 비지니스 로직에 문제가 없는지 테스트
    • TestRestTemplate : 클라이언트 입장에서 사용할 때 문제가 없는지 테스트

 

이제 실제로 화면이 잘 나오는지 확인해보자.

잘 나오는 것을 확인할 수 있다. 이제 게시글 등록 화면을 만들어보자.

 

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

부트스트랩, 제이쿼리 등 프론트엔드 라이브러리를 사용할 수 있는 방법은 2가지가 있다.

  1. 외부 CDN을 사용하는 방법
  2. 직접 라이브러리를 다운로드하는 방법

 

여기서는 CDN을 사용하겠다. 이번에 추가할 라이브러리들은 머스테치 화면 어디서나 필요하다. 매번 해당 라이브러리를 머스테치 파일에 추가하는 방법은 귀찮은 일이므로 레이아웃 파일을 만들어서 추가해주자.

src/main/resources/templates/layout/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>

 

src/main/resources/templates/layout/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의치가 서로 다르다. 이는 페이지 로딩 속도를 높이기 위해서다. HTML은 위에서부터 코드가 실행되기 때문에 head가 다 실행된 후에 body가 실행된다.

즉, head가 다 불러지지 않으면 body 부분의 실행이 늦어지기 때문에 js는 body 하단에 두어 호출하는 것이 좋다.

 

이제 index.mustache에 코드를 추가하고, 글 등록 버튼을 하나 추가해보자.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="utf-8" >
    <meta http-equiv="Content-Type" content="text/html;" charset="UTF-8" />
    <title>스프링 부트 웹서비스</title>
</head>
<body>
{{>layout/header}}

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

{{>layout/footer}}
</body>
</html>
  • {{> }} : 현재 머스테치 파일을 기준으로 다른 파일을 가져옴.
  • <a> 태그를 이용해 글 등록 버튼 생성
  • 이동할 페이지 주소 : /posts/save

 

이제 컨트롤러를 생성해보자. 페이지에 관련된 모든 컨트롤러는 모두 IndexController를 사용한다.

@RequiredArgsConstructor
@Controller
public class IndexController {

    private final PostsService postsService;
    ...

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

컨트롤러 코드가 생성되었다면, posts-save.mustache 파일을 생성한다.

 

src/main/resources/templates/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}}

UI가 완성되었으니, 프로젝트를 실행하고 브라우저에서 http://localhost:8080을 들어가 보자. 글 등록 화면으로 이동하면 화면이 보이지만, 아직 등록버튼은 기능이 없다. API를 호출하는 JS가 전혀 없기 때문이다. JS파일을 만들어보자.

 

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

 

index.js에서 var main={}이라는 코드를 쓰는 이유?

  • 여러 사람이 참여하는 프로젝트에서는 중복된 함수 이름이 자주 발생할 수 있는데, 이런 문제를 피하려고 유효 범위(scope)를 만들어 사용
  • 이렇게 하면 index 객체 안에서만 function이 유효하기 때문에 다른 js와 겹칠 위험이 사라진다.

 

이제 생성된 index.js를 footer.mustache에 추가해보자.

src/main/resources/templates/layout/footer.mustache

...

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

코드를 보면 절대 경로로 바로 시작한다. 스프링 부트는 기본적으로 src/main/resourse/static에 위치한 자바스크립트. CSS, 이미지 등 정적 파일은 URL에서 /로 설정된다.

 

이제 등록 기능을 직접 테스트해보자.

등록 버튼을 클릭하면 다음과 같이 "글이 등록되었습니다. "라는 Alert이 노출된다.

 

localhost:8080/h2-console에 접속해서 실제로 DB에 데이터가 등록되었는지도 확인하자.

 

4. 전체 조회 화면 만들기

전체 조회를 위해 index.mustache의 UI를 변경해보자.

...
{{>layout/header}}
        <h1>스프링 부트로 시작하는 웹 서비스 Ver.2</h1>
    <div class="col-md-12">
    ...
    </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>
{{>layout/footer}}
</body>
</html>
  • {{#posts}}
    • posts라는 List를 순회
    • Java의 for문과 동일하게 생각
  • {{id}} 등의 {{변수명}}
    • List에서 뽑아낸 객체의 필드를 사용

 

기존에 있던 PostsRepository 인터페이스에 쿼리를 추가해보자.

src/main/java/com/jojoldu/book/springboot/domain/posts/PostsRepository.java

package com.jojoldu.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> {
    @Query("SELECT p FROM Posts p ORDER BY p.id DESC")
    List<Posts> findAllDesc();
}

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

 

이제 PostsService에 코드를 추가해보자.

src/main/java/com/jojoldu/book/springboot/service/PostsService.java

...
import java.util.List;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@Service
public class PostsService {
    private final PostsRepository postsRepository;

      ...

    @Transactional(readOnly = true)
    public List<PostsListResponseDto> findAllDesc() {
        return postsRepository.findAllDesc().stream()
                .map(PostsListResponseDto::new)
                .collect(Collectors.toList());
    }
}
  • readOnly
    • 트랜잭션 어노테이션(@Transactional)의 옵션
    • 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도가 개선됨
    • 등록, 수정, 삭제 기능이 없는 메소드에 추천
  • .map(PostsListResponseDto::new)
    • = .map(posts -> new PostsListResponseDto(posts))
    • postRepository 결과로 넘어온 Posts의 Stream을 map을 통해 PostsListResponseDto로 변환 → List로 반환하는 메소드

 

아직 PostsListResponseDto 클래스가 없기 때문에 만들어주자.

src/main/java/com/jojoldu/book/springboot/web/dto/PostsListResponseDto.java

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

import com.jojoldu.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();
    }
}

 

마지막으로 Controlelr를 변경하자.

...
import org.springframework.ui.Model;
...

@RequiredArgsConstructor
@Controller
public class IndexController {

    private final PostsService postsService;

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

 

이제 실행시켜 하나의 데이터를 등록해보자. 그럼 다음과 같이 목록 기능이 정상적으로 작동하는 것을 확인할 수 있다.

 

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

게시글 수정 API는 이미 3장에서 만들었다.

public class PostApiController {
    private final PostsService postsService;

    ...

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

}

해당 API로 요청하는 화면을 개발해보자.

 

게시글 수정

src/main/resources/templates/posts-update.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="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 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}}
  • {{post.id}}
    • 객체의 필드 접근 시 점으로 구분
    • Post 클래스의 id에 대한 접근은 post.id로 사용
  • readonly
    • input 태그에 읽기 기능만 허용하는 속성
    • id와 author는 수정할 수 없도록 읽기만 허용하도록 추가

 

index.js 파일에도 update function을 하나 추가해주자.

var main = {
    init : function () {
        var _this = this;
        ...

        $('#btn-update').on('click', function () {
            _this.update();
        });
    },
    save : function () {
        ...
        });
    },
    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));
        });
    }
};

main.init();
  • $('#btn-update').on('click')
    • btn-update란 id를 가진 HTML 엘리먼트에 click 이벤트가 발생할 때 update function을 실행하도록 이벤트를 등록

 

이제 index.mustache를 수정하자

...
<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>
...

 

IndexController에 다음과 같이 메소드를 추가하자.

...
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";
    }
}

이제 수정 기능을 사용해보자.

 

 

게시글 삭제

src/main/resources/templates/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>

{{>layout/footer}}

 

src/main/resources/static/js/app/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));
        });
    }
};

main.init();

 

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

 

src/main/java/com/jojoldu/book/springboot/web/PostApiController.java

...
@RequiredArgsConstructor
@RestController
public class PostApiController {
    ...

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

 

이제 다음장에서 로그인 기능을 만들어보자.

 

반응형
profile

Peony의 기록 창고 🌼

@myeongju