Skip to content

bbumjun/Moview

Repository files navigation

Moview

배포

웹사이트

프로젝트 소개

TMDB API를 이용하여 개발한 영화/TV프로그램 소개 앱입니다. 추천별로 영화/TV 리스트를 제공합니다. 컨텐츠에 대한 세부내용을 확인할 수 있으며 검색기능을 제공합니다.

기술 스택

  • React
  • Typescript
  • Styled-components
  • Recoil
  • storybook

프로젝트를 통해 배운점/아쉬운점

아토믹 디자인 패턴을 적용한 경험

컴포넌트의 재사용을 위한 아토믹 디자인 패턴을 적용해보았습니다. 컴포넌트를 atom/molecules/organism/template/page 단위로 나누는 것을 시작으로 재사용할 수 있는 컴포넌트를 만들고자 했습니다. 하지만 러닝커브가 생각보다 높고 참고할 수 있는 레퍼런스가 많지 않았습니다. 구현하고자 하는 마음이 앞서 패턴을 성공적으로 적용시키지는 못한것 같지만 아토믹 디자인 패턴을 위한 몇가지 규칙을 알게 되었습니다. 다음에 아토믹 디자인 패턴을 적용할때는 아래 사항을 지키면서 개발해볼 예정입니다.

  1. 최소한 Atom은 재사용성이 높도록 설계하고, 상위 단위 컴포넌트는 융통성있게 설계할 수 있다.
  2. 네이밍에 규칙을 정해 한 눈에 어떤 용도인지 알아볼 수 있도록 한다.
  3. 서비스의 모든 페이지를 구상하고 그 이후에 원자를 뽑아낸다.
  4. Atom
    1. 위치를 결정하는 스타일 속성은 props로 받아 재사용성을 높인다.
    2. 서비스 전체에서 일관적으로 적용되는 색이나 폰트 크기는 props로 주입하지 않는다.
    3. 동적인 효과가 필요한 atom인 경우 isAnimation 등의 props를 전달받아 애니메이션 여부를 결정하도록 한다.
    4. 로직과의 연결이 필요한 atom의 경우 커스텀 훅을 만들어 반환한 함수를 props로 전달한다.
  5. Molecules
    1. 서비스에서 변하지 않는 데이터는 Molecule 에서 주입해 prop drilling을 방지한다.
    2. 한 종류의 atom으로도 molecule을 만들어도 상관 없다.

참고한 레퍼런스

리액트 어플리케이션 구조 - 아토믹 디자인

atomic design 파헤치기

아토믹 디자인(Atomic Design) 적용기 : 한계점, 단점

아토믹 디자인 패턴을 실천하는 방법-1


Intersection Observer API를 활용하여 이미지를 레이지 로딩

컴포넌트에서 개별적으로 사용할 수 있도록 useIntersect라는 커스텀 훅을 만들었습니다.

// useIntersect hook
const useIntersect = (
  elementRef: RefObject<Element>,
  {
    threshold = 0,
    root = null,
    rootMargin = "0%",
    freezeOnceVisible = false,
  }: Args
): IntersectionObserverEntry | undefined => {
  const [entry, setEntry] = useState<IntersectionObserverEntry>();
  const frozen = entry?.isIntersecting && freezeOnceVisible;

  const updateEntry = ([entry]: IntersectionObserverEntry[]): void => {
    setEntry(entry);
  };

  useEffect(() => {
    const node = elementRef?.current;
    const hasIOSupport = !!window.IntersectionObserver;

    if (!hasIOSupport || frozen || !node) return;

    const observerParams = { threshold, root, rootMargin };

    const observer = new IntersectionObserver(updateEntry, observerParams);

    observer.observe(node);

    return () => observer.disconnect();
  }, [elementRef, threshold, root, rootMargin, frozen]);
  return entry;
};

useIntersectIntersection Observer가 구독중인 DOM Elemententry객체를 상태로 갖습니다.

