
6주차에 바닐라 자브스크립트로 만든 MPA에서 리액트로 만든 SPA로 마이그레이팅을 진행하였다. 7주차부터는 페어프로그래밍과 8주차 ~10주차는 프로젝트를 진행하여서 주간 미션이 없었다. 11주차부터 링크브러리 주간미션이 재개되었는데, 기존 프로젝트를 next.js로 마이그레이팅 하고 약간의 기능들을 추가하는 작업을 해야했다.
npx create-next-app@latest명령을 쳐서 next.js의 친절한 부트스트래핑을 진행한다. 다음과 같은 인터랙티브 모드가 실행된다. What is your project named? my-app
Would you like to use TypeScript with this project? No / Yes
Would you like to use ESLint with this project? No / Yes
Would you like to use Tailwind CSS with this project? No / Yes
Would you like to use `src/` directory with this project? No / Yes
Use App Router (recommended)? No / Yes
Would you like to customize the default import alias? No / Yes
나같은 경우 타입스크립트를 사용했고,
src/디렉토리를 선택했다. App Router는 선택하지 않았다. 기존의 pages router로 만들다가 나중에 차차 포팅해볼 생각이다.
다음과 같이 처음의 폴더구조가 형성되어 있다.
┣ 📂public
┃ ┣ 📜favicon.ico
┃ ┣ 📜next.svg
┃ ┗ 📜vercel.svg
┣ 📂src
┃ ┣ 📂pages
┃ ┃ ┣ 📂api
┃ ┃ ┃ ┗ 📜hello.ts
┃ ┃ ┣ 📜_app.tsx
┃ ┃ ┣ 📜_document.tsx
┃ ┃ ┗ 📜index.tsx
┃ ┗ 📂styles
┃ ┃ ┣ 📜Home.module.css
┃ ┃ ┗ 📜globals.css
┣ 📜.eslintrc.json
┣ 📜.gitignore
┣ 📜README.md
┣ 📜next-env.d.ts
┣ 📜next.config.js
┣ 📜package-lock.json
┣ 📜package.json
┗ 📜tsconfig.json
pages: 이 폴더에서 pages router를 사용할 수 있다. 파일 시스템 기반의 라우팅을 따르고 있어서 각 페이지의 pages디렉토리 내의 경로가 곧 url경로가 된다.
_app.js: 모든 페이지들에 적용될 코드가 담겨있는 파일이며, Components와 pageProps를 props로 받는 컴포넌트이다. 여기서 Component란 프로젝트 내 페이지 컴포넌트들을 의미한다. Header, Footer, Main섹션 등을 _app.js에서 배치하고 Component를 원하는 곳에 배치시키면 모든 페이지들에 각각 헤더와 푸터를 적용하지 않아도 된다. pageProps는 Component의 props라고 생각하면 된다.
_documents.js: 리액트 코드를 넘어서는 프로젝트의 html 문서에 대한 공통적인 작업을 할 수 있는 문서이다. meta태그나 html lang 설정등을 이곳에서 할 수 있다.
이제 Pretendard 폰트를 가져와보도록 하자. 폰트 확장자는 woff를 사용할 것이다. woff2가 woff대비 절반가량 압축률이 좋아 woff2를 사용하기로 한다.
폰트는 생각보다 많은 네트워크 waterfall을 야기한다. 폰트를 적용하는 css를 먼저 로드하고, css에서 폰트를 로드하기 때문에 아래와 같은 비효율이 발생한다.

이 때 다음과 같이 preloading을 사용하여 최종 목표인 폰트를 먼저 로드할 수 있는데, 우리는 폰트를 받아오기 위한 url을 이미 알고있기 때문이다.
<Head>
{/* Preload woff2 fonts */}
<link
rel="preload" as="font" type="font/woff2" crossOrigin="" href={`${CDN_BASE_URL}/font/pretendard/Pretendard-Regular.subset.woff2`}/>
{/* Request font.css */}
<link
rel="stylesheet" type="text/css" href={`${CDN_BASE_URL}/font.css`}/>
</Head>

