📚 1. 사전 지식
참고 : pnpm vs npm vs yarn
pnpm
pnpm은 npm의 단점을 개선한 모듈 관리 프로그램이다. npm과 yarn의 가장 큰 문제인 프로젝트 간 사용되는 dependencies의 중복 저장을 보완하였다. npm과 yarn은 node_modules 내부에 flat 하게 패키지를 설치하여 관리한 반면, pnpm은 이러한 호이스팅 방식 대신 다른 devdependencies를 해결하는 전략인 content-addressable storage를 사용했다.
server 컴포넌트 vs client 컴포넌트
참고 : 'use client'
Server 컴포넌트 : 서버로부터 data받아오기
Client 컴포넌트 : 파일 첫 줄에 'use client' 붙이면 적용. event listener(onClick, onChange 등), hooks(useState, useReducer 등) 등을 사용
'use client'를 안 붙인 server 컴포넌트에서 훅을 사용하면 개발 서버에서 오류 발생
Next.js 13 버전은 Client와 Server 컴포넌트를 효율적으로 섞어 쓰도록 권장
🤩 2. 실제로 구현하기
1. 내 로컬환경에 세팅
이 프로젝트는 특이하게 pnpm을 사용하고 있다. leerob.io는 Vercel의 부회장인 Leerob가 만든 프로젝트여서, Vercel가 스폰서인 pnpm을 사용하지 않았나 하는 추측을 한다. 만약 pnpm을 처음 사용한다면 링크처럼 설치하면 된다. 나의 경우 mac이어서 $brew install pnpm을 하였다.
2. 폴더 및 파일 구성
전체적인 파일 및 폴더 구성은 위와 같다.
3. App routing
가장 특이한 점은 pages폴더에서 페이지별 렌더링을 하는 것이 아니라 app폴더에서 페이지를 렌더링 한다는 점이었다.
또한, app폴더에서 기본 페이지의 파일명이 index.tsx가 아니라, pages.tsx임을 확인할 수 있다. 이제 알았는데, 지난 Next.js 13 업데이트에서 pages directory 렌더링에서 app directory렌더링으로 바뀌었다고 한다. 병행하여 사용 가능하긴 하지만, Next.js에서는 App Router 사용을 권장하고 있다.
내가 지난 프로젝트에서 사용한 Next.js임에도 이러한 사실도 모르고 프로젝트를 진행했다니. 앞으로 공식 문서를 꼼꼼히 읽어봐야겠다. 업데이트 내용도 꼬박꼬박 챙겨보고 말이다. 다 챙겨보지 못하더라도, 이러한 최신의 훌륭한 프로젝트를 참고하면 App routing에 대해서 알 수 있었을 것이다.
app 폴더는 위와 같이 구성되어 있다.
4. Pages 폴더 구성
1. error.tsx : 에러 발생했을 경우 보여주는 페이지
2. layout.tsx : 모든 페이지에 적용되는 공통 layout
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="en"
className={clsx(
'text-black bg-white dark:text-white dark:bg-[#111010]',
kaisei.variable
)}
>
<body className="antialiased max-w-4xl mb-40 flex flex-col md:flex-row mx-4 mt-8 md:mt-20 lg:mt-32 lg:mx-auto">
<Sidebar />
<main className="flex-auto min-w-0 mt-6 md:mt-0 flex flex-col px-2 md:px-0">
{children}
<Analytics />
</main>
</body>
</html>
);
}
3. global.css : 전역에서 사용되는 css
// app/layout.tsx
import "./global.css";
// ...
4. 각 페이지 별 export Metadata
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Blog',
description: 'Read my thoughts on software development, design, and more.',
};
export default async function BlogPage() {
return (
<section>
...
</section>
);
}
각 페이지 별로 다른 title과 description을 지정해 주었다.
JS의 코드를 자유자재로 사용하는 Javascript 코드가 가장 먼저 눈에 띄었다. sort로 정렬하고, map으로 mapping 하여 '어떻게'보다는 '무엇을'에 포커싱을 줘 함수형 프로그래밍을 구현하는 것을 볼 수 있다.
{allBlogs
.sort((a, b) => {
if (new Date(a.publishedAt) > new Date(b.publishedAt)) {
return -1;
}
return 1;
})
.map((post) => (
<Link
key={post.slug}
className="flex flex-col space-y-1 mb-4"
href={`/blog/${post.slug}`}
>
<div className="w-full flex flex-col">
<p>{post.title}</p>
<ViewCounter slug={post.slug} trackView={false} />
</div>
</Link>
))}
5. Dynamic Routing
호스트 주소/blog/제목의 제목에 해당하는 부분을 Dynamic Routing 해주었다.
파일 구성은 위와 같다. slug(제목)에 해당하는 부분을 generateStaticParams를 이용하여 가져왔다.
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
return allBlogs.map((post) => ({
slug: post.slug,
}));
}
export default async function Blog({ params }) {
const post = allBlogs.find((post) => post.slug === params.slug);
if (!post) {
notFound();
}
...
return ( ... )
}
이전 프로젝트에서 Dynamic Routing에서 에러가 발생하여 결국 구현 못하고 path를 이용하여 구현한 적이 있다. 다음번에 리팩터링 할 때 위와 같은 방식으로 Dynamic Routing으로 구현을 해보도록 해야겠다.
6. view count
조회수를 측정하는 'view-counter.tsx'컴포넌트가 있다. 이 컴포넌트는 blog/pages.tsx에서 import 해주었다. view-counter.tsx는 다음과 같이 생겼다.
// app/blog/view-counter.tsx
'use client';
import { useEffect } from 'react';
import useSWR from 'swr';
type PostView = {
slug: string;
count: string;
};
async function fetcher<JSON = any>(
input: RequestInfo,
init?: RequestInit
): Promise<JSON> {
const res = await fetch(input, init);
return res.json();
}
export default function ViewCounter({
slug,
trackView,
}: {
slug: string;
trackView: boolean;
}) {
const { data } = useSWR<PostView[]>('/api/views', fetcher);
const viewsForSlug = data && data.find((view) => view.slug === slug);
const views = new Number(viewsForSlug?.count || 0);
useEffect(() => {
const registerView = () =>
fetch(`/api/views/${slug}`, {
method: 'POST',
});
if (trackView) {
registerView();
}
}, [slug]);
return (
<p className="font-mono text-sm text-neutral-500 tracking-tighter">
{data ? `${views.toLocaleString()} views` : ''}
</p>
);
}
- 'use client' : 클라이언트 컴포넌트로 취급하고 컴파일
5. components 폴더 구성
components
1. icons.tsx > TwitterIcon(export), GitHubIcon(export), ViewsIcon(export), ArrowIcon(export), YoutubeIcon(export)
2. mds.tsx > CustomLink, Callout, ProsCard, ConsCard, Mdx(export)
3. sidebar.tsx > navItems(객체), Logo, Navbar(export)
4. tweet.tsx > Tweet(export)
() 표시 안 한 것 : 컴포넌트. export 없는 것 : export 안 함
6. lib 폴더 구성
1. info.tsx > about, bio : 단일 태그와 문구로 구성
2. metrics.tsx > getBlogview(export, func), getTweetCount(export, func), getStartCount(export, func)
3. planetscale.ts > GuestbookTable(interface), ViewsTable(interface), Database(interface), queryBuilder(export, interface) : queryBuilder
4. setup.mjs : Node.js ES 모듈 파일. 주로 파일 열 때 사용(fs)
5. twitter.ts : getTweets(export), getTweetCount(export, api func)
7. pages 폴더 구성
1. api > auth > [... nextauth]. ts
2. api > views > [slug]. ts, index.ts
3. api > guiestbook.ts
4. api > og.tsx
Next.js의 API Routes : Nextjs에서 serverless API Endpoint 구축
// pages/api/views/index.ts
import { queryBuilder } from 'lib/planetscale';
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const data = await queryBuilder
.selectFrom('views')
.select(['slug', 'count'])
.execute();
return res.status(200).json(data);
} catch (e) {
console.log(e);
return res.status(500).json({ message: e.message });
}
}
서버의 역할을 대신한다.
// app/blog/view-counter.tsx
...
const { data } = useSWR<PostView[]>('/api/views', fetcher);
...
위처럼 /api/views로 api를 쏘면 /api/views/index파일에서 api를 받아 데이터를 전송한다.
8. public 폴더 구성
1. fonts > (폰트명). ttf, (폰트명-400). woff2, (폰트명-700). woff2
2. favicon.ico : favicon
3. og-bg.png : 배경화면
4. og.jpg : 로고
9. 그 외 파일
1.. env : 프로젝트에 필요한 환경 변수들을 설정하는 파일. 보안에 중요한 정보를 포함
2.. env.example :. env 파일의 템플릿. 이 파일을 복사하여. env로 만들고, 필요한 환경 변수를 채워 넣는다.
3.. gitignore : Git이 추적하지 않아야 하는 파일이나 폴더를 지정하는 파일. 보통 빌드 결과, 로그, 시스템 파일, 비밀 정보 등을 명시
4. contentlayer.config.js : Contentlayer의 설정 파일. 내용 소스와 변환 룰 등을 정의
5. LICENSE.txt : 프로젝트 라이선스를 명시하는 텍스트 파일. 사용, 수정, 배포 등의 권리를 설명
6. next.config.js : Next.js 프레임워크의 설정 파일. 라우팅, 플러그인, 웹팩 설정 등을 정의
7. package.json : Node.js 프로젝트의 메타데이터를 담고 있음. 의존성, 스크립트, 버전 정보 등을 포함
8. yarn.lock : Yarn 패키지 매니저가 생성하는 파일. 의존성 트리를 정확하게 복원하기 위한 정보를 담고 있
9. postcss.config.js : PostCSS의 설정 파일. CSS 전처리에 대한 설정을 정의
10. README.md : 프로젝트에 대한 개요, 설치 방법, 사용법 등을 기록하는 Markdown 파일.
11. tailwind.config.js : Tailwind CSS의 설정 파일. 테마, 플러그인, 변형 등을 정의
12. tsconfig.json : TypeScript 프로젝트의 설정 파일. 컴파일 옵션, 타입 체킹 설정, 경로 별칭 등을 지정
10. 그 외 Tips
1. 컴포넌트 한 번에 내보내기
// components/mds.tsx
import { useMDXComponent } from 'next-contentlayer/hooks';
...
const components = {
Image: RoundedImage,
a: CustomLink,
Callout,
ProsCard,
ConsCard,
};
...
export function Mdx({ code, tweets }: MdxProps) {
const Component = useMDXComponent(code);
const StaticTweet = ({ id }) => {
const tweet = tweets.find((tweet) => tweet.id === id);
return <Tweet {...tweet} />;
};
return (
<article className="prose prose-quoteless prose-neutral dark:prose-invert">
<Component components={{ ...components, StaticTweet }} />
</article>
);
}
useMDXComponent : MDX를 render 하기 쉽게 만들어줌
# MDX : MarkDown for a component
2. path 받아오기
import { usePathname } from 'next/navigation';
...
export default function Navbar() {
let pathname = usePathname() || '/';
if (pathname.includes('/blog/')) {
pathname = '/blog';
}
}
3. 컴포넌트 props 타입 선언
interface MdxProps {
code: string;
tweets: Record<string, any>;
}
export function Mdx({ code, tweets }: MdxProps) {
const Component = useMDXComponent(code);
const StaticTweet = ({ id }) => {
const tweet = tweets.find((tweet) => tweet.id === id);
return <Tweet {...tweet} />;
};
return (
<article className="prose prose-quoteless prose-neutral dark:prose-invert">
<Component components={{ ...components, StaticTweet }} />
</article>
);
}
4. clsx : 클래스를 조건부로 함께 결합하기 위한 JS 유틸리티
<Link
key={path}
href={path}
className={clsx(
'transition-all hover:text-neutral-800 dark:hover:text-neutral-200 flex align-middle',
{
'text-neutral-500': !isActive,
'font-bold': isActive,
}
)}
>
5. Framer-motion : Framer가 제공하는 리액트용 애니메이션 라이브러리
import { LayoutGroup, motion } from 'framer-motion';
...
function Logo() {
return (
<Link aria-label="Lee Robinson" href="/">
<motion.svg
className="text-black dark:text-white h-[25px] md:h-[37px]"
width="25"
height="37"
viewBox="0 0 232 316"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<motion.path
initial={{
opacity: 0,
pathLength: 0,
}}
animate={{
opacity: 1,
pathLength: 1,
}}
transition={{
duration: 0.5,
type: 'spring',
stiffness: 50,
}}
d="M39 316V0"
stroke="currentColor"
strokeWidth={78}
/>
<motion.path
initial={{ x: -200, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{
duration: 0.5,
type: 'spring',
stiffness: 50,
}}
d="M232 314.998H129.852L232 232.887V314.998Z"
fill="currentColor"
/>
</motion.svg>
</Link>
);
}
6. Content Layer Library
: content를 쉽게 import 할 수 있는 JSON 형태로 변환해 주는 content preprocessor 라이브러리
주로 블로그를 만들 때 사용하는 것 같다.
- 관련 파일 : robots.ts, sitemap.ts
7. Client 컴포넌트 vs Server 컴포넌트
1. Client 컴포넌트 : 같은 폴더 내의 Server 컴포넌트에서만 특정되어 사용하게 될 컴포넌트
<-> Component폴더의 컴포넌트 : 공통적으로 사용되는 컴포넌트(현재는 실제로 그러하지 않아도, 그러할 가능성이 있는)
// app/guestbook/action.tsx
'use client';
import { GitHubIcon } from 'components/icons';
import { signIn, signOut } from 'next-auth/react';
export function SignOut() {
return (
<button
className="text-xs text-neutral-700 dark:text-neutral-300 mt-2 mb-6"
onClick={() => signOut()}
>
→ Sign out
</button>
);
}
export function SignIn() {
return (
<button
className="flex bg-black text-neutral-200 px-4 py-3 rounded-md font-semibold text-sm mb-4 hover:text-white transition-all border border-gray-800"
onClick={() => signIn('github')}
>
<GitHubIcon />
<div className="ml-3">Sign in with GitHub</div>
</button>
);
}
간단한 <SignOut>, <SignIn> 버튼 컴포넌트를 export한다.
// /app/guestbook/form.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
export default function Form() {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isFetching, setIsFetching] = useState(false);
const isMutating = isFetching || isPending;
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsFetching(true);
const form = e.currentTarget;
const input = form.elements.namedItem('entry') as HTMLInputElement;
const res = await fetch('/api/guestbook', {
body: JSON.stringify({
body: input.value,
}),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
});
input.value = '';
const { error } = await res.json();
setIsFetching(false);
startTransition(() => {
// Refresh the current route and fetch new data from the server without
// losing client-side browser or React state.
router.refresh();
});
}
return (
<form
style={{ opacity: !isMutating ? 1 : 0.7 }}
className="relative max-w-[500px] text-sm"
onSubmit={onSubmit}
>
<input
aria-label="Your message"
placeholder="Your message..."
disabled={isPending}
name="entry"
type="text"
required
className="pl-4 pr-32 py-2 mt-1 focus:ring-blue-500 focus:border-blue-500 block w-full border-neutral-300 rounded-md bg-gray-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
/>
<button
className="flex items-center justify-center absolute right-1 top-1 px-2 py-1 font-medium h-7 bg-neutral-200 dark:bg-neutral-700 text-neutral-900 dark:text-neutral-100 rounded w-16"
disabled={isMutating}
type="submit"
>
Sign
</button>
</form>
);
}
<input>과 <button>이 들어있는 <Form>을 export한다.
+ 이 때, /api/guestbook으로 fetch를하여 api처리 -> /api/guestbook.ts에서 처리
2. Server 컴포넌트
// /app/guestbook/page.tsx
import type { Metadata } from 'next';
import { queryBuilder } from 'lib/planetscale';
import { SignIn, SignOut } from './actions';
import { getServerSession } from 'next-auth/next';
import { authOptions } from 'pages/api/auth/[...nextauth]';
import Form from './form';
async function getGuestbook() {
const data = await queryBuilder
.selectFrom('guestbook')
.select(['id', 'body', 'created_by', 'updated_at'])
.orderBy('updated_at', 'desc')
.limit(100)
.execute();
return data;
}
export const metadata: Metadata = {
title: 'Guestbook',
description: 'Sign my guestbook and leave your mark.',
};
export const dynamic = 'force-dynamic';
export default async function GuestbookPage() {
let entries;
let session;
try {
const [guestbookRes, sessionRes] = await Promise.allSettled([
getGuestbook(),
getServerSession(authOptions),
]);
if (guestbookRes.status === 'fulfilled' && guestbookRes.value[0]) {
entries = guestbookRes.value;
} else {
console.error(guestbookRes);
}
if (sessionRes.status === 'fulfilled') {
session = sessionRes.value;
} else {
console.error(sessionRes);
}
} catch (error) {
console.error(error);
}
return (
<section>
<h1 className="font-bold text-3xl font-serif mb-5">Guestbook</h1>
{session?.user ? (
<>
<Form />
<SignOut />
</>
) : (
<SignIn />
)}
{entries.map((entry) => (
<div key={entry.id} className="flex flex-col space-y-1 mb-4">
<div className="w-full text-sm break-words">
<span className="text-neutral-600 dark:text-neutral-400 mr-1">
{entry.created_by}:
</span>
{entry.body}
</div>
</div>
))}
</section>
);
}
🤔 3. 느낀 점 / 배운 점 / 추가로 공부할 것
Vercel의 부회장인 Leerob의 프로젝트를 살펴보았다. 그 과정에서 Next.js에 대해 근본적인 이해를 넓히는 기분을 느꼈다.
특히 app 라우팅과 client component 같은 Next.js 13버전에서 새롭게 업데이트된 기능들을 배울 수 있었다. 작년 12월에 Next.js 13이 출시된 이후로도 프로젝트를 진행했음에도 불구하고 이런 업데이트 사항들을 모르고 있었다는 사실에 아쉬움을 느꼈다. 하지만 이제라도 알게 되어 참 다행이라는 생각이 든다. 웹 생태계는 변화가 빠르기 때문에, 이런 변화에 신속하게 적응하는 능력이 개발자에게 요구된다는 것을 다시 한번 깨달았다.
또한, Leerob의 프로젝트에서는 모듈화와 파일 구분이 깔끔하게 이루어진 것을 확인할 수 있었다. 이를 통해 클린 코딩에 대한 중요성을 재인식하게 되었고, 이러한 경험을 바탕으로 나도 능숙한 시니어 개발자로 성장하겠다는 목표를 더욱 확고히 다질 수 있었다.
웹 개발은 항상 새로운 변화와 도전을 요구하는 분야이다. 항상 새로운 기술 트렌드에 발맞춰 학습하고, 그것을 통해 개인의 기술력을 향상시키는 것이 중요하다. 이러한 점을 계속해서 유념하며, 나는 앞으로도 끊임없이 배우고 성장하는 개발자가 되겠다는 결심을 다지게 되었다.
추가로 공부할 것
1. Next.js 13업데이트 내용
- Client 컴포넌트 vs Server 컴포넌트
- layout.tsx, error.tsx
- app 라우팅
- api Routes
2. Next.js의 SSR 방식에 대한 깊은 이해
'개발 일지 > 개발 일지' 카테고리의 다른 글
[개발 일지] dependencies vs devDependencies 구분하기 (0) | 2023.05.18 |
---|---|
[개발 일지] React Native 도전기 (1) (0) | 2023.05.16 |
[개발 일지] React성능 최적화 (2) : React Query의 캐싱 기능 (1) | 2023.05.05 |
[개발 일지] React성능 최적화 (1) : React.memo로 사용자 경험 개선하기 (0) | 2023.05.03 |
[개발 일지] Next.js에서 접근성 향상하기 (접근성 점수 84점 -> 97점 향상시킨 썰) (0) | 2023.04.27 |
[개발 일지] React Query - Custom Hook 만들기 (+Typescript) (0) | 2023.04.13 |
[개발 일지] JWT + 소셜 로그인을 정복해보자 (React + Spring Boot, Kakao Login, Naver Login) (0) | 2023.04.11 |
[개발 일지] Intersection Observer API & react-query를 이용한 무한스크롤 구현 (0) | 2023.02.28 |