2023년 5월 5주차 기능구현 - 1

김재훈·2023년 5월 29일
0
post-thumbnail
post-custom-banner
  • 주제: 영화 데이터를 크롤링하여 무한 스크롤이 적용된 사이트를 원하는 스타일로 자유롭게 구현합니다.
    (5월 4주차에 끝내지 못 해서 이어서 함)
  • 활용 데이터https://www.themoviedb.org
  • 링크Git 저장소배포

요약

영화 정보를 20개씩 읽어오고, 읽어온 내용으로 영화 목록을 렌더링합니다.
목록에서 마지막 5번째 요소가 화면에 등장할 때 다음 20개를 추가로 읽어와서 기존 요소에 더해 렌더링합니다.

Intersection Observer API 이해하기

해당 글은 내용이 길어져 별도 포스트로 정리했습니다.
👉 Intersection Observer API에서 무한 스크롤에 필요한 부분만 이해하기 읽으러 가기

Next.js 13에서 클라이언트 컴포넌트 작성하기

저는 다음과 같은 이유로 Next.js 프레임워크를 사용해 React 앱을 작성했습니다.
1. react.dev로 React 공식 문서가 업그레이드 된 이후 프레임워크를 사용해 React 앱을 작성하기를 권장함
2. Vercel의 git repo 연동 및 배포 자동화하기가 간편함

하지만 Next.js의 최신 버전(작성일 기준 13.4)에서는 App Router가 stable하게 사용 가능합니다. 이 App Router 방식은 컴포넌트가 기본적으로 서버 컴포넌트로만 동작하게 하는데요.
따라서 useState, useEffect 등 클라이언트 사이드에서 변경 작업이 필요한 React 훅을 사용하려면 use client; 구문을 page.js 최상단에 작성해주어야 합니다.

❓ 왜 Next.js 13의 기본 컴포넌트 방식인 서버 컴포넌트로 작성하지 않나요?
무한 스크롤은 클라이언트에서 특정 요소가 화면에 나타날 때까지 대기하고, 이를 관측한 뒤 클라이언트의 요청에 의해 새로운 네트워크 요청이 발생합니다. 또한 그에 대한 응답을 기반으로 다시 렌더링하기 때문에 서버 컴포넌트는 별로 적합한 방식이 아닙니다.

서버 컴포넌트는 서버에서 접근할 수 있는 리소스들을 모아 하나의 페이지를 완성한 후 클라이언트에게 전달해야 할 때 보다 적합합니다. (해당 내용은 SSR; 서버 사이드 렌더링과는 다릅니다. 하지만 SSR이 서버 컴포넌트를 구현하기 위한 좋은 방법이 될 수 있습니다.)

Next.js 13.4 소개에서는 추후 패턴이 정립되어 가며, 생태계가 풍부해지면 좀 더 쉽게 문제를 해결할 수 있게 될거라고 설명하고 있습니다.

영화 목록 가져오기

앞으로 사용하게 될 기본 fetch 구문 작성

무한스크롤로 영화 목록을 렌더링하기 위해서 기반이 되는 데이터를 긁어오는 로직을 먼저 작성했습니다.
화면에 렌더링하는 로직을 제외한 상태에서 정보가 제대로 불러오는지 확인하기 위해 console.log를 사용하여 원하는 형태(배열)가 될 때까지 코드를 작성했습니다.

const fetchMovies = async () => {
	try {
		const response = await fetch(`https://api.themoviedb.org/3/discover/movie?api_key=${process.env.tmdbApiKey}&page=${moviePage}&language=ko-KR`);
		const json = await response.json();
		console.log(json.results);
	} catch (err) {
		console.error(err);
	}
}

fetchMovies();

위 함수의 실행 결과는 다음과 같습니다.

해당 함수를 실행하기 위한 API key는 next.config.js 파일에서 env 속성으로 작성하여 추가했습니다.

값을 상태에 저장하고, useEffect로 갱신하기

영화 정보를 불러오는 API는 페이지네이션이 적용되어 있고, 한 페이지마다 20개의 항목을 가져옵니다.
이를 응용하여 스크롤을 내려 추가 렌더링 요청이 들어올 때마다 page 값을 +1하여 기존 영화 정보와 합쳐주게끔 작성했습니다.

페이지가 1페이지라면 받아온 결과만 movies에 저장하고, 2페이지부터는 spread syntax를 사용해 기존 movies와 API 응답 결과를 한 배열로 합쳐 movies에 저장하도록 로직을 구성했습니다.

