코로 넘어져도 헤딩만 하면 그만

노마드코더 Next.js 14-3(完) 본문

스터디/야 나두(새로운 기술 학습) - 2023.08~

노마드코더 Next.js 14-3(完)

꼬드리 2024. 4. 5. 18:20

노마드 Next.js 14버전 강의로서는 완강입니다!! 🤩

이후 2주간은 제작하였던 영화 페이지를 제공된 API를 다양한 방식으로 활용하여,

스터디원 각자 Next js를 추가 학습을 하도록 계획하였습니다. 

 

완성한 사이트:  https://nomad-nextjs-c3jj.vercel.app/


 

🚩Next.js의 Error Handling

async function getVideos(id:string){
    console.log(`Fetch video:${Date.now()}`)
    await new Promise((resolve)=>setTimeout(resolve, 3000));
    throw new Error('something Broke...')
    //const response = await fetch (`${API_URL}/${id}/videos`)
    //return response.json()
}

해당 코드를 주석 처리 한 뒤, Error을 발생시킨 상황을 가정해보았다.

페이지로 돌아가면, 3초의 로딩 뒤 런타임 에러가 발생했음을 알려준다. 개발자는 개발 환경에서 에러 코드를 볼 수 있지만 결과적으로 사용자들은 아무것도 보지 못하게 된다.

 

⇒ 원하는 코드의 폴더 내에서 error.tsx라는 이름의 파일을 만든다.

이후 에러가 발생하면 보여줄 코드를 넣어준다. “use client”를 쓰라는 메세지가 뜨기에 추가하여 클라이언트 컴포넌트로 만들어주었다. 이제 에러가 발생한 상황에서도 아래와 같이 메세지가 등장하고, 개발 모드에서만 보이는 하단 에러 창도 발생하게 된다.

에러가 생겨도 전체가 멈추는 대신 얼마든지 다른 페이지로 넘어갈 수 있게 되었다.

"use client";
export default function ErrorOMG(){
    return <h1>Wow! something Broke.</h1>
}

 

이제 빈 화면이 뜨지 않는다.(error.tsx)

 

Warning! error.tsx은 같은 공간에 놓인 page의 에러만을 처리한다. 즉 다른 폴더의 페이지에서 에러가 발생했을 시, 해당 폴더 내부에 다시 error.tsx를 만들어 처리해주어야 한다.


시작 전에, vercel에 가입 후 github에 코드를 올려 배포 준비를 완료하였다.

 

🚩 Next와 CSS (global, css module)

Next.js에서는 CSS를 활용하기 위해 초기에 따로 뭔가를 설치할 필요가 없다. styled-component나 tailwind와 같은 라이브러리도 사용 가능하다.

 

- global style 사용하기

src 폴더 내부에 styles 폴더를 만들고, 내부에 global.css 파일을 만들어주었다. 이후 가장 상위의 layout 파일 상단에서 해당 파일을 import 해준다. 

body의 백그라운드 색을 yellow 로 지정해주었더니 곧바로 전체 페이지에 반영되는 것을 볼 수 있다.

import "../styles/global.css"

 

하지만 이 방법은 효율성 측면에서 그리 좋지 않다. 다른 페이지의 css를 바꾸고 싶을 때 매번 뒤에 .movie .about-us와 같이 style을 붙여주기 힘들기 때문이다. 따라서 기본적인 global 세팅이 끝나면, 앞으로는 특정한 페이지나 컴포넌트 별로 CSS modules를 사용할 것이다.

브라우저마다 css가 달라지는 상황을 막기 위해 reset 코드도 추가로 넣어준다.

/*이 상단에는 따로 자신이 사용하는 css reset 코드 추가*/
body {
    font-family: 'Times New Roman', Times, serif;
    background-color: yellow;
    color:"#fff";
    font-size: 25px;
}

a {
    color: inherit;
    text-decoration: none;
}

a:hover{
    text-decoration: underline;
}

 

 

- CSS modules 사용하기

Navigation을 꾸며주기 위해서는 CSS modules를 사용하기로 한다. 이를 위해 먼저 navigation.module.css 파일을 생성했다. 파일 이름은 중요하지 않고, 어디에 두는지도 상관없다. 다만 module.css가 꼭 파일 이름 뒤에 붙어 있어야 한다. 파일 내부에서는 일반적인 css처럼 태그를 사용하지 않고 오직 클래스 이름만을 사용하여 css를 적용시킨다.

.nav {
    background-color: rgb(145, 98, 255);
}

 

