Todo List 만들기, retro-todo-list

창우·2024년 4월 13일
0

ToyProject

목록 보기
1/7

개요

retro-todo-list

  • 여태 주먹구구 식으로 사용하던 리액트에 어느 순간 한계를 느껴서, 최근부터 리액트 공식 문서를 활용하여 기본 개념과 사용법을 정리하기 시작했다
  • 그렇게 공부를 하다보니 간단한 프로젝트를 통해 학습한 내용을 활용해보고자 하는 욕심이 들었다
  • 그래서, 이제는 토이 프로젝트의 대명사가 되어버린 TodoList를 만들기로 했다!
  • 너무 식상하고 따분한 주제일까도 고민했지만, 현재 수강중인 데브코스의 Node.js와 express를 활용한 CRUD, 그리고 리액트를 모두 접목시킬 수 있는 안성맞춤 주제라고 생각해 결국 선택했다!

구현목표

  • 기본적인 CRUD 기능의 구현
  • 데이터베이스 연동을 통한 데이터 저장
  • 리액트적 사고를 기반으로 한 컴포넌트 설계
  • JWT 토큰/쿠키를 활용한 통신구조

구현

1. 기획 및 컴포넌트 설계

  • Figma를 통해 대략적인 UI를 설계하였다
  • UI 테마는 어셈블리 느낌의 레트로 감성을 최대한 살려보고 싶었다

  • 데이터베이스는 사용자 개별 정보를 받을 테이블과, 리스트 테이블로 구성되어있다

2. 컴포넌트 구현

/* Modal.js */

export default function Modal(props) {
  return (
    <div className="modalContainer">
      <div className="modalLogo fontLarge">{props.logo}</div>
      {props.children} // 합성 요소가 해당 props.children으로 들어오게 된다
    </div>
  );
}
.
.

/* ModalAdd.js*/
.
.
  return (
    <>
      <div className="modalBackground" ref={ref}>
        <Modal logo="할 일 등록"> // 해당 컴포넌트에 하위 항목들은 전부 합성요소가 되어진다
          <div className="modalSubLogo fontMedium">{props.time}</div>
          <form>
            <textarea
              placeholder="할 일을 입력해주세요"  
              className="textField fontMedium"
              value={textField}
              onChange={(e)=>setTextFiled(e.target.value)}
            ></textarea>
            <Button value="등록하기" onClick={handleAdd}/>
          </form>
        </Modal>
      </div>
    </>
  );
  • 공식문서에서 제시하는 '합성 컴포넌트'의 재활용성에 감탄하며 꼭 활용해보고 싶었다.
  • Modal UI의 반복적으로 사용하는 부분을 주요 컴포넌트 요소로서 분리하고 ModalAdd, ModalUpdate, Login, Rester 컴포넌트들의 합성요소로서 활용하며 구현하였다.
/* Main.js */

  const addRef = useRef(null)
  const updateRef = useRef(null)
.
.
  useEffect(() => {
    const handleClickOutside = (e) => {
      if (isModalAdd && !addRef.current.contains(e.target)) setIsModalAdd(false) 
      if (isModalUpdate && !updateRef.current.contains(e.target)) setIsModalUpdate(false)
    };
    document.addEventListener('mousedown', handleClickOutside);

    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
    };
  }, [isModalAdd, isModalUpdate]);

 return (
    <>
      {isModalUpdate && <ModalUpdate time={formattedTime} ref={updateRef} id={listID} text={curText} />}
      {isModalAdd && <ModalAdd time={formattedTime} ref={addRef} />} // 참조할 DOM이 있는 자식 컴포넌트에 ref 변수를 props와 같이 할당한다 
	.
    .
    </>)
       
/* ModalAdd.js */
       
const ModalAdd = forwardRef((props, ref) => { // forwardRef로 ref를 매개변수로 받는다
  const [textField, setTextFiled] = useState('')
  const [cookies, setCookies] = useCookies(['id'])

  return (
    <>
      <div className="modalBackground" ref={ref}> // 부모에게 전달할 DOM 요소를 ref 값으로 설정한다
        <Modal logo="할 일 등록">
          <div className="modalSubLogo fontMedium">{props.time}</div>
          <form>
            <textarea
              placeholder="할 일을 입력해주세요"  
              className="textField fontMedium"
              value={textField}
              onChange={(e)=>setTextFiled(e.target.value)}
            ></textarea>
            <Button value="등록하기" onClick={handleAdd}/>
          </form>
        </Modal>
      </div>
    </>
  );
})
  • Modal창 외부를 클릭하는 경우 닫히는 이벤트를 넣고 싶어 DOM에 접근을 해야하는 사용해야 하는 상황이 생겼다.
  • React에서는 DOM에 직접적인 접근을 하는 경우 리액트 가상 DOM과의 충돌이 생길 수 있기에 해당하는 부분을 고려하며, useRef를 사용하였다.
  • 하지만, 현재 코드 구조는 Main의 자식 컴포넌트의 DOM 요소 에 접근을 해야한다.
  • 이 경우에는, forwardRef를 활용하여 자식의 참조 요소를 부모에서 사용하도록 할 수 있었다

