Skip to content

kagrin97/MyCalendar-MERN

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

88 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

달력 사이트로 사용자가 자신만의 달력에 메모를 할수 있는 앱



🚀 프로젝트 설명

  • Front : React, Typescript, PWA

  • Back : Express, Javascript

  • DB : monggodb, mongoose

  • Front Deploy : Vercel

  • Back Deploy : AWS EC2

  • Image Upload Cloud : Cloudinary

  • 개발 기간 : 2023-01-27 ~ 2023-02-14


1. 데모 영상

  • Login 영상

    login.mp4
  • Signup 영상

    signup.mp4
  • Calendar Memo CRUD 영상

    crud.memoall.mp4

2. 구현 기능

  • Login

    • 기존 유저 확인 : server에 사용자가 입력한 email을 보내 기존에 존재하는 이메일인지 판단

    • 유효한 입력 값 : front, back 모두 email과 password가 유효한 입력값인지 확인 (email형식, password 7자 이상)

    • validtor : back에서 유저에게 받은 password와 db에 암호화된 password와 비교

    • jwt : 유효한 유저일 경우 기간이 12h인 jwt를 발급, front에서 token 값이 유효시간을 지나면 자동 삭제

    • response : res값으로 유저의 정보를 보냄

    • contextAPI : 받은 유저 정보를 contextAPI로 전역 상태로 관리

  • Signup

    • 기존 유저 확인 : 위와 동일

    • 유효한 입력 값 : 위와 동일

    • password hash : 입력받은 password를 암호화해서 db에 저장

    • 프로필 사진 저장 : imgFile을 받을경우 upload폴더에 multer로 임시 저장후 cloudinary 클라우드에 이미지 저장 후 임시 저장 파일 삭제, 만약 프로필 사진을 설정하지 않을경우 기본 아바타로 적용

    • jwt : 위와 동일

    • response : 위와 동일

    • contextAPI : 위와 동일

  • Calendar memo

    • 로그인 확인 : token을 가지고 있고 token 유효시간일 때만 CRUD기능이 정상적으로 작동

    • GET/memo : 유저의 모든 calendr memo를 가져옴

    • POST/memo 생성 : 유효한 입력 값 일때만 memo를 생성, db에서 Transaction으로 문제 발생시 데이터 롤백

    • POST/memo 이미지 생성 : memo를 작성할때 이미지 첨부 가능 첨부시 cloudinary에 이미지 저장

    • PATCH/memo : 유효한 값일떄만 memo 정보를 수정할수있음


3. 핵심 기능

Front

로그인 토큰을 저장, 유효시간 안 자동 로그인, 유효시간 후 자동 삭제

  • 로드인 시 토큰과 토큰 유효시간을 localStorage와 전역상태에 저장
    const login = useCallback(
    ({ userId, token, expirationDate, name, avatar }: LoginPropsTypes) => {
    const tokenExpirationDate =
    expirationDate || new Date(new Date().getTime() + 1000 * 60 * 60 * 12);
    setTokenExpirationDate(tokenExpirationDate);
    localStorage.setItem(
    "userData",
    JSON.stringify({
    userId,
    token,
    expiration: tokenExpirationDate,
    name,
    avatar,
    })
    );
    dispatch({
    type: "SET_AUTH_SUCCESS",
    data: { userId, token, name, avatar },
    });
    },
    []
    );

  • 페이지 새로고침시 토큰 유효시간이 지나지 않았다면 로그인
    useEffect(() => {
    const userData = localStorage.getItem("userData") || "";
    let storedData;
    if (userData) {
    storedData = JSON.parse(userData);
    }
    if (
    storedData &&
    storedData.token &&
    storedData.expiration > new Date().toISOString()
    ) {
    login({
    userId: storedData.userId,
    token: storedData.token,
    expirationDate: storedData.expiration,
    name: storedData.name,
    avatar: storedData.avatar,
    });
    }
    }, [login]);

  • 페이지 새로고침시 토큰 유효시간이 지났다면 자동 로그아웃
    const logout = useCallback(() => {
    setTokenExpirationDate(null);
    localStorage.removeItem("userData");
    dispatch({ type: "SET_AUTH_CLEAN" });
    }, []);
    let logoutTimer: NodeJS.Timeout;
    useEffect(() => {
    if (token && tokenExpirationDate) {
    const remainingTime =
    new Date(tokenExpirationDate).getTime() - new Date().getTime();
    logoutTimer = setTimeout(logout, remainingTime);
    } else {
    clearTimeout(logoutTimer);
    }
    }, [token, logout, tokenExpirationDate]);


