TodoList 프로젝트 - SpringBoot&React

devdo·2022년 2월 9일
0

SpringBoot

목록 보기
14/34

Todo-List
SpringBoot & React 프로젝트 구현 작업을 작성한 블로그입니다.


구현 화면


API 스펙 및 기능 명세


todolist-backend

  • build.gradle dependencies

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'
}

  • application.yml

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

WebConfig

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

todolist Model(TodoEntity)

  • TodoEntity
@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인 스키마가 존재하는지 확인해본다.


  • TodoRequest
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TodoRequest {

    private String title;

    private Long order;

    private Boolean completed;


}
  • TodoResponse
@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 가 되는 메서드를 만들었다.


Repository, Service, Controller

  • TodoRepository
public interface TodoRepository extends JpaRepository<TodoEntity, Long> {
}
  • TodoService
@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();
    }

}
  • TodoController
@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();
    }


}

todolist-front

React 사용, Node.js, npm 설치 후 실행하자.

참고 블로그) https://ffoorreeuunn.tistory.com/199


  • Create React App
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

  • package.json
{
  "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"
    ]
  }
}

  • index.js

프론트 로직이 시작되는 곳

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

  • App.js

여기서 프론트 로직을 만든다.

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 &nbsp;
          <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)} /> &nbsp;
                <label 
                  className= {todo.completed ? "completed" : null}
                >
                {todo.title}
                </label>
                <label onClick={() => deleteTodo(todo.id)}>&nbsp;</label>
              </h3>
            </div>
          )
        })
        : null
      }
    </div>
  );
}

export default App;

Refactoring

: 함수를 객체로 컴포넌트로 분리하여 정리

refactoring(App.js -> input.js, todo.js, App.js)

input.js

function Input(props){
    return (
        <form onSubmit={props.handleSubmit}>
        <label>
        Todo &nbsp;
        <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} /> &nbsp;
                <label 
                  className= {props.todo.completed ? "completed" : null}
                >
                {props.todo.title}
                </label>
                <label onClick={() => props.handleDelete}>&nbsp;</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.css
.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;
}


출처

profile
배운 것을 기록합니다.

0개의 댓글