CINEMATE 프로젝트 중간 회고록

김동영·2024년 6월 23일
2
post-thumbnail

1. 프로젝트 소개

이번 프로젝트의 주제는 그래프를 활용한 영화 추천 서비스인 CINEMATE입니다.

최근 온라인 스트리밍 플랫폼의 성장과 다양한 디지털 콘텐츠의 생산이 영화 소비 경험을 새로운 수준으로 끌어올렸습니다.
이렇게 다양한 볼거리가 존재함에 불구하고 사용자들은 오히려 선택의 어려움을 겪고있습니다. 이러한 문제를 해결하기위해 유의미한 영화 추천과 함께 관련영화 정보를 제공하는 서비스를 만들어보았습니다.

팀구성

프론트엔드 1명, 백엔드 1명 , AI 1명

기술 스택

  • React
  • TypeScript
  • React-query
  • Styled-compont
  • React-Router
  • Recoil
  • Storybook
  • ...

구현 기능

  • 로그인/회원가입(중복확인)
  • 영화 추천을 위한 영화 설문받기
  • 회원기반,장르별 영화추천 페이지 (영화 좋아요 및 상세 페이지 이동)
  • 영화 상세정보페이지 (별점작성,수정기능 리뷰 작성,수정,삭제 기능)
  • 영화 검색 기능(인풋값 변경마다 요청)
  • 최근 검색 기록
  • 마이페이지 (좋아요누른 영화 목록,내가쓴댓글)


2. 프론트엔트 진행계획

이번 프로젝트를 통해 기존에 했던 방식보다 새로운 라이브러리, 폴더구조 등 추가 해보면서 공부하고 싶었습니다.


2-1. 폴더구조

저는 지금까지 프로젝트를 하면서 폴더 구조를 컴포넌트와 페이지로만 분류하고 컴포넌트도 페이지별,기능별로 뚜렸한방법이 있는게 아니라 그때그때마다 추가해서 만들었습니다. 앞으로 팀원들과 협업을 하게되면 이러한 부분은 문제가 되기때문에 고쳐나가고 싶어서 폴더구조에대해 알아보았습니다.

아토믹디자인

폴더구조에 대해 구글링을 해보니 아토믹 디자인 패턴이라는 방식에 대해 알게 되었습니다.
아토믹 디자인 패턴 방식은 화학적 관점에서 영감을 얻은 디자인 시스템이며 아래와 같이 이루어져 있습니다.

  • 원자(atom)
  • 분자(molecule)
  • 유기체(organism)
  • 템플릿(template)
  • 페이지(page)

Atom

atom은 label,input, button과 같이 더이상 분해할 수 없는 컴포넌트입니다.

Molecule

molecule은 인터페이스에서 하나의 단위로 함께 작동하는 단순한 UI요소 그룹입니다. 원자(atom)가 결합되면 다음과 같이 목적을 갖게됩니다.

Organism

organism은 molecule보다 좀더 복잡한 UI요소 그룹입니다.
atom, molecule, organism으로 구성될 수 있습니다. 한가지 목적이 아닌 다양한 목적을 갖게됩니다.

Template

template는 component들을 layout에 배치하고 디자인의 기본 컨텐츠 구조를 명학화게 표현하는것입니다. 즉 페이지의 뼈대를 나타낼 수 있습니다.

Page

page는 template에서 실제 컨텐츠들이 추가된 것입니다. 즉 완성본이라고 볼 수 있습니다.

아토믹 디자인 패턴에 대해 공부하다보니 구성을 보다 정확하고 쉽게 구현할 수 있을거같아서 선택하게 되었습니다.

적용하면서 어려웠던 점

어느정도까지 분리해야하는가

