[코드잇] 위클리 미션 수행기 (11주차)

woolee의 기록보관소·2023년 5월 30일
0

코드잇부트캠프0기

목록 보기
15/24

이번 주(11주차) 위클리미션

디자인 시안 확인 및 요구사항 파악

  • 기존 react로 개발한 페이지들을 next.js로 변경하기
  • /folder 페이지를 새로 개발하기
    • 각 탭에 맞게 폴더를 시각적으로 보여주기
    • 폴더와 링크를 저장/삭제/수정할 수 있는 기능

구현하기

next.js v13 설치

next.js v13의 app router를 사용했다.
다음 주에 배울 타입스크립트를 미리 적용해봤다.

npx create-next-app@latest --ts ./

이때 .github이나 readme.md 파일이 있으면 설치할 수 없으므로
git stash로 저장하고 삭제한 뒤, 위 명령어를 사용해서 설치하고 나서
git stash pop을 사용해준다. (apply가 아니라 pop을 사용하면 기록도 스택에서 제거해주므로 좋다.)

git stash # 변경 내용 임시저장
git stash list # stash 기록 보기 
git stash apply # 가장 최근 stash 가져오기 
git stash drop # 가장 최근 stash 지우기 
git stash clear # 한번에 모든 stash 지우기 
git stash pop # 가장 최근 stash 적용하고 동시에 스택에서 제거 (apply + drop) 

next.js v13 정리

가장 마음에 들었던 건 react의 suspense를 파일 형태로 기본 제공해준다는 점이 매력적이었다. 그 외에도 더 이상 복잡한 함수를 사용하지 않아도 SSR, SSG 등 다양한 렌더링 방식을 선택할 수 있어서 좋았다.

  • 서버 컴포넌트와 클라이언트 컴포넌트의 분리
  • app router : app 내 파일들은 react server component로 렌더링된다.
  • 예약된 파일들 : page.tsx, layout.tsx, loading.tsx, error.tsx, template.tsx, head.tsx (13.4에서는 head.tsx는 사라진 것 같다)
  • 일반적으로 리액트에서 쓰듯이 쓰려면 "use client" 붙여서 client component로 만들어야 한다.
  • getStaticProps, getServerSideProps 대신 간편하게 use라는 훅으로 SSR을 대체할 수 있다. fetch에 다양한 옵션을 줘서 구현할 수도 있다.
    • { cache: 'force-cache' } - 기본값으로 생략가능(getStaticProps와 유사)
    • { cache: 'no-store' } - 모든 요청에서 최신 데이터 받아오기 (getServerSideProps와 유사)
    • { next: { revalidate: 10 } } - 10초 후 새 요청오면 페이지 새로 생성 (revalidate옵션이 있는 getStaticProps와 유사)
  • 원래 13버전 이전에는
    • SSG - getStaticProps, getStaticPaths
    • SSR - getServerSideProps
  • app router로 오면서, page-level로 제한되어 있던 data-fetching이 가능해졌다. 그래서 layout.tsx 파일에서 data fetching이 가능하다.

next.js에 폰트 적용하기

optimizing local fonts in next js
css variables

app 폴더 안에 fonts 폴더를 생성하고 여기에 폰트들을 삽입한다.

그리고 나서 app/layout.tsx 에서 next/font/local에서 localFont를 가져와서 아래 사진처럼 경로를 정의해주고 html 전체에 className으로 적용해주면 된다.

사용할 때는 다음과 같이 var로 선언해서 사용하면 쉽다.

// components/TestItem.tsx 

import styles from "./TestItem.module.css";

const TestItem = ({ testTitle }: { testTitle: string }) => {
  return <div className={styles.text}>{testTitle}</div>;
};

export default TestItem;

// components/TestItem.module.css 

.text {
  font-family: var(--font-pretendard);
  font-weight: 500;
  font-style: normal;
}

next.js에 metadata 추가하기

meta 태그를 layout에서 추가해줬다.

Metadata

// app/layout.tsx 

import "./globals.css";

import { Metadata } from "next";
import localFont from "next/font/local";

export const metadata: Metadata = {
  title: "Codeit Weekly Mission",
  description: "세상의 모든 정보를 쉽게 저장하고 관리해 보세요.",
  openGraph: {
    title: "Linkbrary",
    description: "세상의 모든 정보를 쉽게 저장하고 관리해 보세요.",
    url: "https://weekly-codeit-henry.netlify.app/",
    siteName: "linkbrary",
    images: {
      url: "https://i.ibb.co/DpfKPC9/img-info.png",
      width: 1168,
      height: 561,
      alt: "linkbrary",
    },
    type: "website",
  },
  twitter: {
    card: "summary_large_image",
    images: {
      url: "https://i.ibb.co/DpfKPC9/img-info.png",
      width: 1168,
      height: 561,
      alt: "linkbrary",
    },
    title: "Linkbrary",
    description: "세상의 모든 정보를 쉽게 저장하고 관리해 보세요.",
  },
};

