Next.js 13 버전
Next.js 13 버전을 이용해서 간단한 게시판을 생성하는 앱을 만들어보자.
우선 next app 설치를 위한 폴더를 만들자.
그리고 터미널에 설치해주자.
npx create-next-app@12.1.0 --typescript ./
처음에 보여준 앱 예시에서 각각의 Post들은 데이터베이스에서 가져오는건데, 일일이 하나씩 가져오기에는 시간이 너무 오래걸리니까 pocketbase를 이용하자.
여기서 운영체제에 맞는 것을 다운받자.
나는 m1 pro 이어서 Download v0.16.2 for macOS ARM64 이걸 다운 받았다.
다운 받고 pocketbase 파일을 nextjs13-app 폴더로 옮기자.
그리고 pocketbase를 실행하려면,
./pocketbase server
이렇게 서버가 실행되는 것을 확인할 수 있다.
여기서 Admin UI 링크에 들어가 회원가입/로그인 하고 테이블 생성을 위해 New Collection을 클릭하자.
나는 Post들을 생성할 것이다. Collection이름을 posts라고 하자.
그리고 API Rules에서 모두 Unlock을 해주고 Create를 해주자.
먼저 루트 경로('/' 경로)로 왔을 때 외부에서 접근할 수 있는 부분인 app 폴더 안의 가장 기본적인 파일 page.tsx을 만들어주고, 컴포넌트를 생성해주자.
<page.tsx>
const HomePage = () => {
return (
<div>HomePage</div>
)
}
export default HomePage
그리고 npm run dev를 하면 자동으로 app 폴더 안에 layout.tsx가 생기는 것을 확인할 수 있다.
이 layout은 전체를 위한 레이아웃이다. app/layout.tsx의 코드를 다음과 같이 수정해보자.
<app/layout.tsx>
import Link from "next/link"
export const metadata = {
title: 'Next.js',
description: 'Generated by Next.js',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<nav>
<Link href="/">
Home
</Link>
<Link href="/posts">
Post
</Link>
</nav>
<main>
{children} //여기서 children은 pate.tsx이다.
</main>
</body>
</html>
)
}
<posts/page.tsx>
const PostsPage = () => {
return (
<div>PostsPage</div>
)
}
export default PostsPage
next.js에서 기본적으로 app 디렉토리 안에 있는 컴포넌트들은 Server Component이다.
Server Component는 서버에서만 실행되며, 서버에서 렌더링된 후 전송된 결과를 클라이언트에 보여주는 새로운 컴포넌트 유형이다. 이러한 컴포넌트는 브라우저에게 보내지기 전에 서버에서 먼저 렌더링된다. 이렇게 함으로써 초기 로딩 시간을 줄이고, 클라이언트 측의 코드 크기를 줄이는 등의 이점이 있다.
Server Component와 Client Component는 다음과 같은 경우에 각각 사용한다.
./pocketbase server만 실행시키면 지금 앱이 어떻게 작동되었는지 볼 수가 없고,
npm run dev만 실행시키면 pocketbase를 확인할 수가 없다. 이를 동시에 실행시키기 위해 터미널 창을 2개를 열어서 각각 하나씩 실행시키면 된다.
PostsPage Sever Component에서 PocketBase의 posts 컬렉션 내에 Post1, Post2를 가져오길 바라고 있다. 이는 fetch 메소드를 이용해서 가져올 수 있다. 그래서 서버가 시작된 포트인 http://127.0.0.1:8090의 api의 collections의 posts의 record들을 가져오면 된다.
따라서 아래와 같이 코드를 작성하면 된다.
<posts/pages.tsx>
// Next.js에서 제공하는 Link 컴포넌트를 import합니다.
import Link from "next/link";
// 비동기 함수 getPost를 선언합니다. 이 함수는 게시물 데이터를 API에서 가져옵니다.
async function getPost() {
// fetch를 사용하여 API에서 데이터를 가져옵니다.
const res = await fetch('http://127.0.0.1:8090/api/collections/posts/records');
// API 응답을 JSON 형식으로 파싱합니다.
const data = await res.json();
// 파싱된 데이터에서 items를 반환합니다. 만약 items가 없다면 빈 배열을 반환합니다.
return data?.items as any[];
}
<posts/pages.tsx>
// 비동기 함수인 PostsPage 컴포넌트를 선언합니다.
const PostsPage = async () => {
// getPost 함수를 호출하여 게시물 데이터를 가져옵니다.
const posts = await getPost();
// 게시물 데이터를 활용하여 렌더링합니다.
return (
<div>
<h1>Posts</h1>
{ // 각각의 게시물을 PostItem 컴포넌트로 렌더링합니다.
posts?.map((post) => {
return <PostItem key={post.id} post={post}/>
})}
</div>
)
}
<posts/pages.tsx>
// PostItem 컴포넌트를 선언합니다. 이 컴포넌트는 개별 게시물을 렌더링합니다.
const PostItem = ({ post }: any) => {
// post 객체에서 필요한 프로퍼티를 추출합니다.
const { id, title, created } = post || {};
// 추출한 데이터를 활용하여 렌더링합니다. 각 게시물은 Link 컴포넌트를 활용하여 동적 라우팅을 가능하게 합니다.
return (
<Link href={`/posts/${id}`}>
<div>
<h3>{title}</h3>
<p>{created}</p>
</div>
</Link>
)
}
// PostsPage 컴포넌트를 기본으로 내보냅니다.
export default PostsPage
현재 코드와 같이 고정된 곳에서 fetch를 하게 되면 cache가 되게 된다. 이를 cache가 안되개 하고 모든 리퀘스트마다 다시 가져올 수 있게 하는 방법이 있다. cahce: 'no-store' 옵션을 주면 된다. 이는 getServerSideProps와 유사하다.
const res = await fetch('http://127.0.0.1:8090/api/collections/posts/records',
{cache : 'no-store'});
현재 만든 사이트에 들어가서 post들을 클릭하면 다음과 같은 404 페이지가 뜬다. 이를 추가해보자.
posts폴더 안에 dynamic하게 id 폴더를 만들어주고 그 안에 page.tsx를 만들어준다.
그리고 rafce를 입력하고 다음과 같이 수정해준다.
<posts/[id]/page.tsx>
const PostDetailPage = () => {
return (
<div>PostDetailPage</div>
)
}
export default PostDetailPage
도메인에서 records 뒤에 ${postId}를 추가해주면 해당 postId에 해당하는 디테일한 데이터들을 가져올 수 있다.
<posts/[id]/page.tsx>
async function getPost(postId:string) {
const res = await fetch(
`http//127.0.0.1:8090/api/collections/posts/records/${postId}`
)
}
const PostDetailPage = () => {
return (
<div>PostDetailPage</div>
)
}
export default PostDetailPage
캐시된 데이터를 일정 시간 간격으로 재검증하려면 fetch()에서 next.revalidate 옵션을 사용할 수 있다. 기본 단위는 초이다.
<posts/[id]/page.tsx>
const res = await fetch(
`http//127.0.0.1:8090/api/collections/posts/records/${postId}`,
{ next: { revalidate: 10 } }
)
generateStaticParams을 사용하면 revalidate될 때마다 다시 호출하는 것이 아니라 빌드 타임에 호출된다. 즉, generateStaticParams 함수는 해당 레이아웃 또는 페이지가 생성되기 전에 빌드 시간에 실행된다. Revalidation(ISR) 중에는 다시 호출되지 않는다.
<posts/[id]/page.tsx>
// getPost 함수를 선언합니다. 이 함수는 특정 ID에 해당하는 게시물 데이터를 API에서 가져옵니다.
async function getPost(postId:string) {
// fetch를 사용하여 해당 ID의 게시물 데이터를 API에서 가져옵니다.
const res = await fetch(
`http://127.0.0.1:8090/api/collections/posts/records/${postId}`,
// revalidate 옵션을 설정하여, 이 데이터가 10초 후에 재검증되도록 합니다.
{ next: { revalidate: 10 } }
)
// API 응답을 JSON 형식으로 파싱합니다.
const data = await res.json();
// 파싱된 데이터를 반환합니다.
return data;
}
// 비동기 함수인 PostDetailPage 컴포넌트를 선언합니다.
const PostDetailPage = async ({params}: any) => {
// getPost 함수를 호출하여 특정 게시물 데이터를 가져옵니다.
const post = await getPost(params.id);
// 게시물 데이터를 활용하여 렌더링합니다.
return (
<div>PostDetailPage</div>
)
}
// PostDetailPage 컴포넌트를 기본으로 내보냅니다.
export default PostDetailPage
<posts/[id]/page.tsx>
// getPost 함수를 선언합니다. 이 함수는 특정 ID에 해당하는 게시물 데이터를 API에서 가져옵니다.
async function getPost(postId:string) {
// fetch를 사용하여 해당 ID의 게시물 데이터를 API에서 가져옵니다.
const res = await fetch(
`http://127.0.0.1:8090/api/collections/posts/records/${postId}`,
// revalidate 옵션을 설정하여, 이 데이터가 10초 후에 재검증되도록 합니다.
{ next: { revalidate: 10 } }
)
// API 응답을 JSON 형식으로 파싱합니다.
const data = await res.json();
// 파싱된 데이터를 반환합니다.
return data;
}
// 비동기 함수인 PostDetailPage 컴포넌트를 선언합니다.
const PostDetailPage = async ({params}: any) => {
// getPost 함수를 호출하여 특정 게시물 데이터를 가져옵니다.
const post = await getPost(params.id);
// 게시물 데이터를 활용하여 게시물 세부 정보를 렌더링합니다.
return (
<div>
<h1>posts/{post.id}</h1>
<div>
<h3>{post.title}</h3>
<p>{post.created}</p>
</div>
</div>
)
// <h1> 게시물의 id를 이용하여 헤더를 생성합니다.
// <h3> 게시물의 제목을 표시합니다.
// <p> 게시물의 생성 날짜를 표시합니다.
}
// PostDetailPage 컴포넌트를 기본으로 내보냅니다.
export default PostDetailPage
<posts/[id]/page.tsx>에 일부러 error를 발생시켜보자.
다음과 같이 코드를 추가해주고,
<posts/[id]/page.tsx>
async function getPost(postId:string) {
// fetch를 사용하여 해당 ID의 게시물 데이터를 API에서 가져옵니다.
const res = await fetch(
`http://127.0.0.1:8090/api/collections/posts/records/${postId}`,
// revalidate 옵션을 설정하여, 이 데이터가 10초 후에 재검증되도록 합니다.
{ next: { revalidate: 10 } }
)
//이 부분을 추가
if(!res.ok) {
//가장 가까이에 있는 error.js activated
throw new Error('Failed to fetch data');
}
// API 응답을 JSON 형식으로 파싱합니다.
const data = await res.json();
// 파싱된 데이터를 반환합니다.
return data;
}
이 코드를 추가하면 가장 가까이애 있는 error.js가 활성화 된다.
[id]폴더 내에 error.tsx 파일을 만들어 주고,
https://nextjs.org/docs/app/building-your-application/routing/error-handling
이 사이트에 있는 error.tsx 예제를 복사 붙여넣기를 해준다.
<error.tsx>
'use client'; // Error components must be Client Components
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error);
}, [error]);
return (
<div>
<h2>Something went wrong!</h2>
<button
onClick={
// Attempt to recover by trying to re-render the segment
() => reset()
}
>
Try again
</button>
</div>
);
}
이 코드를 통해 어떠한 error가 발생했는지 console로 찍어준다.
그리고 임의로 원래 경로를 변형시켜 error를 발생시켜보자.
그럼 이렇게 에러가 발생하는 것을 확인할 수 있다.
그리고 이렇게 콘솔 창에서 에러의 원인을 파악할 수 있다.
현재는 pocketbase에서 생성해둔 post들만 확인할 수 있지, 새로운 post들을 생성할 수가 없다. 이 부분을 Client Component를 이용해 구현해보자.
Client Component를 사용하려면 앱 내부에 파일을 만들고 파일 상단에 "use client" 지시문을 추가한다(import 하기 전에).
우선 posts 폴더 내에 CreatePost.tsx 파일을 생성해주자.
그리고 코드를 다음과 같이 작성해주자. (rafce 사용하면 간편!)
<CreatePost.tsx>
'use client';
const CreatePost = () => {
return (
<div>CreatePost</div>
)
}
export default CreatePost
<CreatePost.tsx>
// CreatePost라는 함수형 컴포넌트를 선언합니다.
const CreatePost = () => {
// useState 훅을 이용하여 제목(title)에 대한 상태를 관리합니다. 초기값은 빈 문자열("")입니다.
const [title, setTitle] = useState("")
// 컴포넌트 렌더링
return (
<form onSubmit={handleSubmit}> {/* 폼 제출 이벤트가 발생하면 handleSubmit 함수를 호출합니다. */}
<input
type="text" // 입력 필드 타입을 텍스트로 설정합니다.
placeholder="Title" // 입력 필드의 플레이스홀더를 "Title"로 설정합니다.
value={title} // 입력 필드의 값으로 제목(title) 상태를 설정합니다.
onChange={(e) => setTitle(e.target.value)} // 입력 값이 변경되면 제목(title) 상태를 업데이트합니다.
/>
<button type="submit"> {/* 폼 제출 버튼을 생성합니다. */}
Create Post
</button>
</form>
)
}
<CreatePost.tsx>
const CreatePost = () => {
// useState 훅을 이용하여 제목(title)에 대한 상태를 관리합니다. 초기값은 빈 문자열("")입니다.
const [title, setTitle] = useState("")
// 폼 제출을 처리하는 함수를 선언합니다.
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
// 폼 제출의 기본 동작을 취소합니다. (페이지 리로딩 방지)
e.preventDefault();
// fetch 함수를 사용하여 서버에 POST 요청을 보냅니다.
await fetch('http://127.0.0.1:8090/api/collections/posts/records', {
method:'POST', // 요청 방식을 POST로 설정합니다.
headers: { 'Content-Type': 'application/json'}, // 요청 헤더에 Content-Type을 application/json으로 설정합니다.
body:JSON. stringify({ // 요청 본문에 제목(title)을 JSON 형식의 문자열로 변환하여 전송합니다.
title
})
})
// 요청이 완료되면 제목(title) 상태를 초기화합니다.
setTitle('');
}
// 컴포넌트 렌더링
return (
<CreatePost.tsx>
'use client';
// React와 React의 useState 훅을 import 합니다.
import React, {useState} from "react";
// CreatePost라는 함수형 컴포넌트를 선언합니다.
const CreatePost = () => {
// useState 훅을 이용하여 제목(title)에 대한 상태를 관리합니다. 초기값은 빈 문자열("")입니다.
const [title, setTitle] = useState("")
// 폼 제출을 처리하는 함수를 선언합니다.
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
// 폼 제출의 기본 동작을 취소합니다. (페이지 리로딩 방지)
e.preventDefault();
// fetch 함수를 사용하여 서버에 POST 요청을 보냅니다.
await fetch('http://127.0.0.1:8090/api/collections/posts/records', {
method:'POST', // 요청 방식을 POST로 설정합니다.
headers: { 'Content-Type': 'application/json'}, // 요청 헤더에 Content-Type을 application/json으로 설정합니다.
body:JSON. stringify({ // 요청 본문에 제목(title)을 JSON 형식의 문자열로 변환하여 전송합니다.
title
})
})
// 요청이 완료되면 제목(title) 상태를 초기화합니다.
setTitle('');
}
// 컴포넌트 렌더링
return (
<form onSubmit={handleSubmit}> {/* 폼 제출 이벤트가 발생하면 handleSubmit 함수를 호출합니다. */}
<input
type="text" // 입력 필드 타입을 텍스트로 설정합니다.
placeholder="Title" // 입력 필드의 플레이스홀더를 "Title"로 설정합니다.
value={title} // 입력 필드의 값으로 제목(title) 상태를 설정합니다.
onChange={(e) => setTitle(e.target.value)} // 입력 값이 변경되면 제목(title) 상태를 업데이트합니다.
/>
<button type="submit"> {/* 폼 제출 버튼을 생성합니다. */}
Create Post
</button>
</form>
)
}
// CreatePost 컴포넌트를 내보냅니다.
export default CreatePost
이제 post들 아래에 form을 넣어주자.
posts/page.tsx에 다음과 같이 CreatePost 컴포넌트를 추가하면 된다.
그럼 다음과 같이 UI가 구현된 것을 확인할 수 있다.
여기에 post4를 Create Post하고,
refresh 하면, post4가 생성되었다.
하지만 내가 원하는 것은 refresh를 해서 post가 생성되는 것을 확인하는 것이 아니다. 이는 refresh()를 이용하면 해결할 수 있다.
refresh()는 현재의 라우트를 refresh해주고, 서버로부터 새로운 데이터를 가져올 수 있게 한다.
refresh()를 호출하면 현재 경로가 서버에서 업데이트된 할일 목록을 새로고침하고 가져온다. 이는 브라우저 기록에 영향을 미치지 않지만 루트 레이아웃에서 아래로 데이터를 새로 고친다. refresh()를 사용할 때 React 및 브라우저 상태를 모두 포함하여 클라이언트 측 상태가 손실되지 않는다.
==> full page refresh를 안해도 된다.
<CreatePost.tsx>
'use client';
import React, {useState} from "react";
import { useRouter } from "next/navigation"; // useRouter를 next/navigation으로 부터 import 합니다.
const CreatePost = () => {
const [title, setTitle] = useState("")
// Next.js의 useRouter 훅을 사용하여 라우터 객체를 가져옵니다. 이를 통해 페이지를 새로 고침할 수 있습니다.
const router = useRouter();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
await fetch('http://127.0.0.1:8090/api/collections/posts/records', {
method:'POST',
headers: { 'Content-Type': 'application/json'},
body:JSON. stringify({
title
})
})
setTitle('');
// refresh 메서드를 호출하여 현재 페이지를 새로 고침합니다. 이로써 최신 데이터를 보여줄 수 있습니다.
router.refresh();
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<button type="submit">
Create Post
</button>
</form>
)
}
export default CreatePost
이렇게 추가해주면, refresh를 하지 않아도 post5가 바로 추가되는 것을 확인할 수 있다.