다음과 같이 search창의 input과 로그인 page의 input이 있습니다.
저의 고민은 다음과 같았습니다.

  • input창안에 값을 입력하는부분의 input을 atom으로 분리해야 하는가?
  • 만약 atom으로 분리하게 되면 서로 같은 input태그를 사용하기 때문에 재사용성을 고려해 같은 component로 분리 해야 하는가??
  • 그렇다면 하나의 input component안에 너무 많은 역할을 맡아서 혼란을 야기 하지 않을까??
  • 만약 다른 atom으로 분리하면 다른 input을 만들때마다 계속 따로 만들어야할텐데 굳이 atom으로 만들어야하는가?? 까지 고민했습니다.

그래서 저의 결론으로는 재사용성을 고려해 같은 atom으로 분리하면 하나의 input-component에 너무많은 기능을 담당해야하고,
또한 따로 분리하면 굳이 component의 개수만 많아지기만하고 재사용성이 없다고 판단해서 따로 atom으로 분리하지 않고 form component마다 따로 선언해서 사용했습니다.

이렇게 처음 적용하다보니 헷갈리는것도 많고 어떤게 효율적인지가 구분이 안되는경우가 조금 있었습니다.


2-2. 데이터 관리

지금까지 데이터를 받을때 따로 처리나 관리를 하지 않았습니다.
하지만 많은 개발자분들이 데이터 관리를 해야한다고해서 정리해서 적용해 보았습니다.

client state 와 server state

  • client state : 모달과 같은 데이터, input value를 보고 button 비활성화와 같은 데이터

  • server state : 서버에서 넘어오는 state(사용자 정보,각종 데이터)

왜 client state와 server state를 분리해야하는가??

사실 이것에 대해 정말 잘 이해가 되지 않았습니다..
도대체 분리하지 않으면 어떠한 문제가 생기는거지..?
client state는 recoil,jutai, Zustand...를 쓰고
server state는 react-query,swr... 을 써야하는거지??에 대한 의문점이 들어서 공부해보았습니다.

  • client 와 server state는 성질이 달라서입니다. server state는 클라이언트에서 관리하지 않고 서버에서 관리하기 때문입니다. 만약 client에서 관리하게 된다면 보안적인 문제와 server state는 모든 사용자와 공유되어야되는데 성능에 문제가 될 수 있습니다.

  • 또한 여러 클라이언트가 동시에 server state를 변경할 수 있습니다. 클라이언트 상태는 독립적이며 서버 상태와 동기화되지 않을 수 있습니다.
    즉 예를 들어 온라인 쇼핑몰의 재고 수량 server state인데 client state로 분리하지 않아서 여러 클라이언트가 동시에 이 데이터를 수정하려고 하면 수량에서 문제가 발생할 수 있기때문에 분리해야합니다.


react-query

  • 애플리케이션에서 서버 상태 가져오기, 캐싱, 동기화 및 업데이트를 매우 쉽게 만들어주는 라이브러리
  • React의 ContextAPI를 기반으로 동작합니다.
  • 전역상태를 관리하는 QueryClient가 존재하는데, 해당 QueryClient는 우리가 Query를 사용할 때 명시하는 unique key를 기반으로 데이터를 저장합니다.

캐싱

  • 캐싱이란 특정 데이터의 복사본을 저장하여 이후 동일한 데이터에 접근 할때 보다 빠르게 접근하는 방법입니다.
  • react-query에서는 캐싱을 통해 데이터에 대한 반복적이고, 불필요한 API 호출을 줄여 서버에 대한 부하를 줄입니다.

useQuery

  • Get 요청을 할때 사용합니다.
  • 첫번째 파라미터로 unique key가 들어가고, 두번째 파라미터로 비동기 함수(fetch 함수)가 들어갑니다.
  • return 값은 api의 성공, 실패 , api return 값을 포함한 객체입니다.
function Todos() {
  const { isPending, isError, data, error } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodoList, //
  })

  if (isPending) {
    return <span>Loading...</span>
  }

  if (isError) {
    return <span>Error: {error.message}</span>
  }

  // We can assume by this point that `isSuccess === true`
  return (
    <ul>
      {data.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  )
}
  • useQuery의 반환값을 보면 isPending, isError,data,error 를 통해 각각의 상태에 맞게 확인 할 수 있습니다.