const pretendard = localFont({
  variable: "--font-pretendard",
  src: [
    {
      path: "./fonts/Pretendard-Regular.woff",
      weight: "400",
      style: "normal",
    },
    {
      path: "./fonts/Pretendard-Medium.woff",
      weight: "500",
      style: "normal",
    },
    {
      path: "./fonts/Pretendard-SemiBold.woff",
      weight: "600",
      style: "normal",
    },
    {
      path: "./fonts/Pretendard-Bold.woff",
      weight: "700",
      style: "normal",
    },
  ],
});

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ko" className={pretendard.className}>
      <body>{children}</body>
    </html>
  );
}

유저 데이터 받아오기 & 에러/로딩 처리(error.tsx, loading.tsx)

next.js에서 기본적으로 fetch 함수를 커스터마이징해줬기에 SSR, SSG를 쉽게 구현할 수 있었다. 에러 처리나 로딩 처리도 error.js나 loading.js 파일을 통해 쉽게 구현할 수 있게 되었다.

그래서 기존에 useAsync와 같은 훅을 사용하던 걸 버리고 간단하게 fetch 함수를 사용했다. 찾아보니 axios도 쉽게 next.js에 맞게 사용할 수 있을 것 같아서 적용해볼 예정이다.

// app/page.tsx 
// 페이지에서 사용자의 데이터를 가져오며, 서버 에러 발생시 error.tsx가 실행된다. 
import Gnb from "@/components/Gnb/gnb";
import styles from "./page.module.css";
import getUserData from "@/lib/getUserData";

export default async function Home() {
  const user = await getUserData();
  return (
    <>
      <Gnb user={user} />
      ... 
    </>
  );
}
// app/error.tsx 
// 나중에 커스터마이징을 좀 더 해야겠다. 

"use client";

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div>
      <h2>Something went wrong!</h2>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}
// lib/getUserData.ts 
// res.ok가 falsy 값일 때 가장 가까이 있는 error.tsx가 실행된다. 

const getUserData = async () => {
  const res = await fetch(`https://bootcamp-api.codeit.kr/api/sample/user`, {
    cache: "no-store",
  });

  if (!res.ok) {
    throw new Error(`Failed to fetch data`);
  }
  const data = await res.json();

  return data?.data;
};

export default getUserData;

error.tsx

error.tsx 파일의 경우 경로마다 작성할 수 있다.
이때 layout.tsx에서 에러가 발생할 경우, shared 폴더 안에 있는 error.tsx가 아니라 shared 폴더 밖에서 가장 가까운 error.tsx가 동작한다.

shared 폴더 안에 있는 error.tsx가 동작하려면, layout이 아니라 page에서 에러가 발생해야 한다.

// app/shared/layout.tsx 

import Footer from "@/components/Footer/footer";
import Gnb from "@/components/Gnb/gnb";
import getUserData from "@/lib/getUserData";

export default async function SharedLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await getUserData();
  return (
    <section>
      <Gnb user={user} />
      {children}
      <Footer />
    </section>
  );
}

어떻게 할지 고민하다가, gnb에서 발생하는 유저 에러는 shared 밖에서 처리하기로 했다. 왜냐하면, shared 페이지 안에는 gnb, footer 제외하고 콘텐츠만 들어가도록 관심사를 분리하고 싶었다.

그럴 거면 애초에 gnb, footer를 루트 레이아웃으로 빼는 게 좋을 것 같아서 아예 분리해버렸다.

app/layout.tsx에서 발생하는 에러는 app/global-error.tsx로 잡을 수 있다고 나오는데, 이상하게 global-error.tsx는 못 잡는다.. 왜지?

// app/layout.tsx 

... 
export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await getUserData();
  return (
    <html lang="ko" className={pretendard.className}>
      <body>
        <Gnb user={user} />
        {children}
        <Footer />
      </body>
    </html>
  );
}

next.js의 Image 최적화 기능 사용하기

반드시 이미지 크기를 지정해줘야 한다.
지정해준 이미지 크기를 바탕으로 next에서 이미지 사이즈를 최적화하기 때문이다.

항상 지정할 수는 없을 수는 없는데, 이럴 때 유연하게 크기를 지정하는 방법도 제공해준다.

