Skip to content

코드스테이츠 메인프로젝트 리팩토링 기록입니다.

Notifications You must be signed in to change notification settings

Whaleinmilktea/main-project16-refector_haseong

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

EduSync


👋 Introduce Repository

  • 소개 : 코드스테이츠에서 2023.04.28 ~ 2023.05.25 기간동안 진행했던 메인프로젝트의 리팩토링을 진행한 레포지토리입니다.
  • 주요 개선점 : 코드의 캡슐화+모듈화, React-query 및 React-Testing-Library 적용

🧑‍🤝‍🧑 Participants

FE BE
강하성 양도열
강하성 양도열

⚒️ 주요 개선 내용


useQuery Hook 적용

  • isLoading, isError 상태를 각 axios 요청 함수별로 따로 리팩토링이 요구되는데, 다수의 반복작업 예상
  • Caching을 활용한 불필요한 요청 최소화 필요
  • React-query의 hook들은 이러한 불필요한 작업을 최소화하고, 필요시 hook에 적용될 수 있는 여러 메서드를 제공하기 때문에 추후 추가적인 요청사항이 있을 시 최소한의 코드로 요청사항 반영 가능
// 개선 전 코드
// TODO 최초 페이지 진입 시 유저의 정보를 조회하는 코드
import { useState, useEffect } from "react";
useEffect(() => {
  if (!isLoggedIn) {
    navigate("/login");
  }
  const fetchMemberInfo = async () => {
    try {
      const info = await getMemberInfo(isLoggedIn);
      setMemberInfo(info);
      setIntroduceInfo({ aboutMe: info.aboutMe, withMe: info.withMe });
    } catch (error) {}
  };
  fetchMemberInfo();
}, [isModalOpen, isRendering]);
// 개선 후 코드
import { useQuery } from "@tanstack/react-query";
const { data, isLoading, isError } = useQuery(["userInfo"], () => {
  return getMemberInfo(isLoggedIn);
});
const userInfo = data;

if (!isLoggedIn) navigate("/login");
if (isLoading) return <div>로딩중...</div>;
if (isError) return <div>에러가 발생했습니다.</div>;

useMutation Hook 적용

  • isLoading, isError 상태를 각 axios 요청 함수별로 따로 리팩토링이 요구되는데, 다수의 반복작업 예상
  • 수정/삭제 요청 쿼리의 단순화
// 개선 전 코드
// TODO 최초 페이지 진입 시 유저의 정보를 조회하는 코드
import { useState, useEffect } from "react";
useEffect(() => {
  if (!isLoggedIn) {
    navigate("/login");
  }
  const fetchMemberInfo = async () => {
    try {
      const info = await getMemberInfo(isLoggedIn);
      setMemberInfo(info);
      setIntroduceInfo({ aboutMe: info.aboutMe, withMe: info.withMe });
    } catch (error) {}
  };
  fetchMemberInfo();
}, [isModalOpen, isRendering]);
// 개선 후 코드
import { useQuery } from "@tanstack/react-query";
const { data, isLoading, isError } = useQuery(["userInfo"], () => {
  return getMemberInfo(isLoggedIn);
});
const userInfo = data;

if (!isLoggedIn) navigate("/login");
if (isLoading) return <div>로딩중...</div>;
if (isError) return <div>에러가 발생했습니다.</div>;

Vitest를 활용한 React-testing

  • 해시/리스트와 같은 자료구조를 활용한 함수 유닛 테스팅
describe("요일 맵핑 테스트", () => {
  it("[0,0,0,0,0,0,1]이 파라미터로 전달되면 ['일']을 리턴한다", () => {
    expect(DayOfWeekBinaryToStringMap([0, 0, 0, 0, 0, 0, 1])).toEqual(["일"]);
  });
  it("[1,0,0,1,1,0,0]이 파라미터로 전달되면 ['월', '일']을 리턴한다", () => {
    expect(DayOfWeekBinaryToStringMap([1, 0, 0, 1, 1, 0, 0])).toEqual([
      "월",
      "목",
      "금",
    ]);
  });
  it("[0,0,0,0,0,0,1]이 파라미터로 전달되면 ['0']을 리턴한다", () => {
    expect(DayOfWeekBinaryToNumber([0, 0, 0, 0, 0, 0, 1])).toEqual(["0"]);
  });
  it("[0,1,0,0,1,0,0]이 파라미터로 전달되면 ['2', '5']을 리턴한다", () => {
    expect(DayOfWeekBinaryToNumber([0, 1, 0, 0, 1, 0, 0])).toEqual(["2", "5"]);
  });
});

