! 주의 : TIL 게시글입니다. 다듬지 않고 올리거나 기록을 통째로 복붙했을 수 있는 뒷고기 포스팅입니다.
Next.js에 대해 쓰는건 처음이네요
최근에 처음 배웠기 때문입니다?
Next.js 13 이전에는 Next.js에서 Route를 만들기 위해 Pages Router 방식이 사용되었는데요
Next.js 13 이후로 새로 나와서 지금 밀고 있는게 App Router입니다
지금 두 방식을 모두 지원해요. App Router를 조금 더 권장할 뿐
아직 많은 프로덕트들이 Pages Router를 여전히 사용중입니다
오늘은 App Router방식으로 Next.js 코드 짜는 법을 기록하것습니더잉
폴더구조
- /app
- /about
- /blog
- /post-1
/app/
경로 밑에 웹사이트에 넣을 페이지들을 위치시킵니다
/app/
밑에 존재하는 모든 page.js
파일들은 그에 해당하는 route를 만듭니다.
예를 들어, /app/about/page.js
=> "http://my-domain.com/about"
그리고 이 app폴더 밑에 위치하는 리액트 컴포넌트들은 이제 Next.js에서 특별한 대우를 받게 됩니다.
서버 컴포넌트가 되는 것인데요..
확인하고 싶으시면 console.log
를 컴포넌트함수 내에서 찍어봅시다. 브라우저가 아닌 개발서버에 찍힙니다
페이지 간 이동
만약 지금 http://my-domain.com/
애 있는데, http://my-domain.com/about/
으로 가고 싶다면 우뜩할까요?
<a href="/about">
이러면 되겠죠?
라고 생각할 수는 있는데 딱히 이상적이진 않습니다. 적어도 이 도메인(이 프로젝트)내의 다른 경로로 이동할거면요
<a>
태그로 이동하는 것은 새로운 페이지를 로드합니다.
이는 리액트가 렌더링을 다시 하는 수준이 아닌, 페이지를 갈아엎게 되는 것인데
Next.js는 그렇지 않고도 리액트의 장점을 살리고 SPA로 머무는 것을 허용합니다.
클라이언트 측의 JS코드로 UI를 업데이트할 수 있게한다는 것인데 (기존 리액트 like하게)
이를 위해서는 <Link href="">
라는 next.js에거 제공하는 특별한 컴포넌트를 사용합니다.
명력적인 방법도 있는데, next/navigation
에서 redirect()
함수를 import하고,
redirect('/about')
이렇게 쓰면 됩니다
컴포넌트 추가하기
Next.js의 app
폴더 밑에서는 특별한 컴포넌트로 취급되는, 예약된 파일 명들이 있습니다.
예를 들면..
page.js
신규페이지layout.js
형제/중첩 페이지의 레이아웃 (바로 뒤에 알아보겠습니다)not-found.js
404 Not Found에 대한 폴백 페이지error.js
기타 오류에 대한 폴백 페이지loading.js
형제 또는 중첩 페이지에서 데이터를 가져올 때 표시되는 폴백 페이지route.js
: API 경로를 생성합니다. (jsx가 아닌 JSON 등의 데이터를 반환합니다)
이런게 아니고 막 title-header.jsx
처럼 대충 제목을 지으면 그냥 커스텀 컴포넌트입니다
그리고 이제 app
폴더 밑에는 페이지와 route의 생성 관련한 파일만 남길거고,
다른 커스텀 컴포넌트는 루트에서 component/
폴더를 하나 더 두는게 좋겠네요
그리고 이제 import할 때, from '@/components/header'
와 같이 @로 프로젝트의 루트 경로를 조회할 수 있습니다
app
폴더 밑에서는 예약된 파일명들이 소문자로 시작하니까, 기존 리액트 컴포넌트 파일명의 컨벤션과 다르게, 여기서는 소문자로 시작하게 하면 좋겠네요
기타등등, 공식문서 보면 다양한 구조가 있습니다
layout.js
아까 말한 layout.js
에 대해 살짝 더 알아봅시다
app폴더를 위한 layout.js
파일이 최소 하나는 있어야 합니다.
최소 하나의 근본 레이아웃이 있어야 한다는 뜻입니다
이 layout.js
는 그 하위의 중첩된 경로에 대해 모두 적용됩니다
새로운 layout.js
를 만나지 않는 한은 말이죠
만약 /about/layout.js
를 또 만들었다면, 이는 /about/
이 중첩된 경로에 대해 모두 적용됩니다
import "./globals.css";
export const metadata = {
title: "NextJS Course App",
description: "Your first NextJS app!"
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
루트 layout.js
의 코드는 대충 이런식으로 쓸건데요
보시다시피 루트 layout은 웹사이트의 뼈대 HTML을 정의합니다
그리고 <head>
내에 들어갈 메타데이터들이 있을건데
이건 export const metadata={}
라는 이름으로 객체로써 내보내면 됩니다.
그리고 favicon은 metadata설정 안 해도, app
폴더에 icon.png
라는 이름으로 하나 넣어두면 알아서 이거 가져갑니다.
Dynamic Route (동적 경로)
예를 들어, 블로그 포스트를 생각해봅시다
각 포스트들에 대한 slug는 정해져있지 않고, 매번 다르게 추가됩니다
이걸 미리 다 폴더를 만들고 그럴 수는 없어요
대신 동적으로 route를 만들어내는 방법이 있습니다
폴더 이름에 []
(대괄호)를 쓰면 됩니다. 그리고 그 안에는 임의의 식별자를 추가하구요
예를 들어, /[slug]
처럼 쓴다는 의미입니다
그리고 그 밑에는 page.js
를 두면 됩니다
아래처럼
/app
/about
/blog
/[slug]
page.js
page.js
이 경우 /blog/something-slug
, /blog/another-slug
, ...와 같이 동적으로 경로분할이 이루어집니다
그리고 이제 /[slug]/page.js
에서는 [slug]
에 해당하는 식별자 값을 알아야 할건데
/[slug]/page.js
에는 Next.js가 알아서 props를 넣어줍니다.
BlogPostPage({ params })
params라는 이름으로요
이 params는 동적 경로에 넣은 식별자들이 key-value
로 들어가있습니다
즉 params.slug
와 같이 써서 slug값을 가져올 수 있는거십니다잉
Server Component VS. Client Component
기본적으로 컴포넌트들은 서버에서 실행됩니다.
이건 처음 로드될 때 뿐만 아니라, <Link>
로 네비게이션할 때도 그렇고요
서버 컴포넌트를 사용하면 많은 이점들을 가져갈 수 있습니다
그런데 useEffect같은 훅을 쓰는 경우 서버 컴포넌트로 실행될 수 없습니다
localStorage, Geolocaion API 등 웹브라우저(클라이언트 사이드)에서만 가능한 기능도 또한 그렇습니다
당연히 우리는 컴포넌트를 클라이언트에서 렌더링해야 할 필요가 생기는 시점이 생깁니다
이 경우 클라이언트 사이드 컴포넌트임을 명시하기 위해 "use client" 지시어를 파일 맨 위에 적어줍시다.
근데 use client
를 항상 매 컴포넌트마다 써버린다면 기존 리액트랑 다를게 별로 없겠죠?
서버 컴포넌트의 이점을 활용하려면 최대한 클라이언트 컴포넌트는 컴포넌트 트리의 하단부에 넣는게 좋겠습니다
데이터베이스와 통신하기
데이터베이스와 통신하여 데이터를 가져오거나 데이터를 전송하는 일은 매우 흔합니다
보통은 DB를 따로 두고 CRUD가 가능한 api를 열든지 어쩌든지 하고, useEffect
에서 fetch
같은 것으로 통신할텐데요
Next.js는 풀스택 프레임워크니까 그런거 필요없습니다.
SQLite를 사용하는 경우를 예시로 한번 살펴봅시다
데이터 꺼내오기
데이터베이스에서 직접 꺼내올 수 있는 것은 서버 컴포넌트의 특권입니다
데이터를 꺼내오려면 다음과 같은 과정을 밟습니다. "식사에 대한 정보"를 가져온다고 생각하겠습니다
- 일단 데이터 가져오는 함수를 만듭시다. 보통
/lib/meals.js
처럼 하는가봅니다 - 그 안에서
const db = sql('meals.db')
와 같이, db파일을 가져다 데이터베이스를 준비합니다 getMeals()
라는, 데이터 가져오기 함수를 정의할건데,db.prepare(sql문).all();
처럼 하면 됩니다.prepare(sql).all()
은 조회,prepare(sql).run()
은 데이터 수정입니다
import sql from "better-sqlite3";
const db = sql("meals.db");
export function getMeals() {
return db.prepare("SELECT * FROM meals").all();
}
이런 식이겠네요
그리고 이제 컴포넌트에서 이걸 사용할건데
서버 컴포넌트인 컴포넌트 함수는 async가 가능합니다 (원래 안 됐음)
따라서 Promise에 대해 await
도 가능하죠
그래서 이제
async function MealsPage() {
const meals = await getMeals();
return <MealsGrid meals={meals} />;
}
이렇게 쓸 수 있습니다.
꺼내오는 동안 폴백 보여주기
근데 당연히 꺼내오는 시간은 여전히 존재합니다
그럼 기다리는 동안 뭔가 보여주고 싶은데
전에 배운 <Suspense>
블록이 유용합니다.
또는 loading.js
를 만들어둘 수도 있겠고요
<Suspense>
로 하려면 먼저 meals
데이터를 가져오고 렌더하는 코드를 따로 분리합시다
async function Meals() {
const meals = await getMeals();
return <MealsGrid meals={meals} />;
}
이제 이를 <Suspense>
로 래핑하여 사용합니다
<Suspense fallback={<MealsLoading />}>
<Meals />
</Suspense>
이러면 데이터를 가져오는 부분만 쏙 빼서 "준비중입니다 ^^;;"를 하고
다른 상관없는 부분들은 바로 보여줘버릴 수 있습니다
error.js
이 타이밍에 error.js
가 슬슬 있어야겠습니다
조회가 실패할 수도 있고 말이죠?
아, error.js
는 꼭 클라이언트 컴포넌트여야 합니다. 서버 뿐만 아니라 브라우저에서의 에러까지 모두 잡기 위함입니다
"use client";
export default function Error({ error }) {
return (
<main className="error">
<h1>An error occurred!</h1>
<p>{error.message}</p>
</main>
);
}
대충 이런식으로 하면 되겠는데
이러면 이제 중첩 route에서 에러 발생 시 이 폴백페이지를 띄웁니다
props로는 error
객체를 받아서 디테일을 추가할 수도 있구요
NotFound도 비슷하게 not-found.js
륾 만들면 되는데, 서버컴포넌트여도 됩니다
값 하나만 꺼내오기
export function getMeal(slug) {
return db.prepare("SELECT * FROM meals WHERE slug = ?").get(slug);
}
아까 prepare().all()
했던 것과 다르게 .get()
하면 되는데
그 하나를 식별할 paramter를 넣어서 .get(slug)
처럼 하겠죠?
근데 이 경우 .get(slug)
가 아닌 단순히 "SELECT * FROM meals WHERE slug="+slug
하면 안 됩니다. SQL Injection 당하기 딱좋습니다
없는 값인가? 를 확인하려면 일단 저 반환값을 if(!meal)
처럼 확인하고
notFound()
함수를 Next.js에서 제공하니까 이걸 호출하면 제일 가까운 NotFound폴백이 나타납니다
데이터 수정하기
응당 데이터를 가져왔다면 다시 집어넣어야겠습니다. 때로는 삭제하고 싶을수도 있고요
이것도 다이렉트로 서버에 넣어버리거나 하면 좋겠네요
이를 위해서는 임의의 js함수를 만들고, 'use server'
지시어를 씁니다.
이제 그 파일 안에서 async
함수를 만들면 Server Action으로 생성됩니다.
그럼 서버 컴포넌트가 서버단에서 실행되는 것이 보장되듯이 이 함수도 그렇게 됩니다.
폼 제출
폼 제출을 위한 함수를 만듭니다.
그리고 파일 맨 위에 use server
지시어를 써도 되지만
그냥 함수 정의 맨 위에 use server
를 써서 개별 함수를 Server Action으로 만들 수 있습니다
async function shareMeal(formData) {
"user server";
const meal = {
title: formData.get('title'),
...
}
// meal 객체를 DB에 저장하기.
}
// Form이 있는 컴포넌트 함수에서는..
return (
<form action={shareMeal}>
</form>
)
이제 server action인 함수를 <form>
의 action 프로퍼티로 넣어줍니다.
이게 근데 컴포넌트가 클라이언트 컴포넌트면 오류가 발생하는데
당연히 서버액션을 클라이언트에서 실행하려면 그렇죠
제출중입니다.. 기달
이제 제출중임을 표현하고 싶으면 useFormState()
훅을 사용하는데
아? 훅이라서 클라이언트 컴포넌트여야만 하죠?
그러니 제출버튼만 따로 빼서 아래와 같이 합시다
"use client";
import { useFormStatus } from "react-dom";
export default function MealsFormSubmit() {
const { pending } = useFormStatus();
return (
<button disabled={pending}>
{pending ? "Submitting..." : "Share Meal"}
</button>
);
}
이 useFormStatus
는 form 양식 내부에서만 가능합니다
양식 제출 시 값이 유효하지 않은 경우 그대로 머무르기
Server Action에서 값이 유효하지 않으면 throw Error
해버릴 수 있지만,
그럼 페이지를 나가버리는게 단점입니다. 유저들은 이런걸 원하지 않을것인데
따라서 값이 유효하지 않은 경우 그냥 return { message: "Invalid input." }
처럼 직렬화 가능한 객체를 반환합시다
이제 Form이 있는 페이지에서, useFormState
를 사용해야합니다.
흠.. 그럼 어쩔 수 없이 그 페이지가 이제 클라이언트 컴포넌트가 되어야 하겠네요 ㅜㅜ
그럼 Server Action은 이제 어떻게 쓰죠?
const [state, formAction] = useFormState(serverActionFn, initValue)
이렇게 훅을 쓸거라서, 원래