fill 속성을 사용하면 조상 요소에 꽉차도록 해준다.
조상 요소는 positioning된 요소여야 한다.

div 태그로 감싸고 div 태그를 position: relative로 주면 된다.

예를 들어 아래와 같이 작성한다.

<div style={{ position: 'relative', width: '50px', height: '200px' }}>
	<Image 
    fill 
		src="/images/product.png"
		alt="상품 이미지"
		style={{ objectFit: "cover" }}
  />
</div>

그리고 fill만 쓴다면, sizes 속성도 넣어줘야 최적화가 제대로 된다.

prettier와 eslint

간단하게 타입스크립트를 준수하고, 기본적인 규칙만 작성했다.
추가적으로 prettier를 설정해서 더러워질 수 있는 import들을 정리할 수 있게 해줬다.

// prettier.config.js

/** @type {import('prettier').Config} */
module.exports = {
  endOfLine: "auto",
  semi: true,
  singleQuote: false,
  tabWidth: 2,
  trailingComma: "es5" /* 콤마 붙이는 설정 */,
  /**
   * npm i -D @trivago/prettier-plugin-sort-imports
   * ^[./] : 작성된 것들이 아닌 나머지
   * <THIRD_PARTY_MODULES>는 외부 라이브러리 위치
   */
  importOrder: [
    "^(react/(.*)$)|^(react$)",
    "^(next/(.*)$)|^(next$)",
    "^@components/(.*)$",
    "^@lib/(.*)$",
    "<THIRD_PARTY_MODULES>",
    "^types$",
    "^[./]",
  ],
  importOrderSeparation: true /* 각 범주마다 공백 줄지 말지 */,
  importOrderSortSpecifiers: true /* 설정한 범주 내에서 정렬을 할지 말지를 결정 */,
  importOrderBuiltinModulesToTop: true,
};

eslint도 설정해줬는데,
eslint-config-prettier 를 설치해서 코드 오류를 잡는데는 eslint, 코드 포맷팅에는 prettier를 사용할 수 있는 환경을 만들었다.

  • extends에 prettier를 추가하면, eslint에서 prettier와 충돌할 수 있는 rule을 꺼버린다.
// .eslintrc.json 

{
  "parser": "@typescript-eslint/parser" /* ts 코드에 대한 AST 생성 (ts에서 ESLint 사용할 수 있게 하는 파서) */,
  "extends": [
    "next/core-web-vitals" /* next js 권장사항 */,
    "eslint:recommended" /* https://eslint.org/docs/latest/rules/ 에 체크되어 있는 모든 규칙 활성화. rules로 확장 가능 */,
    "plugin:@typescript-eslint/recommended" /* eslint:recommended 와 유사하다. ts 전용 규칙 사용 */,
    "prettier" /* eslint-config-prettier에서 prettier와 충돌되는 스타일 옵션을 꺼버리는 기능 */,
    "plugin:prettier/recommended",
    "prettier/prettier",
    "plugin:react/recommended",
    "plugin:react-hooks/recommended"
  ],
  "plugins": ["prettier", "@typescript-eslint"],
  /* ESLint 사용을 위해 지원하려는 Javascript 언어 옵션을 지정 */
  "parserOptions": {
    "sourceType": "module" /* parser의 export 형태를 설정 */,
    "ecmaVersion": 2020 /* 사용할 ECMAScript 버전을 설정 */
  },
  "rules": {
    "@typescript-eslint/no-non-null-assertion": "off" /* https://typescript-eslint.io/rules/no-non-null-assertion/ */,
    "no-unused-vars": [
      1,
      { "args": "after-used", "argsIgnorePattern": "^_" }
    ] /* 인자를 사용하지 않아도 에러x, 인자에 _붙여도 에러x */,
    "react-hooks/exhaustive-deps": [
      "warn",
      {
        /* 커스텀 훅 사용할 경우, 여기에 등록하면 된다. */
        "additionalHooks": "useRecoilCallback"
      }
    ],
    /* https://typescript-eslint.io/rules/no-empty-interface/ */
    "@typescript-eslint/no-empty-interface": [
      "error",
      {
        "allowSingleExtends": false
      }
    ],
    "react/prop-types": "off",
    "react/display-name": "off"
  }
}

next.js의 라우팅 - next/navigation

functions
next js 13부터는 next/router가 아니라 next/navigation을 사용한다.

import {
  usePathname,
  useRouter,
  useSearchParams,
  useSelectedLayoutSegment,
  useSelectedLayoutSegments,
  redirect,  
  notFound,
} from 'next/navigation';

