Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

11조 과제 제출 (박진영, 남기훈, 이정우) #7

Open
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

howooking
Copy link

@howooking howooking commented Aug 10, 2023

KDT5-MINI with backend

프로젝트 소개

본 웹 어플리케이션은 50명 내외의 중소기업에서 사용하는 연차/당직 관리 프로그램입니다.

패스트캠퍼스 백엔드 5기 3분과 팀을 이루어 협업을 진행하였습니다.

결과물 보러가기
리포지토리

11조 개쩌는팀 소개

팀원 박진영 남기훈 이정우 성규창 김용원 배종윤
담당 내계정
관리자
회원가입
캘린더
인증인가
연차/당직 신청
백엔드 백엔드 백엔드



시작 가이드

Installation

$ git clone https://github.com/howooking/KDT5_MINI_TEAM11
$ cd KDT5_MINI_TEAM11
$ npm install
$ npm run dev
(.env)
VITE_CLOUD_NAME=namkihun

사용한 기술, 라이브러리

Environment






Config




Frontend








Backend




Co-work






화면 구성

로그인

image-1
  • 사내에서 사용하는 프로그램으로 반드시 로그인이 필요함
    • 로그인, 회원가입 페이지를 제외한 모든 페이지는 protected route
  • 로그인 창을 모달화하였으며 외부 배경을 blur 처리
  • 회원가입 버튼을 눌러 회원가입페이지로 이동

회원가입

2023-08-11_14-32-12-1

  • 입력 항목별 유효성 검사
  • 이메일 중복 체크 이후에 이메일 인증해야 회원가입을 할 수 있다.

메인 화면

image-3
  • 사이드바에서 내가 신청한 연차/당직 목록을 확인 할 수 있다.
  • 사이드바에서 새로운 연차/당직을 신청할 수 있다.
    • 신청한 연차/당직은 대기 상태로 등록이 되며 관리자가 승인 또는 거절을 한다.
    • 심사중인 연차/당직은 취소가 가능하다.
  • 우측 상단에 간략한 내 정보 및 로그아웃 버튼이 있다.
  • 달력에는 모든 사원들의 승인된 일정이 표시된다.
  • 달력 좌측 상단의 Switch 버튼을 눌러 내 일정만을 볼 수 있다.

내 정보 페이지

image-4
  • 내 정보와 수정이 가능한 항목을 수정 할 수 있다.
  • 유효성 검사 결과와 등록 결과 등을 팝업 메세지로 사용자에게 알려준다.

내 연차/당직 페이지

image-6
  • 내가 신청한 연차/당직 정보를 확인할 수 있다.
  • 심사 중인 연차/당직은 삭제가 가능하다.
  • 거절된 연차/당직은 삭제가 불가능하다.
  • 승인된 연차/당직은 삭제가 가능하다.
  • 과거의 연차/당직은 삭제가 불가능하다.

관리자 연차/당직 승인 페이지

image-7
  • 관리자는 모든 사원의 연차/당직 신청을 승인, 거절, 취소할 수 있다.
  • 관리자MANAGER가 메뉴가 보이지 않으며 접근 할 수 없다.

관리자 사원 직책 변경 페이지

2023-08-11_14-52-36

  • 관리자는 사원의 직책을 변경할 수 있다.

고찰

협업

  • 처음으로 백엔드와 협업을 진행

  • 백엔드는 프론트 지식이 프론트는 백엔드 지식이 부족한 관계로 초반 소통과정이 원활하지 않음

    • 가장 중요한 것은 소통, 지식 간극을 적극적인 소통으로 줄여나감
    • data base, 네트워크 통신, 보안에 대한 추가적인 공부가 필요함

  • 노션, 슬랙, 줌, 피그마 툴을 사용
    • 노션 : API 명세서
      image-13


    • 슬랙 : 텍스트 형식의 소통
      image-10

    • 줌 : 영상, 음성 형식의 실시간 소통
      image-12

    • 피그마 : 와이어프레임 작성
      image-14

AccessToken & RefreshToken, HttpOnly 쿠키, Axios Interceptor

