스프링 프레임워크

[리액트 + 스프링부트 + JPA] 오늘 할 일 리스트 만들기

맘모스커피 2023. 7. 11. 15:01

🧑‍🌾 리액트와 스프링부트 그리고 JPA 를통해 todoList를 만들어 볼 예정입니다. 리액트의 공부는 시작단계이기 때문에 참고자료를 많이 활용하였습니다.

 

🔎 Key-Point

 1. 리액트의 활용 및 최적화

 2. 스프링부트 및 JPA를 통해 간단하게 데이터 저장

 

 

💡 React 설치 

 

- VSCode의 터미널을 실행합니다.

미리 설치해둔 node js의 명령어 입력 및 리액트 파일을 설치합니다.

 

- 파일이 정상적으로 설치가 되었다면 localhost:3000에 이런 화면이 생성이 됩니다. 그러면 리액트 설치와 관련된 준비는 모두 마쳤습니다.

 

💡 프로젝트 초기화 및 App.js

리액트는 기본 생성되어있는 src/App.js 파일을 중심으로 화면에 나타나게 됩니다.

💡 TodoListTemplate 컴포넌트

- 헤더 부분 파일 부분 컴포넌트 입니다.

import React from "react";
import '../css/TodoListTemplate.css';

const TodoListTemplate = ({form,children}) =>{
    return(
        <main className="todo-list-template">
            <div className="todo-list-title">
                오늘할일
            </div>
            <section className="form-wrapper">
                {form}
            </section>
            <section className="todoItemList-wrapper">
                {children}
            </section>
        </main>
    );
};

export default TodoListTemplate;

 

 

💡Form 컴포넌트

 - src 밑에 js 파일을 만들고 Form.js  파일을 만들어줍니다.

import React, { useState } from 'react';
import '../css/Form.css';
 
 
// *** Form.js 에서 Hook(useState) 사용으로 인해 수정
// const Form = ({ value, onChange, onCreate, onKeyPress }) => {
const Form = ({ onCreate }) => {
 
    // React Hook > 클래스 타입에서는 사용 X
    const [ input, setInput ] = useState('');
 
    // input 값 변경
    const handleChange = (event) => {
        setInput(event.target.value);
    }
 
    // Enter key event
    const handleKeyPress = (event) => {
        // 눌려진 키가 Enter key 인 경우 handleCreate 호출
        if(event.key === 'Enter') {
            onCreate(input);
            setInput('');
        }
    }
 
 
    return (
        <div className="form">
            <input
                value={input}
                placeholder="오늘 할 일을 입력하세요.."
                onChange={handleChange}
                onKeyPress={handleKeyPress} />
            <div className="create-button" onClick={() => {
                    onCreate(input);
                    setInput('');
                }
            }>
                추가
            </div>
        </div>
    );
};
 
export default Form;

App 컴포넌트 하위에 TodoListTemplate 컴포넌트가 존재하며, TodoListTemplate 컴포넌트의 props 인 children 으로 "오늘 할 일 템플릿입니다" 라는 값이 들어왔음을 확인할 수 있습니다.

 

아래는 src/css 파일에 Form.css 파일입니다.

.form {
    display: flex;
    }
   
.form input {
    flex: 1; /* 버튼을 뺀 빈 공간을 모두 채워줍니다 */
    font-size: 1.25rem;
    outline: none;
    border: none;
    border-bottom: 1px solid #353b54e0;
}
   
.create-button {
    padding-top: 0.5rem;
    padding-bottom: 0.5rem;
    padding-left: 1rem;
    padding-right: 1rem;
    margin-left: 1rem;
    background: #353b54e0;
    border-radius: 3px;
    color: white;
    font-weight: 600;
    cursor: pointer;
}
   
.create-button:hover {
    background: #353b54e0;
}

이후에 App.js에 import 시켜줍니다.

import Form from './components/js/Form';

기본적인 틀이 완성이 됩니다.

 

💡 TodoItemList 컴포넌트

- 위와 마찬가지로 js폴더에 TodoItemList js 파일을 만들어줍니다.

import React from 'react';
import TodoItem from './TodoItem';

class TodoItemList extends React.Component {

    // *** Form.js 에서 Hook(useState) 사용으로 인해 제거
    // shouldComponentUpdate(nextProps, nextState) {
    //     return this.props.todos !== nextProps.todos;
    // }

    render() {
        const { todos, onToggle, onRemove } = this.props;
        console.log(todos);

        const todoList = todos.map(
            ({ id, content, isComplete }) => (
                <TodoItem
                    id={id}
                    content={content}
                    isComplete={isComplete}
                    onToggle={onToggle}
                    onRemove={onRemove}
                    key={id} />
            )
        );

        return (
            <div>
                {todoList}
            </div>
        );
    }
}

export default TodoItemList;

TodoItem 컴포넌트는 5가지 props 를 받는다.

  • content: todo 내용
  • isComplete : 체크박스 on/off 상태를 의미하며, 오늘 할 일의 완료 유무를 판단
  • id : TodoItem 의 Key 값
  • onToggle : 체크박스를 on/off 시키는 함수
  • onRemove : TodoItem 을 삭제시키는 함수

 