usePathname는 현재 URL의 pathname을 읽을 수 있는 클라이언트 컴포넌트 훅이다. (서버 컴포넌트에서 사용 불가능)

'use client';
 
import { usePathname } from 'next/navigation';
 
export default function ExampleClientComponent() {
  const pathname = usePathname();
  return <>Current pathname: {pathname}</>;
}

useRouter는 클라이언트 컴포넌트에서 사용할 수 있으며, 선언적으로 사용하는 Link 컴포넌트와 반대된다. next js에서는 정말 필요한 상황이 아니면 Link 컴포넌트의 사용을 권장하고 있다.

  • router.push(href: string)
  • router.replace(href: string)
  • router.refresh()
  • router.prefetch(href: string)
  • router.back()
  • router.forward()
'use client';
 
import { useRouter } from 'next/navigation';
 
export default function Page() {
  const router = useRouter();
 
  return (
    <button type="button" onClick={() => router.push('/dashboard')}>
      Dashboard
    </button>
  );
}

useSearchParams는 현재 URL의 쿼리 스트링을 읽을 수 있는 클라이언트 컴포넌트 훅이다. 오직 읽을 수만 있다.

'use client';
 
import { useSearchParams } from 'next/navigation';
 
export default function SearchBar() {
  const searchParams = useSearchParams();
 
  const search = searchParams.get('search');
 
  // URL -> `/dashboard?search=my-project`
  // `search` -> 'my-project'
  return <>Search: {search}</>;
}

useSelectedLayoutSegment은 레이아웃의 한단계 아래 segment를 반환한다. 없으면 null을 반환한다.

useSelectedLayoutSegments은 useSelectedLayoutSegment의 복수형인 클라이언트 컴포넌트 훅이다. 한 단계 아래 segment가 아니라 모든 segment를 배열 형태로 제공하며, 없으면 빈 배열을 반환한다.

redirect. 404로 보내고 싶으면 notFound 함수를 사용하면 된다.

svg에 반응형 적용하기

fill 속성을 변경해주면 되는데, 이걸 위해서 컴포넌트를 클라이언트 컴포넌트로 만들기가 싫었다.

filter를 사용해서 색상을 조절해줬다.
CSS filter generator to convert from black to target hex color


.image {
    filter: invert(94%) sepia(34%) saturate(5873%) hue-rotate(179deg)
      brightness(109%) contrast(97%);
  }

vercel에 배포 시 문제 (클라이언트 컴포넌트와 서버 컴포넌트의 명확한 구분 필요)

클라이언트 컴포넌트들을 서버 컴포넌트에서 바로 import 해오면 내부 에러가 발생한다. 렌더링에는 문제가 없지만, 에러가 뜨므로 클라이언트 컴포넌트들은 dynamic으로 변경해줘야 한다.

Uncaught Error: Minified React error

const FolderContents = dynamic(
  () => import("components/FolderContents/FolderContents"),
  { ssr: false }
);

그리고 배포할 때 계속 build가 안 됐는데, 로컬의 폴더명이 origin에 제대로 반영되지 않는 문제였다.
분명 로컬에서는 카멜케이스로 폴더명을 작명했는데, origin에 가보니 소문자로 되어 있다.. push를 해도 폴더명이 제대로 반영되지 않고 있었다..

git config core.ignorecase false 

이렇게 하고 나면, 이전 파일과 업데이트한 파일 2개가 생기므로 빌드할 때 또 문제가 생길 수 있다. 그래서 git rm -r --cached 까지 해주고 push 해줘야 한다.

dynamic으로 변경해보니, 확실히 서버 컴포넌트로 작성한 것들은 ssr이 적용되어 초기 로딩이 깔끔했다.

  • 느낀 점은, 리액트스럽게 작성하는 게 오히려 독이 될 수 있다는 점이었다.
  • 리액트를 사용할 때는 아무렇지 않게 state를 남발했는데, 그러다 보니 불필요하게 클라이언트 컴포넌트가 되어버린 코드들이 많다. 그래서 다음 리팩토링 때는 최대한 서버 컴포넌트로 만들 수 있게 코드를 작성하려고 한다.

너무 쉬운 설정들 robots.ts, sitemap.ts, favicon.ico 등

next.js는 기본적인 태그 설정들이 너무 쉬워서 놀랐다..

IntersectionObserver를 사용해서 메뉴 위치 fixed로 변경하기

요구사항 중에서 요소가 사라졌을 때, 해당 요소를 하단에 고정해달라는 요구사항이 있었다. 이를 위해 IntersectionObserver을 사용했다.