아직은 Get요청인 useQuery에 대해서만 공부해서 추후에 다른 요청에 대해서 공부한다음 react-query에 대해 자세하게 정리해보겠습니다.


2-3. storybook

storybook은 UI 구성요소와 페이지를 독립적으로 구축하기 위한 tool입니다.
이번 프로젝트를 진행하면서 storybook을 추가한 이유는 다음과 같습니다.

  • 추후에 디자이너와의 협업을 위해 storybook을 활용해서 디자이너 분도 component 어떻게 동작하는지,보이는지 바로바로 확인할 수 있습니다.
  • component를 개발할때 불필요하게 코드를 추가해서 일일이 확인할 필요없기때문입니다.

storybook 사용방법

storybook을 설치하게 되면 .storybook폴더가생성되며, 이 폴더 내에는 main.ts, preview.ts가 있습니다. 이 두개의 팡일로 storybook설정을 할 수 있습니다.

main.ts

  • 스토리의 위치와 사용할 애드온을 정의하는 설정이 포함됩니다.
import type { StorybookConfig } from "@storybook/react-webpack5";

const config: StorybookConfig = {
  stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
  //storybook 경로 
  addons: [ //addons 배열은 Storybook에 추가 기능을 제공하는 플러그인 목록입니다.
    "@storybook/preset-create-react-app",
    "@storybook/addon-onboarding",
    "@storybook/addon-links",
    "@storybook/addon-essentials",
    "@chromatic-com/storybook",
    "@storybook/addon-interactions",
  ],
  framework: {
    name: "@storybook/react-webpack5",
    options: {},
  },
  docs: {
    autodocs: "tag",
  },
  staticDirs: ["../public"],
};
export default config;

preview.ts

  • 프로젝트의 모든 스토리에 글로벌하게 적용될 설정을 정의하며, 스토리의 매개변수와 데코레이터를 설정할 수 있습니다.
import type { Preview } from "@storybook/react";
import { withThemeFromJSXProvider } from '@storybook/addon-themes';
import { theme } from './../src/styles/theme';
import { ThemeProvider } from 'styled-components';
import GlobalStyle from '../src/styles/GlobalStyle'


const preview: Preview = {
  parameters: { // storybook global parameter 설정 
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
    backgrounds: { // storybook 배경 설정 
      default: 'dark',
      values:[
        {
          name: 'dark',
          value: '#211F1F'
        },
      ]  
  },
  },
};

export default preview;

export const decorators = [
  withThemeFromJSXProvider({
  themes: {theme},
  Provider: ThemeProvider, //Provider: ThemeProvider를 설정하여 테마를 제공하게 합니다.
  GlobalStyles: GlobalStyle //GlobalStyles: GlobalStyle을 설정하여 전역 스타일을 적용합니다.
})];

이후 main.ts에서 설정한 폴더에서 해당 component에 맞는 storybook 파일을 생성하면됩니다.

//PrimaryButton.stories.ts
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; // 클릭 이벤트 핸들러
import PrimaryButton from '../components/atoms/PrimaryButton'; //해당하는 component import

// 메타 데이터
const meta: Meta<typeof PrimaryButton> = {
  title: 'Components/Button', // 어디로 분류 할건지 
  component: PrimaryButton,  // 렌더링 할 컴포넌트
};

export default meta;
type Story = StoryObj<typeof meta>; // story 객체 타입 정의 

export const PrimaryBtn: Story = { // 각각의 props 초기값 설정 
  args: {
    type: 'button',
    children: '회원가입',
    onClick: fn(),
    state: true,
    size: 'large',
  },
};

storybook은 component 분류 및 화면에 나타내는 정도인 기본적인 기능만 구현해보았습니다.



3. 프로젝트중 트러블 슈팅 및 구현 🔥

프로젝트를 진행하면서 별거아니지만,,, 제가 겪었던 문제상황에 대하여 적어보려고합니다.


3-1. react-hook-form

문제상황