describe("페이지네이션 테스트", () => {
  it("totalPages의 값이 3일 경우 [1,2,3]이 출력된다.", () => {
    expect(getPageArray(3)).toEqual([1, 2, 3]);
  });
});

describe("인코드 테스트", () => {
  it("1이 입력될 경우 'MQ=='가 출력된다.", () => {
    expect(encodedUrl(1)).toEqual("MQ==");
  });
  it("'nickName'이 입력될 경우 'bmlja05hbWU='가 출력된다", () => {
    expect(encodedUrl("nickName")).toEqual("bmlja05hbWU=");
  });
  it("'test01'이 입력될 경우, 'dGVzdDAx'가 출력된다", () => {
    expect(encodedUrl("test01")).toEqual("dGVzdDAx");
  });
  it("불리언 true 값이 입력될 경우 'dHJ1ZQ=='가 출력된다", () => {
    expect(encodedUrl(true)).toEqual("dHJ1ZQ==");
  });
});
export const DayOfWeekBinaryToStringMap = (dayOfWeek: number[]) => {
  interface DayOfWeekMap {
    [key: number]: string;
  }
  const dayOfWeekMap: DayOfWeekMap = {
    0: "월",
    1: "화",
    2: "수",
    3: "목",
    4: "금",
    5: "토",
    6: "일",
  };
  const dayOfWeekArr = [];
  for (let i = 0; i < dayOfWeek.length; i++) {
    if (dayOfWeek[i] === 1) {
      dayOfWeekArr.push(dayOfWeekMap[i]);
    }
  }
  return dayOfWeekArr;
};

useMutation Hook 적용

  • isLoading, isError 상태를 각 axios 요청 함수별로 따로 리팩토링이 요구되는데, 다수의 반복작업 예상
  • 수정/삭제 요청 쿼리의 단순화
// 개선 전 코드
// TODO 최초 페이지 진입 시 유저의 정보를 조회하는 코드
import { useState, useEffect } from "react";
useEffect(() => {
  if (!isLoggedIn) {
    navigate("/login");
  }
  const fetchMemberInfo = async () => {
    try {
      const info = await getMemberInfo(isLoggedIn);
      setMemberInfo(info);
      setIntroduceInfo({ aboutMe: info.aboutMe, withMe: info.withMe });
    } catch (error) {}
  };
  fetchMemberInfo();
}, [isModalOpen, isRendering]);
// 개선 후 코드
  import { useQuery } from "@tanstack/react-query";
  const { data, isLoading, isError } = useQuery(["userInfo"], ()=>{
    return getMemberInfo(isLoggedIn);
  })
  const userInfo = data;

  if (!isLoggedIn) navigate("/login");
  if (isLoading) return <div>로딩중...</div>
  if (isError) return <div>에러가 발생했습니다.</div>

Vitest를 활용한 React-testing

  • 해시/리스트와 같은 자료구조를 활용한 함수 유닛 테스팅
describe("요일 맵핑 테스트", () => {
  it("[0,0,0,0,0,0,1]이 파라미터로 전달되면 ['일']을 리턴한다", () => {
    expect(DayOfWeekBinaryToStringMap([0, 0, 0, 0, 0, 0, 1])).toEqual(["일"]);
  });
  it("[1,0,0,1,1,0,0]이 파라미터로 전달되면 ['월', '일']을 리턴한다", () => {
    expect(DayOfWeekBinaryToStringMap([1, 0, 0, 1, 1, 0, 0])).toEqual(["월", "목", "금"]);
  });
  it("[0,0,0,0,0,0,1]이 파라미터로 전달되면 ['0']을 리턴한다", () => {
    expect(DayOfWeekBinaryToNumber([0, 0, 0, 0, 0, 0, 1])).toEqual(["0"]);
  });
  it("[0,1,0,0,1,0,0]이 파라미터로 전달되면 ['2', '5']을 리턴한다", () => {
    expect(DayOfWeekBinaryToNumber([0, 1, 0, 0, 1, 0, 0])).toEqual(["2", "5"]);
  });
});

describe("페이지네이션 테스트", () => {
  it("totalPages의 값이 3일 경우 [1,2,3]이 출력된다.", () => {
    expect(getPageArray(3)).toEqual([1, 2, 3]);
  });
});