waterfall이 상당히 개선되었음을 알 수 있다.
먼저 font.scss에 font-face를 설정한다.
@font-face {
font-family: 'Pretendard';
font-weight: 400;
font-style: normal;
src: url('https://cdn.jsdelivr.net/gh/webfontworld/pretendard/Pretendard-Regular.eot');
src: url('https://cdn.jsdelivr.net/gh/webfontworld/pretendard/Pretendard-Regular.eot?#iefix') format('embedded-opentype'),
url('https://cdn.jsdelivr.net/gh/webfontworld/pretendard/Pretendard-Regular.woff2') format('woff2'),
url('https://cdn.jsdelivr.net/gh/webfontworld/pretendard/Pretendard-Regular.woff') format('woff'),
url('https://cdn.jsdelivr.net/gh/webfontworld/pretendard/Pretendard-Regular.ttf') format("truetype");
font-display: swap;
}
... /* font-weight 700까지 */
이후 _document.js에 다음과 같이 <link> 태그를 추가한다. rel 속성은 preload로 지정해준다.
import { Html, Head, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html lang="ko">
<Head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{/* Preload woff2 fonts */}
<link
rel="preload"
as="font"
type="font/woff2"
crossOrigin=""
href="https://cdn.jsdelivr.net/gh/webfontworld/pretendard/Pretendard-Regular.woff2"
/>
<link
rel="preload"
as="font"
type="font/woff2"
crossOrigin=""
href="https://cdn.jsdelivr.net/gh/webfontworld/pretendard/Pretendard-Bold.woff2"
/>
<link rel="icon" href="/favicon.ico" />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
가장 많이 쓰이는 Bold체와 Regular체를 먼저 가져와본다.
preloading을 적용한 font waterfall
preloading을 하지 않았을 때의 font waterfall
비동기적으로 먼저 가져오는 것을 확인할 수 있다.
이전에 리액트나 웹 컴포넌트와 html로 만들어 놓은 페이지들을 next.js의 pages폴더로 옮겨준다.
import React from "react";
import logo from "@/assets/logo.svg";
import Link from "next/link";
import Image from "next/image";
import styles from "./Header.module.scss";
const Header = () => {
return (
<header className={styles.headerWrapper}>
<nav>
<Link className="logo" href="/">
<Image src={logo} alt="logo" fill />
</Link>
<Link className="login-btn" href="/signin.html">
로그인
</Link>
</nav>
</header>
);
};
export default Header;
import Link from "next/link";
import Image from "next/image";
import React from "react";
import styles from "./Footer.module.scss";
import facebook from "@/assets/facebook.svg";
import instagram from "@/assets/instagram.svg";
import twitter from "@/assets/twitter.svg";
import youtube from "@/assets/youtube.svg";
const Footer = () => {
return (
<footer className={styles.footerWrapper}>
<div className={styles.bottomNav}>
<div className={styles.copyright}>codeit - 2023</div>
<div className={styles.internalLinks}>
<div>
<Link href="/privacy.html">Privacy Policy</Link>
<Link className={styles.faqLink} href="/faq.html">
FAQ
</Link>
</div>
</div>
<div className={styles.externalLinks}>
<div className={styles.iconbox}>
<Link href="https://www.facebook.com/" target="_blank">
<Image src={facebook} width={20} height={20} alt="facebook" />
</Link>
<Link href="https://twitter.com/" target="_blank">
<Image src={twitter} width={20} height={20} alt="twitter" />
</Link>
<Link href="https://www.youtube.com/" target="_blank">
<Image src={youtube} width={20} height={20} alt="youtube" />
</Link>
<Link href="https://www.instagram.com/" target="_blank">
<Image src={instagram} width={20} height={20} alt="instagram" />
</Link>
</div>
</div>
</div>
</footer>
);
};
export default Footer;
아래와 같이 전체 페이지에 적용될 레이아웃을 만들어 준다. PageContainer컴포넌트는 단순히 페이지 내용을 담을 컨테이너로 만들어 주었다.
import "@/styles/globals.scss";
import type { AppProps } from "next/app";
import Header from "@/components/Header/Header";
import Footer from "@/components/Footer/Footer";
import PageContainer from "@/components/PageContainer/PageContainer";
export default function App({ Component, pageProps }: AppProps) {
return (
<div>
<Header />
<PageContainer>
<Component {...pageProps} />
</PageContainer>
<Footer />
</div>
);
}
아래와 같이 getStaticProps를 사용하여 정적 생성을 해준다. Home페이지에서 받아올 데이터는 없기 때문에 그냥 getStaticProps함수를 정의하고 export해주면 빌드시 Home페이지는 정적 생성을 한다.
export default function Home() {
return (
<>
<Head>
<title>Linkbrary</title>
</Head>
<Hero />
</>
);
}
export const getStaticProps = async () => {
return {
props: {},
};
};
Next.js 공식문서를 보면 새로 만드는 프로젝트들은 13버전부터 지원되는 app routing을 사용할 것을 권장한다고 한다. 나도 이 사실을 pages routing을 구현하던 도중 알게 되었다. 이전에 했던 프로젝트에서 pages routing을 사용했던 경험이 있어서 이번에는 app routing을 시도해보기로 했다.
pages routing과 외관상 구조적으로는 크게 달라진것은 없다. 페이지 이름에 대한 컨벤션이 고정된 점, error.ts, loading.ts, not-found.ts등의 페이지 alternative 기능이 기본 제공되는 점이 다르고, 내부적으로는 기본적으로 모든 페이지가 서버 컴포넌트라는 점이 다르다. 클라이언트단에서 렏더링 할 컴포넌트는 컴포넌트 최상단에 "use client" 디렉티브를 선언하면 된다.
App 라우팅으로 가본 홈 페이지를 만들었다. 방법은 간단하다. app 디렉토리 하위에 page.tsx를 추가하고, 홈 페이지 코드를 그대로 넣으면 된다.
import { Inter } from "next/font/google";
import styles from "@/styles/index.module.scss";
import Head from "next/head";
import Image from "next/image";
import Hero from "@/app/components/Hero/Hero";
const inter = Inter({ subsets: ["latin"] });
export default function Page() {
return (
<>
<Hero />
<div className={styles.tutorialSection}>
<article className={styles.tutorial}>
<section className={styles.explanation}>
<div className={styles.textArea}>
<h1>
<span className={`${styles.emphasis} ${styles._1}`}>
원하는 링크
</span>
를 <br />
저장하세요
</h1>
<section className={`${styles.imageContainer} ${styles.hidden}`}>
<Image src="/tutorial_01.png" alt="image" fill />
</section>
<p>
나중에 읽고 싶은 글, 다시 보고 싶은 영상, <br />
사고 싶은 옷, 기억하고 싶은 모든 것을 <br />한 공간에
저장하세요.
</p>
</div>
</section>
<section className={styles.imageContainer}>
<Image src="/tutorial_01.png" alt="image" fill />
</section>
</article>
<article className={styles.tutorial}>
<section className={styles.imageContainer}>
<Image src="/tutorial_02.png" alt="image" fill />
</section>
<section className={styles.explanation}>
<div className={styles.textArea}>
<h1>
링크를 폴더로 <br />
<span className={`${styles.emphasis} ${styles._2}`}>관리</span>
하세요
</h1>
<section className={`${styles.imageContainer} ${styles.hidden}`}>
<Image src="/tutorial_02.png" alt="image" fill />
</section>
<p>
나만의 폴더를 무제한으로 만들고 <br />
다양하게 활용할 수 있어요.
</p>
</div>
</section>
</article>
<article className={styles.tutorial}>
<section className={styles.explanation}>
<div className={styles.textArea}>
<h1>
저장한 링크를 <br />
<span className={`${styles.emphasis} ${styles._3}`}>공유</span>
해 보세요.
</h1>
<section className={`${styles.imageContainer} ${styles.hidden}`}>
<Image src="/tutorial_03.png" alt="image" fill />
</section>
<p>
여러 링크를 폴더에 담고 공유할 수 있어요. <br />
가족, 친구, 동료들에게 쉽고 빠르게 링크를 <br />
공유해 보세요.
</p>
</div>
</section>
<section className={styles.imageContainer}>
<Image src="/tutorial_03.png" alt="image" fill />
</section>
</article>
<article className={styles.tutorial}>
<section className={styles.imageContainer}>
<Image src="/tutorial_04.png" alt="image" fill />
</section>
<section className={styles.explanation}>
<div className={styles.textArea}>
<h1>
저장한 링크를 <br />
<span className={`${styles.emphasis} ${styles._4}`}>검색</span>
해 보세요
</h1>
<section className={`${styles.imageContainer} ${styles.hidden}`}>
<Image src="/tutorial_04.png" alt="image" fill />
</section>
<p>중요한 정보들을 검색으로 쉽게 찾아보세요.</p>
</div>
</section>
</article>
</div>
</>
);
}
App 라우팅은 서버 컴포넌트 활용을 극대화 시키기 위해 Next.js가 내놓은 라우팅 아키텍쳐다. 따라서 App routing을 이용하면 서버 컴포넌트와 클라이언트 컴포넌트를 적당히 구분할 줄 알아야 한다. 서버 컴포넌트는 서버단에서 렌더링이 이루어지는 컴포넌트라, 리액트 훅은 물론이고 간단한 onClick 핸들러조차 있으면 안된다. data fetching과 정적인 html만 허용한다.
그렇다고 서버 컴포넌트로 만든 페이지가 모두 정적이어야 하는 것은 아니다. 정적인 부분은 서버에서, 동적인 부분은 클라이언트에서 렌더링 하여 비효율을 최대한 줄이는 것이 핵심이다. 서버 컴포넌트로 만들려고 하는 페이지 내 동적인 요소가 있다면 그 부분만 클라이언트 컴포넌트로 빼버리면, 우리의 페이지에 핸들러나 훅을 사용하지 않고 서버 렌더링을 유지할 수 있다. 이렇게 하면 해당 동적 컴포넌트만 따로 클라이언트에서 렌더링하게 된다.
다음과 같이 서버 컴포넌트 페이지를 만들고(app router를 사용한다면 모든 컴포넌트는 디폴트로 서버컴포넌트라서 따로 해줘야 하는 것은 없다) 동적인 요소를 따로 컴포넌트로 만든다.
import React from "react";
...
const getFolderData = async () => {
...
};
const getCardListProps = (dataList: any) => {
...
};
const getFolderInfoProps = (folder: any) => {
...
};
const Page = async () => {
const folderData = await getFolderData();
const folderInfoProps = getFolderInfoProps(folderData.folder);
const cardListProps = getCardListProps(folderData.folder.links);
const handleChangeSearchInput = ()=>{...} //
return (
<main>
<section className={styles.introSection}>
<FolderInfo {...folderInfoProps}></FolderInfo>
</section>
<section className={styles.cardSection}>
<div className={styles.searchBarWrapper}>
<input
placeholder={"원하는 링크를 검색해 보세요"}
onChange={handleChangeSearchInput} {/*핸들러가 있으면 서버 컴포넌트가 될 수 없음*/}
/>
</div>
<LinkCardList cardDataList={cardListProps} />
</section>
</main>
);
};
export default Page;
import React from "react";
...
const getFolderData = async () => {
...
};
const getCardListProps = (dataList: any) => {
...
};
const getFolderInfoProps = (folder: any) => {
...
};
const Page = async () => {
const folderData = await getFolderData();
const folderInfoProps = getFolderInfoProps(folderData.folder);
const cardListProps = getCardListProps(folderData.folder.links);
const handleChangeSearchInput = ()=>{...}
return (
<main>
<section className={styles.introSection}>
<FolderInfo {...folderInfoProps}></FolderInfo>
</section>
<section className={styles.cardSection}>
<div className={styles.searchBarWrapper}>
<SearchBar
action={"/search/links?q=null"}
placeholder={"원하는 링크를 검색해 보세요"}
onChange={handleChangeSearchInput} {/*컴포넌트로 빼서 핸들러를 숨긴다.*/}
/>
</div>
<LinkCardList cardDataList={cardListProps} />
</section>
</main>
);
};
export default Page;
"use client" // 클라이언트 컴포넌트라고 명시
const SearchBar = ({.., onChange, ..})=>{
return (
...
<input onChange={onChange} />
...
);
}
이렇게 분리를 잘 하고 적당히 서버 컴포넌트와 클라이언트 컴포넌트를 섞어가며 페이지를 완성시키면, 정적인 부분과 데이터는 서버에서 가져오고, 동적인 부분만 클라이언트에서 렌더링 하게 하여 클라이언트가 다운받아야 하는 js번들을 대폭 줄이고 코드 스프리팅을 편하게 할 수 있다.