[영화검색] React로 리팩토링

Yeong·2023년 11월 29일
0

프로젝트

목록 보기
1/4

이 프로젝트는 강의를 들으면서 진행한 프로젝트인데, 영화 제목을 검색해주는 애플리케이션이다. 자바스크립트로 구현 후 타입스크립트로 리팩토링하였다.

이 프로젝트를 통해 리액트를 사용한 컴포넌트 단위 SPA이 아니라, 자바스크립트의 Class를 활용한 컴로넌트 단위 SPA 구현에 대해 배울 수 있었다. 그리고 타입스크립트에 대해서도 좀 더 알게 되었다.

그래서 리액트에서의 타입스크립트 공부를 위해 리액트로 리팩토링를 하게 되었다.

타입스크립트: https://movie-search-eight-rho.vercel.app/#/
리액트: https://movie-search-react-ochre.vercel.app/

✅자바스크립트와 리액트 비교

💠Component

자바스크립트 class Component

interface ComponentPayload {
  tagName?: string
  props?: { [key: string]: unknown }
  state?: { [key: string]: unknown }
}
export class Component {
  public el
  public props
  public state
  constructor(payload:ComponentPayload = {}) {
    // 컴포넌트 생성시 최상위 요소 태그, props와 state 생성
    const { tagName = 'div', props = {}, state = {} } = payload;
    this.el = document.createElement(tagName); // 컴포넌트의 최상위 요소
    this.props = props; // 부모 컴포넌트에서 받는 데이터
    this.state = state; // 컴포넌트 안에서 사용할 데이터
    this.render();
  }
  render() {} // 컴포넌트를 렌더링하는 함수
}

export class Store<S> {
  public state = {} as S // 초기화에서 {}이지만 할당 시 S타입으로
  private observers = {} as StoreObservers 
  // 초기화에서 {}이지만 할당 시 StoreObservers타입으로
  constructor(state: S) {
    for(const key in state) {
      // 각 상태에 대한 변경 감시(Setter) 설정
      Object.defineProperty(this.state, key, {
        get: () => state[key],
        set: val => {
          state[key] = val // 상태 변경
          if(Array.isArray(this.observers[key])) { // 호출할 콜백이 있는 경우!
            this.observers[key].forEach(observer => observer(val))
          }
        }
      })
    }
  }
  //상태 변경 구독
  subscribe(key: string, cb: subscribeCallback) {
    Array.isArray(this.observers[key]) 
    ? this.observers[key].push(cb) 
    : this.observers[key] = [cb]
    // 예시)
    // observers = {
    //   구독할상태이름: [실행할콜백1, 실행할콜백2]
    //   movies: [cb, cb, cb],
    //   message: [cb]
    // }
  }
}

자바스크립트

export default class MovieList extends Component {
  constructor() {
    super()
    // state가 변경되었다면 cb실행(재렌더링)
    movieStore.subscribe('movies', () => this.render())
    movieStore.subscribe('message', () => this.render())
    movieStore.subscribe('loading', () => this.render())
  }
  render() {
    this.el.classList.add('movie-list')
    this.el.innerHTML = /* html */ `
      ${movieStore.state.message 
      ? `<div class="message">${movieStore.state.message}</div>`
      : `<div class="movies"></div>` }
      <div class="the-loader hide"></div>
    `
    
    const moviesEl = this.el.querySelector('.movies')
    moviesEl?.append(
      ...movieStore.state.movies.map(movie => new MovieItem({ movie }).el)
    )

    const loaderEl = this.el.querySelector('.the-loader')
    movieStore.state.loading
      ? loaderEl?.classList.remove('hide')
      : loaderEl?.classList.add('hide')
  }
}

리액트

interface MovieListProps {
  movies: moviesState;
}

export const MovieList: React.FC<MovieListProps> = ({ movies }) => {
  return (
    <MovieListContainer>
      {movies.message ? (
        <div className="message">{movies.message}</div>
      ) : (
        <div className="movies">
          {movies.movies.map((movie) => (
            <MovieItem key={movie.imdbID} movie={movie} />
          ))}
        </div>
      )}
      {movies.loading && <div className="the-loader"></div>}
    </MovieListContainer>
  );
};
  • 리액트의 클래스형 컴포넌트와 비슷하다.
  • js는 state를 store에 저장해 어느 컴포넌트에서든 바로 접근 가능하지만 리액트는 컴포넌트의 위치에 따라 state를 props로 내려서 사용해야한다.
  • js는 subscribe(상태 변경 구독함수)를 사용해 재랜더링을 한다면 리액트는 상태변경 시 리액트에서 재랜더링을 해준다.
  • js는 자식요소를 innerHTML이나 append로 넣어준다면 리액트는 jsx문법으로 작성한다.
  • 상태에 따른 class 추가 및 삭제는 js는 classList.add, classList.remove를 사용한다면 리액트는 논리연산자를 주로 사용한다.