describe("인코드 테스트", () => {
  it("1이 입력될 경우 'MQ=='가 출력된다.", () => {
    expect(encodedUrl(1)).toEqual("MQ==");
  });
  it("'nickName'이 입력될 경우 'bmlja05hbWU='가 출력된다", () => {
    expect(encodedUrl("nickName")).toEqual("bmlja05hbWU=");
  });
  it("'test01'이 입력될 경우, 'dGVzdDAx'가 출력된다", () => {
    expect(encodedUrl("test01")).toEqual("dGVzdDAx");
  });
  it("불리언 true 값이 입력될 경우 'dHJ1ZQ=='가 출력된다", () => {

    expect(encodedUrl(true)).toEqual("dHJ1ZQ==");
  });
});
export const DayOfWeekBinaryToStringMap = (dayOfWeek: number[]) => {
  interface DayOfWeekMap {
    [key: number]: string;
  }
  const dayOfWeekMap: DayOfWeekMap = {
    0: "월",
    1: "화",
    2: "수",
    3: "목",
    4: "금",
    5: "토",
    6: "일",
  };
  const dayOfWeekArr = [];
  for (let i = 0; i < dayOfWeek.length; i++) {
    if (dayOfWeek[i] === 1) {
      dayOfWeekArr.push(dayOfWeekMap[i]);
    }
  }
  return dayOfWeekArr;
};

쿼리스트링 난독화

  • 쿼리 전송단계에서 intercept시 url의 query를 조작하여 해커가 원하는 데이터를 임의로 탈취당할 우려
  • 이로 인해 쿼리 요청 단계에서 utf-8 형식을 base64 형식으로 인코딩하여 쿼리 요청 적용
// 개선 전 코드
export async function getStudyGroupInfo(id: number, isLoggedIn: boolean) {
  if (!isLoggedIn) throw new Error("로그인 상태를 확인하세요");
  const response = await tokenRequestApi.get<StudyInfoDto>(
    `/studygroup/${id}`
  );
  const studyInfo = response.data;
  ...
  return studyInfo;
}
// 개선 후 코드
export async function getStudyGroupInfo(id: number, isLoggedIn: boolean) {
  if (!isLoggedIn) throw new Error("로그인 상태를 확인하세요");
  const encodeId = Base64.encode(id.toString());
  const response = await tokenRequestApi.get<StudyInfoDto>(
    `/studygroup/${encodeId}`
  );
  const studyInfo = response.data;
  ...
  return studyInfo;
}

이미지 업로드 시, JSON 형식에서 Form-data 형식으로 변경

  • 이미지 업로드 시, 서버 부하로 인해 RDS에 직접 저장 대신 S3에 저장하는 로직 구현 (서버 구현 사항)
  • 이 과정에서 서버 측에서 JSON 형식으로 된 데이터가 아닌 Form-data 형식으로 된 쿼리를 요청
// 변경 전 코드

// 현재 interface는 별도로 분리하여 관리
// export interface MemberProfileUpdateImageDto {
//   profileImage: string;
// }

export const updateMemberProfileImage = async (
  data: MemberProfileUpdateImageDto
) => {
  await tokenRequestApi.patch("/members/profile-image", data);
};
// 변경 후 코드
export const updateMemberProfileImage = async (
  image: MemberProfileUpdateImageDto
) => {
  if (!image.image) throw new Error("이미지를 확인해주세요.");
  const formData = new FormData();
  formData.append("image", image.image);
  await tokenRequestApi.patch("/members/image", formData, {
    headers: {
      "Content-Type": "multipart/form-data",
    },
  });
};

인터페이스 모듈화 및 분리

// 개선 전 코드
// ~/src/apis/MemberApi 에서 interface 타입 및 api 요청 통합 관리
// TODO : 유저정보 get 요청 DTO 타입 정의
export interface MemberInfoResponseDto {
  uuid: string;
  email: string;
  profileImage: string;
  nickName: string;
  aboutMe: string;
  withMe: string;
  memberStatus: "MEMBER_ACTIVE" | "MEMBER_INACTIVE";
  roles: string[];
}