위와 같이 작성한다면, 적용시킬 nav 태그에 className=”nav”를 적어줄 것이 아니라, 해당 파일을 JS파일인 것처럼 취급하며 import 시켜 사용해야 한다. 즉, 불러온 styles을 하나의 자바 스크립트 객체인 것처럼 생각하고 다루면 된다.

 

태그의 클래스 이름을 className={styles.nav}로 주었다.

//Navigation.tsx
"use client";

import Link from "next/link"
import { usePathname } from "next/navigation"
import styles from "../styles/navigation.module.css"

export default function Navigation(){
    const path= usePathname();
    return (
    <nav className={styles.nav}>
        <ul>
            <li><Link href="/">Home</Link> {path==="/"?"🥰":""}</li>
            <li><Link href="/about-us">About us</Link>{path==="/about-us"?"🥰":""}</li>
        </ul>
    </nav>
    )
    }

navigation이 연보라색이 되었다.

원활하게 navigation의 영역에만 css가 적용된 것을 볼 수 있다. 이렇게 작성 시 매번 특정한 이름을 지어주지 않아도 되어 좋다. 개발자 도구를 열어 이름을 확인해보면 아래와 같이 랜덤하고 중복되거나 충돌하지 않는 고유한 클래스 명이 부여되었다.

class="navigation_nav__0mNBj"
.nav {
    background-color: rgb(145, 98, 255);
    padding: 50px 100px;
}

.nav ul {
    display: flex;
}

TIP: 보통 일반적인 css는 global에, 고유한 css는 modules를 사용한다.

 


 🚩Home 페이지 레이아웃 잡기

앞서 만든 CSS modules를 활용하여 home/page.tsx와 movie.tsx에도 유사한 방식으로 CSS를 적용해준다.

/*(home) page.tsx*/

return <div className={styles.container}>
/*styles.container 추가*/
        {movies.map(movie => (
        <Movie key={movie.id} id={movie.id} poster_path={movie.poster_path} title={movie.title}/>
        ))}
    </div>
/*home.module.css*/
.container {
    display: grid;
    grid-template-columns: repeat(5, 1fr);
    gap: 25px;
    max-width: 90%;
    width: 100%;
    margin: 0 auto;
    }
 /*movie.tsx*/
 <div className={styles.movie}>
 /*styles.movie 추가*/
        <img src={poster_path} alt={title} onClick = {onClick}/> 
        <Link href={`/movies/${id}`}>{title}</Link>
    </div>
/*movie.module.css*/
.movie {
    display: grid;
    grid-template-rows: 1fr auto;
    gap: 20px;
    cursor: pointer;
    place-items: center;
    }
    
    .movie img {
    max-width: 100%;
    min-height: 100%;
    border-radius: 10px;
    transition: opacity 0.3s ease-in-out;
    }
    
    .movie img {
    opacity: 0.7;
    }
    
    .movie img:hover {
    opacity: 1;
    }
    
    .movie a {
    text-align: center;
    }

 

 

- useRouter로 이동하기

앞서 Link 태그를 사용해 제목을 클릭했을 때에 영화의 상세 페이지로 이동하도록 설정하였다. 이번에는 이미지를 클릭했을 때 같은 주소로 이동하도록 처리하기 위해 useRouter을 사용했다. 각 영화의 대표 이미지에 onClick 이벤트가 발생하면 해당 영화의 상세 페이지로 이동시킨다.

Warning! useRouter은 next/navigation으로부터 불러와야 한다.

 

또한 onClick 이벤트는 클라이언트에서 감지하기 때문에 “use client”; 를 추가해야 제대로 동작하는 것을 볼 수 있다. Next.js를 사용한다고 해서 클라이언트 컴포넌트를 무조건적으로 기피할 필요는 없다. 필요하다면, 클라이언트 컴포넌트를 사용해주면 된다.

"use client";

import Link from "next/link";
import styles from "../styles/movie.module.css"
import { useRouter } from "next/navigation";

interface MovieProps{
    title: string;
    id: string;
    poster_path: string;
}

export default function Movie({title, id, poster_path}: MovieProps){
    const router = useRouter();
    const onClick = () => {
        router.push(`/movies/${id}`)
    }    
     return (
    <div className={styles.movie}>
        <img src={poster_path} alt={title} onClick = {onClick}/> 
        <Link href={`/movies/${id}`}>{title}</Link>
    </div>
    )
}