effect Hook에서는 IO(풀네임이 길어서 줄여서 부르겠습니다)가 실행할 콜백과 옵션객체를 매개변수로하는 생성자 함수를 호출해 IO 인스턴스를 생성하고 ref로 전달받은 DOM객체를 구독합니다. 뷰포트에 해당 element가 교차한 경우 entry를 업데이트합니다. return 시에는 구독했던 dom element를 해제하여 cleanup합니다.

entry를 반환하여 컴포넌트는 entry의 프로퍼티를 사용할 수 있습니다.

이 hook을 사용하는 이미지 컴포넌트는 entryisIntersecting 프로퍼티가 참일 경우 data-src 속성을 src 속성에 할당해 실제 이미지를 로드합니다.

참고한 레퍼런스

https://usehooks-typescript.com/react-hook/use-intersection-observer


Suspense, ErrorBoundary를 사용한 경험

토스에서 진행한 온라인 컨퍼런스 세션중 비동기를 우아하게 처리하는 방법 이라는 내용의 발표가 매우 인상적이었습니다. Suspense는 리액트에서 실험적으로 개발되고 있는 Concurrent 모드에서 지원하는 일부 기능 정도로 알고있었는데, Suspense만 독립적으로 사용해 비동기를 호출하는 컴포넌트에 렌더링을 분기할 필요 없이 로딩처리를 외부에 위임할 수 있다는 것이었습니다. Suspense를 사용하면 로딩에 대한 렌더링 처리를 선언적으로 할 수 있다는 장점도 있습니다. ErrorBoundarySuspense와 마찬가지로 컴포넌트에서 발생하는 자바스크립트 에러에 대한 처리를 외부에 위임하고 선언형으로 구현할 수 있었습니다.

