영화앱6: 검색결과 모듈 만들기(feat. 이미지 최적화)

jonyChoiGenius·2023년 2월 7일
0

프로필 사진 수정

프로필 사진 수정은 기본 이미지 11개를 만들고, 해당 이미지 중 하나를 선택하도록 했다. 이미지 확장자도 기존 png에서 mozJpeg로 변경하여 용량 최적화를 했다.

이벤트 버블링을 이용하여, 클릭된 요소에 src가 있는 경우, 해당 src의 마지막 부분을 슬라이스해서 유저 이미지 값으로 넣도록 간단하게 코드를 짰다.

  const [image, setImage] = useState("/profileImages/1.jpg");
...

                <div
                  className={`d-flex align-items-center d-flex justify-content-center small-image-container ${
                    changinProfile ? "" : "d-none"
                  }`}
                  onClick={(e: any) => {
                    if (e.target.src)
                      setImage(
                        `/profileImages/${
                          e.target.src.split("/").slice(-1)[0]
                        }`,
                      );
                  }}
                >
                  <img src="/profileImages/1.jpg" className="small-image" />
                  <img src="/profileImages/2.jpg" className="small-image" />
                  <img src="/profileImages/3.jpg" className="small-image" />
                  <img src="/profileImages/4.jpg" className="small-image" />
                  <img src="/profileImages/5.jpg" className="small-image" />
                  <img src="/profileImages/6.jpg" className="small-image" />
                  <img src="/profileImages/7.jpg" className="small-image" />
                  <img src="/profileImages/8.jpg" className="small-image" />
                  <img src="/profileImages/9.jpg" className="small-image" />
                  <img src="/profileImages/10.jpg" className="small-image" />
                  <img src="/profileImages/11.jpg" className="small-image" />
                </div>

영화 검색하기

영화 검색 유닛은 Nav-bar에서도 재사용될 것이므로 따로 분리해서 만들자.

conponents/Search.tsx

import React from "react";
import StyledForm from "../styles/StyledForm";

interface iProps {
  label?: string;
  onResultClick?: React.MouseEventHandler;
}

const Search = ({ label, onResultClick }: iProps) => {
  return (
    <StyledForm className="form-floating">
      <input
        className="form-control text-center"
        type="text"
        name="text"
        placeholder="닉네임을 입력하세요"
      />
      <label htmlFor="floatingInput">
        {label ? label : "인생 영화를 검색하세요"}
      </label>
    </StyledForm>
  );
};

export default Search;

프로필 생성창에 있던 검색 인풋을 위와 같이 변경하였다.
StyledForm 역시 분리하여 재사용하였고,

검색창의 디자인을 위해 레이블 태그와 검색결과 클릭했을 때에 일어날 이벤트를 props로 받았다.

interface iProps {
  label?: string;
  onResultClick?: React.MouseEventHandler;
}

const Search = ({ label, onResultClick }: iProps) => {
  const [input, setInput] = useState("");
  const [movies, setMoives] = useState([]);
  const debouncedInput = useDebounce(input, 500);

  useEffect(() => {
    if (!debouncedInput) return setMoives([]);
    getSearchData(debouncedInput).then((res) => setMoives(res.data.results));
  }, [debouncedInput]);
  return (
    <StyledForm className="form-floating">
      <input
        className="form-control text-center"
        type="text"
        name="text"
        placeholder="인생 영화를 검색하세요"
        onChange={(e) => setInput(e.target.value)}
      />
      <label htmlFor="floatingInput">
        {label ? label : "인생 영화를 검색하세요"}
      </label>
      <div className={debouncedInput ? "" : "d-none"}>
        <StyledSearchResults>
          <div className="RowContainer">
            <MovieRow
              title="검색결과"
              id="SearchResult"
              movieList={movies}
            ></MovieRow>
          </div>
        </StyledSearchResults>
      </div>
    </StyledForm>
  );
};

검색결과가 적용된 Search 모듈이다.
Search 모듈의 검색 결과는 MovieRow를 불러오지만, 검색결과에 맞는 스타일을 씌우기 위해 컨테이너 컴포넌트와 Row의 이미지 컴포넌트를 분리했다.

그리고 분리하고 보니 결과적으로 Search에서 쓰이는 리스트와 메인 페이지에서 쓰일 리스트가 성격이 달라서 분리하는 것은 적절한 방식인 것 같다. Search에서 쓰이는 리스트는 지속적으로 검색어에 따라 변경되어야 하기 때문에 동적인 최적화가 필수적이다. 반면 메인 페이지에서 쓰이는 리스트는 어느 정도 값이 고정되어 있을 예정이기 때문에(ISG를 통해 사전 렌더링할 예정이다.) 서버사이드에서 최적화가 이루어질 수 있다.

StyledSearchResults는 검색 결과를 absolute로 보여주고 backdropFilter를 적용하여 꾸몄다. 그리고 레이아웃 shift를 방지하기 위해 어느정도 높이를 주었다.(높이는 하드코딩 했다. ㅠㅠ)

