[To-Do 앱]스프링 부트(Spring Boot) RESTful API - PUT & Vue.js/Node.js Update 기능 구현
지난번 포스트까지 해서 우리는 Spring Boot로 RESTful API를 만들고, Node.js와 Vue.js로 Frontend 서버를 만들어 보았다. 이전 포스트까지 전부 진행했다면 이제부터는 혼자 원하는 기능을 만들어가도 된다. 이번 포스트에서는 RESTful API의 메서드 중 하나인 PUT을 만들고 서비스 레벨부터 UI레벨까지 full stack으로 개발을 진행 해 보도록 하겠다.
예상독자
- IntelliJ, Webstorm, Atom 등 자바스크립트 IDE중 하나를 설치했다.
- 자바스크립트를 좀 안다.
- 백엔드는 알아서 구현할 수 있거나 아래의 튜토리얼들을 마쳤다.
- [To-Do 앱]스프링부트(SpringBoot) 웹 어플리케이션)
- [To-Do 앱] 스프링 부트(Spring Boot) RESTful API - GET
- [To-Do 앱] 스프링 부트(Spring Boot) RESTful API - POST
- [To-Do 앱]vue.js와 node.js를 이용해 웹 앱 만들기
- [To-Do 앱]Vue.js/Node.js 앱 에서 API Call 하기 (Axios)
- [To-Do 앱]Vue.js/Node.js Bootstrap-vue를 이용한 UI 구현
- 도커에 스프링 앱을 올리고 싶다면: 스프링 부트 도커에 올리기 (Dockerizing Spring Boot App) 참고
목표
- Backend - Spring Boot
- Service
- Controller
- CORS 에러 해결
- Frontend - node.js/vue.js
- method - update
- template - checkbox
- v-if/else를 이용한 UI
Backend - Spring Boot
백엔드를 개발하는 순서는 어떤 개발방법을 따르느냐에 따라 다르다. 보통 Test-Driven Development 개발방법에서는 클래스와 메서드의 껍데기를 정의 한 후, Unit Tests를 작성하고 이후에 클래스/메서드의 내부를 구현한다. 이 포스트에서는 TDD 개발방법을 사용하지 않으므로, 그냥 서비스 레벨부터 구현하도록 하겠다.
Service
ToDoItem을 수정하기 위해 ToDoItemService.java에 update메서드를 추가하도록 하자.
public ToDoItem update(final ToDoItem toDoItem) {
if (toDoItem == null) {
throw new NullPointerException("To Do Item cannot be null");
}
final ToDoItem original = toDoItemRepository.findById(toDoItem.getId())
.orElseThrow(() -> new RuntimeException("Entity Not Found"));
original.setTitle(toDoItem.getTitle());
original.setDone(toDoItem.isDone());
return toDoItemRepository.save(original);
}
이 메서드가 하는 일은 1) 인자로 수정 할 toDoItem을 받는다. 이 toDoItem의 id를 이용해 기존 toDoItem을 찾는다. 유저에게서 넘어온 toDoItem을 바로 저장하면 안된다. 해당 아이템이 데이터베이스에 존재하는지 안하는지 먼저 확인해야하고, 또 보안 문제를 예방하기 위한 목적도 있다. 보통은 유저에게서 넘어온 toDoItem을 검사하고(validation) 해당 내용의 값을 복사하여 집어넣는다. 여기서는 검사 기능이 없으니 바로 복사하여 save하도록 한다.
참고를 위해 ToDoItemService.java를 첨부한다.
package com.fsoftwareengineer.MySpringApp.ToDoItem;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ToDoItemService {
@Autowired
private ToDoItemRepository toDoItemRepository;
public ToDoItem get(final String id) {
// do id validation
return toDoItemRepository.findById(id).orElse(null);
}
public ToDoItem create(final ToDoItem toDoItem) {
if (toDoItem == null) {
throw new NullPointerException("To Do Item cannot be null.");
}
return toDoItemRepository.insert(toDoItem);
}
public ToDoItem update(final ToDoItem toDoItem) {
if (toDoItem == null) {
throw new NullPointerException("To Do Item cannot be null");
}
final ToDoItem original = toDoItemRepository.findById(toDoItem.getId())
.orElseThrow(() -> new RuntimeException("Entity Not Found"));
original.setTitle(toDoItem.getTitle());
original.setDone(toDoItem.isDone());
return toDoItemRepository.save(original);
}
public List<ToDoItem> getAll() {
return toDoItemRepository.findAll();
}
}
Controller
update서비스를 만들었다면 이제 update서비스를 이용할 controller의 메서드를 만들어 주어야 한다.
@RequestMapping(method = RequestMethod.PUT)
public @ResponseBody ToDoItemResponse update(@RequestBody final ToDoItemRequest toDoItemRequest) {
List<String> errors = new ArrayList<>();
ToDoItem toDoItem = ToDoItemAdapter.toToDoItem(toDoItemRequest);
try {
toDoItem = toDoItemService.update(toDoItem);
} catch (final Exception e) {
errors.add(e.getMessage());
e.printStackTrace();
}
return ToDoItemAdapter.toToDoItemResponse(toDoItem, errors);
}
이 메서드의 구현은 create과 아주 비슷하다. 우리는 create하는 대신에 update를 할 예정이다. RequestMethod를 PUT으로 바꾸고, toDoItemService.update메서드를 부른다. PUT을 사용하지 않고 POST만 사용하는 어플리케이션들의 경우에는 upsert(update+insert)라고해서 없으면 create을 있으면 update를 하는 메서드 하나로 생성/수정을 구현하는 경우도 있다. 일단 이렇게 했으면 컨트롤러 만드는 것에 성공 한 것이다.
참고를 위해 ToDoItemController.java 전부를 첨부한다.
package com.fsoftwareengineer.MySpringApp.ToDoItem;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
@RestController
@RequestMapping("/todo")
public class ToDoItemController {
@Autowired
private ToDoItemService toDoItemService;
@RequestMapping(method = RequestMethod.GET, value = "/{id}")
public @ResponseBody ToDoItemResponse get(@PathVariable(value="id") String id) {
List<String> errors = new ArrayList<>();
ToDoItem toDoItem = null;
try {
toDoItem = toDoItemService.get(id);
} catch (final Exception e) {
errors.add(e.getMessage());
}
return ToDoItemAdapter.toToDoItemResponse(toDoItem, errors);
}
@RequestMapping(method = RequestMethod.GET)
public @ResponseBody List<ToDoItemResponse> getAll() {
List<String> errors = new ArrayList<>();
List<ToDoItem> toDoItems = toDoItemService.getAll();
List<ToDoItemResponse> toDoItemResponses = new ArrayList<>();
toDoItems.stream().forEach(toDoItem -> {
toDoItemResponses.add(ToDoItemAdapter.toToDoItemResponse(toDoItem, errors));
});
return toDoItemResponses;
}
@RequestMapping(method = RequestMethod.POST)
public @ResponseBody ToDoItemResponse create(@RequestBody final ToDoItemRequest toDoItemRequest) {
List<String> errors = new ArrayList<>();
ToDoItem toDoItem = ToDoItemAdapter.toToDoItem(toDoItemRequest);
try {
toDoItem = toDoItemService.create(toDoItem);
} catch (final Exception e) {
errors.add(e.getMessage());
e.printStackTrace();
}
return ToDoItemAdapter.toToDoItemResponse(toDoItem, errors);
}
@RequestMapping(method = RequestMethod.PUT)
public @ResponseBody ToDoItemResponse update(@RequestBody final ToDoItemRequest toDoItemRequest) {
List<String> errors = new ArrayList<>();
ToDoItem toDoItem = ToDoItemAdapter.toToDoItem(toDoItemRequest);
try {
toDoItem = toDoItemService.update(toDoItem);
} catch (final Exception e) {
errors.add(e.getMessage());
e.printStackTrace();
}
return ToDoItemAdapter.toToDoItemResponse(toDoItem, errors);
}
}
잠깐 짚고 넘어가야 할 부분이 있다. 우리는 ToDoItemAdapter를 이용해 ToDoItemRequest를 ToDoItem으로 변환한다. update를 할 시에는 id가 반드시 필요하므로 id를 제대로 adapt하는지 확인 해 보자. ToDoItemAdapter.java의 toToDoItem메서드가 아래를 아래처럼 수정하라.
public static ToDoItem toToDoItem(final ToDoItemRequest toDoItemRequest) {
if(toDoItemRequest == null) {
return null;
}
return ToDoItem.builder()
.id(toDoItemRequest.getId()) // 이 부분을 반드시 추가 해 주어야 한다.
.title(toDoItemRequest.getTitle())
.done(toDoItemRequest.isDone())
.build();
}
id를 넘기는 부분이 없어서 위와 같이 메서드를 수정했다.
CORS 에러 해결
이렇게 하면 될 줄 알고 실행 했는데 안돼서 당황 했을 것이다. 다양한 리퀘스트 메서드를 사용하려면 WebConfig에 사용 할 메서드를 명시해 주어야 한다. WebConfig.java로 가서 아래와 같이 allowedMethods를 추가 해 준다. 길지 않으니 소스 전체을 첨부하도록 하겠다.
package com.fsoftwareengineer.MySpringApp.Config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedMethods("HEAD", "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
.allowedOrigins("http://127.0.0.1:8080")
.allowedOrigins("http://localhost:8080");
}
}
이렇게 하고 gradle bootRun을 이용해 스프링 부트 앱을 실행시켜보도록 하자. 아무런 에러가 나지 않는다면 이제 frontend를 개발하러 가면 된다.
Frontend - node.js/vue.js
Frontend에서 개발 할 부분은 다음과 같다. 첫번째로 axios를 이용해 PUT메서드를 call하는 메서드부분을 작성한다. 두번째로 template에서 checkbox를 체크시 앞에서 만든 메서드를 call하는 부분을 작성한다. 세번째로 체크된 To Do 아이템에는 회색과 strikethrough를 적용한다.
method - update
우리가 만들 메서드의 이름은 markDone이다. 이 메서드는 toDoItem의 done이 true -> false로 false -> true로 변경한다. 그리고 변경된 아이템을 PUT메서드를 이용해 Spring Boot 백엔드 서버로 리퀘스트를 보낸다. Hello.vue에 다음과 같은 메서드를 추가하도록 하자.
markDone: function (toDoItem) {
if (!toDoItem) return // null이면 그냥 리턴한다.
let vm = this
toDoItem.done = !toDoItem.done // true -> false, false -> true로 변경한다.
axios.put(baseUrl, toDoItem) // 백엔드 서버로 리퀘스트를 보낸다.
.then(response => {
vm.initToDoList() // 리스트를 갱신한다.
})
.catch(error => {
console.log(error)
})
}
참고를 위해 스크립트 전체를 첨부하도록 하겠다.
<script>
import axios from 'axios'
let baseUrl = 'http://127.0.0.1:5000/todo/'
export default {
name: 'hello',
data: () => {
return {
toDoItems: [],
newToDoItemRequest: {}
}
},
methods: {
initToDoList: function () {
let vm = this
axios.get(baseUrl)
.then(response => {
vm.toDoItems = response.data.map(r => r.data)
})
.catch(e => {
console.log('error : ', e)
})
},
createToDo: function (event) {
event.preventDefault()
let vm = this
if (!vm.newToDoItemRequest.title) return
axios.post(baseUrl, vm.newToDoItemRequest)
.then(response => {
vm.initToDoList()
vm.newToDoItemRequest = {}
})
.catch(error => {
console.log(error)
})
},
markDone: function (toDoItem) {
if (!toDoItem) return
let vm = this
toDoItem.done = !toDoItem.done
axios.put(baseUrl, toDoItem)
.then(response => {
vm.initToDoList()
})
.catch(error => {
console.log(error)
})
}
},
created () {
this.initToDoList()
}
}
</script>
참고! 이 포스트를 작성하면서 내가 약간 refactoring을 한 부분이 있다. 어디냐면 initToDoList부분이다. 혹시 현재까지의 본인의 코드와 이 포스트의 코드가 다르다면 initToDoList부분을 수정해야 할 것이다.
template - checkbox
위처럼 메서드를 만들었다면 이제 체크박스 클릭시 이 메서드를 불러야 한다. 그냥 부르는게 아니라 이 메서드에 체크된 부분의 toDoItem을 넣어줘야 한다.
b-list-group을 아래처럼 수정 해 주자.
<b-list-group v-if="toDoItems && toDoItems.length">
<b-list-group-item
v-for="toDoItem of toDoItems"
v-bind:data="toDoItem.id"
v-bind:key="toDoItem.id" style="display: flex;">
<b-form-checkbox
v-model="toDoItem.done"
v-on:change="markDone(toDoItem)">
</b-form-checkbox>
{{toDoItem.title}}
</b-list-group-item>
</b-list-group>
유심히 봐야 할 부분은 v-model과 v-on:change부분이다. v-model에 toDoItem.done을 넣으면 done이 true일때는 체크가 되고 done이 false일때는 체크가 안 될 것이다. v-on:change에는 아까 만들었던 markDone을 연결 해 주어야 한다. 그리고 markDone이 눌리면 눌린 toDoItem을 markDone의 파라미터로 넘겨주어야한다.
여기까지 했으면 테스트를 해 보아라. 체크박스를 누르고 새로고침을 해도 체크박스가 여전히 체크되어있는지 확인 해 봐라.
참고를 위해 템플릿 전체를 첨부한다.
<template>
<div class="hello">
<b-card
header="오늘 해야 할 일"
style="max-width: 40rem; margin: auto; margin-top: 10vh;"
class="mb-2"
border-variant="info"
align="left">
<b-form-group id="to-do-input">
<b-container fluid>
<b-row class="my-1">
<b-col sm="10">
<b-form-input v-model="newToDoItemRequest.title" type="text"
placeholder="새 할 일을 적으세요." @keyup.enter="createToDo"/>
</b-col>
<b-col sm="2">
<b-button variant="outline-primary" v-on:click="createToDo">추가</b-button>
</b-col>
</b-row>
</b-container>
</b-form-group>
<b-list-group v-if="toDoItems && toDoItems.length">
<b-list-group-item
v-for="toDoItem of toDoItems"
v-bind:data="toDoItem.id"
v-bind:key="toDoItem.id" style="display: flex;">
<b-form-checkbox
v-model="toDoItem.done"
v-on:change="markDone(toDoItem)">
</b-form-checkbox>
{{toDoItem.title}}
</b-list-group-item>
</b-list-group>
</b-card>
</div>
</template>
v-if/else를 이용한 UI
이제 UI를 더 가꾸기 위해 체크된 아이템은 회색처리를 하고 strikethrough로 완료되었음을 표시 해 보자. <b-list-group-item></b-list-group-item>의 내부를 다음과 같이 수정 해 보자.
<b-form-checkbox
v-model="toDoItem.done"
v-on:change="markDone(toDoItem)">
</b-form-checkbox>
<span v-if="toDoItem.done" style="text-decoration: line-through; color:#D3D3D3;"> {{toDoItem.title}}</span>
<span v-else>{{toDoItem.title}}</span>
</b-list-group-item>
달라진게 무엇인가? 바로 span이 생겼다는 것이다. 현재 두가지 span을 추가 했다. 첫번째 span은 v-if="toDoItem.done"이 존재한다. 무슨 뜻인가? 이 toDoItem.done이 true이면 이 span을 이용해라라는 뜻이다. 완료된 toDoItem일 경우 text-decoration이 line-through이고 color가 #D3D3D3 (grey)로 스타일된 span을 보여줄 것이다. v-else가 있는 span은 done이 false일 경우 그냥 title을 보여 줄 것이다. 여기까지 잘 따라왔다면 여러분은 아래와 같은 테스트를 할 수 있을 것이다.
[To Do List 테스트]
끝
이번 포스트에서는 PUT RESTful API의 벡엔드를 구현하고 프론트엔드의 기능까지해서 full stack으로 개발 해 보았다. 다음 포스트에서는 Delete 메서드를 이용해 삭제하는 기능을 구현 해 볼 것이다. 그리고 그 다음에는 드디어! 임베디드 몽고디비가 아닌 도커에 몽고디비를 설치하고 스프링 부트를 몽고디비에 연결 해 볼 것이다.