💡 react-hook-form을 사용하여 공통 컴포넌트에 validation을 적용하려고했는데 register를 사용하여 validation을 적용할 경우 동작하지 않습니다.

  • onChange,required등 ref도 포함되어있어서 결국 props로 ref를 넘기고 있기 때문에 에러가 발생합니다.

ref를 props로 못넘기는이유

💡 ref는 react에서 DOM에 직접 접근하기 위해 사용되기 때문입니다. 따라서 일반적인 props로는 사용불가능합니다

에러 해결 방법

  • forwardRef를 이용해서 컴포넌트를 한 번 감싸주는 방법을 사용하여 ref를 props로 넘겨주었습니다.

export interface FormInputProps {
  type: 'nickName' | 'password' | 'email';
  value?: string;
  placeholder?: string;
  onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
  validationStatus: 'default' | 'error' | 'success';
  register?: UseFormRegisterReturn;
  duplicatedStatus?: boolean;
}

const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
  (
    {
      type,
      placeholder,
      value,
      onChange,
      validationStatus,
      register,
    }: FormInputProps,
    ref,
  ) => {
    let image;
    if (type === 'nickName') {
      image = nameSvg;
    } else if (type === 'email') {
      image = emailSvg;
    } else if (type === 'password') {
      image = passwordSvg;
    }

    return (
      <InputContainer $validationStatus={validationStatus}>
        <InputImg src={image} />
        <InputField
          ref={ref}
          type={type}
          placeholder={placeholder}
          value={value}
          {...register}
          onChange={onChange}
        />
        {validationStatus !== 'default' && (
          <CheckImg
            src={validationStatus === 'error' ? errorSvg : successSvg}
          />
        )}
      </InputContainer>
    );
  },
);
FormInput.displayName = 'FormInput';

export default FormInput;

3-2. input state

문제상황

💡 최근검색어 기능을 구현하기 위해 검색어를 로컬스토리지에 저장하였습니다.
검색창이 header로 구성되어있어서 검색어 값을 전역상태로 두어야하는 상태여서 검색어를 전역상태 + 로컬스토리지에 저장하려고 recoil-persist 사용했습니다. submit 버튼을 누르자 input의 state값이 초기화 되는 에러가 발생했습니다.

에러 해결 방법

  1. recoil-persist를 처음 사용하는거라 무언가를 잘못설정했나 생각했지만 설정에서 틀린것을 발견하지 못했습니다.
  2. 그렇다면.. 혹시.. event.preventDefault()에서 문제가되나...? 그럴리가...? 역시나 아니였습니다.
  3. 그래서 console을 찍으면서 하나하나 value값이 어디서 사라지는지 확인했습니다.
  4. onChange가 되었을때는 값이 존재하는것을 확인하였고, onSubmit을 하자 값이 사라진것을 확인했습니다.
  5. 따라서 submit이 되었을때 문제가 되는것을 확인하였고 form태그를 다시 확인해보니 form태그안에 form을 제출하는 버튼과 input의 value값을 지우는 cancel 버튼이 있는데 cancel버튼의 type을 지정을안해서 type이 submit으로 설정이되어버려서 submit이 되었을때 cancel버튼까지 같이 동작해버려서 생기는 에러였다......

버튼의 type설정을 잘하자...


3-3. 회원가입 input의 validation오류

문제상황

💡 회원가입할때 inputvalue값이 이메일,닉네임의 중복확인(API)정규식(react-hook-form)에 부합하는지를 onChange가 될때마다 확인했어야합니다 .
하지만 어째서인지 onChange될때 react-hook-form의 validation 확인은 되지만 중복확인API요청을 보내지 않는 error가 발생했습니다.