// TODO: 유저정보 get 요청하는 axios 코드
export const getMemberInfo = async (isLoggedIn: boolean) => {
  if (!isLoggedIn) throw new Error("로그인 상태를 확인해주세요.");
  // tokenRequestApi를 사용하여 /members 엔드포인트로 GET 요청 전송
  const response = await tokenRequestApi.get<MemberInfoResponseDto>("/members");
  // 응답 데이터 추출
  const data = response.data;
  return data; // 데이터 반환
};
// 개선 후 코드
// types/MemberApiInterfaces
import { MemberInfoResponseDto } from "../types/MemberApiInterfaces";
// /src/apis/MemberApi
export const getMemberInfo = async (isLoggedIn: boolean) => {
  if (!isLoggedIn) throw new Error("로그인 상태를 확인해주세요.");
  const response = await tokenRequestApi.get<MemberInfoResponseDto>("/members");
  const data = response.data;
  return data;
};

비밀번호 유효성 검사 정규화

  • 클라이언트와 서버 모두 유효성 검사 수행
  • 유효성 검사 수행 시, 같은 양식의 리턴값을 공유하기 위해 유효성 검증식 정규화
const passwordTest = (data: string) => {
  // 비밀번호는 8~25자리의 영문 대소문자, 숫자, 특수문자 조합이어야 합니다.
    return /^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,25}$/g.test(data);
  };
const handleSignUpButton = () => {
  ...
  else if (passwordTest(password) === false) alert("비밀번호는 8~25자리의 영문 대소문자, 숫자, 특수문자 조합이어야 합니다.");
  else {
    eduApi.post(`/members`, {
    ...
    }
  }

스터디 리스트 무한스크롤 구현

  • 스터디 리스트의 무한스크롤 기능 추가
  • 서버로 page와 size를 쿼리를 요청하는 방법으로 구현
  • 무한스크롤 기능은 바닐라 JS를 활용하여 구현할 경우 쓰로틀에 의한 이벤트 과다 이슈가 있어, 이를 효과적으로 제어하는 라이브러리를 활용하여 구현
  • 주석으로 처리된 부분은, 서버의 배포 이슈로 인해 임시 json-server로 테스트하여 동작여부 확인한 코드
// ~/src/apis/StudyGroupApi.ts
export const getStudyGroupList = async (
  currentPage: number
): Promise<StudyGroupListDto[]> => {
  const requestEndpoint = Base64.encode(
    `$studygroups?page${currentPage}&size=6}`
  );
  const response = await axios.get<StudyGroupListDto[]>(
    `${import.meta.env.VITE_APP_API_URL}/list?p=${currentPage}&s=6`
  );
  // const response = await axios.get(
  //   `http://localhost:3000/list?_page=${currentPage}&_limit=6`
  // );
  return response.data;
};
// ~/src/pages/StudyList
// ... dependancy
import { useInView } from "react-intersection-observer";

const StudyList = () => {
  const [ref, inView] = useInView();
  const [list, setList] = useState<StudyGroupListDto[]>([]);
  const [currentPage, setCurrentPage] = useState(1);
  // ... 기타 states (재정렬 및 데이터 편집)
  const navigate = useNavigate();

  useEffect(() => {
    if (inView) fetchList();
  }, [inView]);

  const fetchList = async () => {
    const res = await getStudyGroupList(currentPage);
    setList((prev) => [...prev, ...res]);
    setCurrentPage((prevPage) => prevPage + 1);
  };

  useEffect(() => {
    filterList(sortValue);
  }, [list]);

  const handleSortOrder = (e: React.ChangeEvent<HTMLSelectElement>) => {
    // ... select option에 따른 이벤트 핸들링
  };

  const filterList = (sortValue: string) => {
    // ... 재정렬 조건문
  };

  return (
    <StudyListContainer>
      <StudyListBody>
          // ...
        </StudyListTop>
        <ListFilterWrapper>
          // ...
        </ListFilterWrapper>
        <StudyBoxContainer>
          {filterData?.map((item: StudyGroupListDto) => (
            <StudyBox
              key={item?.id}
              onClick={() => navigate(`/studycontent/${item?.id}`)}
            >
              <StudyListImage image={item.image}></StudyListImage>
              <div>
                <div className="studylist-title">
                  <h3>{item?.title}</h3>
                </div>
                <div className="studylist-interest">
                  <div id="studylist-interest_likes">
                    ❤️ {item?.likes}
                  </div>
                  <div id="studylist-interest_views">🧐 {item?.views}</div>
                </div>
                <div className="studylist-tag">
                  <StudyListTag item={item.tags} />
                </div>
              </div>
            </StudyBox>
          ))}
        </StudyBoxContainer>
      </StudyListBody>
      <div ref={ref}></div>
    </StudyListContainer>
  );
};

export default StudyList;

About

코드스테이츠 메인프로젝트 리팩토링 기록입니다.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published