💠Route

자바스크립트 routeRender

interface Route {
  path: string
  component: typeof Component
}
type Routes = Route[]

function routeRender(routes: Routes) {
  // 해시 없을 경우 현재 url를 /#/으로 대체한다
  if (!location.hash) {
    history.replaceState(null, '', '/#/'); // (상태, 제목, 주소)
  }
  
  const routerView = document.querySelector('router-view');
  const [hash, querystring = ''] = location.hash.split('?');
  // 물음표를 기준으로 해시 정보와 쿼리스트링을 구분(쿼리스트링 없을 때를 고려해 기본값 주기)
  
  // 1) 쿼리스트링을 각각 key와 value 나누어 객체로 히스토리의 상태에 저장!
  interface Query {
    [key: string]: string
  }
  const query = querystring
    .split('&')
    .reduce((acc, cur) => {
      const [key, value] = cur.split('=');
      acc[key] = value;
      return acc;
    }, {} as Query); // 매개변수에 직접 타입 지정 시 첫 값 {}으로 지정 안됨
  history.replaceState(query, '') //(상태, 제목) 주소입력 안하면 현재 url유지

  // 2) 현재 라우트 정보를 찾아서 렌더링!
  const currnetRoute = routes.find(route => new RegExp(`${route.path}/?$`).test(hash))
  if(routerView) {
    routerView.innerHTML = ''
    currnetRoute && routerView.append(new currnetRoute.component().el)
  }
  
  // 3) 화면 출력 후 스크롤 위치 복구!
  window.scrollTo(0,0)
}
export function createRouter(routes: Routes) {
  // 원하는(필요한) 곳에서 호출할 수 있도록 함수 데이터를 반환!
  return function () {
    window.addEventListener('popstate', () => { //히스토리가 변할 때마다 렌더링
      routeRender(routes)
    })
    routeRender(routes) // 최초 호출(popstate 처음은 인식x, 변경만)
  }
}

js에서는 a태그 href속성에 #id값을 주면 해당 페이지의 id를 가진 요소로 이동하게되는 점을 활용해 route함수를 구현한다.
리액트에서는 react-router-domd을 사용했다.

✅vercel

💠환경변수 추가하기

vercal에서 이미 파일올리고 나서 환경변수를 추가할려고 하는데 어디서 추가해야는지 한참 찾았다.

setting에서 Environment Variables 들어가면 있다.

💠Serverless Function

fetch를 사용할 때 api주소 바로 사용하면 api주소가 노출되기 때문에 보안상 안 좋을 수 있다. vercel에서 지원해주는 Serverless Function을 사용하면 요청이 vercel을 걸쳐 가서 실제 api주소를 감출 수 있다.

api폴더를 만들어서 api에 사용할 이름으로 파일을 만든다. 나는 movie.ts로 만들어서 요청 URL 끝을 api/movie로 해주었다.
vercel/node를 설치해야하고 node에서 fetch 사용 못하므로 node-fetch도 설치해 fetch가 적용될 수 있게한다.

import fetch from "node-fetch";
import {VercelRequest, VercelResponse} from "@vercel/node";

const { APIKEY } = process.env

export default async function handler(request: VercelRequest, response: VercelResponse) {
  const {title, page, id} = JSON.parse(request.body)
  const url = id
    ? `https://www.omdbapi.com/?apikey=${APIKEY}&i=${id}&plot=full`
    : `https://www.omdbapi.com/?apikey=${APIKEY}&s=${title}&page=${page}`

  const res = await fetch(url)
  const json = await res.json()
  response.status(200).json(json)

요청 시에 /api/파일명으로 요청

      const res = await fetch('/api/movie', {
        method: 'POST',
        body: JSON.stringify({title: movies.searchText, page}),
      })

✅리액트의 타입

💠props

리액트에 타입스크립트 적용 시 가장 낯설었던것 props사용 시 타입을 같이 작성해주어야하는 점이 였다.
화살표 함수로 작성 시 앞에 prpss의 타입의 정의해 주고 React.FC<타입> 형식으로 작성해 주면된다.

interface SearchProps {
  movies: moviesState;
  setMovies: React.Dispatch<React.SetStateAction<moviesState>>
  searchMovie: (page:number) => Promise<void>
}

export const Search: React.FC<SearchProps> = ({ movies, setMovies, searchMovie }) => {
  const onChangeSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
    setMovies({ ...movies, searchText: e.target.value });
  };

  return (
    <SearchContainer>
      <input
        value={movies.searchText}
        placeholder="Enter the movie title to search!"
        onChange={onChangeSearch}
        onKeyDown={(e) => (e.key === 'Enter' && movies.searchText.trim()) ? searchMovie(1) : null}
      />
      <button className="btn btn-primary" onClick={() => movies.searchText.trim() ? searchMovie(1) : null}>Search!</button>
    </SearchContainer>
  );
};

