코로 넘어져도 헤딩만 하면 그만
노마드코더 Next.js 14-2 본문
지난 주에 마쳤던 부분을 이어서 추가 포스팅합니다! 학습 범위는 #2.5부터 #3.6까지 해당됩니다.
🚩Server 컴포넌트에서 fetch 해오기
클라이언트 컴포넌트에서 해오던 방식과 다르게, next의 서버 컴포넌트에서 다른 방식으로 fetch를 해볼 것이다.
이전에 작성한 코드를 지우고 아래와 같이 최소한의 코드만 남긴다.
export const metadata = {
title:"Home",
}
const URL = "https://nomad-movies.nomadcoders.workers.dev/movies"
async function getMovies(){
const response = await fetch(URL);
const json = await response.json();
return json;
}
export default function HomePage(){
return <div>
JSON.stringify(movies)
</div>
}
이제 await를 통해 fetch 해오는 코드를 컴포넌트에 넣어 수정한다.
const URL = "https://nomad-movies.nomadcoders.workers.dev/movies"
async function getMovies(){
const response = await fetch(URL);
const json = await response.json();
return json;
}
export default async function HomePage(){
const movies = await getMovies();
return <div>
{JSON.stringify(movies)}
</div>
}
next.js는 프레임워크이기 때문에 fetch된 url을 자동으로 캐싱해준다. 다른 페이지로 갔다 home를 클릭해도 다시 fetch 하지 않아 로딩 표시가 뜨지 않는다. 따라서 첫 번째 fetch만 ‘진짜 API 요청’이라고 할 수 있다. 이후에는 앞서 불러온 같은 데이터를 보여준다. 만약 최신 데이터를 불러와야 하는 경우, 따로 해결방식이 존재하는데 추가적으로 캐싱이나 revalidation을 참고 하면 된다.
또한 이때 fetch는 서버 컴포넌트에서 실행되기에 console.log로 실행을 확인하기 위해서는 개발자 도구가 아니라 터미널을 보아야 한다.
🍬 Loading?
next js를 쓰면 속도가 체감상 매우 빠르게 느껴지지만, Loading이 아예 사라진 건 아니다. 다른 페이지로 이동했다가 돌아온다면 데이터가 ‘캐싱’되기 때문에 로딩 상태가 없는 것처럼 보인다. 그러나 서버를 껐다가 다시 켜보면 처음 데이터를 불러오는 동안 Loading 상태가 유지되는 것을 볼 수 있다.
이 첫번째 응답의 속도가 충분히 느릴 수 있기 때문에 Loading 상태도 따로 처리해주어야 한다.
명확하게 확인하기 위해 아래와 같이 fetch 해오기까지 5초의 간격을 줘보자.
setTimeout 함수를 사용하여 5초 이상 로딩이 지속되도록 코드를 수정해주었다.
async function getMovies(){
await new Promise ((resolve)=>{setTimeout(resolve, 5000)})
const response = await fetch(URL);
const json = await response.json();
return json;
}
위와 같이 5초간 탭 부분에 스피너가 돌아간다면, 백엔드에서 아직 응답이 오지 않았다는 뜻이다. 이렇게 사용자가 초기에 빈 화면만 보는 것은 전혀 사용자 경험에 좋지 않다.
따라서 로딩 UI 상태를 사용자가 먼저 볼 수 있도록 추가로 구현해야 할 것이다.
클라이언트 측 / 서버 측에서 fetch 시도한 경우로 나누어 설명하자면, 아래와 같이 정리할 수 있다.
1) 클라이언트에서 시도했을 때: 사용자가 웹에 방문하자마자 메뉴를 볼 수 있었고, Loading이 끝난 뒤에 데이터를 볼 수 있었다. 하지만 데이터를 불러오는 코드가 썩 좋지 못했다.
2) 서버에서 시도했을 때: 사용자가 웹에 방문했지만, 데이터를 가져오기 전까지 메뉴도 보지 못하고 있다. ← 현재 우리가 처한 상황.
이런 상황은 전에 not-found 파일을 만들어 해결했듯이 ‘loading’파일로 쉽게 해결할 수 있다.
🚩Loading File로 로딩 처리하기
async function getMovies(){
await new Promise ((resolve)=>{setTimeout(resolve, 5000)})
const response = await fetch(URL);
const json = await response.json();
return json;
}
export default async function HomePage(){
console.log("fetch")
const movies = await getMovies();
return <div>
{JSON.stringify(movies)}
</div>
}
서버 컴포넌트에서 오가는 fetch는 사용자에게 전달되지 않는다. 보안상 안전하기 때문에 개발자가 DB와 통신을 하거나 API를 마음대로 써도 좋다. 그러나 데이터를 백엔드에서 불러왔기 때문에, 사용자가 화면을 보기까지 시간이 걸린다는 문제가 새로 등장한다. fetch 처리가 끝나기 전까지는 백엔드에서 렌더링 작업이 이루어지지 않는다.
⇒ 이는, 같은 폴더 내부에 loading.tsx 파일을 만들어 해결할 수 있다.(반드시 이름은 loading)
로딩 상태를 처리하고 싶은 폴더 내부에 loading 파일을 만드는데, 컴포넌트 이름은 마음대로 지어도 좋다. 사용자가 Loading이라는 메세지를 볼 수 있도록 우선 최소한의 설정을 해주었다.
export default function Loading(){
return <h2>Loading...</h2>
}
새로고침을 할 시 Loading이 진행되는 동안 자동적으로 해당 페이지의 “Loading…”이 화면에 바로 나타나는 것을 확인할 수 있었다. 이전처럼 데이터를 불러오는 동안 페이지가 비어 있지 않다.
또한 로딩이 끝나고 나면, Loading이 사라지고 서버에서 불러온 데이터가 즉시 보여진다.
이 과정에서 Next.js는 페이지를 구성하는 HTML을 작은 단위로 나누어 순차적으로 전송한다. 즉, Navigation과 layout, Loading 컴포넌트부터 먼저 브라우저에게 보내준다. 브라우저는 백엔드에서 아직 작업이 끝나지 않았다고 인식하고 fetch가 끝나기 전까지 자동으로 loading 파일 내부의 값을 보여준다. 작업이 끝나고 나면 결과값에 있는 컴포넌트가 브라우저에 전달되고 앞서 보여줬던 Loading 컴포넌트를 대체한다.
따라서 아래와 같이 async를 보여주고 싶은 컴포넌트에 붙여준 이유는, Next JS가 해당 컴포넌트에서 await을 해주어야 하기 때문이다. await가 끝나고 나면 브라우저에게 마지막 HTML인 아래 부분을 전달한다.
export default async function HomePage(){
const movies = await getMovies();
return <div>
{JSON.stringify(movies)}
</div>
}
즉 아래와 같은 조건을 가진 코드라고 생각하면 된다.
isLoading?<Loading/>: html
이처럼 next js는 html을 작은 단위들로 쪼개주기 때문에, 동시에 많은 것을 fetch 할 수도 있다. fetch가 완료되면 통신을 종료하고, 프레임워크가 완료된 부분을 교체해준다.
🚩 영화 상세 페이지에서 병렬적 fetch하기
앞서 만들어둔 동적으로 만드는 상세 페이지로 이동한다.
Link를 통해 영화 제목을 클릭할 때 해당 페이지로 이동할 수 있도록 코드를 적는다. 제목을 클릭하면, 해당 아이디를 제목을 가진 http://localhost:3000/movies/634492주소로 이동하는 것을 볼 수 있다. 앞서 동적 라우팅을 통해 생성한 영화 상세 페이지이다.
import Link from "next/link"
export const metadata = {
title:"Home",
}
const URL = "https://nomad-movies.nomadcoders.workers.dev/movies"
async function getMovies(){
await new Promise ((resolve)=>{setTimeout(resolve, 1000)})
const response = await fetch(URL);
const json = await response.json();
return json;
}
export default async function HomePage(){
const movies = await getMovies();
return <div>
{movies.map(movie => <li key={movie.id}>
<Link href={`/movies/${movie.id}`}>{movie.title}</Link></li>)}
</div>
}
이제 각 제목을 눌러서 상세 영화 페이지에 접속할 때, 해당 id를 통해 영화에 대한 상세 데이터를 fetch 해오고 싶은 상황을 가정해보자.
이를 위해 일단 URL을 export 하여 다른 컴포넌트에서도 불러올 수 있게 처리해준다.
export const API_URL = "https://nomad-movies.nomadcoders.workers.dev/movies"
또 다른 곳에서 URL이라는 이름이 쓰일 수 있기 때문에 명확하게 API_URL로 변수명을 바꿔주었다.
이제 [id]의 상세 page 파일에서 다음과 같이 작업한다.
먼저 API_URL을 불러온 뒤, 해당 주소에 id를 붙여 다시 데이터를 fetch해오는 함수를 작성한다. 이후 컴포넌트 내부에서 await를 통해 getMovie함수를 호출하고 불러온 title을 페이지에 보이는 제목으로 넣어준다.
import { API_URL } from "@/app/(home)/page"
async function getMovie (id:string){
const response = await fetch (`${API_URL}/${id}`)
return response.json()
}
export default async function MovieDetail({params:{id},}:{params:{id:string}}){
const movie = await getMovie(id);
return <h1>{movie.title}</h1>
}
이제 각 리스트를 클릭할 때마다 불러온 영화 데이터의 제목을 보여주는 것을 확인할 수 있다.
이렇게 만들어준 상세 페이지 역시, loading.tsx 파일을 만들어 해당 페이지에 대한 로딩 fallback 처리를 해줄 수 있다. 하단과 같이 파일을 만들면 이제 처음 데이터를 불러올 때에는 로딩 페이지가 보이고, 로딩이 끝난 뒤 fetch 해온 영화의 제목이 보이게 된다.
export default function Loading(){
return <h2>Loading a movie:id</h2>
}
그렇다면, 영화의 예고편 자료를 다른 주소에서 추가 fetch하는 경우에는 어떤 현상이 발생할까?
아래와 같이 유사하게 fetch 함수를 하나 더 만들어준다. 문제는 getMovie 함수가 실행된 뒤에야 getVideos가 순차적으로 실행된다는 것이다.
import { API_URL } from "@/app/(home)/page"
async function getMovie (id:string){
const response = await fetch (`${API_URL}/${id}`)
return response.json()
}
async function getVideos(id:string){
const response = await fetch (`${API_URL}/${id}/videos`)
return response.json()
}
export default async function MovieDetail({params:{id},}:{params:{id:string}}){
const movie = await getMovie(id);
const videos = await getVideos(id);
return <h1>{movie.title}</h1>
}
이 현상을 조금 더 자세히 들여다보기 위해, 아래와 같이 setTimeout와 console.log를 추가다. 터미널에서 보면 5초가 지나 Fetch movie가, 약 5초가 더 지나 Fetch video가 나오는 것을 확인할 수 있었다.
두 fetch가 직렬적으로 앞의 작업이 끝난 뒤에 다음에야 다음 fetch가 실행되는 것이다. 이는 사용자가 여러 데이터를 보기 위해 그만큼 오래 지루하게 기다려야 함을 의미한다.
import { API_URL } from "@/app/(home)/page"
async function getMovie (id:string){
await new Promise((resolve)=>setTimeout(resolve, 5000))
console.log(`Fetch movie::${Date.now()}`)
const response = await fetch (`${API_URL}/${id}`)
return response.json()
}
async function getVideos(id:string){
await new Promise((resolve)=>setTimeout(resolve, 5000))
console.log(`Fetch video:${Date.now()}`)
const response = await fetch (`${API_URL}/${id}/videos`)
return response.json()
}
export default async function MovieDetail({params:{id},}:{params:{id:string}}){
const movie = await getMovie(id);
const videos = await getVideos(id);
return <h1>{movie.title}</h1>
}
Fetch movie::1711356555259
Fetch video:1711356560269
//터미널에 나온 결과
//55와 60을 보다시피 5초의 차이가 난다.
이런 경우에는 크게 두 가지 개선 방법이 존재한다.
1) Promise.all()
Promise.all을 사용하면 두 Promise 작업을 함께 진행하여, 병렬적으로 데이터를 불러온다.
export default async function MovieDetail({params:{id},}:{params:{id:string}}){
const [movie, videos] = await Promise.all([getMovie(id), getVideos(id)]);
return <h1>{movie.title}</h1>;
}
Fetch movie::1711356717748
Fetch video:1711356717751
//이제 병렬적으로 두 Promise 작업이 함께 진행되었다.
2) Suspense
앞선 방식으로 Promise.all을 사용한다면, 병렬적으로 불러올 수 있지만 두 함수가 모두 끝난 뒤에야 사용자가 UI를 볼 수 있다는 새로운 문제가 생긴다. 그렇다면 두 함수를 분리할 수는 없을까?
두 함수가 동시에 실행된 뒤, getMovie와 getVideos중 먼저 끝난 것부터 보여지게 만들기 위해서는 suspense를 사용해야 한다. 여러 데이터를 사용하게 될 때는 React의 기능인 Suspense를 고려해보자.
우선 info와 video를 fetch해오는 두 함수를 각각 component 폴더의 하위 파일로 나누었다.
//movie-video.tsx
import { API_URL } from "@/app/(home)/page"
async function getVideos(id:string){
console.log(`Fetch video:${Date.now()}`)
await new Promise((resolve)=>setTimeout(resolve, 3000))
const response = await fetch (`${API_URL}/${id}/videos`)
return response.json()
}
export default async function MovieVideos({id}:{id:string}){
const videos = await getVideos(id)
return <h6>{JSON.stringify(videos)}</h6>
}
//movie-info.tsx
import { API_URL } from "@/app/(home)/page"
async function getMovie (id:string){
await new Promise((resolve)=>setTimeout(resolve, 5000))
console.log(`Fetch movie::${Date.now()}`)
const response = await fetch (`${API_URL}/${id}`)
return response.json()
}
export default async function MovieInfo({id}:{id:string}){
const movie = await getMovie(id)
return <h6>{JSON.stringify(movie)}</h6>
}
이렇게 나뉜 두 컴포넌트는 각각 서버 컴포넌트에서 async로 동작하며, 자신의 정보만 불러오고 있다. 이제 처음 두 fetch함수를 담았던 page.tsx로 돌아와 아래와 같이 수정했다.
분리한 컴포넌트를 불러오고, 각각 React의 Suspense 기능으로 감싸준다. 또한 Suspense에는 기본적으로 로딩 동안 렌더링하여 보여줄 fallback을 설정해야 하기 때문에 각각에 맞는 fallback 요소를 주었다.
현재 setTimeout을 통해 각각의 fetch 함수가 강제로 로딩 상태에 머물도록 하였기 때문에, movieVideo는 3초, movieInfo는 5초의 대기가 걸려있다.
export default async function MovieDetail({params:{id},}:{params:{id:string}}){
return <div>
<Suspense fallback={<h1>Loading Movie Info!</h1>}>
<MovieInfo id={id}/>
</Suspense>
<Suspense fallback={<h1>Loading Movie Video!</h1>}>
<MovieVideos id={id}/>
</Suspense>
</div>
}
두 개의 로딩 fallback이 등장 → Video 정보가 먼저 등장하고 → Info 정보가 나온다.
병렬적으로 2가지 데이터를 동시에 fetch하고, 하나의 요청이 완료되면 즉시 화면에 UI가 등장하도록 개선했다. 이전까지는 loading.tsx 파일이 전체 페이지 로딩을 완전히 대체했다면, 이제는 더 작은 단위로 쪼개어 각각의 컴포넌트에 대해 await 할 수 있는 것이다.
'스터디 > 야 나두(새로운 기술 학습) - 2023.08~' 카테고리의 다른 글
Docker 심화 학습 (0) | 2024.05.29 |
---|---|
Docker에 대해 알아보기 (1) | 2024.05.29 |
노마드코더 Next.js 14-3(完) (0) | 2024.04.05 |
노마드코더 Next.js 14 -1 (0) | 2024.03.14 |
Cat API으로 무한 스크롤 복습(feat. Intersective Observer) (0) | 2024.02.19 |