대출 상품 추천 서비스 론즐리 회고

부트캠프 과정 중 진행했던 프로젝트 회고입니다.

2023-04-27

개요

백엔드와 협업한 미니 프로젝트의 소개, 진행 과정, 제가 구현한 기능들 그리고 회고를 다룹니다.

프로젝트 소개

론즐리(Lonsily)REST API를 이용한 대출 상품 추천 서비스입니다. 론즐리는 loan과 easily를 합친 말로 대출을 쉽게라는 뜻을 갖고 있습니다. 즉 손쉽게 대출 상품을 찾고 신청하는 서비스입니다.

프로젝트는 프론트엔드 4명 그리고 백엔드 4명 총 8명이 한 팀으로 구성이 되었고, 개발 기간은 2023.02.13부터 2023.02.24까지 진행하였습니다. 저는 프로젝트의 팀장과 전체 프로젝트 세팅, 마이페이지, 관심 상품, 구매내역, 유저정보, 권한별 라우트, 인증 로직 그리고 배포를 맡았습니다.

프로젝트 진행 과정

금융상품 쇼핑몰을 만들기라는 주제가 정해진 프로젝트로 그중 어떤 금융상품을 선택할지는 자유였습니다. 그중 저희는 대출 상품을 추천하는 서비스로 방향을 잡았습니다. 이때 백엔드 측에서 금융감독원의 Open API의 대출 상품 데이터를 활용해 서버와 데이터를 구성하였습니다. 프로젝트에는 필수로 구현해야 하는 기능들이 있었는데, 그 기능은 다음과 같습니다.

  • 로그인/회원가입
  • 금융상품 정보 조회 및 필터링
  • 특정 금융상품 관심 상품으로 등록/해제
  • 관심 상품으로 등록된 상품 조회
  • 특정 금융상품 장바구니 담기

각각의 기능별로 페이지를 나누는 방식으로 기능을 구현하기로 하였습니다. 짧은 개발 기간이므로 mainfeature 브랜치만을 사용하는 브랜치 전략인 GitHub Flow를 채택하여 사용하였습니다. 또 fork 하여 작업하는 방식이 아닌 clone을 통해 작업을 진행하였습니다. 기능 분담과 브랜치 전략을 정하고 저희는 기술 스택을 정하기로 하였습니다. 기술 스택과 선정 이유는 다음과 같습니다.

  • React (CRA)
    • 빠르게 작업하는 게 중요한 프로젝트이니 esbuild 기반의 vite를 사용할까 고민하였는데, Redux-toolkit을 사용해 프로젝트를 진행하기 위해 템플릿이 있는 CRA 툴체인을 활용하여 리액트 앱을 초기화하였습니다.
  • TypeScript
    • 정적 타이핑을 통해 런타임이 아닌 컴파일 시 에러를 잡을 수 있다는 장점, 인텔리센스를 통한 코드 생산성 증가로 인해 선택하였습니다. 또 저도 그렇고 팀원들이 익숙해지기 위해 선택하였습니다.
  • Styled-Components
    • TailwindCSS, SCSS 등 다양한 선택지가 있었지만, 팀원들이 가장 익숙한 Styled-Components를 사용하기로 하였습니다. Styled-Components의 장점은 props를 활용한 조건부 스타일링, 스타일의 컴포넌트화, SCSS 지원, 유니크한 클래스 등이 있습니다.
  • Redux-Toolkit
    • 프로젝트의 요구 사항이고 리액트에서 클라이언트 상태 관리는 필수이므로 선택하였습니다. 기존의 Redux 보다 간결하게 상태 관리를 할 수 있다는 장점이 있습니다.
  • React-Query
    • 대부분의 전역 상태 관리의 경우 서버 상태를 관리하는 경우일 때가 많습니다. 이렇게 되면 store의 역할이 너무 비대해지고 비효율을 초래합니다. 클라이언트 상태와 서버 상태를 분리하여 관리하기 위해 React-Query를 도입하였습니다. 또한 캐시를 활용하여 API 호출을 줄이며 서버에 대한 부하를 감소합니다.
  • React-Hook-Form
    • 로그인, 회원가입 그리고 유저정보 수정 등에서 폼을 활용하는데, 제어 컴포넌트로 폼을 다루기 위해선 상태를 만들어야 하고 그 상태를 변경시키기 위한 핸들러 함수들을 만들어야 합니다. 이렇게 되면 간단한 폼이라도 너무 많은 상태와 함수가 필요하게 됩니다. 이런 문제점을 해결하기 위해 폼을 쉽고 효율적으로 관리할 수 있는 React-Hook-Form을 도입하였습니다.
  • Zod
    • ZodTypeScript 타입 스키마와 유효성 검증을 위한 라이브러리로 폼의 유효성 검증을 쉽게 하기 위해 도입하였습니다. 또한 infer를 통해 스키마를 타입으로 추출할 수 있습니다.