// detail page component
const ContentList = React.lazy(
  () => import("src/components/organisms/ContentList")
);
const Profile = React.lazy(() => import("src/components/organisms/Profile"));
const Overview = React.lazy(() => import("src/components/organisms/Overview"));
const CastList = React.lazy(() => import("src/components/organisms/CastList"));
const DetailPage: React.FC = () => {
  const { id, contentType } = useParams<{
    id: string;
    contentType: "movie" | "tv";
  }>();
  return (
    <ErrorBoundary> // 하위 컴포넌트에서 발생한 에러를 캐치해 fallback UI 렌더링
      <Suspense fallback={<Loader />}> // suspense로 감싸 하위 컴포넌트들의 Promise가 펜딩 중인 동안 Fallback Component 렌더링
        <Template profile={<Profile contentType={contentType} id={id} />}>
          <Overview contentType={contentType} id={id} />
          <CastList contentType={contentType} id={id} />
          <ContentList
            contentTitle="비슷한 작품"
            contentType={contentType}
            url={`${contentType}/${id}/similar`}
            wrap={true}
            titleFontSize={1.2}
          />
        </Template>
      </Suspense>
    </ErrorBoundary>

ErrorBoundary의 경우 네트워크 에러도 함께 처리하기 위해 axios에서 catch block에서 에러가 처리되기 전에 then의 두번째 콜백함수로 Promise.reejct(err)를 전달했고, ErrorBoundary 는 전달받은 error 객체를 status에 따라 다른 에러메시지를 유저에게 보여줄 수 있도록 구현했습니다.

class ErrorBoundary extends React.Component<
  {},
  { hasError: boolean; message: string; status: number }
> {
  constructor(props: {}) {
    super(props);
    this.state = { hasError: false, message: "", status: null };
  }

  componentDidCatch(error: AxiosError, info: React.ErrorInfo) {
    const { status } = error.response;
    this.setState({ hasError: true });
    this.setState({ status });
    if (status === 404) {
      this.setState({ message: "요청한 데이터를 찾을 수 없어요." });
    } else if (status >= 401 && status <= 403) {
      this.setState({ message: "데이터에 접근할 수 있는 권한이 없어요." });
    } else if (status >= 500) {
      this.setState({ message: "서버에 문제가 생긴 것 같아요." });
    } else {
      this.setState({ message: "페이지 로드 중 문제가 생겼어요 ㅠㅠ" });
    }
  }

  render(): React.ReactNode {
    if (this.state.hasError) {
      return (
        <Wrapper>
          <Container>
            <Emoticon>🤔</Emoticon>
            <Text color="red" fontSize={4} fontWeight={800}>
              {this.state.status}
            </Text>
            <Text>{this.state.message}</Text>
          </Container>
        </Wrapper>
      );
    }
    return this.props.children;
  }
}

에러 페이지

참고한 레퍼런스

https://ko.reactjs.org/docs/concurrent-mode-suspense.html

https://ko.reactjs.org/docs/error-boundaries.html


Webpack 을 이용한 모듈 번들링 경험

WebpackReact App을 어떻게 동작하게 만드는지 알기 위해서 직접 빈 프로젝트에서부터 Webpack 설정 파일을 채워나갔습니다. 작은 프로젝트가 실행될 수 있을 정도의 간단한 설정이었음에도 상당히 까다로운 작업이었습니다.

//webpack.config.js
module.exports = {
  // mode에 따라 빌드 결과물이 달라진다. 콘솔에 로그 여부나 번들 파일 압축 등
  mode: isProd ? "production" : "development",
  // entry : 의존성 그래프를 생성하는 진입점
  entry: "./src/index.tsx",
  // 번들링의 결과물 파일 설정. 파일의 변화가 생긴 chunk만 hash값이 바뀌도록 설정
  output: {
    filename: "[name].[chunkhash:8].js",
    path: path.join(__dirname, "public"),
    publicPath: "/",
  },
  // 파일 경로 또는 확장자 처리를 위한 옵션
  resolve: {
    // import 시 인식할 모듈
    modules: [path.join("./"), "node_modules"],
    // import시 확장자 생략 가능
    extensions: [".tsx", ".ts", ".js", "jsx"],
  },
  // .js 외의 파일들을 해석하기 위한 로더 설정
  // 로더는 베열의 우측->좌측 순서로 실행되는 것을 유의해야함
  module: {
    rules: [
      {
        // tsx,jsx,ts 모듈 로더 설정
        test: /\.[tj]sx?$/,
        use: ["babel-loader", "ts-loader"],
        exclude: /node_modules/,
      },
      {
        // reset.css 파일을 위한 css 로더설정
        test: /\.css$/,
        use: ["style-loader", "css-loader", "postcss-loader"],
      },
      {
        // 그외 assets 파일 로더 설정
        test: /\.(png|jpe?g|webp|svg)$/,
        loader: "file-loader",
        options: {
          name: "[name].[ext]",
        },
      },
    ],
  },
  // 그외 웹팩의 다양한 기능을 플러그인을 설치해 사용 할 수 있음
  plugins: [
    new webpack.ProgressPlugin(), // 빌드 진행상황을 출력
    new CleanWebpackPlugin(), // 이전에 빌드된 파일들을 정리
    new HtmlWebpackPlugin({
      // 빌드 결과물을 html 파일로 생성
      template: "src/index.html",
      hash: true,
    }),
    // 배포환경에서 환경변수에 접근할 수 있도록 해줌
    new Dotenv({
      systemvars: true,
    }),
    isDev && new webpack.HotModuleReplacementPlugin(), // 개발서버 실행중에 변경사항 자동 적용
    isDev && new BundleAnalyzerPlugin(), // 번들 사이즈 분석
  ].filter(Boolean),
  // 개발서버 설정
  devServer: {
    hot: true, //HMR on
    port: port,
    historyApiFallback: true, // route 외의 url fallback 으로 index.html 서빙
  },
  // 빌드 상태를 콘솔에 출력해줌
  stats: "summary",
};

그외 프로젝트를 통해 배운 점

  • hook을 사용한 컴포넌트 상태 관리
  • Recoil을 사용한 전역상태 관리
  • Styled Components를 사용한 CSS-in-JS 스타일링
  • React dev tools 를 사용한 프로파일링 및 성능 최적화
  • React Router로 클라이언트 사이드 라우팅