3. API 서버 구현

  • express 라이브러리를 활용한 API 서버를 구현하였다. method와 endpoint가 명시적인, RESTful한 API를 설계하기위해 노력하였다
const { body, param, validationResult } = require("express-validator")

const validate = (req, res, next) => {
  const err = validationResult(req);
  if (err.isEmpty()) {
    return next();
  } else {
    console.log(err);
    return res.status(400).json(err.array());
  }
};
.
.
/* lists.js */

// 리스트 추가
router.post("/",
    [body("context").notEmpty().isString().withMessage("내용이 비어있음"),
    body("token").notEmpty().isString().withMessage("사용자 아이디가 비어있음"),
        validate], // validator 라이브러리를 통해 검사한 후, 검사의 오류가 있는지 식별하는 것으로 유효성 검사를 실시하였다 
    (req, res, err) => {
        const { token, context } = req.body;
        let sql = `INSERT INTO lists (context, user_id) VALUES (?,?)`;
        let decodedToken = jwtHandler.decodeToken(token)
        let userId = decodedToken.userId
        let values = [context, userId]
        conn.query(sql, values, (err, results, field) => {
            if (err) {
                console.log(err)
                return res.status(400).json({ message: '에러를 처리하는 메세지(리스트 추가 오류)' })
            } else {
                return res.status(201).json(results);
            }
        })
    })
  • API 통신에서 데이터 유효성 검사는 exress-validator 라이브러리를 활용하였다.
  • 다음과 같이 해당하는 미들웨어 함수를 구현하여 통신을 통해 값을 요청 받으면 그에 맞는 유효성 검사가 동작한다.
/* users.js */

const jwt = require("jsonwebtoken");
.
.
router.post(
  "/login",
  [
    body("userId").notEmpty().withMessage("아이디 비어있음"),
    body("userPW").notEmpty().withMessage("비밀번호 비어있음"),
  ],
  (req, res, next) => {
    const { userId, userPW } = req.body;
    console.log(userId, userPW);
    let sql = `SELECT * FROM users WHERE id = ?`;
    conn.query(sql, userId, (err, results, field) => {
      if (err) {
        console.log(err);
        res
          .status(400)
          .json({ message: "에러를 처리하는 메세지(로그인 오류)" });
      }
      let loginUser = results[0];
      if (loginUser && loginUser.password === userPW) {
        let token = jwt.sign( // 로그인이 성공하면, 성공한 userId값을 숨기기 위해 토큰을 발행한후 클라이언트에게 전달한다
          {
            userId: loginUser.id,
          },
          process.env.TOKEN_KEY,
          {
            expiresIn: "3h",
            issuer: "changchangwoo",
          }
        );
        res.status(200).json({
          userId: userId,
          token: token,
        });
      } else {
        res.status(400).json({ message: "로그인 실패를 알리는 메세지" });
      }
    });
  }
);
  • 사용자가 로그인을 하면 서버에서 jsonwebtoken 라이브러리를 활용하여 JWT 토큰을 발행하고 해당하는 토큰을 쿠키로 전송하여 사용자의 로그인을 확인하도록 하였다.
  • 기존 쿠키와 세션을 통한 통신보다, 사용자 데이터를 더 안전하게 관리할 수 있다!

4. 로그인 및 회원가입

/* Register.js */

import { useEffect, useState } from "react";
import API from "../utils/api";
.
.

export default function Register() {
    const [userId, setUserId] = useState('')
    const [idCheck, setidCheck] = useState(false)

    const debounce = (func, delay) => {
        let timer;
        return function (...args) {
            clearTimeout(timer);
            timer = setTimeout(() => func.apply(this, args), delay);
        };
    };
  // 사용자가 입력한 timer를 동작한다. 사용자의 입력이 추가로 들어오는 경우 해당 timer를 갱신한다, 만약 timer가 갱신되지않아 setTimeout이 동작하면 인자로받은 함수(아이디 중복검사 함수)를 실행시킨다 

    const checkDuplicate = async (value) => {
        API.post('/users/check', {
            userId : value
        }).then(response => {
            if(response.data === "성공") setidCheck(true)
        }).catch(err => {
            setidCheck(false)
        })
        };


    const debounceCheckDuplicate = debounce(checkDuplicate, 200);
  // debounceCheckDuplicate 함수는 checkDuplicate 함수와 타이머의 시간을 인자로 받아 debounce 함수를 실행시킨다, 
  .
  .

    return (
        <>
            <Modal logo="사용자 등록">
                <div style={{ marginTop: '60px' }} />
                <input className="inputBox fontSmall" placeholder="사용자명" value={userId} onChange={(e) => {
                    setUserId(e.target.value)
                    debounceCheckDuplicate(e.target.value) // 사용자가 값을 입력할 때마다 debounceCheckDuplicate 함수를 실행한다
                }}></input>
                <div className="registerCheck fontSmall" style={{ color: idCheck ? "white" : "red" }}>
                    {idCheck ? `사용 가능한 좋은 아이디입니다` : `이미 사용중인 아이디입니다`}
                </div>
        </>
    )
}
  • 디바운싱을 활용하여 아이디 중복 검사 기능을 구현하였다.
  • 사용자의 입력시간을 고려하여 지연된 통신을 사용할 수 있는 방식으로, 중복검사 외에도 페이징네이션등 정말 다양하게 활용할 수 있을 것이라는 생각이 들었다.