img_1

  • Authorization

    • 사용자가 로그인을 요청을 보내면 서버에서 accessToken은 res.body에 담아서 보내고, refreshToken은 Http Only 쿠키로 보낸다.

  • Athentication

    • 로그인 한 사용자가 요청을 보낼 때 header에 accessToken을 담아 보낸다.
    • Axios interceptor를 이용하면 accessToken이 필요한 모든 요청들에 토큰을 담아 보낼 수 있다.

    export const customAxios = axios.create({
      baseURL: BASE_API_URL,
      timeout: 5000,
    });
    
    customAxios.interceptors.request.use(
      async (req) => {
        const accessToken = getAccessTokenFromCookie();
        if (!accessToken) {
          return req;
        }
        req.headers.Authorization = `Bearer ${accessToken}`;
        return req;
      },
      (error) => {
        return Promise.reject(error);
      },
    );
    • 서버는 해당 accessToken을 검증하고 응답을 보낸다.

  • AccessToken이 만료 된 경우

    • accessToken이 만료 된 경우 서버에서는 401 상태메세지를 보내고 이를 Axios interceptor를 통해 중간에서 새로운 accessToken을 요청하는 로직을 구현할 수 있다.
    // 여러개의 요청이 밀렸을 경우 리프레시토큰 api가 여러번 실행되는 것을 막는 flag
    let isRefreshing = false;
    
    // 만료된 토큰으로 인해 pending상태가 된 기존의 요청들을 배열에 담음, 새로운 토큰이 발행되면 이들 요청을 진행
    let refreshSubscribers: ((accessToken: string) => void)[] = [];
    
    customAxios.interceptors.response.use(
      (response) => {
        return response;
      },
      async (error) => {
        const status = error.response?.data.error.status;
    
        if (status === 401) {
          if (!isRefreshing) {
            isRefreshing = true;
    
            try {
              const response = await axios(
                `${BASE_API_URL}/v1/auth/refresh-token`,
                {
                  withCredentials: true,
                },
              );
    
              if (response.status === 200) {
                setAccessTokenToCookie(response.data.response.accessToken);
    
                const config = error.config;
                config.headers.Authorization = `Bearer ${response.data.response.accessToken}`;
                config.withCredentials = true;
    
                const retryOriginalRequest = new Promise((resolve) => {
                  resolve(axios(config));
                });
    
                isRefreshing = false;
    
                refreshSubscribers.forEach((callback) =>
                  callback(response.data.response.accessToken),
                );
                refreshSubscribers = [];
    
                return retryOriginalRequest;
              }
            } catch (error) {
              deleteAccessTokenFromCookie();
              isRefreshing = false;
              return Promise.reject(error);
            }
          } else {
            return new Promise((resolve) => {
              refreshSubscribers.push((accessToken: string) => {
                error.config.headers.Authorization = `Bearer ${accessToken}`;
                resolve(axios(error.config));
              });
            });
          }
        } else {
          return Promise.reject(error);
        }
      },
    );
  • HttpOnly 쿠키

    • 401응답을 받은 클라이언트는 accessToken 재발급을 위해 "/v1/auth/refresh-token" GET요청을 보낸다. 이 때 refreshToken은 이미 쿠키에 담겨있다.
    • HttpOnly 쿠키는 클라이언트측에서 자바스크립트로 접근 할 수 없으므로 XXS와 같은 공격을 무력화 시킨다.
    • withCredentialstrue로 설정해야 한다.
    • 클라이언트 배포 주소와 서버 배포 주소가 모두 SSL인증서를 필요로 한다.
      • 서버에서는 무료 dns와 SSL인증서를 발급받아서 해결
      • 클라이언트는 vecel로 배포시 https로 배포가 되므로 서버와 같은 작업이 필요없다.
      • 그러나 로컬 환경에서 테스트를 할 때는 HttpOnly 쿠키 사용이 불가능하므로 배포 환경과 로컬 환경에서의 api를 분리하여서 작업하였다.
      • 해당 리포지토리는 로컬용

외부 서비스 (cloudinary)

  • 프로필 이미지 자체를 서버에 저장해서 가져오는 형식이 아닌, 업로드한 프로필 이미지를 cloudinary라는 외부 서비스를 이용해서 관리하여, 업로드한 이미지의 url만 서버에 저장해서 사용했다.
    • 프로젝트 기획 단계에서 프로필 이미지를 url형식으로 가져와서 사용하기로 했는데, cloudinary라는 외부 서비스를 같은 팀원인 정우님의 추천으로 사용하게 되었고, 초기 과정에서 cloudinary를 사용하기 위해서 주말 이틀을 꼬박 ant design upload ui에 cloudinary를 연결하기 위해 헤메었다.
    • 생각과는 다르게 cloudinary 서비스를 사용하기 위해서, API KEY나 API SELECT등 cloudinary 홈페이지에 초기에 적혀있던 환경변수들은 딱히 필요가 없었고, 이 때문에 많은 착오를 겪었던거 같다.
    • 외부 서비스이기 때문에 이미지 업로드하는데 있어서 그러한 환경 변수는 필수적으로 있어야한다는 생각이 있었고, 관련 글을 찾아보는데도 API KEY를 관리를 해야한다. 이런 글들이 있었기 때문이었다. 하지만 주말 내내 헤메다가 찾아낸건 다름아닌 cloudinary 영문 docs에 필요한 사항이 다 적혀있었다.
    • 필요한 것은 자신의 cloudinary의 이름과 preset이게 끝이었다.
    • 우여곡절 끝에 정말 주말 토,일을 다 할애해서 upload를 하면 url을 받아오는데 성공했다.
    • 이렇게 회원가입 부분에서 프로필 이미지 업로드가 동작하게 되었고, 추후에 마이페이지 부분에서 프로필 이미지 수정하는 부분도 맡아서 정리하게 되었다. -by. 기훈

불필요한 리렌더링과 통신

  • 기획 설계 단계에서의 헛점이 여실히 드러나는 부분이었다고 생각드게 불필요한 리렌더링이 일어나는 것과 서버에 요청하는 것이다.

  • 헤더에 사용자 정보에 있는 연차의 상태가 업데이트 될 때 마다 리렌더링 일어나게 하였으나, 연차와 상관없는 당직을 신청하거나 삭제할 때도 같은 상태가 업데이트가 되어서 연차와 당직을 구분하는 로직을 추가하였다.

  • 메인 페이지에서 switch를 토글하면 모든 일정과 사용자의 일정을 필터링해서 볼 수 있게 했는데, 초기에는 토글 할 때 마다 서버에 통신을 해서 모든 일정을 볼 때 1년치 데이터를 가져왔고, 사용자의 일정을 볼 때에도 1년치 데이터를 또 받아온 다음에 사용자 정보 데이터도 받아와서 필터링 하는 구조였었다.
    생각해보니 처음에 모든 일정 데이터를 1년 단위로 서버에서 받아왔고, 이 받아온 데이터에는 사용자의 일정도 포함되어있는 상태였다.
    서버에서 보내온 1년 치 데이터와 사용자 정보를 받아와서 모든 일정과 사용자 일정을 필터링하는 로직을 추가해서 불필요한 통신을 줄였다.