// 몇 페이지의 영화 목록을 fetch할지 저장한 상태
// 초기값이 1이므로 화면이 처음 렌더링될 때는 1페이지의 영화 목록을 렌더링
const [moviePage, setMoviePage] = useState(1);

// 영화 항목들이 배열 형태로 담겨진 상태
const [movies, setMovies] = useState();

// 페이지를 증가시키는 사이드 이펙트
// 아래 상태에서는 무한 스크롤 로직이 추가되지 않아 아직 동작하지 않음
useEffect(() => {
	setMoviePage((prevPage) => prevPage+1);
}, [movies]);

// 네트워크 요청을 통해 영화 정보를 상태에 저장
useEffect(() => {
	const fetchMovies = async () => {
		try {
			const response = await fetch(`https://api.themoviedb.org/3/discover/movie?api_key=${process.env.tmdbApiKey}&page=${moviePage}&language=ko-KR`);
			const json = await response.json();
			if (moviePage > 1) {
				setMovies([...movies, ...json.results]);
			} else {
				setMovies(json.results);
			}
		} catch (err) {
			console.error(err);
		}
	}
	fetchMovies();
}, [moviePage]);

영화 정보 렌더링하기

네트워크 요청을 통해 영화 정보도 받았고, 이를 useState 훅을 통하여 상태로도 저장해두었습니다.
이제 상태에 저장된 내용을 표시하는 컴포넌트를 작성해보겠습니다.

컴포넌트 폴더 구조

Next.js 13의 App Router에서는 폴더 이름이 새로운 path로 라우팅되는 방식입니다. () 둥근 괄호를 적용해, 폴더가 새 라우팅 경로로 인식되지 않도록 할 수 있습니다.
이에 (MovieCard)라는 이름으로 폴더를 만들어 컴포넌트를 생성하고, 실제 로직을 수행하는 요소와 styled-components를 통해 작성된 스타일을 구분하여 각각 index.js와 style.js 이름으로 코드를 작성했습니다.

index.js

'use client';

import { useState, forwardRef } from 'react';
// Style 컴포넌트는 로직을 포함하는 컴포넌트와 구분 짓기 위하여 S라는 postfix를 붙임
import * as S from './style';

const MovieCard = ({ movie }) => {
	
	const [poster, setPoster] = useState(movie.poster_path);
	return (
		<S.MovieCard src={poster} alt={`영화 ${movie.title}의 포스터 사진`}>
			<S.Poster>
				<img src={poster} alt={`영화 ${movie.title}의 포스터 사진`} />
			</S.Poster>
			<S.MovieInfo>
				<h1>{movie.title}</h1>
				<p>{`평점 : ${movie.vote_average} / 10`}</p>
			</S.MovieInfo>
		</S.MovieCard>
	)
});

export default MovieCard;

style.js

import styled from "styled-components";

export const MovieCard = styled.div`
	width: 30vw;
	min-width: 200px;
	max-width: 400px;
	
	height: 40vh;
	min-height: 300px;
	max-height: 500px;
	
	display: flex;
`;

export const Poster = styled.div`
	width: 30%;
	height: 100%;
	
	display: flex;
	justify-content: center;
	align-items: center;
	
	img {
		height: 90%;
	}
`;

export const MovieInfo = styled.div`
	width: 70%;
	height: 100%;
	
	display: flex;
	flex-direction: column;
	justify-content: center;
	align-items: center;
	
	color: white;
	background: rgba(0, 0, 0, 0.7);
	
	h1 {
		font-size: 1.5rem;
		font-weight: bold;
	}
	
	p {
		font-size: 1rem;
	}
`;

영화 정보 렌더링

이제 Next.js의 기본으로 렌더링되는 코드인 page.js에서 작성된 컴포넌트를 통해 렌더링할 수 있습니다.

처음 화면이 렌더링될 때는 movies가 아직 초기화되지 않은 상태입니다. 네트워크 요청은 비동기적으로 발생하며, 네트워크 요청이 완료될 때 movies 값이 갱신됩니다.
따라서 movies 값이 유효한지 movies ? 구문을 통해 먼저 확인하고, 아직 네트워크 요청이 완료되지 않아 값이 없다면 Loading...을 먼저 표시하도록 했습니다.

movies 값이 존재한다면 영화 정보를 받을 수 있도록 map으로 나누어 MovieCard 컴포넌트를 렌더링합니다.

page.js

'use client';