5. TodoList CRUD 기능

/* Main.js */

  useEffect(() => {
    API.get('/lists', {
      headers: {
        'Authorization': cookies.id
      }
    }).then(response => {
      setListArr (response.data)
    }).catch(error => {
      console.log(error)
    })
  }, [])
.
.
  return (
    <>
    .
    .
        {/* 배열의 각 요소에 대해 JSX 요소를 생성 */}
        {listArr.map((item, index) => (
          <li key={index}><TodoContentBox text={item.context} id={item.id} user={item.user_id} state={item.checked} date={item.created_at}
          handleUpdate={handleUpdate} /></li>
        ))}
      </ul>
    </>
  );
  • Main 컴포넌트로 이동하면 쿠키로부터 받은 사용자 정보의 리스트를 요청하고, 응답받은 데이터를 js의 map 함수를 활용하여 반복출력하도록 하였다.
    해당하는 부분을 구현하면서 get 메소드에 body값에는 데이터를 포함할 수 없다는걸 처음 알게되었다.. 한 시간과 바꾼 지식이다ㅠ
/* ModalAdd.js */

  const handleAdd = () => {
    API.post('/lists', {
      context : textField,
      token : cookies.id
    }).then(response => {
      window.location.reload();
    }).catch(error => {
      console.log(error)
    })
  }
  .
  .
    return (
    <>
      <div className="modalBackground" ref={ref}>
        <Modal logo="할 일 등록">
          <div className="modalSubLogo fontMedium">{props.time}</div>
          <form>
            <textarea
              placeholder="할 일을 입력해주세요"  
              className="textField fontMedium"
              value={textField}
              onChange={(e)=>setTextFiled(e.target.value)}
            ></textarea>
            <Button value="등록하기" onClick={handleAdd}/> // 버튼은 공통 컴포넌트이지만 콜백함수에 따라 다른 동작을 수행한다
          </form>
        </Modal>
      </div>
    </>
  );
  • 버튼은 공통컴포넌트이지만 요청하는 상황에 따라 다른 기능을 수행해야하기때문에 props의 callBack 함수를 활용하여 기능을 구분하였다.
  • 이와 비슷하게 자식 컴포넌트의 데이터를 부모 컴포넌트에서 접근하는 과정도 콜백함수를 통해 값을 입력받도록 하였다.

리팩토링

  • 해당 토이프로젝트를 데브코스 과정의 복습발표 시간을 활용해 짧막한 발표를 진행했다.
  • 정말 고맙게도 팀원분들께서 많은 피드백을 주셨다.
  • 현재 코드는 너무나도 많은 부분이 잘못되어있다는걸 느꼈다...ㅠ

쿠키를 통해 token을 통신하는 과정에서 res.cookie를 활용하고, 이를 cookie-paraser 라이브러리를 통해 구현한 미들웨어로 추출하는 것이 훨씬 효율적이다!


/* lists.js */

router.get("/",decodeToken, // 커스텀 미들웨어인 decodeToken을 통해서 req값으로 받게 된 cookie의 값 중 필요한 값인 'id'를 입력받고 복호화를 수행한다. 

    (req, res,) => {
        const token = req.decodeToken.userId
        let sql = `SELECT * FROM lists WHERE user_id = ?`;
        conn.query(sql, token, (err, results, field) => {
            if (err) {
                return res.status(400).json({ message: '에러를 처리하는 메세지(리스트 출력 오류)' })
            } else {
                return res.status(200).json(results)
            }
        })
    })

/* utils/jwt.js */

require("dotenv").config();
const jwt = require("jsonwebtoken");

const TOKEN_KEY = process.env.TOKEN_KEY;