영화 리스트 꾸미기 (Feat. Swiper.js)

interface iProps {
  title: string;
  id: string;
  fetchUrl?: string;
  movieList?: Array<any>;
  onResultClick?: Function;
}
function MovieRow({ title, id, fetchUrl, movieList, onResultClick }: iProps) {
  const [movies, setMovies] = useState([]);

  useEffect(() => {
    fetchMovieData();
  });
  const [movieSelected, setMovieSelected] = useState({});
  const fetchMovieData = async () => {
    let request;
    if (fetchUrl) request = await tmdbApi.get(fetchUrl);
    const newMovies = movieList || request.data.results;
    setMovies(newMovies);
  };
  const handleClick = (movie) => {
    if (onResultClick) {
      onResultClick(movie);
      return;
    }
    setMovieSelected(movie);
  };
  const MovieFigure = movieList?.length ? MovieRowSearch : MovieRowContent;
  
  return (
    <StyledMovieRow className="row">
      {title? <h2>{title}</h2> : <></>}
      <Swiper
        modules={[Navigation, Pagination, Scrollbar, A11y]}
        autoHeight={true}
        loop={false}
        pagination={{ clickable: true }}
        navigation={{
          prevEl: ".swiper-button-prev",
          nextEl: ".swiper-button-next",
        }}
        breakpoints={{
          1378: {
            slidesPerView: 6,
            slidesPerGroup: 6,
          },
          998: {
            slidesPerView: 5,
            slidesPerGroup: 5,
          },
          625: {
            slidesPerView: 4,
            slidesPerGroup: 4,
          },
          0: {
            slidesPerView: 3,
            slidesPerGroup: 3,
          },
        }}
      >
        <div id={id} className="row__posters">
          {!movies.length ? (
            <div>
              <SwiperSlide key="noResult">
                <figure>
                  <img
                    alt="결과가 없습니다"
                    style={{
                      cursor: "pointer",
                    }}
                    className="row__poster"
                    src="/noResult.jpg"
                  />
                  <div
                    className="overlay"
                    style={{ height: "100%", backdropFilter: "blur(10px)" }}
                  >
                    <div
                      className="description"
                      style={{
                        fontSize: "x-small",
                      }}
                    >
                      결과가 없습니다
                    </div>
                  </div>
                </figure>
              </SwiperSlide>
            </div>
          ) : (
            movies.map((movie) => {
              return (
                movie.backdrop_path && (
                  <SwiperSlide key={movie.id}>
                    <MovieFigure movie={movie} />
                  </SwiperSlide>
                )
              );
            })
          )}
        </div>
        <div className="swiper-button-prev arrow"></div>
        <div className="swiper-button-next arrow"></div>
      </Swiper>
    </StyledMovieRow>
  );
}

export default MovieRow;

Swiper JS 8.4를 사용하고 있다.
Swiper객체를 생성하여 사용하는 Swiper API를 방법이 권장되지만,
Swiper라이브러리를 학습하는 것이 본 프로젝트의 목표가 아니므로 컴포넌트 형식으로 임포트해서 사용하였다.
Swiper에서 Loop를 활성화하는 경우 동일한 컴포넌트를 3번씩 렌더링하는 문제가 있어 이를 Swiper API를 이용하여 Unmount시켜줘야 하지만, Swiper API를 사용하지 않아 Loop 기능을 비활성화시켰다.
한편 위에서

        navigation={{
          prevEl: ".swiper-button-prev",
          nextEl: ".swiper-button-next",
        }}

와 같이 특정 div 태그를 버튼으로 변경한 부분이 있는데, 이렇게 하면 Swiper의 기본 버튼보다 더 넓은, 커스텀 가능한 버튼을 만들 수 있어서 사용하기 편했다.

Search 모듈과 연동시키기 위해 몇가지 Props를 사용하고 있는데
1. TMDB에서 fetch로 데이터를 불러오는 방식에 더하여, MovieList가 Props로 넘어오는 경우 데이터를 fetch하지 않고 해당 MovieList를 사용하도록 했다.
2. MovieFigure는 Swiper에 들어갈 이미지들을 의미하는데, MovieList가 존재하는 경우에는 검색결과인 것으로 판단하여 MovieRowSearch라는 이미지 컴포넌트를, 아닌 경우엔 MovieRowContent라는 기본 이미지 컴포넌트를 사용하도록 하였다.
3. 마지막으로 Title 부분도 타이틀이 없는 경우에는 렌더링하지 않도록 변경하였다.

이미지 최적화하기 (feat.Intersections API)