수정 전
switch토글과 month의 상태가 변할 때 마다 서버에 불필요하게 요청을 함.

const listResponse = await scheduleList(year);
const infoResponse = await getMyAccount();

scheduleList의 response에 있는 userName과 getMyAccount response에 있는 userName이 같을 시에 필터링 되게 하였었음.

useEffect(() => {
    const schedule = async () => {
      // getAccessTokenFromCookie를 이용해서 쿠키에 저장된 accessToken을 가져옴
      const accessToken = getAccessTokenFromCookie();
      // 엑세스 토큰이 없으면 서버에 요청하지 않음
      if (!accessToken) {
        return;
      }

      setIsLoading(true);

      const listResponse = await scheduleList(year);
      const infoResponse = await getMyAccount();

      // 실제 응답 데이터 추출
      const listResponseData = listResponse.data.response;
      const infoResponseData = infoResponse.data.response;

      // response data를 가져오는데 그 내부에 있는 response라는 배열 데이터를 각각의 요소를
      // 아래의 형태의 객체로 변환해서 events 변수에 저장, setEvents에 전달
      const events = listResponseData
        .filter(
          (item: ScheduleItem) =>
            (isAllChecked && item.state === 'APPROVE') ||
            (item.userName === infoResponseData.userName &&
              item.state === 'APPROVE'),
        )
        .map((item: ScheduleItem) => {
          return {
            title: item.userName,
            start: item.startDate,
            end: item.endDate,
            color: DUTY_ANNUAL[item.scheduleType].color,
          };
        });
      setEvents(events);
      setIsLoading(false);
    };
    schedule();
  }, [isSignedin, year, month, isAllChecked]);

수정 후
scheduleList response로 오는 data스키마를 수정하여 userEmail을 확인 할 수 있게 하였고,
userEmail을 리코일을 통해서 전역으로 관리하여, response의 userEmail같으면 필터링 되게 하였다.
스위치 토글과 month의 상태값 변화를 수정 전과 달리 의존성 배열에서 삭제하였다.
scheduleList는 년 단위의 데이터를 받아오니 월 단위 변경에 대해서 의존성 배열에서 필요없다고 판단하였고,
스위치 토글 또한 처음 받아온 년 단위 데이터를 이용하면 되어서 의존성 배열에서 제거했다.

useEffect(() => {
    const getUsersYearlySchedules = async () => {
      if (!accessToken) {
        return;
      }
      try {
        setUserYearlySchedulesLoading(true);
        const listResponse = await scheduleList(year);
        const listResponseData = listResponse.data.response;

        const sideMyScheduleData = listResponseData
          .filter((item: mySchedule) => item.userEmail === userEmail)
          .map((item: mySchedule) => {
            return {
              id: item.id,
              key: item.id,
              scheduleType: item.scheduleType,
              startDate: item.startDate,
              endDate: item.endDate,
              state: item.state,
            };
          });
        setSideMyschedule(sideMyScheduleData);

        const events = listResponseData
          .filter((item: mySchedule) => item.state === 'APPROVE')
          .map((item: ScheduleItem) => {
            const adjustEndDate = dayjs(item.endDate)
              .add(1, 'day')
              .format('YYYY-MM-DD');
            return {
              userEmail: item.userEmail,
              title: item.userName,
              start: item.startDate,
              end: adjustEndDate,
              color: DUTY_ANNUAL[item.scheduleType].color,
            };
          });
        setEvents(events);
      } catch (error) {
        console.log(error);
      } finally {
        setUserYearlySchedulesLoading(false);
      }
    };
    getUsersYearlySchedules();
  }, [year, accessToken, userEmail]);

kse-seong-eun added a commit that referenced this pull request Aug 11, 2023
@howooking howooking self-assigned this Aug 11, 2023
Copy link

@DevYBecca DevYBecca left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안녕하세요, 2조 윤금엽입니다! axios interceptor, cloudinary를 비롯해 README를 상세하게 작성해주셔서 네트워크 통신 등에 대한 공부가 많이 필요하겠다는 생각을 하게 됐습니다😭 setTimeOut() 함수 등으로 요청 처리·속도에 대해서도 신경쓰신 것 같아 많이 배우게 된 것 같습니다! 이번 프로젝트 수고 많으셨습니다🙌🏻✨

timeout: 5000,
});

customAxios.interceptors.request.use(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

axios의 interceptor api를 활용해서 then이나 catch로 요청 처리 전 요청과 응답을 처리할 수 있는 기능을 덕분에 알게 됐네요! 다른 프로젝트에서 공식 문서를 참고해서 사용해 봐야겠습니다:)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리뷰 달아주셔서 감사합니다!

const currentTime = Math.floor(new Date().getTime() / 1000);
// 만료시간 > 5분 남은 경우
const expirationLeft = expirationTime - currentTime;
console.log(`만료 ${Math.floor(expirationLeft / 60)}분 남았습니다.`);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

만료 시간을 확인하는 console.log가 모든 페이지에서 반복적으로 출력되고 있어서 확인해주시면 좋을 것 같습니다!

async (error) => {
const status = error.response?.data.error.status;

if (status === 401) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

조건문이 길어서 유지·보수가 어려울 수도 있을 것 같다는 생각이 드는데 협업하시는 동안 불편하지 않으셨는지 궁금합니다! hooks 분리를 하거나 조건문 내에서 반복되고 있는 response.data.response.accessToken을 변수로 저장해보시는 건 어떨까요?

createAt: string;
}

