<!DOCTYPEHTML><html><head><title>스프링 부트 웹서비스</title><metahttp-equiv="Content-Type"content="text/html; charset=UTF-8"/></head><body><h1>스프링 부트로 시작하는 웹 서비스</h1></body></html>
IndexController.java
package com.jojoldu.book.springboot.web.dto;import org.springframework.stereotype.Controller;import org.springframework.web.bind.annotation.GetMapping;@Controllerpublic class IndexController{ @GetMapping("/") public String index() { return "index"; }}
머스테치 스타더 덕분에 컨트롤러에서 문자열을 반환할 때, 앞의 경로와 뒤의 파일 확장자는 자동으로 지정됨
앞의 경로는 src/main/resources/templates
뒤의 파일 확장자는 .mustache
여기선 “index”가 리턴
따라서, src/main/resources/templates/index.mustache로 전환되어 View Resolver가 처리
View Resolver는 URL 요청의 결과를 전달할 타입과 값을 지정해줌
IndexControllerTest.java
package com.jojoldu.book.springboot.web;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.web.client.TestRestTemplate;import org.springframework.test.context.junit.jupiter.SpringExtension;import static org.assertj.core.api.Assertions.assertThat;import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;@ExtendWith(SpringExtension.class)@SpringBootTest(webEnvironment = RANDOM_PORT)public class IndexControllerTest{ @Autowired private TestRestTemplate restTemplate; @Test public void loadMainPage() { // when String body = this.restTemplate.getForObject("/", String.class); // then assertThat(body).contains("스프링 부트로 시작하는 웹 서비스"); }}
bootstrap.js는 제이쿼리가 꼭 있어야 함(bootstrap.js가 제이쿼리에 의존)
부트스트랩보다 먼저 호출되도록 작성
라이브러리를 비롯한 기타 HTML 태그들이 모두 레이아웃에 추가됨
index.mustache에 필요한 코드만 남김
index.mustache
{{>layout/header}}<h1>스프링 부트로 시작하는 웹 서비스</h1>{{>layout/footer}}
{{>}}는 현재 머스테치 파일(index.mustache)를 기준으로 다른 파일을 가져옴
index.mustache(글 등록 버튼 추가)
{{>layout/header}}<h1>스프링 부트로 시작하는 웹 서비스</h1><divclass="col-md-12"><divclass="row"><divclass="col-md-6"><ahref="/posts/save"role="button"class="btn btn-primary">글 등록</a></div></div></div>{{>layout/footer}}
<a> 태그를 사용하여 글 등록 페이지로 이동하는 버튼 생성
IndexController.java
public class IndexController{ // 기존 코드 @GetMapping("/posts/save") public String postSave() { return "posts-save"; }}
var main ={init:function(){var _this =this;$('#btn-save').on('click',function(){ _this.save();});$('#btn-update').on('click',function(){ _this.update();});$('#btn-delete').on('click',function(){ _this.delete();});},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));});},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));});},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();
index.js에서 var main = {}을 선언하여 index라는 변수의 속성으로 function을 추가한 이유
e.g.
var init =fuction(){//...};var save = function (){ //...};init();
만약 index.mustache에 a.js가 추가돼 init과 save function을 가진다면?
브라우저의 스코프는 공용 공간이므로 나중에 로딩된 js의 init, save가 먼저 로딩된 js의 function을 덮어씀
중복된 함수 명으로 발생할 수 있는 문제를 피하고자 index.js만의 유효범위(scope)를 만들어서 사용
위 예시에서는 var index란 객체를 만들어 해당 객체에서 필요한 모든 function을 선언해 사용하고 있음
not-null property references a null or transient value
어떤 에러인지는 스택오버플로우를 참고하자. Posts.java에서 nullable을 전부 true로 바꿔주니 해결하긴 했다.
전체 조회 화면 만들기
index.mustache
{{>layout/header}}<h1>스프링 부트로 시작하는 웹 서비스</h1><divclass="col-md-12"><divclass="row"><divclass="col-md-6"><ahref="/posts/save"role="button"class="btn btn-primary">글 등록</a></div></div><br><!--목록 출력 영역--><tableclass="table table-horizontal table-bordered"><threadclass="thread=strong"><tr><th>게시글번호</th><th>제목</th><th>작성자</th><th>최종수정일</th></tr></thread><tbodyid="tbody"> {{#posts}} <tr><td>{{id}}</td><td>{{title}}</td><td>{{author}}</td><td>{{modifiedDate}}</td></tr> {{/posts}} </tbody></table></div>{{>layout/footer}}
{{#post}}: posts라는 List를 순회하는 일종의 for문
{{id}}: List에서 뽑아낸 객체의 필드를 사용
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();}
SpringDataJpa에서 제공하는 메소드로도 위의 동작을 할 수 있으나, 쿼리로 작성하는 것이 더 가독성이 좋아 @Query 사용
PostsService.java
// 기존 코드import com.jojoldu.book.springboot.web.dto.PostsListResponseDto;import java.util.List;import java.util.stream.Collectors;@RequiredArgsConstructor@Servicepublic class PostsService{ // 기존 코드 @Transactional(readOnly = true) // 트랜잭션 범위는 유지하되 조회 기능만 남겨두어 조회 속도 개선 public List<PostsListResponseDto> findAllDesc() { return postsRepository.findAllDesc().stream() .map(PostsListResponseDto::new) .collect(Collectors.toList()); }}
.map(PostsListResponseDto::new)는 람다식
.map(posts -> new PostsListResponseDto(posts))와 같음
// 기존 코드import org.springframework.ui.Model;import lombok.RequiredArgsConstructor;@RequiredArgsConstructor@Controllerpublic class IndexController{ private final PostsService postsService; @GetMapping("/") public String index(Model model) // 기존 index 수정 { model.addAttribute("posts", postsService.findAllDesc()); return "index"; } // 기존 코드}
게시글 수정, 삭제 화면 만들기
게시글 수정
PostsApiController.java
public class PostsApiController{ // 아래 내용 추가 @PutMapping("/api/v1/posts/{id}") public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) { return postsService.update(id, requestDto); }}
var main ={init:function(){// ... // btn-update란 id를 가진 HTML 엘리먼트에 click 이벤트가 발생할 때 update function을 실행하는 이벤트 등록 $('#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, // 어느 게시글을 수정할지 URL PATH로 구분하기 위해 id 추가 dataType: 'json', contentType: 'application/json; charset=urf-8', data: JSON.stringify(data) }).done(function (){ alert('글이 수정되었습니다.'); window.location.href = '/'; }).fail(function (error) { alert(JSON.stringify(error)); }); }};
type: 'PUT
HTTP 메소드 중 PUT을 선택
PostsApiController에 있는 API에 @PutMapping으로 선언했기 때문에 PUT을 사용(REST 규약에 따른 설정)
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)) }); }};
PostsService.java
@RequiredArgsConstructor@Servicepublic class PostsService{ // ... @Transactional public void delete (Long id) { Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id)); // JpaRepository에서 지원하는 메소드 활용 // deleteById(id)도 사용 가능 postsRepository.delete(posts); }}
PostsApiController.java
@RequiredArgsConstructor@RestControllerpublic class PostsApiController{ //... @DeleteMapping("/api/v1/posts/{id}") public Long delete(@PathVariable Long id) { postsService.delete(id); return id; }}