Todo-List
SpringBoot & React 프로젝트 구현 작업을 작성한 블로그입니다.
web, jpa, mysql, lombok 디팬더시 적용
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
compileOnly 'mysql:mysql-connector-java'
implementation group: 'mysql', name: 'mysql-connector-java', version: '8.0.26'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
mysql username, password 은 사용자 임의대로 만들면 된다.
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/todolist
username: todolist
password: todolist
jpa:
open-in-view: true
hibernate:
ddl-auto: update
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
use-new-id-generator-mappings: false
show-sql: true
properties:
hibernate.format_sql: true
dialect: org.hibernate.dialect.MySQL5InnoDBDialect
logging:
level:
org.hibernate.SQL: debug
cors 허용
을 위해 만든 것!
✅ 프론트에서도 CORS 허용할 수 있는 방법(proxy사용)이 있지만 production환경에서는 사용할 수 없다. CORS 허용 설정은 백앤드에서 하는 것이 국롤!
@Configuration
public class WebConfig implements WebMvcConfigurer{
@Override
public void addCorsMappings(CorsRegistry registry) {
registry
.addMapping("/**")
.allowedOrigins("http://localhost:3000") // 이 주소는 cors 허용
.allowedMethods(
HttpMethod.GET.name(),
HttpMethod.POST.name(),
HttpMethod.PUT.name(),
HttpMethod.DELETE.name()
);
}
}
@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Table(name = "Todo")
public class TodoEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(name = "todoOrder", nullable = false)
private Long order; // order는 사실 빼도 됨!
@Column(nullable = false)
private Boolean completed;
}
실제 run을 해보면서 mysql에 이름이 Todo
인 스키마가 존재하는지 확인해본다.
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TodoRequest {
private String title;
private Long order;
private Boolean completed;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TodoResponse {
private Long id;
private String title;
private Long order;
private Boolean completed;
public TodoResponse(TodoEntity todoEntity) {
this.id = todoEntity.getId();
this.title = todoEntity.getTitle();
this.order = todoEntity.getOrder();
this.completed = todoEntity.getCompleted();
}
}
TodoEntity -> TodoResponse 가 되는 메서드를 만들었다.
public interface TodoRepository extends JpaRepository<TodoEntity, Long> {
}
@Service
@RequiredArgsConstructor
public class TodoService {
public final TodoRepository todoRepository;
// 추가
public TodoEntity add(TodoRequest todoRequest) {
TodoEntity todoEntity = getTodoEntity(todoRequest);
return todoRepository.save(todoEntity);
}
// add method refactoring
private TodoEntity getTodoEntity(TodoRequest todoRequest) {
return TodoEntity.builder()
.title(todoRequest.getTitle())
.order(todoRequest.getOrder())
.completed(todoRequest.getCompleted())
.build();
}
// 조회
public TodoEntity searchById(Long id) {
var byId = todoRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return byId;
}
// 전체 조회
public List<TodoEntity> searchAll() {
// 정렬 id 기준 desc
var all = todoRepository.findAll(Sort.by(Sort.Direction.DESC, "id"));
return all;
}
// update1
public TodoEntity updateById(Long id) {
TodoEntity todoEntity = this.searchById(id);
todoEntity.setCompleted(true);
return todoRepository.save(todoEntity);
}
// update2
public TodoEntity updateById(Long id, TodoRequest request) {
TodoEntity todoEntity = this.searchById(id);
if (request.getTitle() != null) {
todoEntity.setTitle(request.getTitle());
}
if (request.getOrder() != null) {
todoEntity.setOrder(request.getOrder());
}
if (request.getCompleted() != null) {
todoEntity.setCompleted(request.getCompleted());
}
return todoRepository.save(todoEntity);
}
// 삭제
public void deleteById(Long id) {
todoRepository.deleteById(id);
}
// 전체 삭제
public void deleteAll() {
todoRepository.deleteAll();
}
}
@Slf4j
@CrossOrigin
@AllArgsConstructor
@RestController
@RequestMapping("/todo")
public class TodoController {
@Autowired
private final TodoService service;
@PostMapping
public ResponseEntity<TodoResponse> create(@RequestBody TodoRequest request) {
log.info("Create");
if (ObjectUtils.isEmpty(request.getTitle())) {
return ResponseEntity.badRequest().build();
}
if (ObjectUtils.isEmpty(request.getOrder())) {
request.setOrder(0L);
}
if (ObjectUtils.isEmpty(request.getCompleted())) {
request.setCompleted(false);
}
TodoEntity result = this.service.add(request);
return ResponseEntity.ok(new TodoResponse(result));
}
@GetMapping("{id}")
public ResponseEntity<TodoResponse> readOne(@PathVariable Long id) {
log.info("Read One");
TodoEntity result = this.service.searchById(id);
return ResponseEntity.ok(new TodoResponse(result));
}
@GetMapping
public ResponseEntity<List<TodoResponse>> readAll() {
log.info("Read All");
List<TodoEntity> list = this.service.searchAll();
List<TodoResponse> responses = list.stream()
.map(TodoResponse::new)
.collect(Collectors.toList());
return ResponseEntity.ok(responses);
}
@PutMapping("{id}")
public ResponseEntity<TodoResponse> update(@PathVariable Long id) {
log.info("Update");
TodoEntity result = this.service.updateById(id);
return ResponseEntity.ok(new TodoResponse(result));
}
@DeleteMapping("{id}")
public ResponseEntity<?> deleteOne(@PathVariable Long id) {
log.info("Delete");
this.service.deleteById(id);
return ResponseEntity.ok().build();
}
@DeleteMapping
public ResponseEntity<?> deleteAll() {
log.info("Delete All");
this.service.deleteAll();
return ResponseEntity.ok().build();
}
}
React
사용, Node.js
, npm
설치 후 실행하자.
참고 블로그) https://ffoorreeuunn.tistory.com/199
npm install npx -g
npx create-react-app my-app
cd my-app
npm run start
그외 필요한 npm 라이브러리
npm i axios
npm i react-router-dom
npm i bootstrap
{
"name": "todolist-front",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"axios": "^0.24.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-scripts": "4.0.3",
"web-vitals": "^1.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
프론트 로직이 시작되는 곳
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
ReactDOM.render(
<React.StrictMode>
<App /> // App.js를 랜더링
</React.StrictMode>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
여기서 프론트 로직을 만든다.
import React, {useState, useEffect} from 'react'
import axios from 'axios'
import './App.css';
function App() {
const baseUrl = "http://localhost:8090"
const [todos, setTodos] = useState([]);
const [input, setInput] = useState("");
useEffect(() => {
getTodos();
}, []) // [] 리액트 열렸을 때 한번만 실행하는 게 하는 것!
async function getTodos(){
await axios // 다 받을 때까지 기다리는 것
.get(baseUrl + "/todo")
.then((res) => {
console.log(res.data)
setTodos(res.data)
})
.catch((err) => {
console.log(err)
})
}
function insertTodo(e){
e.preventDefault()
const insertTodo = async() => {
await axios
.post(baseUrl + "/todo", {
title: input
})
.then((res) => {
console.log(res.data)
setInput("")
getTodos()
})
.catch((err) => {
console.log(err)
})
}
insertTodo()
console.log("할일이 추가되었습니다.")
}
function updateTodo(id){
const updateTodo = async() => {
await axios
.put(baseUrl + "/todo/" + id, {})
.then((res) => {
console.log(res.data)
// getTodos() 굳이 db에 더 조회하지 말자!
// 화면에서 바꾸자
setTodos(
todos.map((todo) =>
todo.id === id ? {...todo, completed: !todo.completed} : todo
)
)
})
.catch((err) => {
console.log(err)
})
}
updateTodo()
}
function deleteTodo(id){
const deleteTodo = async() => {
await axios
.delete(baseUrl + "/todo/" + id, {})
.then((res) => {
console.log(res.data)
setTodos(
todos.filter((todo) => todo.id !== id)
)
})
.catch((err) => {
console.log(err)
})
}
deleteTodo()
}
function changeText(e){
e.preventDefault()
setInput(e.target.value)
console.log(input)
}
return (
<div className="App">
<h1>TODO LIST</h1>
<form onSubmit={insertTodo}>
<label>
Todo
<input type="text" required={true} value={input} onChange={changeText}/>
</label>
<input type="submit" value="Add" />
</form>
{
todos
? todos.map((todo) => {
return (
<div className="todo" key={todo.id}>
<h3>
<input type="checkbox" className="todoapp__item-checkbox" onClick={() => updateTodo(todo.id)} />
<label
className= {todo.completed ? "completed" : null}
>
{todo.title}
</label>
<label onClick={() => deleteTodo(todo.id)}> ❌</label>
</h3>
</div>
)
})
: null
}
</div>
);
}
export default App;
: 함수를 객체로 컴포넌트로 분리하여 정리
refactoring(App.js -> input.js, todo.js, App.js)
input.js
function Input(props){
return (
<form onSubmit={props.handleSubmit}>
<label>
Todo
<input type="text" required={true} value={props.input} onChange={props.handleChange}/>
</label>
<input type="submit" value="Add" />
</form>
)
}
export default Input;
todo.js
function Todo(props){
return (
<div className="todo" key={props.todo.id}>
<h3>
<input type="checkbox" className="todoapp__item-checkbox" onClick={() => props.handleClick} />
<label
className= {props.todo.completed ? "completed" : null}
>
{props.todo.title}
</label>
<label onClick={() => props.handleDelete}> ❌</label>
</h3>
</div>
)
}
export default Todo;
App.js
import React, {useState, useEffect} from 'react'
import axios from 'axios'
import './App.css';
import Input from "./components/input"
import Todo from "./components/todo"
function App() {
const baseUrl = "http://localhost:8090"
const [todos, setTodos] = useState([]);
const [input, setInput] = useState("");
useEffect(() => {
getTodos();
}, []) // [] 리액트 열렸을 때 한번만 실행하는 게 하는 것!
async function getTodos(){
await axios // async, await 비동기통신 처리
.get(baseUrl + "/todo")
.then((res) => {
console.log(res.data)
setTodos(res.data)
})
.catch((err) => {
console.log(err)
})
}
function insertTodo(e){
e.preventDefault()
const insertTodo = async() => {
await axios
.post(baseUrl + "/todo", {
title: input
})
.then((res) => {
console.log(res.data)
setInput("")
getTodos()
})
.catch((err) => {
console.log(err)
})
}
insertTodo()
console.log("할일이 추가되었습니다.")
}
function updateTodo(id){
const updateTodo = async() => {
await axios
.put(baseUrl + "/todo/" + id, {})
.then((res) => {
console.log(res.data)
// getTodos() 굳이 db에 더 조회하지 말자!
// 화면에서 바꾸자
setTodos(
todos.map((todo) =>
todo.id === id ? {...todo, completed: !todo.completed} : todo
)
)
})
.catch((err) => {
console.log(err)
})
}
updateTodo()
}
function deleteTodo(id){
const deleteTodo = async() => {
await axios
.delete(baseUrl + "/todo/" + id, {})
.then((res) => {
console.log(res.data)
setTodos(
todos.filter((todo) => todo.id !== id)
)
})
.catch((err) => {
console.log(err)
})
}
deleteTodo()
}
function changeText(e){
e.preventDefault()
setInput(e.target.value)
console.log(input)
}
return (
<div className="App">
<h1>TODO LIST</h1>
<Input handleSubmit={insertTodo} input={input} handleChange={changeText}/>
{
todos
? todos.map((todo) => {
return (
<Todo key={todo.id} todo={todo}
handleClick={() => updateTodo(todo.id)}
handleDelete={() => deleteTodo(todo.id)}/>
)
})
: null
}
</div>
);
}
export default App;
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.completed {
text-decoration: line-through;
}
App.css refactoring
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.completed {
text-decoration: line-through;
color: gray;
}