const decodeToken = (req, res, next) => {
    let token = req.cookies.id; // cookie-paraser 라이브러리를 통해 req.cookies의 클라이언트의 쿠키값들이 전부 객체로 저장된다.
    if (token) {
        jwt.verify(token, TOKEN_KEY, (err, decoded) => {
            if (err) {
                console.log(err);
                return res.status(401).json({ message: "유효하지 않은 토큰입니다." });
            } else {
                req.decodeToken = decoded; // 정상적으로 쿠키가 존재한다면 복호화 과정을 한 값을 req.decodeToken에 저장한다.
                next();
            }
        });
    } else {
        return res.status(401).json({ message: "토큰이 필요합니다." });
    }
};

module.exports = decodeToken;
  • cookie의 데이터를 body와 마찬가지로 코드로 입력처리를 해야한다는 잘못된 생각을 하고 있었다.
  • res.cookie와 cookie-parser 라이브러리를 통해 훨씬 간편하고 직관적인 코드를 구현할 수 있었다.

사용자 회원가입시 개인정보를 데이터베이스에 저장할 때 crpyto를 활용해 암호화 한 후 저장하는 것이 더 안전하다!

/* users.js */

router.post("/join", // 회원가입
    [body("userId").notEmpty().withMessage("아이디 비어있음"),
    body("userPW").notEmpty().withMessage("비밀번호 비어있음")],
    (req, res, next) => {
        const { userId, userPW } = req.body;
        const salt = crypto.randomBytes(64).toString("base64");
  // crpyto로 변환된 값을 복호화하는것을 막기 위해 랜덤 string을 추가한다
        const hashPassword = crypto
        .pbkdf2Sync(userPW, salt, 100, 10, "sha512")
        .toString("base64");
  // 사용자로부터 입력받은 비밀번호를 해시함수를 통해서 암호화한다
        let sql = `INSERT INTO users (id, password, salt) VALUES (?,?,?)`;
        let values = [userId, hashPassword, salt]
        conn.query(sql, values, (err, results, field) => {
            if (err) {
                console.log(err)
                res.status(400).json({ message: '에러를 처리하는 메세지(회원가입 오류)' })
            } else {
                res.status(201).json(results);
            }
        })
    })
  • node.js에서 제공하는 crypto 모듈과 salt 변수를 활용해 암호화하는 과정을 추가했다.
  • salt 값 또한 데이터베이스에 저장해야하므로, 데이터베이스 구조를 변경하였다.
  • 처음 설계를 할 때, 보안에 대한 부분 역시 고려해야된다는것을 새삼 느꼈다.

라우터 파일 내 작성된 동작 코드를 컨트롤러 미들웨어로서 분리해 작성하는것이 훨씬 보기 좋다!

/* users.js */

const express = require("express")
const router = express.Router()
const {check, join, login} = require("../controller/UserController")
router.use(express.json())

// 로그인/비밀번호 확인
router.post("/check",check)

// 회원가입
router.post("/join",join)

//로그인
router.post("/login",login)

module.exports = router

/* UserController */

const conn = require('../dbconfig.js')
const { body, param, validationResult } = require("express-validator")
const jwt = require("jsonwebtoken")
const crypto = require("crypto"); 
require("dotenv").config()

const check = (req, res) => {
.
.
};

const join = (req, res) => {
.
.
};

const login = (req, res) => {
.
.
};

module.exports = {check, join, login}
  • 라우터 파일 내에 동작 코드를 분리해서, 컨트롤러 파일에서 주요 동작들을 수행할 수 있도록 구분하였다
  • 모듈화를 통해 훨씬 가독성이 좋아지고 직관적인 코드가 되었다

기능 동작마다 윈도우 새로고침이 실행되어 블링크 현상이 발생한다

/* Main.js */
.
.
  useEffect(() => {
    API.get('/lists').then(response => {
      setListArr (response.data)
    }).catch(error => {
      console.log(error)
    })
  }, [listArr])
  • 임시로 window.location.reload() 주었으나 깜박하고 수정을 하지 않았다.
  • useEffect로 list를 받아오는 항목에 의존성 배열로 리스트 값을 넣어 blink 없이 자연스레 동작하도록 하였다
  • 이미 gif 이미지를 올려 아쉽게 되었다..ㅠ

마치고

리액트적 사고하기

  1. UI를 컴포넌트 계층 구조로 나누기
  2. React로 정적인 버전을 만들기
  3. UI state에 대한 최소한의 표현 찾아내기
  4. State가 어디에 있어야 할 지 찾기
  5. 역방향 데이터 흐름 추가
  • 다음과 같은 리액트적 사고하기를 최대한 의식하고 진행하였지만, 역시 아직은 어렵다. 더 많은 경험이 필요하다.
  • 간단한 프로젝트지만 리액트의 useEffect, useRef, useState등 주요 훅의 원리를 이해할 수 있었고, 활용 할 수 있을것이란 자신감이 들었다
  • 다음 진행할 토이 프로젝트가 벌써 기대된다!
profile
물을 줘야지😂

0개의 댓글