[TIL/React] 2024/08/31

원민관·2024년 8월 31일
0

[TIL]

목록 보기
152/159
post-thumbnail

✅ 나만의 로제타 스톤 구현하기

0. 원하는 기술 스택 👨‍💻

MongoDB: NoSQL 데이터베이스로, JSON 형태의 데이터를 저장하고 관리한다.

Express.js: Node.js 기반의 웹 애플리케이션 프레임워크로, 서버 및 API를 쉽게 구축할 수 있도록 지원한다.

React.js: 사용자 인터페이스를 구축하기 위한 JavaScript 라이브러리로, 컴포넌트 기반의 UI 개발을 지원한다.

Node.js: JavaScript 런타임 환경으로, 서버 사이드 애플리케이션을 구축할 수 있도록 한다.

Material-UI: React용 UI 컴포넌트 라이브러리로, Google의 Material Design을 기반으로 한 디자인 시스템을 제공한다.

Jotai: React 상태 관리 라이브러리로, 간단하고 직관적인 API로 상태를 관리한다.

React-Query: React 애플리케이션의 서버 상태를 관리하기 위한 라이브러리로, 데이터 fetching, caching, syncing을 지원한다.

(금일 목표: 내가 구현하길 원하는 기술 스택 조합을 아우르는, 가장 간단한 형태의 로제타 스톤을 만들어보자!)

1. Backend 👨‍💻

MERN stack Backend 셋팅을 6단계로 나누어보자.

1-1. 초기화 및 패키지 설치 ✍️

// 프로젝트 초기화 관련 command line
mkdir todolist-app
cd todolist-app
mkdir backend
cd backend
yarn init -y

// 패키지 설치 관련 command line
yarn add express mongoose dotenv cors
yarn add -D nodemon

1-2. server.js 설정 ✍️

const express = require("express");
const mongoose = require("mongoose");
const cors = require("cors");
require("dotenv").config();

// Express 애플리케이션을 생성한다.
const app = express();
const PORT = process.env.PORT || 5000;

// CORS를 설정하여 모든 출처의 요청을 허용한다.
app.use(cors());

// JSON 형식의 요청 본문을 파싱한다.
app.use(express.json());

// MongoDB 데이터베이스에 연결한다.
mongoose
  .connect(process.env.MONGO_URI, {
    useNewUrlParser: true,
    useUnifiedTopology: true, // 새로운 서버 탐지 및 모니터링 엔진 사용
  })
  .then(() => console.log("MongoDB connected")) // 연결 성공 시 메시지 출력
  .catch((err) => console.log(err)); // 연결 실패 시 에러 출력

// '/api/todos' 경로로 들어오는 요청을 처리할 라우트를 등록한다.
// todos 라우트 파일을 가져와서 처리
app.use("/api/todos", require("./routes/todos"));

// 서버가 지정된 포트에서 요청을 수신한다.
// 서버가 성공적으로 시작되었음을 출력
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));

1-3. MongoDB 모델 생성 ✍️

const mongoose = require("mongoose");

// Todo 스키마 정의
const TodoSchema = new mongoose.Schema({
  title: {
    type: String, // 제목은 문자열 타입
    required: true, // 제목은 필수 입력 항목
  },
  completed: {
    type: Boolean, // 완료 상태는 불리언 타입
    default: false, // 기본값은 false (할 일이 완료되지 않은 상태로 시작한다는 뜻)
  },
});

// 'Todo' 모델을 정의하고, TodoSchema를 사용하여 Mongoose 모델을 생성
module.exports = mongoose.model("Todo", TodoSchema);

1-4. Controller 생성 ✍️

const Todo = require("../models/Todo");

// 모든 Todo 항목을 가져오는 함수
exports.getTodos = async (req, res) => {
  try {
    const todos = await Todo.find(); // 모든 Todo 항목을 MongoDB에서 찾음
    res.json(todos); // 결과를 JSON 형식으로 클라이언트에 응답
  } catch (err) {
    res.status(500).json({ message: "Error fetching todos" });
  }
};

// 새로운 Todo 항목을 생성하는 함수
exports.createTodo = async (req, res) => {
  try {
    const newTodo = new Todo({
      title: req.body.title, // 요청 본문에서 제목을 가져옴
      completed: req.body.completed, // 요청 본문에서 완료 상태를 가져옴
    });
    const savedTodo = await newTodo.save(); // 새 Todo 항목을 데이터베이스에 저장
    res.json(savedTodo); // 저장된 Todo 항목을 JSON 형식으로 클라이언트에 응답
  } catch (err) {
    res.status(500).json({ message: "Error creating todo" });
  }
};

