retro-todo-list
- 프론트엔드 : React
- 백엔드 : Node.js(express), Mysql
- 깃허브 : https://github.com/changchangwoo/RetroTodoList
- 기본적인 CRUD 기능의 구현
- 데이터베이스 연동을 통한 데이터 저장
- 리액트적 사고를 기반으로 한 컴포넌트 설계
- JWT 토큰/쿠키를 활용한 통신구조
/* 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>
</>
);
/* 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>
</>
);
})
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);
}
})
})
/* 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: "로그인 실패를 알리는 메세지" });
}
});
}
);
/* 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>
</>
)
}
/* 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>
</>
);
/* 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>
</>
);
쿠키를 통해 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;
사용자 회원가입시 개인정보를 데이터베이스에 저장할 때 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);
}
})
})
라우터 파일 내 작성된 동작 코드를 컨트롤러 미들웨어로서 분리해 작성하는것이 훨씬 보기 좋다!
/* 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])
리액트적 사고하기
- UI를 컴포넌트 계층 구조로 나누기
- React로 정적인 버전을 만들기
- UI state에 대한 최소한의 표현 찾아내기
- State가 어디에 있어야 할 지 찾기
- 역방향 데이터 흐름 추가