위 코드의 Line 9 와 Line 10 을 보면, 해당 컴포넌트의 최상위 DOM 의 클릭 이벤트에는 onToggle 을 설정하고, x 가 있는 부분에는 onRemove 를 설정해주었다.

또한 onRemove 를 호출하는 곳에는 e.stopPropagation() 이라는 함수가 호출된 것을 확인할 수 있다.

만약에 이 함수를 호출하지 않으면 x 를 눌렀을 때 onRemove 만 실행되는 것이 아니라 해당 DOM 의 부모의 클릭 이벤트에 연결되어 있는 onToggle 도 실행되게 된다.

즉 onRemove → onToggle 순으로 함수가 호출되면서 코드가 의도치 않게 작동하여 삭제가 제대로 진행되지 않게 된다.

 

e.stopPropagation() 은 이벤트의 확산을 멈춰준다. 즉, 삭제 부분에 들어간 이벤트가 해당 부모의 이벤트까지 전달되지 않도록 해준다. 따라서 onToggle 은 실행되지 않고 onRemove 만 실행된다.

 

💡TodoItem 컴포넌트

import React from 'react';
import '../css/TodoItem.css';
 
class TodoItem extends React.Component {
 
    // *** Form.js 에서 Hook(useState) 사용으로 인해 제거
    // shouldComponentUpdate(nextProps, nextState) {
    //     return this.props.isComplete !== nextProps.isComplete;
    // }
 
    render() {
        const { content, isComplete, id, onToggle, onRemove } = this.props;
        console.log(id);
 
        return (
            <div className="todo-item" onClick={() => onToggle(id)}>
                <div className="todo-item-remove" onClick={(e) => {
                    e.stopPropagation();    // onToggle 이 실행되지 않도록 함
                    onRemove(id)
                }
                }>
                    &times;
                </div>
                <div className={`todo-item-text ${isComplete && 'isComplete'}`}>
                    <div>
                        {content}
                    </div>
                </div>
                {
                    isComplete && (<div className="isComplete-mark"></div>)
                }
            </div>
        )
    }
}
 
export default TodoItem;

- js파일을 만들어주면 기본적인 js파일 생성은 끝이납니다.

- TodoItemList 컴포넌트와 마찬가지로 TodoItem 컴포넌트도 최적화가 필요합니다.

  TodoItem 컴포넌트 isComplete 값의 변경 및 TodoItem 컴포넌트 추가 및 삭제의 경우에도 모든 컴포넌트가 렌더링되고 있기 때문입니다.

 

아래는 TodoItem css 파일입니다.

 

.todo-item {
    padding: 1rem;
    display: flex;
    align-items: center; /* 세로 가운데 정렬 */
    cursor: pointer;
    transition: all 0.15s;
    user-select: none;
    background-color: whitesmoke;
    margin: 0.3rem;
    border-radius: 5px 5px 5px 5px;
}
   
    .todo-item:hover {
        background: #353b544d;
    }
   
    /* todo-item 에 마우스가 있을때만 .remove 보이기 */
    .todo-item:hover .todo-item-remove {
        opacity: 1;
    }
   
    /* todo-item 사이에 윗 테두리 */
    .todo-item + .todo-item {
        border-top: 1px solid #f1f3f5;
    }
   
    .todo-item-remove {
        margin-right: 1rem;
        color: #e64980;
        font-weight: 600;
        opacity: 0;
    }
   
    .todo-item-content {
        flex: 1; /* 체크, 엑스를 제외한 공간 다 채우기 */
        word-break: break-all;
    }
   
    .isComplete {
        text-decoration: line-through;
        color: #adb5bd;
    }
   
.isComplete-mark {
    font-size: 1.5rem;
    line-height: 1rem;
    margin-left: 1rem;
    color: #353b54e0;;
    font-weight: 800;
}

💡App. js

- 위 파일들을 import 시키고 값들을 받아오는 코드를 만들어줍니다.

import React from 'react';
import TodoListTemplate from './components/js/TodoListTemplate';
import Form from './components/js/Form';
import TodoItemList from './components/js/TodoItemList';
 