추가적으로 각 팀원들의 코드를 통일하기 위해 eslintprettier를 적용하고 huskylint-staged를 통해 커밋전에 코드의 통일을 자동화하였습니다. 피그마를 다룰 줄 아는 팀원이 대략적인 UI를 만든 후 본격적으로 작업에 들어갔습니다.

😵 프로젝트를 진행하며 UI/UX가 얼마나 중요한지 알게 되는 계기였습니다..

기능 구현

마이페이지

MyPage.tsx
const MyPage = () => {
  const { auth } = useAppSelector((state) => state);
  const { birth } = auth;
  const gender = birth?.slice(-1);
 
  return (
      // ...
 
      <ProfileWrapper>
        {gender === "1" || gender === "3" ? <MaleAvatar /> : <FemaleAvatar />}
        <div>
          <Username>{auth?.name}</Username>님 환영합니다.
          <div>
            <LogOutBtn onClick={handleClickLogout}>로그아웃</LogOutBtn>
          </div>
        </div>
      </ProfileWrapper>
 
      // ...

마이페이지의 전체적인 레이아웃을 구성하였는데 이때 React-Router-Dom의 중첩 라우팅을 활용하여 하위 라우트를 구성하였습니다. 또 Redux-Toolkit을 활용해 로그인 시 store에 저장된 유저 정보를 조회할 수 있도록 하였습니다.

관심 상품

ProductDetail.tsx
const ProductDetail = () => {
  const like = useAppSelector((state) => state.like);
  const dispatch = useAppDispatch();
  const { data: detail } = useQuery(["data"], () => getDetail(id as string));
  const isLiked = like.find((item) => item.productId === detail?.productId);
 
  // ...
 
  // mutate
  const queryClient = useQueryClient();
  const likeMutation = useMutation((id: string) => postLikeLists(id), {
    onSuccess(data) {
      queryClient.invalidateQueries(["like"]);
      dispatch(
        addLike({
          likeId: data.likeId,
          productId: data.productId,
        }),
      );
    },
  });
  const deleteLikeMutation = useMutation((id: number) => deleteLikeList(id), {
    onSuccess(data, id) {
      queryClient.invalidateQueries(["like"]);
      dispatch(deleteLike(id));
    },
  });
 
 
  return (
    <div>
      {detail && (
          <ProductBox>
            <ProductTitle>{detail?.productName}</ProductTitle>
            {isLiked?.likeId ? (
              <button
                onClick={() =>
                  deleteLikeMutation.mutate(isLiked.likeId as number)
                }
              >
                <HiHeart />
              </button>
            ) : (
              <button onClick={() => likeMutation.mutate(detail?.productId)}>
                <HiOutlineHeart />
              </button>
            )}
          </ProductBox>
 
          // ...

버튼 클릭 시 React-QueryuseMutation 훅의 mutate를 사용해 관심 상품을 추가하고 삭제하는 기능을 구현하였습니다. 또 쿼리를 무효화해 새로 데이터를 fetch해오는 invalidateQueries()를 사용해 데이터를 최신화하도록 구현하였습니다.

이때 관심 상품을 Redux-Toolkit으로 관리하였습니다. 먼저 상태안에 관심 상품의 productId와 데이터를 fetch한 상품 상세 정보의 productId가 같은지 find메서드를 활용하여 찾습니다. 이렇게 찾아진 관심 상품의 상태 값을 isLiked 변수에 담아줍니다. 그 후 관심 상품이 있을 경우 색이 칠해진 아이콘과 삭제하는 deleteLikeMutation.mutate()를 없을 경우 빈 아이콘과 추가하는 likeMutation.mutate()를 조건부 렌더링하도록 구현하였습니다.

🤔 당시에는 React-Query에 익숙지 않고 기능을 구현하는 것에 급급해 가독성이 떨어지는 코드를 작성했던 것 같습니다. 만약 다시 작성한다면 mutatate를 담당하는 기능들을 커스텀 훅으로 분리하였을 것 같습니다. 또 isLiked의 경우 불리언 변수에 어울리는 컨벤션인데 컨벤션을 제대로 지키지 못하고 코드를 작성하였습니다.

Likes.tsx
const Likes = () => {
  const queryClient = useQueryClient();
  const dispatch = useAppDispatch();
  const {
    data: likeItem,
    isLoading,
    isError,
  } = useQuery<Item[]>({
    queryKey: ["like"],
    queryFn: getLikeLists,
  });
 
  const deleteMutation = useMutation((id: number) => deleteLikeList(id), {
    onSuccess(data, id) {
      queryClient.invalidateQueries(["like"]);
      dispatch(deleteLike(id));
    },
  });
 
  // ...
};

관심 상품 페이지에서 useQuery훅을 사용하여 관심 상품의 목록을 조회할 수 있도록 구현하였습니다.
위와 마찬가지로 관심 상품을 삭제하는 mutatate가 존재하는데, 커스텀 훅으로 만들었다면 코드의 재사용성이 훨씬 높아졌을 텐데라는 아쉬움이 있습니다.

구매 내역

Order.tsx
const Order = () => {
  const queryClient = useQueryClient();
 
  const { data, isLoading, isError } = useQuery<Item[]>({
    queryKey: ["order"],
    queryFn: getOrderLists,
  });
 
  const deleteMutation = useMutation((id: number) => deleteOrder(id), {
    onSuccess(data) {
      queryClient.invalidateQueries(["order"]);
    },
  });
 
  // ...

구매 내역 역시 React-QueryuseQuery 훅을 사용하여 데이터를 fetch하였습니다. 또 구매 내역 삭제를 위해 관심 상품과 마찬가지로 mutatate를 활용하였습니다. 커스텀 훅으로 만들지 못한 아쉬움이 있습니다.

유저 정보

TextFiled.tsx
interface ITextFiled {
  id:
    | "name"
    | "password"
    | "confirmPassword"
    | "asset"
    | "income"
    | "job"
    | "region"
    | "joinType";
  label:
    | "이름 *"
    | "비밀번호 변경 *"
    | "비밀번호 확인 *"
    | "총 자산"
    | "소득"
    | "직업"
    | "지역"
    | "가입목적";
  type?: "text" | "password" | "select";
  error?: string;
  placeholder?: string;
  register?: {
    onChange: (e: ChangeEvent<HTMLInputElement>) => void;
    onBlur: (e: FocusEvent<HTMLInputElement>) => void;
    ref: RefCallback<HTMLInputElement>;
    name: string;
    min?: string | number;
    max?: string | number;
    maxLength?: number;
    minLength?: number;
    pattern?: string;
    required?: boolean;
    disabled?: boolean;
  };
}
 
const TextFiled = ({
  id,
  label,
  type,
  error,
  placeholder,
  register,
}: ITextFiled) => {
  return (
    <>
      <InputWrapper>
        <Label htmlFor={id}>{label}</Label>
        <input
          {...(register ?? {})}
          type={type ?? "text"}
          id={id}
          placeholder={placeholder}
        />
      </InputWrapper>
      {error ? <ErrorMessage>{error}</ErrorMessage> : null}
    </>
  );
};
export default TextFiled;

유저 정보의 경우 여러 개의 인풋들을 관리해야 했습니다. 그렇기에 컴포넌트의 재사용성에 대해 저 스스로 고심하였고 TextFiled 컴포넌트를 만들어 인풋을 관리하였습니다. React-Hook-Form에서 제공되는 register의 타입인 UseFormRegister<TFieldValues>를 사용하지 않고 모든 옵션을 일일이 타이핑한 것이 코드의 가독성을 저하시키는 것 같습니다. 또 idlabel도 타입을 만들어 관리를 한다면 가독성이 좀 더 좋은 코드가 될 수 있을 것 같습니다.

UserForm.tsx
const validationSchema = z
  .object({
    name: z.string().min(2, "2글자 이상 입력해주세요."),
    password: z
      .string()
      .min(8, "비밀번호는 8자 이상 입력해주세요.")
      .max(20, "비밀번호는 20자 이하로 입력해주세요.")
      .regex(
        /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*])[A-Za-z\d!@#$%^&*]{8,20}$/,
        "8-20자 영문, 숫자, 특수문자를 사용하세요."
      ),
    confirmPassword: z
      .string()
      .min(8, "비밀번호는 8자 이상 입력해주세요.")
      .max(20, "비밀번호는 20자 이하로 입력해주세요.")
      .regex(
        /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*])[A-Za-z\d!@#$%^&*]{8,20}$/,
        "8-20자 영문, 숫자, 특수문자를 사용하세요."
      ),
    asset: z.string().optional(),
    income: z.string().optional(),
    job: z.string().optional(),
    region: z.string().optional(),
    joinType: z.string().optional(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    path: ["confirmPassword"],
    message: "비밀번호가 일치하지 않습니다.",
  });
 
export type EditValues = z.infer<typeof validationSchema>;
 
const UserForm = () => {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<EditValues>({
    resolver: zodResolver(validationSchema),
  });
  const dispatch = useAppDispatch();
  const navigate = useNavigate();
  const { auth } = useAppSelector((state) => state);
 
  const deleteMutation = useMutation(() => deleteUser(), {
    onSuccess(data) {
      dispatch(logoutAction());
      alert("탈퇴 성공");
      navigate("/");
    },
  });
 
  const editMutation = useMutation((data: EditValues) => editUser(data), {
    onSuccess(data) {
      navigate("/");
    },
  });
  const onSubmit = (data: EditValues) => {
    editMutation.mutate(data);
 
    dispatch(
      updateUser({
        ...auth,
        name: data.name,
        asset: data.asset,
        income: data.income,
        job: data.job,
        region: data.region,
        joinType: Number(data.joinType),
      }),
    );
  };
 
// ...
 

이때 React-Hook-FormZod를 활용하여 인풋 필드 관리와 유효성 검사를 효율적으로 처리하였습니다. 유효성 검증을 위한 validationSchema를 만들고 useForm 훅의 resolver 옵션에 추가합니다. 그리고 Zodinfer를 활용하여 스키마에서 타입을 추출하여 폼의 타입을 쉽게 지정할 수 있었습니다.

수정과 탈퇴의 경우 react-Querymutate를 통해 구현하였습니다. 각각의 mutatate가 성공하면 dispatch를 통해 유저 정보를 업데이트하도록 하였습니다.

권한별 라우터

권한별 라우터의 경우 두 가지 경우로 처리하기로 하였습니다.

1. PublicRouter

PublicRouter
const PublicRouter = ({ children }: { children: ReactElement }) => {
  const {
    auth: { accessToken },
  } = useAppSelector((state) => state);
 
  return accessToken ? <Navigate to="/" /> : children;
};

PublicRouter의 경우 로그인이 하지 않은 유저가 접근할 수 있는 라우트(회원가입, 로그인 등)입니다. 우선 로그인 시 store 저장된 토큰을 가져옵니다. 그리고 토큰의 여부에 따라 <Navigate />를 활용해 메인 페이지로 보낼지, children을 렌더링 할지 삼항 연산자로 판단합니다.

2. PrivateRouter

PrivateRouter
const PrivateRouter = ({ children }: { children: ReactElement }) => {
  const {
    auth: { accessToken },
  } = useAppSelector((state) => state);
 
  return accessToken ? children : <Navigate to="/signin" />;
};

PrivateRouter의 경우 로그인된 유저가 접근할 수 있는 라우트(장바구니, 마이페이지, 관심 상품 등)입니다. 토큰이 있을 시 children을 렌더링하고, 없을 시 로그인을 할 수 있도록 /signin으로 리다이렉트 시키도록 구현하였습니다.

axios interceptors를 활용한 인증 로직

지난 원티드 프리온보딩 과정을 준비하며, 알게 된 interceptors를 프로젝트에 적용해 보기로 하였습니다. 요청을 위한 request와 응답을 위한 response 두 경우에 서로 다른 로직을 구현하였습니다.

1. request

api.ts
client.interceptors.request.use((config) => {
  if (!config.headers) return config;
 
  if (accessToken !== null) {
    config.headers.Authorization = `Bearer ${accessToken}`;
  }
 
  return config;
});

request는 요청이 전달되기 전에 로직을 추가할 수 있게 해줍니다. 토큰으로 인증이 필요한 엔드 포인트들이 많기에 요청이 전달되기 전에 토큰 여부에 따라 토큰을 Authorization에 주입하는 로직을 추가하였습니다.

2. response

api.ts
client.interceptors.response.use(
  (response) => response,
  async (error) => {
    const {
      config,
      response: { status },
    } = error;
 
    if (status !== 401 || config.url !== "/refresh") {
      return Promise.reject(error);
    }
    if (accessToken && status === 401) {
      const { data: newAccessToken } = await axios.post(
        `${config.baseURL}/refresh`
      );
 
      config.headers.Authorization = `Bearer ${newAccessToken}`;
    }
 
    return axios(config);
  }
);

토큰이 만료되어 응답 오류가 있을 경우 로직을 구현하였습니다. 응답 데이터가 정상이라면 response를 반환합니다. 만약 토큰이 존재하고 유효한 자격이 없을 때 나타나는 응답 상태 코드가 401일 경우 create로 만든 인스턴스가 아닌 axios를 통해 새로운 토큰을 발급받습니다. 이렇게 발급된 토큰을 Authorization에 주입한 후 axios(config)를 반환합니다.

위와 같이 처리하게 되면 인증이 만료된 경우 자동으로 새로운 토큰을 발급 하여 새로운 토큰을 주입하게 됩니다.

마주한 문제

http와 https

Vercel을 이용해 배포를 진행하였는데, 배포 주소는 암호화된 https이고 백엔드 서버의 주소는 암호화되지 않은 httpMixed content에러가 발생하였습니다. 제가 시도한 해결법은 다음과 같습니다.

  1. upgrade-insecure-requests 추가

    upgrade-insecure-requests는 네트워크 요청을 하기 전에 안전하지 않은 URL을 업그레이드하도록 브라우저에 알려주는 지시문입니다. <meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">  메타 태그에 위와 같은 태그를 추가하여 에러 해결을 시도하였으나, 여전히 에러는 사라지지 않았습니다.

  2. 브라우저 안전하지 않은 콘텐츠 설정

    두 번째로 시도한 해결법은 브라우저 자체의 설정을 바꾸는 것이었습니다. 안전하지 않은 콘텐츠 설정을 허용으로 바꾸어 https를 사용하지 않도록 하였습니다. 그 결과 http 서버 통신이 가능하게 되었습니다.

당장 프로젝트를 제출하고 시연해야하는데 오류를 발견하고 아찔했던 기억이 있습니다. 아무래도 다른 파트와의 협업이 처음이라 각자의 개발만 하느라 위와 같은 사항은 고려를 못 했던 것 같습니다. 오류를 통해 http와 https의 중요성에 알게되었습니다. 🥲

프로젝트 회고 ✨

처음 회고를 작성할 때, 벌써 진행한지 2개월이 지난 프로젝트라 기억이 잘 안 나면 어쩌지 하는 마음으로 회고를 작성하기 시작하였습니다. 역시나..! "이해가 하나도 안 가는데, 이게 내가 짠 코드가 맞아?!"라는 생각이 드는 코드들이 가득했습니다. 코드들을 천천히 읽어보며, 기억을 더듬어 갔습니다. 그러다 보니 코드의 흐름들이 보였고 개선할 사항들이 제법 눈에 밟혔습니다. 내가 진짜 이해하지 못하고 프로젝트를 그저 완성시키기 위해 휘갈긴 코드들도 있었고 충분히 재사용할 수 있는 로직들도 많았습니다. 물론 프로젝트 직후 보다, 다른 파트와 협업하여 하나의 프로젝트를 완성한 그 성취감은 희미해졌지만, 시간이 지난 후 코드들을 다시 읽고 문제점을 발견하며 그동안 내가 정체해있지만은 않았다는 것을 느꼈습니다.

프로젝트를 진행하며 가장 아쉬웠던 부분을 하나 꼽자면 팀원들과 소통을 활발히 못한 점을 꼽고 싶습니다. 아무래도 다른 파트와의 협업이 처음이고 서로에 대해 잘 모르다 보니 무엇을 요구해야 하는지, 어떤 식으로 작업을 진행해야 하는지, http/https는 어떻게 처리해야 하는지와 같은 의문이 계속 생기게 되었던 기억이 있습니다. 그뿐만 아니라 같은 파트의 팀원들과도 각자 개발을 진행하느라 충분히 소통을 못한 아쉬움이 있습니다. 개발자라는 직업은 자신의 기술을 가지고 개발만 잘하면 된다고 생각한 제 자신의 생각을 되돌아보는 계기가 되었고 개발자에게 있어서 의사소통 능력은 필수불가결하다는 것을 몸소 알게 되었습니다.

프로젝트를 진행하며 새로운 기술들을 도입하기 위해 부단히 노력했던 것 같습니다. 처음 도입하는 기술들이라 부족한 부분들도 많았지만, 이런 프로젝트에 사용을 안 한다면 더 익숙해지기 어렵고 앞으로 접할 기회가 줄어들 것 같은 마음이 컸습니다. 그래서 폼 관리를 위한 React-Hook-Form, 스키마와 유효성을 위한 Zod 그리고 서버 상태 관리를 위한 React-Query 등을 도입하였는데, 정말 만족스러운 개발 경험을 가질 수 있었습니다. 단순히 일회성이 아닌, 좀 더 공부하여 효율적으로 사용하고 싶은 마음이 커졌습니다. 점차 사용해 보지 않은 기술들을 지속적으로 접하며 적용할 것입니다.

앞으로 많은 프로젝트를 맡고 진행하게 될 텐데, 기록을 더 열심히 해야겠습니다. 단순히 "~기능 구현"이 아닌 왜 그런 식으로 구현하였는지 이유를 염두에 두며 기록하도록 노력해야겠습니다.

또 필히 결과물을 gif나 영상으로 남겨야겠습니다!
결과물은 이곳에서 확인하실 수 있습니다.