에러 해결 방법

  1. 디바운싱을 적용하지 않아 onChange가 될때마다 중복확인API 요청을 짧은 시간안에 빠르게 보내는 문제로 인해 단순 느려짐인줄 알았지만...아니였습니다.

  2. 네트워크 창을 확인해보니 요청자체가 보내지지 않아서 API자체에 문제인줄 알았는데 아니였습니다...

  3. react-hook-form은 동작하는데 그러면 왜..? API요청만 동작이 되지않는지 모르겠어서 찾아보니 원인을 알게되었습니다.

  4. react-hook-form은 기본적으로 input의 필드를 ref로 비제어 컴포넌트로 관리하기때문에, onChangeinputvalue값을 react상태로 관리하려면 inputfield가 일관성이 사라져 충돌하는 문제가 발생한다는것을 알게되었습니다.

  5. react-hook-formController를 사용하면 비제어 컴포넌트와 제어 컴포넌트를 같이 사용할 수 있다는것을 알게 되었습니다.

따라서 다음과같이 구현하였습니다.

export interface FormInputProps {
  type: 'nickName' | 'password' | 'email';
  value?: string;
  control: Control<SignupInput>;
  placeholder?: string;
  onInputChange: (event: ChangeEvent<HTMLInputElement>) => void;
  validationStatus: 'default' | 'error' | 'success';
  duplicatedStatus?: boolean;
}

const CustomFormInput = ({
  type,
  placeholder,
  control,
  onInputChange,
  validationStatus,
  duplicatedStatus,
}: FormInputProps) => {
  let image: string;
  if (type === 'nickName') {
    image = nameSvg;
  } else if (type === 'email') {
    image = emailSvg;
  } else if (type === 'password') {
    image = passwordSvg;
  }

  return (
    <Controller
      control={control}
      name={type}
      render={({ field: { onChange, value } }) => (
        <InputContainer $validationStatus={validationStatus}>
          <InputImg src={image} />
          <InputField
            name={type}
            type={type}
            placeholder={placeholder}
            value={value || ''}
            onChange={(event) => {
              onChange(event.target.value);
              onInputChange(event);
            }}
          />
          {validationStatus !== 'default' && (
            <CheckImg
              src={validationStatus === 'error' ? errorSvg : successSvg}
            />
          )}
        </InputContainer>
      )}
    />
  );
};

export default CustomFormInput;

3-4. 별점 구현(트러블 슈팅 보단 구현 방법)

문제상황

💡 별점을 아래의 사진같이 색이 없는 별을 클릭하게 되었을때 별점이 등록되도록 설정하려고 하였고 별점을 0.5점 단위로 구현하려했습니다. 하지만 0.5점이면 클릭했을때를 어떻게 구별할 수 있을까..?

구현 방법

  1. 위에 보이는 별위에 가운데를 짤라서 세로로 button을 반개씩 두개 만들었습니다.
  2. 왼쪽을 클릭하면 현재 index보다 작은 index의 별들을 다 색칠하고 현재 index의 별은 반개만 색칠(점수는 현재 index-0.5점으로)
  3. 오른쪽을 클릭하면 현재 index까지 별들을 색칠(점수는 현재 index)

코드는 다음과 같습니다.

const GradeStar = ({
  score,
  movieId,
  index,
  setScore,
  onRatingClick,
}: GradeStarProps) => {
  const handleLeftClick = () => {
    const rating = index - 0.5;
    setScore(rating);
    onRatingClick({
      movieId: movieId,
      rating,
    });
  };

  const handleRightClick = () => {
    setScore(index);
    onRatingClick({
      movieId: movieId,
      rating: index,
    });
  };

  const renderStar = () => {
    if (index - score === 0.5) {
      return <HalfStar />;
    } else if (score >= index) {
      return <FillStar />;
    } else {
      return <EmptyStar />;
    }
  };

  return (
    <StarContainer>
      <ButtonContainer>
        <LeftButton onClick={handleLeftClick} />
        <RightButton onClick={handleRightClick} />
      </ButtonContainer>
      {renderStar()}
    </StarContainer>
  );
};

export default GradeStar;


4. 프로젝트를 진행하면서 느낀점


초반설계가 굉장히 중요하다

디자인의 초반설계를 하게되면서 초반 설계가 얼마나 중요한지 깨달았습니다.
아무래도 캡스톤디자인겸으로 만들어서 시간이 많지않아서 급하게 하다보니 대략 적으로만 설계하고 디테일한 부분들은 잘 고려하지 않았는데 그러한 부분때문 에 개발과정에서 계속적으로 수정해야하는 부분들이 늘어났습니다.