class App extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            // *** Form.js 에서 Hook(useState) 사용으로 인해 제거
            // input : "",
            todos : [
 
            ]
        }
        // this.handleChange = this.handleChange.bind(this);
        this.handleCreate = this.handleCreate.bind(this);
        // this.handleKeyPress = this.handleKeyPress.bind(this);
        this.handleToggle = this.handleToggle.bind(this);
        this.handleRemove = this.handleRemove.bind(this);
        this.handleInitInfo = this.handleInitInfo.bind(this);
    }
 
 
    componentDidMount() {
        this.handleInitInfo()
    }
 
 
    handleInitInfo() {
        fetch("/api/todos")
            .then(res => res.json())
            .then(todos => this.setState({todos : todos}))
            .catch(err => console.log(err))
    }
 
 
    // *** Form.js 에서 Hook(useState) 사용으로 인해 제거
    // input 값 변경
    // handleChange(event) {
    //     this.setState({
    //         input: event.target.value
    //     });
    // }
 
 
    // *** Form.js 에서 Hook(useState) 사용으로 인해 state 에서 input 을 제외하고
    // parameter 로 받는다.
    // 등록
    handleCreate(inputValue) {
        const { todos } = this.state;
        if (inputValue === "") {
            alert("오늘 할 일을 입력해주세요!");
            return;
        }
 
        // 화면에서 먼저 변경사항을 보여주는 방법으로 이용
        this.setState({
            // input: "",
            // concat 을 사용하여 배열에 추가
            todos: todos.concat({
                id: 0,    // 임의의 id를 부여하여 key error 를 방지
                content: inputValue,
                isComplete: false
            })
        });
 
        // 처리
        const data = {
            body: JSON.stringify({"content" : inputValue}),
            headers: {'Content-Type': 'application/json'},
            method: 'post'
        }
        fetch("/api/todos", data)
            .then(res => {
                if(!res.ok) {
                    throw new Error(res.status);
                } else {
                    return this.handleInitInfo();
                }
            })
            .catch(err => console.log(err));
    }
 
 
    // *** Form.js 에서 Hook(useState) 사용으로 인해 제거
    // Enter Key 이벤트
    // handleKeyPress(event) {
    //     if (event.key === "Enter") {
    //         this.handleCreate();
    //     }
    // }
 
 
    // 수정
    handleToggle(id) {
        const { todos } = this.state;
 
        const isComplete = todos.find(todo => todo.id === id).isComplete;
        if(!window.confirm(isComplete ? "미완료 처리 하시겠습니까?" : "완료 처리 하시겠습니까?")) {
            return;
        }
 
        // 파라미터로 받은 id 를 가지고 몇 번째 아이템인지 찾는다.
        const index = todos.findIndex(todo => todo.id === id);
 
        // 선택한 객체를 저장한다.
        const selected = todos[index];
 
        // 배열을 복사한다.
        const nextTodos = [...todos];
 
        // 기존의 값을 복사하고 isComplete 값을 덮어쓴다.
        nextTodos[index] = {
            ...selected,
            isComplete : !selected.isComplete
        };
 
        this.setState({
            todos : nextTodos
        });
 
        const data = {
            headers: {'Content-Type':'application/json'},
            method: 'put'
        }
        fetch("/api/todos/" + id, data)
        .then(res => {
            if(!res.ok) {
                throw new Error(res.status);
            } else {
                return this.handleInitInfo();
            }
        })
        .catch(err => console.log(err));
    }
 
 
    // 삭제
    handleRemove(id) {
        const { todos } = this.state;
 
        const removeContent = todos.find(todo => todo.id === id).content;
        if(!window.confirm("'" + removeContent + "' 을 삭제하시겠습니까?")) {
            return;
        }
 
        this.setState({
            todos : todos.filter(todo => todo.id !== id)
        });
 
        const data = {
            headers: {'Content-Type':'application/json'},
            method: 'delete'
        }
        fetch("/api/todos/" + id, data)
        .then(res => {
            if(!res.ok) {
                throw new Error(res.status);
            } else {
                return this.handleInitInfo();
            }
        })
        .catch(err => console.log(err));
    }
 
 
    render() {
        return (
            <TodoListTemplate form={(
                <Form
                    // *** Form.js 에서 Hook(useState) 사용으로 인해 제거
                    // value={this.state.input}
                    // onChange={this.handleChange}
                    // onCreate={this.handleCreate}
                    // onKeyPress={this.handleKeyPress}
                    onCreate={this.handleCreate}
                />
            )}>
                <TodoItemList
                    todos={this.state.todos}
                    onToggle={this.handleToggle}
                    onRemove={this.handleRemove} />
            </TodoListTemplate>
        );
    }
}
 
export default App;

 

 

 

 

💡 완료

💡자바 코드는 글이 길어질거 같아 github 코드로 대체합니다. 

(BeginAdvanture/todolist (github.com))

자바 코드를 테스트 해보면 아래와 같이 값이 들어간것을 확인할수 있습니다.

 

다시 VSCode로 돌아와 package.json 파일의 localhost 주소를 변경해줍니다.

{
  "name": "todolist",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
      "@testing-library/jest-dom": "^4.2.4",
      "@testing-library/react": "^9.3.2",
      "@testing-library/user-event": "^7.1.2",
      "react": "^16.13.0",
      "react-dom": "^16.13.0",
      "react-scripts": "3.4.0"
  },
  "scripts": {
      "start": "react-scripts start",
      "build": "react-scripts build",
      "test": "react-scripts test",
      "eject": "react-scripts eject"
  },
  "eslintConfig": {
      "extends": "react-app"
  },
  "browserslist": {
      "production": [
          ">0.2%",
          "not dead",
          "not op_mini all"
      ],
      "development": [
          "last 1 chrome version",
          "last 1 firefox version",
          "last 1 safari version"
      ]
  },
  "proxy": "http://localhost:8081"
}

💡다시 프론트앤드와 백앤드 서버를 터미널로 구동시켜주면 작업이 완료됩니다.

🏇 서버를 재구동 하더라도 데이터가 H2 Database에 저장되어있는것을 확인할 수 있습니다.