이제 이미지 혹은 제목을 클릭 시 각 영화의 상세 페이지로 이동하게 된다.


🚩상세 페이지 레이아웃 잡기

- movie-info.tsx

앞서 만들었던 movie-info 파일에서 우선적으로 보여야 하는 상세정보를 적용하기로 한다. getMovie를 통해 fetch해온 정보를 movie 변수에 담고, 이미지, 타이틀, 별점, 줄거리 데이터를 화면에 보여주었다.

//movie-info.tsx
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 MovieInfo({id}:{id:string}){
    const movie = await getMovie(id)
    return <div>
        <img src={movie.poster_path}></img>
        <div>
            <h1>{movie.title}</h1>
            <h3>*{movie.vote_average}</h3>
            <p>{movie.overview}</p>
        </div>
    </div>
}

 

이후 동일한 modles css 방식으로 상세 레이아웃을 처리한다.

.container {
    display: grid;
    grid-template-columns: 1fr 2fr;
    gap: 50px;
    width: 80%;
    margin: 0 auto;
    margin-top: 100px;
    }
    
    .poster {
    border-radius: 20px;
    max-width: 70%;
    place-self: center;
    }
    
    .title {
    color: white;
    font-size: 36px;
    font-weight: 600;
    }
    
    .info {
    display: flex;
    flex-direction: column;
    margin-top: 20px;
    gap: 20px;
    }
//movie-info.tsx
export default async function MovieInfo({id}:{id:string}){
    const movie = await getMovie(id)
    return <div className={styles.container}>
        <img src={movie.poster_path} className={styles.poster}></img>
        <div className={styles.info}>
            <h1 className={styles.title}>{movie.title}</h1>
            <h3>⭐{movie.vote_average}</h3>
            <p>{movie.overview}</p>
        </div>
    </div>
}

각각의 태그에 알맞은 className을 부여하여 알맞은 레이아웃을 설정해준다.

 

실제 영화 페이지로 이동할 수 있는 요소를 추가해보았다.

target={”_blank”}를 사용하여 새 탭에서 열리도록 할 수 있다.

 

TIP: &rarr은 우측 화살표.

<a href={movie.homepage} target={"_blank"}>Homepage &rarr;</a>

 

TIP: 소수점인 별점을 반올림해주고 싶다면 toFixed를 사용하면 된다.

<h3>⭐{movie.vote_average.toFixed(1)}</h3>

 

 

 

- movie-video.tsx

movie의 video 데이터는 현재 객체 리스트 형식으로 들어오고 있다. 따라서 map 함수를 사용해서, 들어오는 비디오 데이터를 유튜브의 iframe으로 보여주기로 한다.

 

TIP: allowFullScreen 및 allow 설정을 통해 유튜브 영상에 대한 재생, 자동재생, 전체화면 등의 설정 권한을 사용자에게 부여할 수 있다. 가령 allowFullScreen True를 해주지 않으면 사용자는 전체 화면으로 영상을 시청할 수 없다.

import { API_URL } from "@/app/(home)/page"
import styles from "../styles/movie-videos.module.css"