import { useEffect, useRef, useState } from "react";
import MovieCard from "./(MovieCard)/index";

// 요소가 적당히 나열되도록 만든 스타일
import * as S from './style';

export default function Home() {
	... // 영화 정보 가져오는 부분 생략
	
	return (
	<S.ListContainer>
		{movies ? movies.map((movie) => (
			<MovieCard key={movie.id} movie={movie} />
		)) : <p>Loading...</p>}
	</S.ListContainer>
	)
}

[Bug] (fixed)영화 정보의 포스터 사진이 표시되지 않음

하지만 요소는 잘 렌더링되는데 포스터 사진이 깨지는 문제가 있었습니다.
영화의 포스터 사진 속성인 poster_path 값을 보면 원인을 알 수 있는데, / 문자로 시작하는 것을 보아 이미지가 업로드된 네트워크 경로를 의미하는 것으로 유추가 가능합니다.
poster_path: '/kHen25Yk0DnaB2pqaB1mcZDKqMv.jpg',

저는 이미지의 웹 주소를 알아내기 위해서 실제 사이트에 접속한 뒤 새로운 탭에서 이미지 열기를 클릭했습니다.

그 결과, 이미지의 웹 주소는 https://image.tmdb.org/t/p/w440_and_h660_face/{poster_path} 꼴로 이뤄지는 것을 알아낼 수 있었고, 이를 MovieCard 코드에 적용하여 해결했습니다.

(MovieCard)/index.js

'use client';

import { useState, forwardRef } from 'react';
import * as S from './style';

const MovieCard = ({ movie }) => {
	// 참고한 웹 주소 형태로 상태 저장
	const [poster, setPoster] = useState(`https://image.tmdb.org/t/p/w440_and_h660_face${movie.poster_path}`);
	return (
		...
	)
};

export default MovieCard;

결과 화면

무한 스크롤 구현하기

이제 영화 정보도 잘 받아올 수 있고, 화면 렌더링도 성공했으니 스크롤을 내릴 때마다 렌더링을 반복하여 무한히 스크롤이 가능하도록 구현해보겠습니다.

이해한 Intersection Observer API의 동작을 기반으로 기능을 구현해보겠습니다.

target이 될 대상 선정하기

저는 적당히 밑으로 스크롤했을 때 추가 렌더링이 이뤄지길 원했고, 대강 마지막에서 5번째 요소가 화면에 등장했을 때 새 요소를 렌더링하면 되겠다고 생각했습니다.

target은 HTML element 형태로 저장되어야 하는데요. React에서는 가상 DOM과 실제 DOM을 평가하는 과정을 거친 뒤에 실제 DOM에 요소가 렌더링되기 때문에 코드 작성 시점에 런타임 때 생성될 DOM의 위치를 알 수 없는 문제가 존재합니다.

React에서는 이를 위해 useRef 훅이 존재하는데요. 훅을 통해서 변수를 만들고, HTML 요소에 ref 속성으로 연결하면 화면에 해당 요소가 나타났을 때 위치를 변수에 저장해줍니다.

또한, map에서 두번째 매개변수가 인덱스 값인 것을 이용하여 목록 끝에서부터 5번째인 항목을 구하는 데 사용했습니다.
구하는 것은 movies 상태의 전체 길이에서 -5를 하여 구했습니다.

page.js

'use client';

import { useEffect, useRef, useState } from "react";
import MovieCard from "./(MovieCard)/index";
import * as S from './style';

export default function Home() {
	// 일반적으로 초기값은 null로 선언
	const ref = useRef(null);
	
	...
	
	return (
		<S.ListContainer>
			{movies ? movies.map((movie, idx) => (
				idx === (movies.length - 5) ? <MovieCard key={movie.id} movie={movie} ref={ref} />
				: <MovieCard key={movie.id} movie={movie} />
			)) : <p>Loading...</p>}
		</S.ListContainer>
	)
}

관측할 observer 생성하기

관측을 위해 observer를 생성하지만, 마찬가지로 React 특성상 요소가 생긴 뒤에 observer를 생성해야 합니다.
따라서 useEffect를 통해 observer를 생성하고, dependancy array를 영화 목록이 저장된 movies로 지정합니다.

이렇게 하면 영화가 추가로 렌더링될 때마다 연장된 영화 목록에서 마지막 5번째 요소를 관측하는 observer를 새로 생성할 수 있습니다.