// 특정 Todo 항목을 업데이트하는 함수
exports.updateTodo = async (req, res) => {
  try {
    const updatedTodo = await Todo.findByIdAndUpdate(req.params.id, req.body, {
      new: true, // 업데이트된 결과를 반환
    });
    res.json(updatedTodo); // 업데이트된 Todo 항목을 JSON 형식으로 클라이언트에 응답
  } catch (err) {
    res.status(500).json({ message: "Error updating todo" });
  }
};

// 특정 Todo 항목을 삭제하는 함수
exports.deleteTodo = async (req, res) => {
  try {
    await Todo.findByIdAndDelete(req.params.id); // 요청 경로의 ID를 가진 Todo 항목을 삭제
    res.json({ message: "Todo deleted" }); // 삭제 성공 메시지를 JSON 형식으로 클라이언트에 응답
  } catch (err) {
    res.status(500).json({ message: "Error deleting todo" });
  }
};

1-5. Router 설정 ✍️

const express = require("express");

const {
  getTodos,
  createTodo,
  updateTodo,
  deleteTodo,
} = require("../controllers/todoController");

// Express 라우터를 생성
const router = express.Router();

// GET 요청이 '/' 경로로 들어올 때, 모든 Todo 항목을 가져오는 핸들러를 호출
router.get("/", getTodos);

// POST 요청이 '/' 경로로 들어올 때, 새 Todo 항목을 생성하는 핸들러를 호출
router.post("/", createTodo);

// PUT 요청이 '/:id' 경로로 들어올 때, 특정 Todo 항목을 업데이트하는 핸들러를 호출
router.put("/:id", updateTodo);

// DELETE 요청이 '/:id' 경로로 들어올 때, 특정 Todo 항목을 삭제하는 핸들러를 호출
router.delete("/:id", deleteTodo);

// 설정한 라우터를 모듈로 내보냄
module.exports = router;

1-6. env 및 nodemon 설정 ✍️

// .env 설정 양식
MONGO_URI=your_mongodb_uri_here

// nodemon 관련 설정을 package.json에 추가
"scripts": {
  "start": "node server.js",
  "dev": "nodemon server.js"
}

2. Frontend 👨‍💻

MERN stack Frontend 셋팅을 6단계로 나누어보자.

2-1. 디렉토리 및 패키지 설정 ✍️

// vite project 생성
mkdir frontend
cd frontend
yarn create vite .

// 패키지 설정
yarn install
yarn add @mui/material jotai react-query axios

2-2. main.jsx 설정 ✍️

import ReactDOM from "react-dom/client";
import App from "./App";
// React-Query에서 QueryClient와 QueryClientProvider를 가져옴
import { QueryClient, QueryClientProvider } from "react-query";

// 새로운 QueryClient 인스턴스를 생성
// QueryClient는 React-Query의 데이터 캐싱 및 상태 관리를 처리
const queryClient = new QueryClient();

// React 애플리케이션을 DOM에 렌더링
ReactDOM.createRoot(document.getElementById("root")).render(
  // QueryClientProvider를 사용하여 전체 애플리케이션에 QueryClient를 제공
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
);

2-3. App.jsx 설정 ✍️

 // TodoList Component 렌더링
import TodoList from "./components/TodoList";
import { Container } from "@mui/material";

const App = () => {
  return (
    <Container>
      <TodoList />
    </Container>
  );
};

export default App;

2-4. atom 설정(jotai) ✍️

// src/atoms.js
import { atom } from "jotai";

const todoListAtom = atom([]);
const newTodoAtom = atom("");
const editTodoAtom = atom(null);
const editTodoTitleAtom = atom("");

export { todoListAtom, newTodoAtom, editTodoAtom, editTodoTitleAtom };

2-5. CRUD를 위한 hook 설정(React-Query, Axios) ✍️

// src/hooks/useTodos.js
import { useQuery, useMutation, useQueryClient } from "react-query";
import axios from "axios";
import { useAtom } from "jotai";
import { todoListAtom } from "../atoms/todoAtom";

// Todo 항목을 가져오는 비동기 함수
const fetchTodos = async () => {
  // API에서 Todo 항목을 가져옴
  const { data } = await axios.get("http://localhost:5000/api/todos");
  // 가져온 데이터를 반환
  return data;
};

// 새로운 Todo 항목을 생성하는 비동기 함수
const createTodo = async (newTodo) => {
  // API에 새로운 Todo 항목을 POST 요청으로 보냄
  const { data } = await axios.post("http://localhost:5000/api/todos", newTodo);
  // 생성된 Todo 항목을 반환
  return data;
};