async function getVideos(id:string){
    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 <div className={styles.container}>
        {videos.map(video=><iframe key={video.id} 
        src={`https://youtube.com/embed/${video.key}`} title={video.name}
        allow="accelerometer; autoplay; clipboard-write; encrypted-media; 
        gyroscope; picture-in-picture" allowFullScreen/>)}
    </div>
}
//movie-video.module.css
.container {
    width: 80%;
    margin: 0 auto;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
    gap: 20px;
    margin-top: 100px;
    padding-bottom: 100px;
    }
    
    .container iframe {
    border-radius: 10px;
    opacity: 0.8;
    transition: opacity 0.2s ease-in-out;
    }
    
    .container iframe:hover {
    opacity: 1;
    }

🚩동적으로 metaData 변경하기

export async function generateMetadata(){
    return {
        title:"lalala"
    }
}

generateMetadata()동적인 metadata를 가진 경우 사용하는 함수이다.

상세 영화 페이지는 id값에 따라 탭의 이름도 동적으로 바뀌어야 한다. 가령 영화 제목이 ‘죠스’라면 탭에 표시되는 이름도 ‘죠스’여야 한다. 따라서 해당 영화의 id가 있어야 데이터를 불러와서 영화 제목을 탭의 meta data에 표시해 줄 수 있을 것이다. 이런 경우에는 어떻게 할까?

 

다행히 MovieDetail의 params로 영화의 id가 들어오는 것처럼, generateMetadata 함수 역시 params로 같은 id 값을 전달받는다. 따라서 movie-info.tsx에서 export시킨 getMovies 함수와 params로 받은 id를 사용하여 메타 데이터를 적용시켜줄 수 있다.

import MovieInfo from "@/components/movie-info"
import MovieVideos from "@/components/movie-videos"
import { Suspense } from "react"
import { getMovie } from "@/components/movie-info"

interface Iparams {
    params : {id: string}
}

export async function generateMetadata({params:{id}} : Iparams){
    const movie = await getMovie(id);
    /*다른 곳에서 export 해준 getMovie함수를 여기서 사용한다.*/
    return {
        title: movie.title
    }
}

export default async function MovieDetail({params:{id}}:Iparams){
    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>
}

 

상단 탭에 표시되는 제목이 해당 영화의 제목을 성공적으로 반영하고 있음을 볼 수 있다.

동적으로 meta data를 표시할 수 있게 된 것이다.

 

✨여기서 잠깐!!🤔겨우 meta data만을 위해 fetch하는 것이 비효율적이지 않을까?

: 최신 버전의 Next js는 캐시를 제공하기 때문에 오히려 유용하다. meta data에서 fetch하면 API 호출이 처음 실행되지만, 두번째로 영화 info를 얻기 위해 동일한 getMovies로 fetch하게 되면 다시 API를 호출하지 않고 앞서 캐시한 데이터를 그대로 사용한다. 즉, 어떤 경우든 호출은 한번만 진행된다.

만약 최신 버전이 아니라 그 이전 버전을 사용하고 있다면 사용 전에 고려해보아야 할 사안이기도 하다.

 

 

TIP: 동일 경로에서 metadata 객체와 generateMetadata 함수를 둘다 export 할 수는 없다. (둘 중 하나만 사용)

또한 metadata 객체와 generateMetadata 함수 모두 서버 컴포넌트에서만 지원된다.

TIP: 이 경우 사용할 함수의 이름은 정확히 generateMetadata여야 한다. (규칙)

//추가 학습용 API
/movies
/movies/:id
/movies/:id/credits
/movies/:id/providers
/movies/:id/similar

🚩배포하기

package.json으로 가서 script에 하단과 같이 명령어를 추가한다. 이미 존재한다면 건드리지 않아도 된다.

  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },

 

- vercel로 이동

지금까지 한 모든 작업을 우선 git에 push해서 올린다 ⇒

로그인 된 vercel 페이지에서 Add new Project 클릭 ⇒

프로젝트를 저장된 repository를 선택한다 ⇒

프로젝트의 이름을 지정하고, root Directory는 그대로 놔둔다. ⇒

Deploy를 클릭한 뒤 기다리면 빌드가 시작된다.

 

Warning! 중간에 에러가 나면 빌드 단계에서 멈추게 된다. 터미널에서 직접 npm run build를 실행하여 빌드가 잘 되는지 확인하고 vercel에 올리면 시간이 단축된다.

 

 

- 빌드 중에 발생한 에러들 해결하기

1) 타입 에러

내가 사용한 타입 스크립트 때문에 발생했다.

map을 돌린 부분에 인터페이스 관련 에러가 발생해서 알맞은 타입을 지정해주었다.

interface MovieType {
    id: string;
    poster_path: string;
    title: string;
}

async function getMovies(): Promise<MovieType[]>{
    const response = await fetch(API_URL);
    const json = await response.json();
    return json;
}

export default async function HomePage(){
    const movies = await getMovies();

    return <div className={styles.container}>
        {movies.map(movie => (
        <Movie key={movie.id} id={movie.id} poster_path={movie.poster_path} title={movie.title}/>
        ))}
    </div>
}

 

2) API_URL 처리

빌드 중 하단과 같은 오류가 떴는데, page.tsx에서 API_URL을 내보낼 수 없다는 정보다.

  "API_URL" is not a valid Page export field.

page.tsx에서는 원래 허용된 요소만 export가 가능하다. 

따라서 API 부분을 다른 파일에 생성하여 그곳에서 export 해주기로 한다.

⇒ env 환경변수 고려해볼 것. 같이 스터디 해주는 분이 언급해주심~

//contant.tsx 파일을 만들어 API 관련된 부분을 넣고, 필요한 파일마다 import 하여 사용
export const API_URL = "https://nomad-movies.nomadcoders.workers.dev/movies"

 

 

오류를 해결하고 다시 push 해주었다.