export const DUMMY_WORKERS: DataType[] = [
Copy link

@DevYBecca DevYBecca Aug 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

commit에 더미데이터를 push할 경우 프로젝트 기능을 시연할 때 용이하고, 리팩토링 유지보수 및 전·후 변경 사항을 검증할 수 있는 장점이 있다고 알고 있는데 이러한 목적으로 push를 하신 게 맞으실까요? 혹시 다른 이유가 있으시다면 궁금합니다!

Copy link
Author

@howooking howooking Aug 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안지우고 푸시했습니다.

message.error('이메일 인증 실패!');
}
} catch (error) {
message.error('서버 요청에 실패하였습니다.');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이메일 인증번호를 발송한 후 이메일을 변경했을 때 변경한 이메일로 재발송을 하기 위해선 회원가입 페이지를 재렌더링하거나 시간만료 후 재발송 버튼을 클릭했을 때만 가능한 것 같습니다! 사용자가 오타를 입력한 상황이라면 재요청을 해야 할 경우가 생길 것 같은데 혹시 어떻게 생각하시나요?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오타에 대해서 미처 고려하지 못 한 부분이 있었네요. 일단 최종적으로 재인증 기능을 넣긴했는데 , 처음부터 오탈자에 대해서 염두를 하지 않은 부분이었네요. 이 부분 추가로 보완해서 오탈자에 대해서 대처 할 수 있게 하겠습니다.

}}
>
<Avatar src={userHeaderInfo.profileThumbNail} />
<Link to="/myaccount">{userHeaderInfo.userName}</Link>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'내 정보', '내 연차/당직', '관리자' 기능이 있는 페이지(일명 마이 페이지)로 이동하는 메뉴가 가시적으로 잘 보이지 않아서 헤매게 되는 것 같습니다! 메인 페이지에서 쉽게 확인할 수 있도록 Header의 가운데로 메뉴를 옮겨보시는 건 어떨까요?

disabledDate={pastDates}
/>
) : (
<DatePicker

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

App.tsx에서 <ConfigProvider theme={theme} locale={koKR}> locale 속성을 추가해주셨기 때문에 DatePicker에서 년도는 한글화가 되어있지만 월과 요일이 여전히 영어로 출력되고 있습니다! ant design은 dayjs 라이브러리를 기본적으로 사용하고 있기 때문에 dayjs도 한글화를 해줘야 DatePicker의 요소들이 모두 한글로 변환이 잘 되더라구요! 이 내용을 추가해주시면 될 것 같습니다:)

import dayjs from 'dayjs';
import 'dayjs/locale/ko';
import locale from 'antd/locale/ko_KR';

// dayjs 라이브러리 한글화
dayjs.locale('ko');

@howooking
Copy link
Author

안녕하세요, 2조 윤금엽입니다! axios interceptor, cloudinary를 비롯해 README를 상세하게 작성해주셔서 네트워크 통신 등에 대한 공부가 많이 필요하겠다는 생각을 하게 됐습니다😭 setTimeOut() 함수 등으로 요청 처리·속도에 대해서도 신경쓰신 것 같아 많이 배우게 된 것 같습니다! 이번 프로젝트 수고 많으셨습니다🙌🏻✨

@howooking howooking closed this Aug 15, 2023
@howooking howooking reopened this Aug 15, 2023
Copy link

@ruddnjs3769 ruddnjs3769 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안녕하세요! 2조 김경원입니다! 감사하게도 README 파일을 엄청 자세히 작성해주시고, 주석도 잘 달아주셔서 코드를 이해하는데 큰 도움이 되었습니다😊. 이번 프로젝트를 진행하면서 놓치고 신경 못썼던 부분에 대해 알게되고, 저희 프로젝트와 차이점을 보는게 흥미로워 즐겁게 리뷰했습니다! 리뷰를 진행하면서 refresh 토큰 및 cookie보안 등, 중요한 부분을 신경쓰지 못한 것 같아 스스로 부끄러웠네요.😅
많이 배울 수 있었던 시간이었습니다. 개 쩌는 팀이군요, 수고 많으셨습니다~!

headers: { 'Content-Type': 'application/json' },
});
return response;
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

인증 메일 발송 api는 백엔드 측에서 작업한 것 같은데, 메일 내용을 보니까, 백엔드쪽에서 메일 인증 관련 라이브러리를 사용하는 것 같은데 맞을까요? 저는 생각만 해보고 복잡할 것 같아서 넘겼는데, 실제로 작동하는게 놀랍습니다!👍

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵, 백엔드 쪽에서 작업한 내용인데 인증 관련 라이브러리를 사용했다고 들었습니다.

locale={'ko'} // 지역
dayCellContent={renderDayCellContent} // '일' 문자 렌더링 변경
ref={calendarRef}
headerToolbar={false}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희도 fullCalendar 를 사용했는데, 캘린더 조작 부분에서 많이 헤매었던 기억이 납니다.😂
headerToolbar를 false로 두고 아예 커스텀하는 방법도 있군요. antDesign 사용하시면서 ui/ux적으로 많이 고민하신 흔적이 느껴져서 좋습니다.

}
};
getUsersYearlySchedules();
}, [year, accessToken, userEmail]);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

home.tsx에서 관리하는 상태들이 많은 것 같은데, setEvents나 scheduleList api활용 관련 코드는 calendar.tsx 컴포넌트로 분리하여 관리하셔도 좋을 것 같습니다!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

모든 일정 또는 사용자의 승인된 일정을 렌더링하는 calendar컴포넌트와 사이드바에서 사용자의 신청 대기 상태와 신청 결과 상태를 확인할 수 있는 mySchedule 컴포넌트가 home컴포넌트의 하위 컴포넌트라 home컴포넌트에서 1년치 데이터를 받아와서 calendar와 mySchedule에 전달해주고 있는 구조입니다.