MovieRowSearch는 가로길이가 300인 이미지를 TMDB로부터 받아 렌더링한다. 화질이 낮기 때문에 반투명한 배경의 text를 overlay하여 이미지를 살짝 감추고자 했다.
그럼에도 로딩속도가 원하는 만큼 나오지 않았는데, 프론트엔드 최적화에서 사용했던 몇가지 기법을 적용해보았다.

  1. 플레이스홀더 이미지
      <img
        src="/noResult.jpg"
        className={Loading ? "" : "d-none"}
      ></img>
        <img
          alt={movie.title || movie.original_title}
          className={`row__poster ${Loading ? "d-none" : ""}`}
          onLoad={() => setIsLoading(0)}
          src={`https://image.tmdb.org/t/p/w300/${movie.backdrop_path}`}
          onClick={() => console.log("안녕하세요")}
        />
      <div
        className="overlay"
        style={{
          height: "100%",
          backdropFilter: `${Loading ? "blur(10px)" : "blur(0px)"}`,
        }}
      >

부트스트랩의 'd-none' 클래스를 이용하여, onLoad이벤트가 발생하기 전에는 약 400byte의 이미지를 보여주고, onLoad이벤트가 발생하는 경우에는 d-none클래스를 서로 토글하여 img태그를 보여주도록 하였다.
이 과정에서 overlay 텍스트에 backdropFilter를 적용하였는데 (의도하진 않았지만)overlay에 있는 transition옵션이 함께 적용되어 blur가 서서히 사라지는 애니메이션이 되었다. (해당 트랜지션 효과는 safari에서는 작동하지 않음 ㅠㅠ)

  1. LazyLoading
    지난 번 프론트엔드 최적화 글을 정리하면서 봐두었던 Intersections API를 사용해보았다.
  const [isInView, setIsInView] = useState(false);
  const imgRef = useRef();
  useIntersection(imgRef, () => {
    setIsInView(true);
  });
    <figure
      ref={imgRef}
    >
  		      {isInView && (
        <img
          alt={movie.title || movie.original_title}
          className={`row__poster ${Loading ? "d-none" : ""}`}
          onLoad={() => setIsLoading(0)}
          src={`https://image.tmdb.org/t/p/w300/${movie.backdrop_path}`}
          onClick={() => console.log("안녕하세요")}
        />
      )}
  	</figure>

useIntersection이라는 커스텀 훅은 html요소와 콜백 함수를 인자로 받아 맵 객체에 저장한 후,
IntersectionObserver로 해당 객체를 Observing하는 함수이다.
이를 통해 LazyLoading을 구현하였다.

  1. 이미지 바꿔버리기
    LazyLoading을 했는데도 너무 늦게 떠서
    아에 이미지를 교체해버리기로 했다.
    TMDB API 명세서에서 지원되는 이미지 중 가장 작은 이미지는 w300의 backdrop 이미지와 w92의 poster이미지이다. 앞서 말했듯 저화질의 이미지를 써도 적당히 눈속임이 되도록 이미 overlay를 해둔 상황이라 poster이미지를 불러와 16:9비율로, object-fit : 'cover'하기로 하였다.
<div style={{
  width: "100%",
  paddingTop: "56.25%",
      background:"url('data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wEEEAGQAZABkAGQAakBkAHCAfQB9AHCAnECowJYAqMCcQOdA1IDBwMHA1IDnQV4A+gEMwPoBDMD6AV4CE0FLQYOBS0FLQYOBS0ITQdTCOMHOga9BzoI4wdTDS8KWgkuCS4KWg0vDzwMywwcDMsPPBJ1EIEQgRJ1Fz4WEhc+Hl8eXyjSEQGQAZABkAGQAakBkAHCAfQB9AHCAnECowJYAqMCcQOdA1IDBwMHA1IDnQV4A+gEMwPoBDMD6AV4CE0FLQYOBS0FLQYOBS0ITQdTCOMHOga9BzoI4wdTDS8KWgkuCS4KWg0vDzwMywwcDMsPPBJ1EIEQgRJ1Fz4WEhc+Hl8eXyjS/8IAEQgACQAQAwEiAAIRAQMRAf/EACgAAQEBAAAAAAAAAAAAAAAAAAEAAgEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEAMQAAAAykf/xAAUEAEAAAAAAAAAAAAAAAAAAAAg/9oACAEBAAE/AB//xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAECAQE/AH//xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAEDAQE/AH//2Q==')"
}}>
        <img
          alt={movie.title || movie.original_title}
          style={{
            position:"absolute",
            top:'0',
            height:'100%',
            objectFit:'cover',
          }}
          className={`row__poster ${Loading ? "invisible" : ""}`}
          onLoad={() => setIsLoading(0)}
          // src={`https://image.tmdb.org/t/p/w300/${movie.backdrop_path}`}
          src={`https://image.tmdb.org/t/p/w92/${movie.poster_path}`}

          onClick={() => console.log("안녕하세요")}
        />
  </div>

Div태그에 배경으로 base64를 넣고 그 위에 이미지를 띄우는 방식으로 변경하였고, 이에 따라 이미지 태그가 하나만 필요하므로 삼항연산자과 d-none클래스를 없애고 visible 클래스를 수정하도록 하였다.

profile
천재가 되어버린 박제를 아시오?

0개의 댓글