무슨 타입인지 잘 모르겠을 때는 알고 싶은 변수에 마우스 갖다 대면 나온다.

그리고 스타일컴포넌트 props 사용 시에도 타입 추가해줘야하는데 <{props명: 타입>형식으로 작성해 주면된다.

export const MovieItem: React.FC<MovieProps> = ({movie}) => {
  return (
    <MovieWarpper poster={movie.Poster}>
      <div className="info">
        <div className="year">{movie.Year}</div>
        <div className="title">{movie.Title}</div>
      </div>
    </MovieWarpper>
    )
}

const MovieWarpper = styled.div<{poster: string}>`
  --width: 200px;
  width: var(--width);
  height: calc(var(--width) * 3 / 2);
  border-radius: 4px;
  background-image: url();
  background-color: var(--color-black);
  background-size: cover;
  overflow: hidden;
  position: relative;

💠이벤트

리액트의 이벤트의 타입은 단순히 Event가 아니라 이벤트 종류와 html 종류에 따라 다르다.
아래 코드처럼 input에서 발생한 change 이벤트일 경우 이벤트의 타입은 React.ChangeEvent<HTMLInputElement>이다.

interface SearchProps {
  movies: moviesState;
  setMovies: React.Dispatch<React.SetStateAction<moviesState>>
  searchMovie: (page:number) => Promise<void>
}

export const Search: React.FC<SearchProps> = ({ movies, setMovies, searchMovie }) => {
  const onChangeSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
    setMovies({ ...movies, searchText: e.target.value });
  };

  return (
    <SearchContainer>
      <input
        value={movies.searchText}
        placeholder="Enter the movie title to search!"
        onChange={onChangeSearch}
        onKeyDown={(e) => (e.key === 'Enter' && movies.searchText.trim()) ? searchMovie(1) : null}
      />
      <button className="btn btn-primary" onClick={() => movies.searchText.trim() ? searchMovie(1) : null}>Search!</button>
    </SearchContainer>
  );
};

✅에러

💠gap 적용이 안될 때

단위를 잊지 말자..!

💠setState 작동 안될 때

api요청 완료 후 페이지 갱신해야하는데 배치때문에 적용 안 되었다.
스프레드 문법으로 {...원래 state, 바뀐 state}로 변경했었는데 배치로 인해 변경이 안된 state가 계속해서 덧씌워져서 page의 값이 변하지 않았던 것 같다.
그래서 이전 state를 매개변수로 받아 상태를 변경해주어서 변경이 순차적으로 적용이 될 수 있게 하였다.

const searchMovie = async (page: number) => {
    setMovies((prevMovies) => ({
      ...prevMovies,
      loading: true,
      message: '',
      page,
    }));
    if (page === 1) {
      setMovies((prevMovies) => ({ ...prevMovies, message: '', movies: [] }));
    }
    try {
      const res = await fetch('/api/movie', {
        method: 'POST',
        body: JSON.stringify({title: movies.searchText, page}),
      })
      const { Response, Search, totalResults, Error } = await res.json();
      if (Response === 'True') {
        const newPageMax = Math.ceil(Number(totalResults / 10));
        setMovies((prevMovies) => ({
          ...prevMovies,
          movies: [...prevMovies.movies, ...Search],
          pageMax: newPageMax,
          page: movies.page + 1,
        }));
        console.log(movies);
      } else {
        setMovies((prevMovies) => ({
          ...prevMovies,
          message: Error,
          pageMax: 1,
        }));
      }
    } catch (error) {
      console.log('searchMovies error:', error);
    } finally {
      setMovies((prevMovies) => ({ ...prevMovies, loading: false }));
    }
  };

💠Link에 스타일컴포넌트 적용

styled.Link가 아니라 styled(Link)로 작성하면 된다.

💠type: module

import 및 export 사용 시 package.json에 "type": "module" 추가하기

💠build 에러

배포 중에 에러나서 확인해 보니 typescript5.3.2과 react-scripts5.0.1이 충돌이 나서 버전을 변경하라고 하라고 나와서 "typescript": "^4"으로 변경해 주었다.

profile
긍정적으로~✍️(◔◡◔)

0개의 댓글