return current < dayjs().startOf('day');
};

const mySchedule = events.filter((event) => event.userEmail === userEmail);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

개인적인 의견이지만 나의 일정 확인에서는 단순 전체 목록 필터링이 아닌,
내 연차/당직 확인 api 데이터와 연차 / 당직 내 신청 목록 api 데이터(맞는 api인지는 잘 모르겠습니다!😅)도 캘린더에 보여주시면 유저들이 더 직관적으로 이해할 수 있을 것 같습니다!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희는 모든 사용자의 연차/당직 스케쥴 1년치 데이터를 받아와서 승인 상태의 데이터만 달력에 렌더링 되게 하였습니다.

그리고 필터링을 통해 신청 목록, 그러니까 대기 중인 상태는 메인페이지에 사이드바에서 요청대기 리스트에
승인 또는 거절 상태는 요청결과 리스트에 표시 중에 있습니다. 이 부분들은 모두 사용자의 일정만 표시 됩니다.

불필요한 서버와의 통신을 최대한 줄이기 위해서 1번의 데이터를 통해서 필터링을 이용해 <모든 일정>/<나의 일정>을 스위치로 필터링, <사이드 바 요청대기>, <사이드 바 요청 결과> 이렇게 렌더링 되게 하였습니다.

title: item.userName,
start: item.startDate,
end: adjustEndDate,
color: DUTY_ANNUAL[item.scheduleType].color,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 색깔이 무엇을 의미하는지 Calendar에서 보여주는 지표가 있으면 좋을 것 같습니다!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

constants 파일내부에 DUTY_ANNUAL객체에 DUTY와 ANNUAL이 key값으로, 거기에 따른 {labe, color}가 value값으로 있습니다.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제가 추상적으로 리뷰를 작성했군요 죄송합니다😂. 렌더링되는 화면 안에서 유저가 볼 수 있는 범례가 있으면 좋겠다는 뜻이었습니다..!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추가 수정 중에 연차/당직 을 구분 할 수 있는 체크박스를 만들었고, 연차/당직에 해당 색상들을 적용시켜뒀습니다 ㅎㅎ 이 부분은 개인적으로 수정 중이라 현재 배포된 주소에서 확인이 불가능합니다 ㅠㅠ
GOMCAM 20230816_2003190446

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

취향 차이기는 한것 같지만, 해당 파일처럼 타입들을 한 폴더에서 관리하면 유지 / 보수에 더 용이할 것 같습니다!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

원래 그런 용도로 만들 파일이었지만..작업하다 보니 컴포넌트 자체에서 때려박은 경우가 많습니다..😂

Copy link

@minsoo-web minsoo-web left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안녕하세요! 11조 담당 멘토 김민수입니다.
리뷰가 조금 늦었습니다. 죄송합니다..

전반적으로 하나의 파일에 모든 스크립트가 쓰여있다보니, 가독성이 떨어지는 점,
any 활용과 불필요한 주석 개선
변수명 개편
불필요하게 반복되는 try catch 문 개선

등이 보여 리뷰로 남겨봤습니다.
serverState 관리를 유용하게 해주는 react-query나 swr 을 활용해봤으면 더 좋았을 것 같습니다.
form 관리도 react-hooks-form 이라는 툴을 활용했다면 조금 더 유연한 상태관리가 가능하니, 다음에 공부해보시는 것도 좋을 것 같아요!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

필요한 파일들만 ignore 하는 건 어떨까요? 지금은 쓰이지 않는 애들도 같이 포함된 것 같습니다.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

node_modules
.env

제외 모두 삭제했습니다.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제출용 레포지토리라서 추후에 업데이트한 내용이 포함되어있지 않는거 같습니다.

signup 컴포넌트는 무겁고 가독성이 좋지 않아서 분리 작업 완료했습니다.