또한 observer가 새로 생성되면, 기존 observer는 파괴하기 위해 useEffect의 cleanup 함수를 이용했습니다.

❓ unobserve()와 disconnect()의 차이
제 코드에서는 disconnect를 사용하고 있는데, Intersection Observer API에는 관측 중인 연결을 끊기 위해서 unobserve() 메서드를 사용할 수도 있습니다.

unobserve()는 한 observer로 여러 요소를 관측 중일 때, 하나의 특정 요소에 대한 연결을 끊기 위해 사용되며, disconnect()는 해당 observer 객체와 연결된 모든 연결을 끊는다는 차이가 있습니다.

page.js

...
export default function Home() {
	// 일반적으로 초기값은 null로 선언
	const ref = useRef(null);
	...
	// 화면 요소를 1 증가시키는 사이드 이펙트
	useEffect(() => {
		// ref 달린 요소가 뷰포트의 가장 하단에 등장했을 때 페이지 값을 1 증가시키는 옵저버 생성
		const observer = new IntersectionObserver((entries) => {
			// target이 화면에 등장했을 때, moviePage 값 증가
			if (entries[0].isIntersecting) {
				setMoviePage((prevPage) => prevPage+1);
			}
		}, {
			root: null,
			threshold: 1,
		});

		// 대상이 존재할 때만 옵저버로 관측
		if (ref.current) {
			observer.observe(ref.current);
		}

		// cleanup
		return () => observer.disconnect();
	}, [movies]);
	
	return (
		<S.ListContainer>
			{movies ? movies.map((movie, idx) => (
				idx === (movies.length - 5) ? <MovieCard key={movie.id} movie={movie} ref={ref} />
				: <MovieCard key={movie.id} movie={movie} />
			)) : <p>Loading...</p>}
		</S.ListContainer>
	)
}

[Bug] (fixed)forwardRef 오류 발생

위와 같이 무한 스크롤을 적용하고, 새로 고침을 하니 다음과 같은 오류가 발생했습니다.

Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()? Check the render method of `Home`. at MovieCard

해당 오류로 검색하니 금방 원인을 찾을 수 있었습니다. React는 기본적으로 다른 컴포넌트에서 또 다른 컴포넌트의 DOM 요소에 접근하는 것이 불가능하다고 합니다. 그것이 자식 컴포넌트일지라도 예외는 없는데요. 대신 forwardRef API를 통해 ref를 자식 컴포넌트의 HTML 요소에게 전달하여 해결이 가능하다고 합니다.

이를 제 코드에 바로 적용해보았습니다.

(MovieCard)/index.js

import { useState, forwardRef } from 'react';
...
const MovieCard = forwardRef(({ movie }, ref) => {
	// 참고한 웹 주소 형태로 상태 저장
	const [poster, setPoster] = useState(`https://image.tmdb.org/t/p/w440_and_h660_face${movie.poster_path}`);
	return (
		// 기존 내용을 감싸기 위한 wrapping용 div를 추가하고 여기에 ref를 연결
		<div ref={ref}>
			...
		</div>
	)
});

export default MovieCard;

[Bug] (fixed) Component definition is missing display nameeslintreact/display-name 오류 발생

ref 문제를 해결하니까 새로운 문제가 곧바로 발생했는데요. ESLint의 React 관련 규칙에는 컴포넌트가 displayName을 가지고 있어야 한다는 규칙이 있다고 합니다.
하지만 forwardRef를 통해 생성된 컴포넌트는 기본적으로 displayName을 가지지 않아 생기는 문제였습니다.

아래와 같이 코드에 displayName을 수동으로 설정하도록 수정하여 해결했습니다.

(MovieCard)/index.js

import { useState, forwardRef } from 'react';
...
const MovieCard = forwardRef(({ movie }, ref) => {
	...
});

MovieCard.displayName = 'MovieCard';

export default MovieCard;

결과 화면

마치며

5월 4주차 동안은 구현을 못 하다가 Intersection Observer API의 동작 원리를 이해하고, 곧바로 구현에 성공할 수 있었습니다. 이에 무작정 구현부터 시도하기 보다는 구현 시도와 기반이 되는 기술에 대한 학습을 적절히 섞어야겠다는 생각을 했습니다.

다음 글에는 이어서 스타일을 수정하여 화면의 완성도를 높이는 걸 목표로 합니다.

감사합니다.

profile
개발하면서 새롭게 배운 내용, 시행착오한 내용들을 잊지 않기 위해 기록합니다.
post-custom-banner

0개의 댓글