// Todo 항목을 업데이트하는 비동기 함수
const updateTodo = async (updatedTodo) => {
  // API에 PUT 요청으로 Todo 항목을 업데이트
  const { data } = await axios.put(
    `http://localhost:5000/api/todos/${updatedTodo._id}`,
    updatedTodo
  );
  // 업데이트된 Todo 항목을 반환
  return data;
};

// Todo 항목을 삭제하는 비동기 함수
const deleteTodo = async (id) => {
  // API에 DELETE 요청으로 Todo 항목을 삭제
  await axios.delete(`http://localhost:5000/api/todos/${id}`);
};

// Todo 항목을 가져오기 위한 커스텀 훅
export const useTodos = () => {
  const [, setTodos] = useAtom(todoListAtom); // 전역 상태의 todos를 가져옴
  return useQuery("todos", fetchTodos, {
    // 서버에서 데이터를 가져온 후 전역 상태를 업데이트
    onSuccess: (data) => setTodos(data),
    refetchOnWindowFocus: false,
  });
};

// 새로운 Todo 항목을 생성하기 위한 커스텀 훅
export const useCreateTodo = () => {
  const queryClient = useQueryClient();
  return useMutation(createTodo, {
    // Todo 항목이 성공적으로 생성되면 'todos' 쿼리를 무효화하여 데이터를 새로고침
    onSuccess: () => {
      queryClient.invalidateQueries("todos");
      queryClient.refetchQueries("todos");
    },
  });
};

// Todo 항목을 업데이트하기 위한 커스텀 훅
export const useUpdateTodo = () => {
  const queryClient = useQueryClient();
  return useMutation(updateTodo, {
    // Todo 항목이 성공적으로 업데이트되면 'todos' 쿼리를 무효화하여 데이터를 새로고침
    onSuccess: () => {
      queryClient.invalidateQueries("todos");
      queryClient.refetchQueries("todos");
    },
  });
};

// Todo 항목을 삭제하기 위한 커스텀 훅
export const useDeleteTodo = () => {
  const queryClient = useQueryClient();
  return useMutation(deleteTodo, {
    // Todo 항목이 성공적으로 삭제되면 'todos' 쿼리를 무효화하여 데이터를 새로고침
    onSuccess: () => {
      queryClient.invalidateQueries("todos");
      queryClient.refetchQueries("todos");
    },
  });
};

2-6. 예제 component 설정 ✍️