export const checkEmail = async (userEmail: string) => {
// 추후에 /v1/auth/check-email 변경
const response = await customAxios.post('/v1/auth/check-email', userEmail, {
headers: { 'Content-Type': 'application/json' },

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

header를 axiosInstance 생성간 미리 넣어뒀으면 어땠을까요..?!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@BearHumanS

export const checkEmail = async (userEmail: string) => {
  // 추후에  /v1/auth/check-email 변경
  const response = await customAxios.post('/v1/auth/check-email', userEmail);
  return response;
};
export const customAxios = axios.create({
  baseURL: BASE_API_URL,
  timeout: 5000,
  headers: { 'Content-Type': 'application/json' },
});

수정했습니다.


export const checkEmail = async (userEmail: string) => {
// 추후에 /v1/auth/check-email 변경
const response = await customAxios.post('/v1/auth/check-email', userEmail, {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

axios에 제네릭을 활용하면 응답값을 typed 하게 관리할 수 있습니다!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

response를 콘솔에 찍어보면 다음과 같이 나옵니다.
image
제너릭을 활용 하려면 response.data를 리턴값으로 지정하고, 리턴 타입을 Promise<제네릭>으로 지정하면 될까요?

Comment on lines +10 to +12
if (!file) {
return null;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

file이 있고 없고는 함수에서 처리할 필요가 없습니다. file의 타입이 정해져있기도 하고, 인자를 제대로 전달해주지 않아서 생기는 이슈는 함수 단에서 제어문을 할 필요가 없습니다.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const currentTime = Math.floor(new Date().getTime() / 1000);
// 만료시간 > 5분 남은 경우
const expirationLeft = expirationTime - currentTime;
console.log(`만료 ${Math.floor(expirationLeft / 60)}분 남았습니다.`);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

배포까지 진행되는 프로젝트에서는 로그를 남길 필요가 없고, 개발환경에서만 로그가 남도록 커스텀 로그를 사용하는 것도 방법일 것 같습니다.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

개발환경, 배포환경 관리하는 부분 공부해보겠습니다.
일단 해당 로그는 배포 환경에서 주석처리하였습니다.

Comment on lines +67 to +69
if (!accessToken) {
return;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

엑세스 토큰이 있는지 없는지를 매번 검사해줘야 하나요? customAxios에서 처리해줄 수 있는 부분 같습니다.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interceptor에서 처리했습니다.

Comment on lines +144 to +145
messageApi.open({
type: 'error',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

보면, error 와 success 가 비슷하게 많이 쓰였죠?!
이럴 때는 둘 중 하나를 default param 으로 설정해두면, 일일이 type을 선언하지 않아도 되는 장점도 있습니다.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import { message } from 'antd';
import { NoticeType } from 'antd/es/message/interface';

export const useMessage = () => {
  const [messageApi, contextHolder] = message.useMessage();

  const customMessage = (content: string, type: NoticeType = 'success') =>
    messageApi.open({
      type,
      content,
    });
  return { customMessage, contextHolder };
};
customMessage('비밀번호를 수정하였습니다.');
customMessage('비밀번호 수정에 실패하였습니다.', 'error');

Comment on lines +27 to +29
const NUMBER_REGEX = /\d/;
const SPECIAL_REGEX = /[!@#$%^&*()-+=]/;
const ENGLISH_REGEX = /[a-zA-Z]/;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정규식은 constants 폴더에서 관리했으면 좋았을 것 같습니다.

Comment on lines +52 to +53
const CheckedVacationRequestsData = response.data
.response as CheckedVacationRequestType[];

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

axios에서 제네릭을 사용했으면 as 키워드를 사용할 필요가 없어집니다.

Comment on lines +98 to +100
const pastDates = (current: dayjs.Dayjs) => {
return current < dayjs().startOf('day');
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

boolean 을 return 해주는 함수는 is 키워드를 쓰면 가독성이 좋아집니다.
함수를 작성할 때 호출부를 고려하면 좀 더 나은 함수명을 작성할 수 있습니다.

pastDates(currentDate)

이렇게 쓰이면 무슨 일을 하는지 어떤 결과 값이 나오는지 알 수가 없겠죠,,?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Author

@howooking howooking left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

가독성 떨어지고 난잡한 코드를 꼼꼼히 리뷰해주셔서 감사드립니다.
리뷰해주신 부분 수정, 추가 질문 코멘트 남겼습니다.
시간 되실 때 천천히 봐주시면 정말 감사드리겠습니다!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

node_modules
.env

제외 모두 삭제했습니다.


export const checkEmail = async (userEmail: string) => {
// 추후에 /v1/auth/check-email 변경
const response = await customAxios.post('/v1/auth/check-email', userEmail, {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

response를 콘솔에 찍어보면 다음과 같이 나옵니다.
image
제너릭을 활용 하려면 response.data를 리턴값으로 지정하고, 리턴 타입을 Promise<제네릭>으로 지정하면 될까요?

export const checkEmail = async (userEmail: string) => {
// 추후에 /v1/auth/check-email 변경
const response = await customAxios.post('/v1/auth/check-email', userEmail, {
headers: { 'Content-Type': 'application/json' },
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@BearHumanS

export const checkEmail = async (userEmail: string) => {
  // 추후에  /v1/auth/check-email 변경
  const response = await customAxios.post('/v1/auth/check-email', userEmail);
  return response;
};
export const customAxios = axios.create({
  baseURL: BASE_API_URL,
  timeout: 5000,
  headers: { 'Content-Type': 'application/json' },
});

수정했습니다.

const currentTime = Math.floor(new Date().getTime() / 1000);
// 만료시간 > 5분 남은 경우
const expirationLeft = expirationTime - currentTime;
console.log(`만료 ${Math.floor(expirationLeft / 60)}분 남았습니다.`);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

개발환경, 배포환경 관리하는 부분 공부해보겠습니다.
일단 해당 로그는 배포 환경에서 주석처리하였습니다.

import { customAxios } from '@/api/customAxios';

export const getVacationRequests = async () => {
const response = await customAxios.get('/v1/admin/list', {});
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

삭제했습니다.

Comment on lines +75 to +81
const options = [
{ label: '연차', value: 'ANNUAL' },
{ label: '당직', value: 'DUTY' },
];

const [messageApi, contextHolder] = message.useMessage();
const [checkedBox, setCheckedBox] = useState(['ANNUAL', 'DUTY']);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위에 'PENDING' | 'APPROVE' | 'REJECT' 와 같은 내용이네요.
수정했습니다.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 이부분이 가장 큰 문제라고 생각합니다.
일단 나름의 방식으로 정리를 해봤는데 잘했는지는 의문입니다.

(Home.tsx)
import { Layout, Modal } from 'antd';
import { Content } from 'antd/es/layout/layout';
import { useState } from 'react';
import Calendar from './calendar';
import Signin from '@/page/home/signin';
import {
  useAddingSchedule,
  useDataFetching,
  useLoginModalOpen,
} from './home.hook';
import Sidebar from './sidebar';

export default function Home() {
  const [year, setYear] = useState(new Date().getFullYear());

  const {
    events,
    mySchedule,
    sideMySchedule,
    usersYearlySchedulesLoading,
    myPendingScheduleList,
    pendingLoading,
    setReRender,
    setMyPendingScheduleList,
  } = useDataFetching(year);

  const { isModalOpen, setIsModalOpen } = useLoginModalOpen();

  const {
    scheduleInput,
    contextHolder,
    isAddingRequest,
    accessToken,
    handleSelect,
    handleDatePicker,
    handleRangePicker,
    handleSubmitSchedule,
  } = useAddingSchedule(setReRender, setMyPendingScheduleList);

  return (
    <>
      {contextHolder}

      {/* 로그인 창 */}
      <Modal
        title="로그인"
        centered
        closeIcon={false}
        footer={null}
        open={isModalOpen}
      >
        <Signin setIsModalOpen={setIsModalOpen} />
      </Modal>

      <Layout
        style={{
          filter: accessToken ? '' : 'blur(5px)',
          userSelect: 'none',
          height: 'calc(100vh - 60px)',
          display: 'flex',
          flexDirection: 'row',
        }}
      >
        <Sidebar
          handleDatePicker={handleDatePicker}
          handleRangePicker={handleRangePicker}
          handleSelect={handleSelect}
          handleSubmitSchedule={handleSubmitSchedule}
          isAddingRequest={isAddingRequest}
          myPendingScheduleList={myPendingScheduleList}
          pendingLoading={pendingLoading}
          scheduleInput={scheduleInput}
          setMyPendingScheduleList={setMyPendingScheduleList}
          sideMySchedule={sideMySchedule}
          usersYearlySchedulesLoading={usersYearlySchedulesLoading}
        />
        <Layout style={{ paddingLeft: 10, flex: 1, height: '100%' }}>
          <Content
            style={{
              background: 'white',
              height: '100%',
              overflow: 'auto',
            }}
          >
            <Calendar
              mySchedule={mySchedule}
              events={events}
              year={year}
              setYear={setYear}
              usersYearlySchedulesLoading={usersYearlySchedulesLoading}
            />
          </Content>
        </Layout>
      </Layout>
    </>
  );
}
(hooks)
import { scheduleList } from '@/api/home/scheduleList';
import { AccessTokenAtom } from '@/recoil/AccessTokkenAtom';
import { useEffect, useState } from 'react';
import { SetterOrUpdater, useRecoilState, useRecoilValue } from 'recoil';
import { UserEmailAtom } from '@/recoil/UserEmailAtom';
import dayjs from 'dayjs';
import { DUTY_ANNUAL } from '@/data/constants/commons';
import { pendingList } from '@/api/home/pendingList';
import { ReRenderStateAtom } from '@/recoil/ReRenderStateAtom';
import { DatePickerProps, RangePickerProps } from 'antd/es/date-picker';
import { message } from 'antd';
import { addScheduleRequest } from '@/api/mySchedule';
import { ScheduleItem, mySchedule } from './home.type';

export const useDataFetching = (year: number) => {
  const accessToken = useRecoilValue(AccessTokenAtom);
  const userEmail = useRecoilValue(UserEmailAtom);
  const [events, setEvents] = useState<ScheduleItem[]>([]);
  const [reRender, setReRender] = useRecoilState(ReRenderStateAtom);

  const [sideMySchedule, setSideMyschedule] = useState<
    {
      id: number;
      key: number;
      scheduleType: EmployeeRequestType;
      startDate: string;
      endDate: string;
      state: EmployeeRequestResult;
    }[]
  >([]);

  const [usersYearlySchedulesLoading, setUsersYearlySchedulesLoading] =
    useState(false);

  useEffect(() => {
    const getUsersYearlySchedules = async () => {
      if (!accessToken) {
        return;
      }
      try {
        setUsersYearlySchedulesLoading(true);
        const listResponse = await scheduleList(year);
        const listResponseData = listResponse.data.response;
        const sideMyScheduleData = listResponseData
          .filter((item: mySchedule) => item.userEmail === userEmail)
          .map((item: mySchedule) => {
            return {
              id: item.id,
              key: item.id,
              scheduleType: item.scheduleType,
              startDate: item.startDate,
              endDate: item.endDate,
              state: item.state,
            };
          });
        setSideMyschedule(sideMyScheduleData);
        const events = listResponseData
          .filter((item: mySchedule) => item.state === 'APPROVE')
          .map((item: ScheduleItem) => {
            const adjustEndDate = dayjs(item.endDate)
              .add(1, 'day')
              .format('YYYY-MM-DD');
            return {
              userEmail: item.userEmail,
              title: item.userName,
              start: item.startDate,
              end: adjustEndDate,
              color: DUTY_ANNUAL[item.scheduleType].color,
            };
          });
        setEvents(events);
      } catch (error) {
        console.error(error);
      } finally {
        setUsersYearlySchedulesLoading(false);
      }
    };
    getUsersYearlySchedules();
  }, [year, accessToken, userEmail, setUsersYearlySchedulesLoading]);

  const mySchedule = events.filter((event) => event.userEmail === userEmail);

  const [myPendingScheduleList, setMyPendingScheduleList] = useState<
    {
      id: number;
      key: number;
      scheduleType: EmployeeRequestType;
      startDate: string;
      endDate: string;
      state: 'PENDING';
    }[]
  >([]);

  const [pendingLoading, setPendingLoading] = useState(false);
  useEffect(() => {
    const myPendingSchedule = async () => {
      if (!accessToken) {
        return;
      }
      try {
        setPendingLoading(true);
        const response = await pendingList(year);
        const responseData = response.data.response;

        const myPendingScheduleData = responseData.map((item: mySchedule) => {
          return {
            id: item.id,
            key: item.id,
            scheduleType: item.scheduleType,
            startDate: item.startDate,
            endDate: item.endDate,
            state: item.state,
          };
        });
        setMyPendingScheduleList(myPendingScheduleData);
      } catch (error) {
        console.error(error);
      } finally {
        setPendingLoading(false);
      }
    };
    myPendingSchedule();
  }, [year, accessToken, reRender]);

  return {
    events,
    usersYearlySchedulesLoading,
    sideMySchedule,
    mySchedule,
    pendingLoading,
    myPendingScheduleList,
    setReRender,
    setMyPendingScheduleList,
  };
};

export const useLoginModalOpen = () => {
  const accessToken = useRecoilValue(AccessTokenAtom);

  const [isModalOpen, setIsModalOpen] = useState<boolean>(!accessToken);
  // 로그아웃을 하면 isModalOpen이 !accessToken의 상태를 바로 반영하지 않음
  // 따라서 useEffect로 반영이 되도록함
  useEffect(() => {
    setIsModalOpen(!accessToken);
  }, [accessToken]);

  return {
    isModalOpen,
    setIsModalOpen,
  };
};

export const useAddingSchedule = (
  setReRender: SetterOrUpdater<boolean>,
  setMyPendingScheduleList: React.Dispatch<
    React.SetStateAction<
      {
        id: number;
        key: number;
        scheduleType: EmployeeRequestType;
        startDate: string;
        endDate: string;
        state: 'PENDING';
      }[]
    >
  >,
) => {
  const [messageApi, contextHolder] = message.useMessage();
  const [isAddingRequest, setIsAddingRequest] = useState(false);
  const accessToken = useRecoilValue(AccessTokenAtom);
  accessToken;
  const [scheduleInput, setScheduleInput] = useState<{
    scheduleType: string;
    startDate: string;
    endDate: string;
  }>({
    scheduleType: '',
    startDate: '',
    endDate: '',
  });
  const handleSelect = (value: string) => {
    setScheduleInput({
      startDate: '',
      endDate: '',
      scheduleType: value,
    });
  };
  const handleRangePicker = (value: RangePickerProps['value']) => {
    const startDate = value && value[0];
    const endDate = value && value[1];
    setScheduleInput((prev) => ({
      ...prev,
      startDate: dayjs(startDate).format('YYYY-MM-DD'),
      endDate: dayjs(endDate).format('YYYY-MM-DD'),
    }));
  };

  const handleDatePicker = (value: DatePickerProps['value']) => {
    const startDate = dayjs(value).format('YYYY-MM-DD');
    setScheduleInput((prev) => ({
      ...prev,
      startDate: dayjs(startDate).format('YYYY-MM-DD'),
      endDate: dayjs(startDate).format('YYYY-MM-DD'),
    }));
  };

  const handleSubmitSchedule = async () => {
    if (!accessToken) {
      return;
    }
    try {
      setIsAddingRequest(true);
      const response = await addScheduleRequest(scheduleInput);
      if (response.status === 200) {
        messageApi.open({
          type: 'success',
          content: `${
            DUTY_ANNUAL[
              response.data.response.scheduleType as EmployeeRequestType
            ]?.label
          } 신청 완료`,
        });

        if (response.data.response.scheduleType === 'ANNUAL') {
          setReRender((prev) => !prev);
        }

        if (response.data.response.scheduleType === 'DUTY') {
          const newPendingSchedule = {
            id: response.data.response.id,
            key: response.data.response.id,
            scheduleType: response.data.response.scheduleType,
            startDate: response.data.response.startDate,
            endDate: response.data.response.endDate,
            state: response.data.response.state,
          };
          setMyPendingScheduleList((prev) => {
            return [...prev, newPendingSchedule];
          });
        }
      }
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
    } catch (error: any) {
      messageApi.open({
        type: 'error',
        content:
          error.response?.data.error.message ||
          '연차, 당직 신청 등록 중 오류가 발생하였습니다.',
      });
    } finally {
      setIsAddingRequest(false);
    }
  };

  return {
    contextHolder,
    isAddingRequest,
    accessToken,
    handleSelect,
    handleRangePicker,
    handleDatePicker,
    handleSubmitSchedule,
    scheduleInput,
  };
};

Comment on lines +57 to +58
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

만약 error내부의 속성을 이용해야 하는 경우에는 type지정을 어떻게 하나요?

  const handleChangePassword = async (values: {
    newPassword: string;
    confirmPassword: string;
  }) => {
    try {
      if (!accessToken) {
        return;
      }
      const response = await changeMyInfo({
        userPassword: values.newPassword,
      });
      if (response.status === 200) {
        setIsModalOpen(false);
        customMessage('비밀번호를 수정하였습니다.');
      }
      if (formRef.current) {
        formRef.current.resetFields();
      }
    } catch (error) {
      customMessage(error.response.data.error.message, 'error'); // 'error' is of type 'unknown'. 에러
    }
  };

Comment on lines +144 to +145
messageApi.open({
type: 'error',
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import { message } from 'antd';
import { NoticeType } from 'antd/es/message/interface';

export const useMessage = () => {
  const [messageApi, contextHolder] = message.useMessage();

  const customMessage = (content: string, type: NoticeType = 'success') =>
    messageApi.open({
      type,
      content,
    });
  return { customMessage, contextHolder };
};
customMessage('비밀번호를 수정하였습니다.');
customMessage('비밀번호 수정에 실패하였습니다.', 'error');

Comment on lines +67 to +69
if (!accessToken) {
return;
}
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interceptor에서 처리했습니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants