Next.js의 App Router와 TypeScript, SCSS 등으로 이때까지 작업한 결과물들에 대한 내용들을 기록하고, 관련하여 작성한 글들을 모아두는 블로그 겸 개인 포트폴리오 사이트입니다. 일부 다국적 서비스가 지원되며 Vercel로 배포되었습니다.
- > 둘 중 한 링크로 들어가셔서 오른쪽 상단의 언어 버튼을 누르셔도 한/영 전환이 됩니다.
히스토리
[v1.0]
[v1.1]
🧐 기술 스택
이번 프로젝트에서 SCSS를 선택한 이유 ( 👈🏼 클릭!)
- 특히 SCSS를 선택한 이유는 앞서 사용해본 Styled-Component와 Emotion과 같은 CSS-in-JS는 런타임에서 스타일 직렬화가 일어나기 때문에 어느정도
런타임 비용이 든다
는 문제가 있기 때문이었습니다.
- CSS-in-JS 방식 중에서도 런타임 비용이 들지 않는 Vanilla Extract 등의 라이브러리 경우에도 결국 CSS-in-JS의 특징인 컴포넌트가 처음 마운트 될 때 스타일이 계속 삽입되어 브라우저가 모든
DOM 노드에서 스타일이 다시 계산된다
는 한계가 있습니다.
- 결론적으로 CSS-in-JS를 쓰는 이유 중 하나인 스타일이 지역 스코프라는 점과, CSS 파일이 해당 컴포넌트와 같은 위치에 배치된다는 것은 CSS 모듈로 해결할 수 있고, CSS 모듈에서 코드 중복의 단점은 SCSS를 사용하여 mixin 변수활용으로 해결했습니다.
폴더 구조
packages |--- core // (블로그 코드) | |--- src | | |--- app // (페이지) | | ... | |--- public | | ... |--- notion // (nextjs-notion 라이브러리 코드) | |--- pages (페이지) | |--- site.config.js // 노션 연동 설정 |--- shared // (css, 컴포넌트 등 공유 코드) | |--- src
⌨️ 로컬 실행 방법
1. 노션 연동 설정
해당 레포지토리를 clone하셔서 여신 후, 위 폴더 구조 챕터 속
site.config.js
파일에서 아래 링크를 참고하셔서 노션 연동을 해주시면 됩니다.2. 패키지매니저로 로컬 설치 및 개발모드로 실행
그 뒤, 로컬에서 실행을 할 수 있습니다. 프로젝트는
pnpm
으로 관리됩니다.pnpm install pnpm dev
🌟 구현 기능
목록 (상세 설명에는 생략된 내용 포함)
v1.0
- 다국적 언어 지원
- 렌더링 방식에 따른 한/영 변환 기능
- JEST 동적 라우팅 테스트
- 최적화
- next.js 내장기능을 사용한 최적화
next/font
next/dynamic
- 노션API
- 노션 API를 활용한 페이지 연동
- 미들웨어를 활용한 리다이렉션 설정
- 인터랙티브
- 3D 카드 효과 추가 (
useRef
의 데이터 저장 로직,useLayoutEffect
) - 타이핑 효과 애니메이션 컴포넌트 및 영/한 데이터 추가
useRef
배열로 관리하며 도미노 글자 애니메이션 직접 구현
- 기타
- 페이지 반응형 적용
v1.1
generateMetadata
를 이용한 SSR 메타 태그 적용- SSR로 동적 메타 태그 생성
- 한/영 언어별(동적) 정적 메타 태그 생성
- 사용자 편의를 위해 상세 페이지 스크롤 기능 추가
- 스크롤 게이지바 UI
- 특정 스크롤 바의 위치를 클릭시 해당 페이지 위치로 이동
- 클릭 후 드래그하여 동시에 페이지 실시간 이동
- 기타
- 공통 버튼 컴포넌트에 disabled 속성 추가
v1.2
- 성능 최적화
- 그전 버전 성능 저하의 원인이었던 노션 라이브러리 교체
- 메모리 누수 개선
setTimeout
등 코드 정리로 메모리 누수 최소화- FCP, LCP 개선
useLayoutEffect
적용으로 글자 애니메이션 최적화font-display: swap;
으로 대체 글자 적용- 페이로드 최적화
- 대용량 폰트 제거
- png, gif -> webp 파일로 전환
- 챗봇 기능 추가
- 구글의 'Dialogflow' 서비스를 활용하여 개발 관련 문답을 할 수 있는 챗봇 페이지를 삽입
- 모노레포 구축
- core, notion, shared로 패키지 구성
- shared (core & notion의 공용 패키지)
- core (기존 블로그 코드가 들어있는 곳)
- notion (next-starter-kit의 커스텀 노션 코드가 들어있는 곳)
- shared 패키지의 Footer 컴포넌트를 notion 패키지로 import해와서 적용
- 웹접근성
aria-label
, 배경 대비 글자 색 등 웹 접근성 개선
v1.0 상세설명
다국적 언어 지원
1. 렌더링 방식에 따른 한/영 변환 기능
- 지원하는 언어 별 json 데이터 생성
- SSR / CSR용 useTranslation 훅 개별 생성
- 페이지마다 params 및 URL 추출 로직을 추가
en/roadmap
에서 언어 전환 버튼을 눌렀을 때,ko
가 아니라ko/roadmap
으로 이동하도록 함.
- 헤더에서 한/영 변환에 따른 폰트를 개별적으로 적용
2. JEST 동적 라우팅 테스트
- 전역에 jest react-i18next 모듈 추가
- 동적 라우팅에 따라 라우팅 테스트 수정
최적화
1. next.js 내장기능을 사용한 최적화
next/link
,next/dynamic
,next/font
,next/image
등을 이용한 성능 최적화
[next/font]
- 폰트의 경우 아래와 같이 전역 변수로 등록하여 사용
import { Space_Mono, // Google font의 Space Mono 같이 띄어쓰기가 되어있는 폰트명은 언더바 사용 ... } from 'next/font/google'; export const spaceMono = Space_Mono({ subsets: ['latin'], weight: '400', variable: '--font-spaceMono', // 전역변수로 등록 });
// 최상위 layout.tsx import { ... spaceMono, } from '../../../public/fonts/fonts'; export default function RootLayout({ children, params: { lng } }: RootLayoutProps) { const fontVariables = ` ... ${spaceMono.variable} `; return ( <html lang={lng} dir={dir(lng)} className={fontVariables}> // html 태그에 className으로 넣어준 뒤 사용
next/font/google에 내장되어 있는 영어 폰트에 한하여 빌드타임에 미리 로컬에 폰트를 저장할 수 있기 때문에 영문 폰트와 관련된 layout shift를 최소화하여 성능을 최적화하였습니다. 외부에서 가져온 한글 폰트는 로딩 컴포넌트를 삽입하여 로드되기 전 레이아웃이 깨지는 현상을 막았습니다.
[next/dynamic]
preload 될 필요가 없는 컴포넌트는 Lazy Loading으로 네트워크 비용을 절감시키고자 했습니다.
const ContactArticle = dynamic(() => import('@/components/contacts/ContactArticle'), { ssr: false, });
노션API
1. 노션 API를 활용한 페이지 연동
2. 미들웨어를 활용한 리다이렉션 설정
- 리다이렉션 이슈를 미들웨어를 활용하여 해결
현재 웹사이트는 다국어 지원으로 /ko 또는 /en과 같이 지원 언어 데이터 값이 경로에 포함이 됩니다. 문제는 노션 페이지 개별 게시글을 입력할 경우 자동으로 /{페이지 값}으로 이동한다는 것이었습니다.
- useRouter을 쓸 수 없는 SSR 메인 페이지였고, SSR에서는 리다이렉션 기능이 지원되지 않음을 확인했습니다.
- 따라서 react.config에서 redirection 설정을 하고자 했으나
/{페이지값} -> /ko/{페이지값}
이렇게 동적 언어 데이터가 아닌 특정 데이터 값을 입력해줘야 했고, 그후 다시 홈 버튼을 누르면/ko/{페이지값}/ko
등으로 나오는 사이드 이펙트가 있었습니다.
- app 폴더 동위에 middleware를 생성하여 redirection 시키는 것으로 해결했습니다.
인터랙티브
1. 3D 카드 효과 추가
transform-style: preserve-3d
속성을 활용
- 데이터가 변동되면 화면이 리렌더링되는 useState 대신 useRef를 사용하여 데이터 변경
useLayoutEffect
를 이용하여 컴포넌트가 렌더링되기 전에 동기적으로 애니메이션 이벤트 등록 및 함수 실행
- 애니메이션 최적화 API
requestAnimationFrame()
적용
2. 타이핑 효과 애니메이션 컴포넌트 및 영/한 데이터 추가
react-typist
라이브러리를 사용했지만 최신 React 18버전 이상에서 호환되지 않는 일부 성능 문제가 발생
- 해당 라이브러리 레포지토리 이슈에서 관련 문제 발견 후, 2022년 초부터 업데이트가 안 되고 있다는 것을 확인
react-simple-typist
로 라이브러리 교체 후 이상없이 작동
3. useRef 배열로 관리하며 도미노 글자 애니메이션 직접 구현
- span을 생성하는
useEffect
, 해당 span에 시간차로 css를 적용하는useEffect
로 도미노처럼 차례대로 쓰러지는 듯한 글자 애니메이션을 적용
useEffect
안에서useRef
과 같은 훅 사용이 불가능하기 때문에useEffect
안에서 각 글자 데이터들이 map 함수에서 span 태그를 생성하는 로직을 짤 때createRef
를 사용하였지만 추후 함수 컴포넌트 방식에 맞게useRef
를 배열로 선언해준 다음useEffect
안의 map 함수에서 해당 배열에 span 태그와ref
값을 차례로 할당시키는 방법으로 리팩토링
const [childRef, setChildRef] = useState<React.JSX.Element[]>([]); const spanRefs = useRef<null[] | HTMLSpanElement[]>([]); ... useEffect(() => { ... letters.map((letter, index) => { // 예 ) letters = '망고'.split(''); const newSpan = ( <span key={Math.random()} ref={(el) => { spanRef.current[index] = el; }} > {letter} </span> ); return setChildRef((prev) => [...prev, newSpan]); }); } return () => setChildRef([]); }, [titleLetters]); ... return ( <div> {childRef} // 예) <span>망</span> <span>고</span> </div>
기타
1. 페이지 반응형 적용
- 예상하는 사용자 접속 경로는 웹이지만, 갤럭시 폴드 (min-width : 280px)까지 반응형 적용
화면
v1.1 상세설명
generateMetadata
를 이용한 SSR 메타 태그 적용
1. SSR로 동적 메타 태그 생성
type Props = { params: { pageId: string }; // params에서 현재 pageId 추출 }; export const generateMetadata = async ({ params: { pageId }, }: Props): Promise<Metadata> => { const recordMap = await notion.getPage(pageId); const title = getPageTitle(recordMap); // 해당 pageId의 제목 데이터 가져오기 return { title, openGraph: { title, }, }; };
2. SSR로 한/영 언어별(동적) 정적 메타 태그 생성
type Props = { params: { lng: string }; // params 에서 언어 상태 추출 }; export const generateMetadata = async ({ params: { lng } }: Props): Promise<Metadata> => { // 언어 상태에 따른 정적 메타 데이터 생성 return lng === 'ko' ? homeMetaData.metadataKO : homeMetaData.metadataEN; };
사용자 편의를 위해 상세 페이지 스크롤 기능 추가
1. 스크롤 게이지바 UI 출력
useRef
를 사용하여 전체 브라우저의 높이에서 100vh를 뺀 후, scrollTop 위치를 구하여 비율 계산
2. 클릭 후 드래그하여 동시에 페이지 실시간 이동
- mousedown, mousemove, click 이벤트를 이용하여 이벤트가 일어난 순서대로 events라는 변수를 useState로 상태 관리
- click 했을 때 바로 해당 클릭된 위치로 스크롤 이동
- click -> mousedown -> mousemove 가 일어난 경우 역시 실시간으로 마우스 위치로 스크롤 동기화
🧐 배움
[모노레포의 필요성과 마이그레이션에서의 어려움]
프로젝트 중후반에 기존에 사용하던 노션 DB 연동 라이브러리에서 성능 이슈가 발생하였습니다. 따라서 기존에 Next.js에 최적화된 라이브러리로 변경이 필요하였고, 해당 라이브러리 사용 방식은 레포지토리 자체를 클론하여 페이지 주소만 바꾸고 배포만 하면 되는 것이었기에 해당 코드를 통으로 가져와야 할 필요성이 생겼습니다. 그 과정에서 모노레포 시스템 구축의 필요성을 인지하게 되었고, 바로 기존 코드를 3개의 패키지(기존 코드, 노션 라이브러리 코드, 공용 코드) 산하의 모노레포 구조로 마이그레이션하는 작업을 진행하였습니다. 이미 구축된 코드를 베이스로 작업을 하는 것이었기 때문에 tsconfig.json과 eslintrc.json 설정을 새롭게 다시 하였고, 특히 기존에 사용하던 절대 경로를 (@/components/…) 다른 패키지에서도 그대로 적용하여 사용하기 위해 설정을 하는 데 어려움을 겪었으나 결국 해결하여 공용 패키지에서 필요한 패키지로 컴포넌트를 import하여 사용하게 되었습니다.(예: notion 패키지에서 shared 패키지의 Footer 컴포넌트를 가져올 때 - @mono-repo/shared/components/Footer..) 레퍼런스를 그대로 적용하기 보다는 원리 자체를 이해하고 알맞은 상황에 적용하는 것이 중요하다는 깊은 깨달음을 얻은 시간이었습니다.
[Next.js의 App Router]
useRef와 useState의 데이터 저장방식의 차이점, useRef.current.children을 활용한 애니메이션 구현, useLayoutEffect와 useEffect의 차이점 등 많은 것을 배울 수 있었던 프로젝트였지만 전반적으로 Next.js의 App Router의 font 최적화 기능을 비롯하여 내장되어 있는 유용한 기능들을 익힐 수 있는 기회이기도 했습니다.
특히 SCSS를 사용하면서 mixins.scss를 전역으로 설정하기, Jest 설정, 언어 변환 라이브러리 테스트를 위한 Jest mock 생성 등 이번 프로젝트에서는 비교적 최신 기능인 App Router에 Notion, JEST, GSAP 애니메이션 등 외부 라이브러리를 사용하면서 관련 자료가 많이 없었기 때문에 공식 문서와 해당 깃헙 이슈란에서 많은 트러블슈팅을 해결했습니다.
Next.js에서도 App Router 사용을 권장하고 있었기 때문에 기존에 사용했던 Pages Router 방식과 비교를 해가며 기능을 구현할 수 있었고, react-i18next와 notion API를 함께 사용하면서 라우팅 이슈가 생겼는데, Next.js의 미들웨어 기능을 활용하여 노션 개별 페이지를 클릭할 시 /{노션 상세페이지값}으로 이동하는 대신 현재 언어가 추가된 /ko/{노션 상세페이지값} 등으로 리다이렉션되도록 하는 등 Next.js를 좀 더 심화적으로 다뤄볼 수 있었습니다.
[성능 최적화를 위한 끊임없는 리팩토링]
버전 1 기능을 구현한 후, 개발자 도구의 Performance, Memory 탭, LightHouse 확장 프로그램 등을 활용하여 성능 최적화 작업을 진행하였습니다. 특히 한 애니메이션 훅에서 useEffect 안의 setTimeout를 작동시키고 return을 시키지 않아 메모리 누수가 생긴 것을 확인하고 코드를 개선하였고, 페이로드를 줄이기 위하여 png 또는 gif 파일을 webp 파일로 교체하고, next/font 사용, 대용량 폰트를 제거하는 등 놓친 부분에 대해 보강을 할 수 있었습니다. next/font를 통한 최적화와 font에 font-display: swap; 속성을 넣어주어 FCP, LCP를 개선하였고 useEffect대신에 useLayoutEffect 를 사용하여 글자 애니메이션 content painting 속도 최적화와 그외에도 @next/bundle-analyzer을 이용하여 각 번들 크기 등을 확인하고 Performance Insight 탭에서 Render blocking request가 뭔지, 또 어느 시점에서 CLS가 생기는 지 확인하는 등 다양한 루트로 성능을 개선시키고자 했습니다. 사이트에 인터랙티브 기능이 들어간 만큼 성능 개선에 다소 어려움이 있었고, 아직은 완전하게 이해가 되지 않는 개선점들도 있지만 점진적으로 연구해나갈 예정입니다.
[SSR로 동적 메타 태그 생성하기]
getServerSideProps를 이용하여 서버에서 데이터를 가져온 후 동적 메타 태그를 생성하는 Page Router 방식과는 달리, App Router은 기본이 SSR이기에 많은 방식에서의 차이점을 확인할 수 있었습니다. 언어에 따른 동적 메타 태그는 여타 SSR로 동적 메타 태그를 구현하는 방식처럼 Params에서 동적 라우팅 값인 언어(en/ko)를 추출하여 그와 걸맞는 메타 태그를 생성하였습니다. 더불어 App Router에서의 메타 태그 설정 방식을 공식 문서를 보며 따라가면서 metadataBase, canonical, template, openGraph 등 메타 태그의 많은 속성과 중요성을 확인할 수 있었습니다.
[다국적 언어 지원 및 JEST로 단위 테스트]
react-i18next를 통한 한국어, 영어 다국적 언어 지원 서비스를 프로젝트에 적용하면서 최대한 일관적인 맥락에서 메시지를 전달하고자 했고 한/영 전환이 될 때 같은 뜻의 문장이어도 어구가 반대인 점 등을 고려하여 애니메이션에 적용하면서 각 언어별 폰트와 스타일을 따로 조정하여 통일된 디자인이 될 수 있도록 하였습니다. 또한 공식 문서를 보면서 json으로 전달되는 문자열 안의 데이터 중 특정 단어를 강조하여 스타일링하고 싶을 때, 또 map 함수로 각 데이터를 가공하고 싶을 때 등 다양한 상황에서의 라이브러리 적용을 경험할 수 있었습니다. 기능을 완성한 후에는 언어를 기준으로 동적으로 변하는 라우팅이 제대로 작동하는 지 확인하기 위한 Jest로 코드를 작성해보았습니다. 역시 App Router에서의 부족한 레퍼런스 등으로 초반에 설정하는 부분에서 많은 어려움이 있었지만 여러 시행착오 끝에 성공하였고, 향후에도 기능 별 테스트 코드를 짜야 겠다는 의지를 가지게 되었습니다.
[가상 DOM을 조작하여 인터랙티브 효과를 Next.js로 구현하기]
개인적으로 재미있게 작업했던 부분은 예전 레퍼런스에서 원본 DOM을 직접 가공하는 방식으로 구현되는 인터랙티브 효과와 커스텀 훅을 가상 DOM을 가공하는 방식으로 구현되는 React, 특히 Next.js로 마이그레이션하는 부분이었습니다. CSS 레퍼런스 사이트에서 jquery와 querySelector 등으로 구현된 오래된 코드들을 원리를 이해하여 useRef, useEffect, useState, useLayoutEffect, 등을 사용하여 과정은 쉽지 않았지만 끝내 동일한 기능을 구현했을 때 정말 즐겁고 보람찬 기분을 느낄 수 있었습니다.
특히 useRef의 많은 성질에 대해서 좀 더 깊이 있게 이해하고 적용할 수 있었는데, useRef를 배열로 선언하여 관리하는 방법, useState처럼 데이터를 저장하지만 리렌더링을 하지 않는 useRef의 특징, ref.current.children을 이용한 자식 태그들을 배열로 관리하여 애니메이션 조건 적용하기 등 이전에 알지 못했던 부분을 경험적으로 익힐 수 있었습니다.
그 외에도 구현된 스크롤바를 클릭하여 드래그를 했을 때 해당 스크롤 높이에 맞게 이동하며 스크롤바 UI도 변경되는 스크롤 커스텀 훅을 만들 때 각 ref.current, event, window 객체 안에 들어있는 스크롤 등의 높이 속성 등 레퍼런스가 없는 상황에서 홀로 이것저것 시도하며 원리를 이해하고 될 때까지 적용해보면서 많은 것을 배울 수 있었습니다. 다음 개인 프로젝트에서는 더 많은 인터랙티브 효과를 적용해볼 예정입니다.