// src/components/TodoList.jsx
import { useAtom } from "jotai";
import {
  todoListAtom,
  newTodoAtom,
  editTodoAtom,
  editTodoTitleAtom,
} from "../atoms/todoAtom";
import {
  Box,
  Button,
  TextField,
  Checkbox,
  List,
  ListItem,
  ListItemText,
  IconButton,
  Container,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import EditIcon from "@mui/icons-material/Edit";
import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
import {
  useTodos,
  useCreateTodo,
  useUpdateTodo,
  useDeleteTodo,
} from "../hooks/useTodos";

const TodoList = () => {
  const [todos] = useAtom(todoListAtom); // 전역 상태에서 todos 가져오기
  const [newTodo, setNewTodo] = useAtom(newTodoAtom); // 전역 상태에서 새 Todo 제목 가져오기
  const [editTodoId, setEditTodoId] = useAtom(editTodoAtom); // 전역 상태에서 편집 중인 Todo의 ID 가져오기
  const [editTodoTitle, setEditTodoTitle] = useAtom(editTodoTitleAtom); // 전역 상태에서 편집 중인 Todo 제목 가져오기

  const { isLoading } = useTodos(); // 데이터와 로딩 상태 가져오기
  const createTodo = useCreateTodo();
  const updateTodo = useUpdateTodo();
  const deleteTodo = useDeleteTodo();

  if (isLoading) return <div>Loading...</div>; // 로딩 중일 때 로딩 메시지 표시

  const handleAddTodo = () => {
    if (newTodo.trim()) {
      createTodo.mutate({ title: newTodo }); // 새 Todo 항목을 서버에 전송
      setNewTodo(""); // 입력 필드 초기화
    }
  };

  const handleEditTodo = (todo) => {
    setEditTodoId(todo._id); // 편집 중인 Todo의 ID 설정
    setEditTodoTitle(todo.title); // 편집 중인 Todo 제목 설정
  };

  const handleUpdateTodo = (todo) => {
    updateTodo.mutate({ ...todo, title: editTodoTitle }); // Todo 제목 업데이트
    setEditTodoId(null); // 편집 모드 종료
    setEditTodoTitle(""); // 제목 입력 필드 초기화
  };

  const handleCancelEdit = () => {
    setEditTodoId(null); // 편집 중인 Todo의 ID 초기화
    setEditTodoTitle(""); // 제목 입력 필드 초기화
  };

  return (
    <Container maxWidth="sm" sx={{ textAlign: "center" }}>
      {/* 제목 */}
      <h1>먹킷리스트</h1>

      {/* 새 Todo 항목 추가 섹션 */}
      <Box display="flex" justifyContent="center" alignItems="center" my={2}>
        <TextField
          value={newTodo}
          onChange={(e) => setNewTodo(e.target.value)}
          label="먹고 싶은거 추가하셈(MERN Test용)"
          variant="outlined"
          fullWidth
        />
        <Button
          onClick={handleAddTodo}
          variant="contained"
          sx={{ ml: 2, height: "50px", bgcolor: "black" }}
        >
          Add
        </Button>
      </Box>

      {/* Todo 항목 목록 섹션 */}
      <List>
        {todos.map((todo) => (
          <ListItem key={todo._id} dense>
            {/* 완료 상태를 표시하는 체크박스 */}
            <Checkbox
              checked={todo.completed}
              onChange={() =>
                updateTodo.mutate({ ...todo, completed: !todo.completed })
              }
            />

            {/* Todo 항목 제목: 편집 모드일 때와 아닐 때 */}
            {editTodoId === todo._id ? (
              <TextField
                value={editTodoTitle}
                onChange={(e) => setEditTodoTitle(e.target.value)}
                variant="outlined"
                size="small"
                fullWidth
              />
            ) : (
              <ListItemText primary={todo.title} />
            )}

            {/* 편집 모드일 때와 아닐 때의 버튼들 */}
            {editTodoId === todo._id ? (
              <>
                <IconButton edge="end" onClick={() => handleUpdateTodo(todo)}>
                  <CheckIcon />
                </IconButton>
                <IconButton edge="end" onClick={handleCancelEdit}>
                  <CloseIcon />
                </IconButton>
              </>
            ) : (
              <>
                <IconButton edge="end" onClick={() => handleEditTodo(todo)}>
                  <EditIcon />
                </IconButton>
                <IconButton
                  edge="end"
                  onClick={() => deleteTodo.mutate(todo._id)}
                >
                  <DeleteIcon />
                </IconButton>
              </>
            )}
          </ListItem>
        ))}
      </List>
    </Container>
  );
};

export default TodoList;

3. 완성본 이미지 👨‍💻

4. 분석 방향과 앞으로의 고민 👨‍💻

4-1. 분석 ✍️

frontend의 대응관계4가지다.

2-4. atom 설정(jotai)
2-5. CRUD를 위한 hook 설정(React-Query, Axios)
2-6. 예제 component 설정

1) 4와 5
2) 5와 6
3) 4와 6
5) 4와 5와 6

backend의 대응관계11가지다.

1-2. server.js 설정
1-3. MongoDB 모델 생성
1-4. Controller 생성
1-5. Router 설정

1) 2와 3
2) 2와 4
3) 2와 5
5) 3과 4
6) 3과 5
7) 4와 5
8) 2와 3과 4
9) 2와 3과 5
10) 2와 4와 5
11) 2와 3과 4와 5

frontend와 backend 대응관계도 위 논리에 기반하여 분석하면 된다. 복잡한 것은 풀어나가면 된다. 복잡함과 어려움은 전혀 다른 얘기라는 점을 주장하고 싶었다.

4-2. 고민 ✍️

무엇이 전역 상태가 되어야 하는가에 대한 고민이 생겼다. 컴포넌트 분리와 같이, 정답이 아니라 최선에 가까운 선택지가 있을 것임이 분명하다. 다만, 무엇을 전역 상태의 대상으로 삼아야 할까에 대한 기준?에 대한 고민을 많이 해보지 않았다는 생각이 든다. 어기적어기적, 전역 상태에 대한 고민을 할 수 있는 수준까지 왔음에 감사하다.

✅ 회고

테슬라 Product Manager 출신으로, MoreLabs라는 숙취음료 제조 및 판매 기업을 창업한 이시선 대표.

MoreLabs는, "hang over는 한국에도 있고 미국에도 있는데 왜 미국에서는 숙취음료를 판매하지 않을까"라는 단순한 궁금증을 시발점으로, 3,300만 달러 이상의 기업 가치를 인정받았다.

잘난 아이디어는 이미 도처에 깔려 있을 가능성이 높다. 세상에 나보다 똑똑한 사람은 차고 넘치니까. 효율적인 방법론이 너무 많아서, 차리리 비효율적으로라도 생각을 빨리 실천하는 것이 더 중요하지 않을까 하는 생각이 든다.

8월 종료.

profile
Write a little every day, without hope, without despair ✍️

0개의 댓글