react-intersection-observer 라이브러리가 있기는 했는데, 굳이 라이브러리까지 쓸 필요가 없을 정도로 애초에 api 자체가 간단해서 커스터마이징했다.

모달은 createPortal을 사용해서, 스크롤을 막기 위한 useEffect

모달창이 요구사항에 많았다.

모달창은 createPortal을 사용했다.

아래와 같이 Wrapper를 만들어주고

// components/DeleteLinkModal/DeleteLinkPortalWrapper.tsx

"use client";

import React from "react";

import { createPortal } from "react-dom";

const DeleteLinkPortalWrapper = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const el = document.getElementById("delete-link-portal") as HTMLElement;
  return createPortal(children, el);
};

export default DeleteLinkPortalWrapper;

모달창에서는 스크롤을 막을 수 있게 useEffect를 사용해줬다.

함수는 다음과 같다.

/**
 * 스크롤을 방지하고 현재 위치를 반환한다.
 */
export const preventScroll = (): number => {
  const currentScrollY = window.scrollY;
  document.body.style.position = "fixed";
  document.body.style.width = "100%";
  document.body.style.top = `-${currentScrollY}px`; // 현재 스크롤 위치
  document.body.style.overflowY = "scroll";
  return currentScrollY;
};

/**
 * 스크롤을 허용하고, 스크롤 방지 함수에서 반환된 위치로 이동한다.
 */
export const allowScroll = (prevScrollY: number) => {
  document.body.style.position = "";
  document.body.style.width = "";
  document.body.style.top = "";
  document.body.style.overflowY = "";
  window.scrollTo(0, prevScrollY);
};

외부 요소를 클릭했을 때, 모달창을 끄기 위한 useOutsideClick

// hooks/useOutsideClick.ts 

"use client";

import { useEffect } from "react";

const useOutsideClick = (
  ref: React.MutableRefObject<HTMLDivElement | null>,
  callback: () => void
) => {
  const handleClick = (e: React.BaseSyntheticEvent | MouseEvent) => {
    if (ref.current && !ref.current.contains(e.target)) {
      callback();
    }
  };

  useEffect(() => {
    setTimeout(() => {
      document.addEventListener("click", handleClick);
    }, 0);

    return () => {
      document.removeEventListener("click", handleClick);
    };
  });
};

export default useOutsideClick;

params를 인자로 받아서 사용하기

page.tsx에서 params라는 prop을 받아서 파라미터를 사용할 수 있다.
파라미터를 커스터마이징하고 next.config.js를 사용해서 경로를 redirects 함수로 바로 변경해줬다.

// app/folder/[slug]/page.tsx 

const Tab = ({
  params,
  searchParams,
}: {
  params: { slug: string };
  searchParams?: { [key: string]: string | string[] | undefined };
}) => {
  const currentTab =
    params.slug === undefined
      ? 0
      : params.slug === "favorites"
      ? 1
      : Number(params.slug);
  return (
    <main className={styles.main}>
      <FolderContents
        links={[]}
        folders={MOCK_FOLDERS}
        currentTab={currentTab}
      />
    </main>
  );
};

export default Tab;
// next.config.js 

async redirects() {
    return [
      {
        source: "/folder/0",
        destination: "/folder",
        permanent: true,
      },
      {
        source: "/folder/1",
        destination: "/folder/favorites",
        permanent: true,
      },
    ];
  },

next.js v13 느낀 점

서버 컴포넌트로 만들 수 있는 컴포넌트는 최대한 클라이언트 컴포넌트로 만들지 않는 게 유리했다. 그러다 보니 오히려 리액트보다 더더욱 html, css를 잘 활용할 줄 알아야 next.js의 장점을 잘 살릴 수 있을 것 같다는 느낌을 많이 받았다.

참고

Next.js 13 local font 적용하기
Next.js 13버전에서 로컬 폰트 적용하기
(Next.js) @next/fonts

[Next.js] - v13.2 Feature: Metadata
Next.js 13.2

타입스크립트의 {} 타입

[Next.js 13] 공식 문서 Data Fetching 1(Fundamentals ~ Caching)
이렇게 레거시 코드가 되버려.. 1편 (feat. Next.13)
next 13 next/navigation 커스텀해서 사용하기
Understanding the Complexities of Next.js v13
NEXT.JS 13.4 문서 - 2(Routing - 1)

Next.js 13 - 1. Routing - 1.6. Error Handling

Nextjs image optimization with examples

prettier와 eslint를 구분해서 사용하자
사내 ESLint & Prettier 적용기

[React/Web API] IntersectionObserver

profile
https://medium.com/@wooleejaan

0개의 댓글

관련 채용 정보