AbortController로 비동기 작업 취소

  • unmount시 AbortController 인스턴스가 들어있는 activeHttpRequests를 모두 abort 메서드를 실행함으로 비동기 작업취소
    export const useHttpClient = () => {
    const [isLoading, setIsLoading] = useState(false);
    const [error, setError] = useState<string | null>();
    const activeHttpRequests = useRef<AbortController[]>([]);
    const sendRequest = useCallback(
    async (url: string, method = "GET", body?: any, headers?: any) => {
    setIsLoading(true);
    let start = new Date().getTime();
    const httpAbortCtrl = new AbortController();
    activeHttpRequests.current.push(httpAbortCtrl);
    try {
    const response = await fetch(url, {
    method,
    body,
    headers,
    signal: httpAbortCtrl.signal,
    });
    const responseData = await response.json();
    activeHttpRequests.current = activeHttpRequests.current.filter(
    (reqCtrl: AbortController) => reqCtrl !== httpAbortCtrl
    );
    if (!response.ok) {
    throw new Error(responseData.message);
    }
    let end = new Date().getTime();
    const notFlashLoadingSpinner = () => {
    if (end - start < 200) {
    setTimeout(() => {
    setIsLoading(false);
    }, 200);
    } else {
    setIsLoading(false);
    }
    };
    notFlashLoadingSpinner();
    return responseData;
    } catch (err: unknown) {
    if (err instanceof Error) {
    console.error(err);
    setError(err.message);
    setIsLoading(false);
    throw err;
    }
    }
    },
    []
    );
    const clearError = () => {
    setError(null);
    };
    useEffect(() => {
    return () => {
    activeHttpRequests.current.forEach((abortCtrl: AbortController) =>
    abortCtrl.abort()
    );
    };
    }, []);
    return { isLoading, error, sendRequest, clearError };
    };

Back

calendar의 RUD 요청시 토큰 검사

  • 토큰 검사 미들웨어를 구현해서 인증된 사용자만 RUD 요청 동작
    const jwt = require("jsonwebtoken");
    const HttpError = require("../models/http-error");
    module.exports = (req, res, next) => {
    if (req.method === "OPTIONS") {
    return next();
    }
    try {
    const token = req.headers.authorization.split(" ")[1];
    if (!token) {
    throw new Error("사용자 인증에 실패하셨습니다.");
    }
    const decodedToken = jwt.verify(token, process.env.DB_JWT_KEY);
    req.userData = { userId: decodedToken.userId };
    next();
    } catch (err) {
    const error = new HttpError("사용자 인증에 실패하셨습니다.", 403);
    return next(error);
    }
    };

image 파일들을 cloudinary 클라우드 서버에 저장

  • image 파일들을 따로 저장
    const cloudinary = require("cloudinary").v2;
    const uploadImgCalendar = (req, res, next) => {
    cloudinary.config({
    cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
    api_key: process.env.CLOUDINARY_API_KEY,
    api_secret: process.env.CLOUDINARY_API_SECRET,
    });
    cloudinary.uploader.upload(
    req.file.path,
    { resource_type: "image", width: 300, height: 300, crop: "limit" },
    (err, result) => {
    if (err) {
    const error = new HttpError(err.message, 400);
    return next(error);
    }
    fs.unlink(req.file.path, (err) => {
    if (err) {
    console.error(err);
    }
    res
    .status(200)
    .json({ message: "Image 업로드 ", imgURL: result.secure_url });
    });
    }
    );
    };


4. ERD


5. SERVER API 명세서


6. 제가 블로그에 작성한 프로젝트 진행중 배운점

7. 한계점

jest를 이용해 테스트코드 작성 실패

문제 : 모든 기능을 구현하고 나서 jest를 사용해서 테스트 코드를 작성하려고 했지만 toast ui와 react가 버전 충돌이 일어났다.

💡 시도한 방법 1 :

toast ui가 react 17버전과 호환이 된다는 에러 메시지를 보고 17버전으로 다운그레이드 하려고 했지만 17버전으로 다운그레이드 시 다른 많은 패키지들도 다운그레이드 해야 하기 때문에 포기했다.


💡 시도한 방법 2 :

--legacy-peer-deps를 사용해서 충돌을 무시했지만 이렇게 사용하면 나중에 더 큰 문제가 발생할 것 같아서 포기


💡 시도한 방법 3 :

react 18과 호환되는 에디터를 탐색하였지만 에디터 부분과 코드를 다 뜯어 고쳐야 해서 포기했다.


결론 : 결국 jest로 test 코드 작성은 포기했다. 나중에 다른 에디터로 변경하던가 처음 코드를 작성할 때부터 TDD로 구현했으면 진작에 버전 문제를 확인하고 고칠 수 있었을 것을 하는 아쉬움이 남는다.