ex)회원가입할때 닉네임과 이메일같은 부분들은 중복확인이 필수인데... 생각을못해서 디자인적으로 버튼을 넣기가 애매해져서.... input의 value값이 onChange 될때마다 중복확인API요청을 보냈습니다... 물론 잘못된 방법은 아니지만 조금더 깔끔하게 진행할 수 있지 않았을까 생각이 들었습니다.

그리고 저희 서비스의 header가 총 3개로

  • 뒤로 가는 버튼이 있는 header
  • logo가 있는 header
  • searchInput이 있는 header
    다음과 같이 있습니다.

제일 처음에는 main-layout에서 3개의 header를 page에 맞게 각각 불러왔지만,
한번의 header만을 불러서 header-component 안에서 각각의 page에 맞게 하는것이 더 가독성의 측면에서나 성능에서나 좋다고 생각했습니다.
하지만 search-input값을가지고 최근검색어 구현 및 각각의 page에 따른 뒤로가기 버튼 구현은 공통component로 구현하기에는 다소 복잡함이 있었습니다.

이러한 경험을 통해 초반에 어떠한기능이 정확하게 있고 어떤식으로 구현할지에 대해서 시간이 오래걸리더라도 정확하게 설계하고 들어가는것이 중요하다고 느꼈습니다.

PR메시지 및 Issue 활용

이번 프로젝트를 마치고 PR창을 다시 보니 생각보다 가독성이 떨어진 느낌이 들었습니다... 중구난방이고 한번에 어떤 기능을 구현했고 어떤것을 만들었는지 불명확한 느낌이 들었습니다.
PR을 작성할때 이것도 마찬가지로 초반에 어떤식으로 작성할지에 대해 조금더 고민하고 많은 레퍼런스들을 참고해서 작성해 보도록 하겠습니다.
또한 Issue는 정말 어떠한 error나 issue가 생겼을때 작성하는거로 알고있었는데.... 알고보니 작업단위로도 나타낼 수 있어서 좀 더쉽게 볼 수 있는 기능인걸 알았습니다...
다음프로젝트를 진행할때는 이러한 부분도 조금더 공부해서 적용해 보도록 하겠습니다.

아토믹 디자인 패턴

사실 아토믹디자인 패턴을 사용할때 초반에는 atom,molecule,organism로 나눴을때 정말 분리가 잘되는 느낌을 받았었습니다.
하지만 컴포넌트의 개수가 점점 많아지다보니 각각의 폴더에서 원하는것을 찾기가 어려워졌습니다.
그렇다 보니 폴더구조의 패턴을 정해놓는것이 좀더 쉽게 파일을 찾기위해서인데 이렇게 찾기 어려우면 굳이 원칙대로 사용을 해야될까?? 라는 생각이 들었습니다.
그래서 앞으로는 아토믹 디자인 패턴을 사용한다면 아래와같이
아토믹 디자인 패턴 + 컴포넌트의 종류 로 사용할거 같습니다.

5. 이후 보완할점

사실아직 캡디기간내에 기본적으로 구현할 수 있는 부분만 구현했습니다. 팀원들과 상의를 해보니 다들 7월까지는 정해진 일정이 있어서 7월말에서 ~8월에 추가기능 및 보완할점들을 구현할거 같습니다.

추가로 구현 해야 할 점

  • 무한스크롤
  • 회원가입 중복,검색창(debounce적용)
  • react-query(post요청)
  • 에러,로딩 핸들링
  • 등등 ...

이후에 서비스를 완성하고 다시 회고록을 작성해 보도록 하겠습니다.

profile
안녕하세요 프론트엔드개발자가 되고싶습니다

2개의 댓글

comment-user-thumbnail
2024년 6월 23일

너무 멋지시네여 😳 저도 꼭 사용해보고 싶어요!

1개의 답글