처음 만든 project로 돌아가 vercel을 확인해보면, 하단과 같이 배포 완료된 것이 보인다.

배포 사이트: https://nomad-nextjs-c3jj.vercel.app/

 

 

 

- prefetch 기능 추가하기

<Link prefetch href={`/movies/${id}`}>{title}</Link>

제목에 prefetch를 추가하면 유저의 스크롤이 닿는 영화들마다, 유저가 클릭하여 fetch 요청을 보낸 것처럼 작동하게 된다.

네트워크 탭을 열어 확인하면, 유저는 클릭하지 않고 스크롤만 내렸을 뿐인데 해당 영화들에 대한 정보를 요청해 받아온 것을 볼 수 있다. 유저가 어떤 영화를 클릭해도 백그라운드에서 미리 준비시킨 내용을 바로 보게 된다.

즉, 초기 로딩 속도가 줄어들게 된다.

 

물론 좋은 점이 있다면 당연히...

Warning! 충분히 예상되다시피, 모든 페이지를 미리 prefetch하게 되면 DB 측에 과도한 부담이 될 수 있기 때문에 이 기능은 신중하게 사용해야 한다.


번외!!! (강의와 무관하게 개인 학습)

 

- <img/> 태그를 <Image/>태그로

<img src={poster_path} alt={title} onClick = {onClick}/>

기존까지 img 태그 사용에 익숙해져 있었다면 다음과 같이 노란 줄이 표시되며 권고 문구가 뜰 때 당황할 수 있다. 개인적으로는 작업하며 꽤나 눈에 거슬려서 에러 문구를 찬찬히 읽어보았다. <img>태그보다 최적화를 위해 <Image/>태그를 사용하는 것이 추천된다는 표시다.

Using <img> could result in slower LCP and higher bandwidth. Consider using <Image /> 
from next/image to automatically optimize images. This may incur additional usage or 
cost from your provider. See: https://nextjs.org/docs/messages/no-img-element

 

Image 태그는 이미지 최적화를 위해 next 10버전부터 등장했다.

이 태그는 자동으로 lazy loading & 사이즈 최적화 & layout shift 방지를 도와준다. 필수로 alt속성과 src 주소가 필요하고, 파일이 아닌 인터넷 상의 이미지를 사용할 때에는 무조건 width, height 속성을 함께 입력해주어야 한다.

노란 줄은 빨간 줄 에러와 달리 빌드를 못 하게 막진 않지만 시각적으로 거슬리는 측면이 있어 일단 강의에서 img로 작성된 부분을 전부 Image로 바꾸어 주는 작업을 했다.

 

Warning! 상단에서 import Image 해주지 않으면, 적용되지 않으니 주의할 것. 

import Image from "next/image";
<Image src={poster_path} alt={title} onClick = {onClick}/>

 

 

- 이미지 호스트 구성하기

Image 태그를 사용하며 width 와 height도 넣었는데, npm run dev을 시도하자 갑자기 다음과 같은 에러가 떴다.

Error: Invalid src prop (https://image.tmdb.org/t/p/w780/wkfG7DaExmcVsGLR4kLouMwxeT5.jpg) 
on `next/image`, hostname "image.tmdb.org" is not configured under images in your 
`next.config.js`See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host

 

추가로 알아보니, 프로젝트 루트 디렉토리의 next.config.mjs(14이전 버전에서는 js라고 되어 있음.) 에 이용하고자 하는 외부 이미지 도메인을 하단과 같이 추가 해주어야 잘 적용이 된다고 한다. (보안상의 이유.)

/** @type {import('next').NextConfig} */
const nextConfig = {
    images: {
      domains: ['image.tmdb.org'], // 사용할 이미지의 호스트를 추가
    },
  };
  export default nextConfig;

 

 

 

 

Image 태그의 최적화 기능 중에서 특히 layout shift 방지가 흥미로워서, 두 태그를 직접 비교해보았다.

<img/>를 사용한 경우, 초반 이미지가 로딩되기 전에 글자가 몰려있다가 아래로 밀려 내려가는 layout shift가 발생한다. 이런 경우 유저가 처음 클릭하고자 한 대상이 아니라 다른 대상을 잘못 클릭하는 불편함이 생길 수 있다. 

 

<Image/>를 사용한 경우, 아래 사진처럼 이미지가 로딩되는 동안에도 자동으로 자신의 위치를 지킨다! 일종의 스켈레톤을 자동으로 제공하는 느낌이라고 보았다.

 

Comments