Gatsby로 만든 포트폴리오 사이트
2022.03.08.
React / Gatsby

프로젝트 repository 바로가기


🖥 프로젝트 소개

평소에 구현해보고 싶었던 Full Page의 기능을 중점으로

포트폴리오와 작성한 블로그들을 정리하기 위한 사이트

📚 기술 스택

  • React (hooks)

  • TypeScript

  • Emotion

  • Gatsby

  • Graphql

🛠 주요 기능

  • Gatsby를 이용한 블로그
  • Full Page 사이트
  • 무한 스크롤 기능
  • 반응형 UI 구현

👍 기술 특장점

1️⃣ Graphql을 이용한 Markdown 데이터 요청
2️⃣ Gatsby의 slug 기능을 이용하여 해당 Markdown 게시글과 연동
3️⃣ Full Page 구현
4️⃣ 무한 스크롤 구현
5️⃣ Fullpage 태블릿 & 모바일 환경 호환

1️⃣ Graphql을 이용한 Markdown 데이터 요청

Gatsby에서 제공해주는 Graphql을 이용하여 Markdown 데이터 소스를 얻고, 이를 html로 변환할 수 있다. 이를 위해서는 먼저 두 가지 플러그인이 필요하다.

  • gatsby-source-filesystem
  • gatsby-transformer-remark

플러그인을 설치한 후 gatsby-config에 다음과 같이 작성해준다.

// gatsby-config.js module.exports = { plugins: [ { resolve: `gatsby-source-filesystem`, options: { name: `contents`, path: `${__dirname}/contents`, }, }, { resolve: `gatsby-transformer-remark`, options: { plugins: [...], }, } ], };

gatsby-source-filesystem

로컬 파일 시스템의 파일을 File 노드로 생성하여 graphql을 통해 데이터를 얻을 수 있다.
단, 마크다운을 path에 지정한 경로에 저장해야한다.

gatsby-transformer-remark

마크다운 파일을 파싱하여 MarkdownRemark 노드로 생성한다. 생성된 노드는 graphql을 통해 데이터를 얻을 수 있다.


query getMarkdownData { allMarkdownRemark { edges { node { html id frontmatter { title, summary, ... } } } } }

2️⃣ Gatsby의 slug 기능을 이용하여 해당 Markdown 게시글과 연동

slug 생성

마크다운 데이터에 slug 필드를 추가하여 해당 게시물에 접근하기 위한 url을 생성한다.

해당 기능을 사용하기 위해선 onCreateNode라는 gatsby에서 제공하는 API를 사용한다.


// gatsby-node.js exports.onCreateNode = ({ node, getNode, actions }) => { const { createNodeField } = actions; if (node.internal.type === `MarkdownRemark`) { const slug = createFilePath({ node, getNode }); createNodeField({ node, name: 'slug', value: slug }); } };
마크다운 데이터에 한해서 slug 필드를 추가한다. 그리고 slug데이터는 경로와 파일명을 조합해 생성된다.

ex) contents/ShallWeSound.md -> domain/ShallWeSound

slug를 이용해 페이지 생성

마크다운 데이터를 이용해 slug를 생성해주었으니, 이제는 이 slug를 이용해 접근할 페이지를 생성한다. 페이지 생성은 gatsby의 createPages API를 이용한다.


// gatsby-node.js exports.createPages = async ({ actions, graphql, reporter }) => { const { createPage } = actions; const queryAllMarkdownData = await graphql( ` { allMarkdownRemark { edges { node { fields { slug } } } } } `, ); if (queryAllMarkdownData.errors) { reporter.panicOnBuild(`Query Error`); return; } const PostTemplateComponent = path.resolve(__dirname, 'src/templates/post.tsx'); const generatePostPage = ({ node: { fields: { slug }, }, }) => { const pageOptions = { path: slug, component: PostTemplateComponent, context: { slug }, }; createPage(pageOptions); }; queryAllMarkdownData.data.allMarkdownRemark.edges.forEach(generatePostPage); };

template 폴더에 post.tsx로 게시글 페이지의 템플릿을 정의한다. 그리고 그 템플릿 컴포넌트를 불러온다.

다음으로 pageOtions을 정의하고, 실제로 페이지를 생성해줄 generatePostPage 함수를 정의한다. 여기서 path는 페이지의 경로를 나타내고, component는 게시글을 렌더링할 컴포넌트를 의미한다. 마지막으로 context는 바로 앞서 정의한 component에 props로 넘겨줄 수 있는 값이다. 이 slug를 통해 해당 컴포넌트에서 맞는 마크다운 문서를 찾아 불러올 수 있다.

그리고 graphql을 통해 불러온 queryAllMarkdownData 값에서 markdown 데이터가 들어있는 edges를 반복하여 페이지 생성 함수를 실행해 준다.

3️⃣ Full Page 구현

useFullPage라는 커스텀 훅을 생성 - index.tsx

  • 반환 값으로는
    • 최상단을 지정하기 위한 outerRef
    • 현재 부분(페이지)를 저장하기 위한 currentPageName
    • full page 상단의 navBar를 클릭했을 때의 이벤트 핸들러 onClickNavBar
// index.tsx const Index: React.FC = () => { const [outerRef, currentPageName, onClickNavBar] = useFullPage({ maxPageCount: 3 }); return ( <Background ref={outerRef} className="outer"> <NavBar currentPageName={currentPageName} onClickNavBar={onClickNavBar} /> <About /> <PostList /> <PostList /> <Contact /> </Background> ); };

useFullPage.tsx

  • 인자로 페이지(파트)의 최대 수를 받는다.
    • 페이지 수는 0부터 시작
  • 현재 페이지 번호를 useRef로 관리한다.
    • 번호를 state로 관리하면 스크롤을 이동할 때, state의 최신값을 사용하기가 어려워진다.
// useFullPage.tsx const PAGE_NAMES = ['About', 'Project', 'Blog', 'Contact']; const useFullPage = ({ maxPageCount }: { maxPageCount: number }) => { const outerRef = useRef<any>(); const currentPage = useRef(0); const [currentPageName, setCurrentPageName] = useState(PAGE_NAMES[currentPage.current]); ... }

wheel 동작에 따라 currentPage가 가리키는 부분으로 스크롤을 이동시킨다.

  • 이동 시키는 기준은 현재 화면 크기(window.innerHeight)와 currentPage가 가리키는 값이다.
  • 현재 화면 크기가 720px이고 현재 페이지 번호가 0일 때 top은 0이되고, 페이지 번호가 1이면 top은 720px이 된다.
  • 즉, 0~720px은 페이지 0이 차지하고, 720px ~ 1440px 은 페이지 1이 차지한다는 뜻이다.
// useFullPage.tsx const useFullPage = ({ maxPageCount }: { maxPageCount: number }) => { ... const scrollToCurrentPage = () => { outerRef.current.scrollTo({ top: window.innerHeight * currentPage.current, left: 0, behavior: 'smooth', }); }; const scrollDown = () => { currentPage.current += 1; scrollToCurrentPage(); setCurrentPageName(PAGE_NAMES[currentPage.current]); }; const scrollUp = () => { currentPage.current -= 1; scrollToCurrentPage(); setCurrentPageName(PAGE_NAMES[currentPage.current]); }; }

outerRef에 이벤트 등록

  • deltaY 값을 확인해, wheel 이벤트가 위인지, 아래인지 구분하여 동작시킨다.
  • debounce를 적용하여 한번에 wheel 이벤트 핸들러가 연속으로 수행되지 않도록 한다.
    • 마우스 휠의 경우 굉장히 민감하기 때문에 조금만 움직여도 수십개의 이벤트가 발생한다.
    • 따라서 스크롤 한번으로 첫 페이지에서 마지막 페이지 까지 내려가는 현상이 발생했고, 이를 debounce를 적용함으로써 해결하였다.
// useFullPage.tsx const useFullPage = ({ maxPageCount }: { maxPageCount: number }) => { ... useEffect(() => { const wheelHandler = debounce((e: WheelEvent) => { e.preventDefault(); const { deltaY } = e; if (deltaY > 0 && currentPage.current < maxPageCount) { scrollDown(); } else if (deltaY < 0 && currentPage.current > 0) { scrollUp(); } }, 50); outerRef.current?.addEventListener('wheel', wheelHandler); return () => { outerRef.current?.removeEventListener('wheel', wheelHandler); }; }, []); }
// debounce.ts const debounce = (callback: (...arg: any) => void, delay: number) => { let timer: NodeJS.Timeout; return (...arg: any) => { clearTimeout(timer); timer = setTimeout(() => callback(...arg), delay); }; }; export default debounce;

마지막으로 브라우저 창의 크기를 변경할 시, window.innerHeight를 다시 계산하여 해당 값에 맞게 스크롤을 이동시켜야 한다.

  • resize 이벤트 발생 시, 현재 페이지를 기준으로 스크롤을 이동시켜주는 함수를 실행시킨다.
// useFullPage.tsx useEffect(() => { window.addEventListener('resize', scrollToCurrentPage); return () => { window.removeEventListener('resize', scrollToCurrentPage); }; });

4️⃣ 무한 스크롤 구현

useInfiniteScroll 라는 커스텀 훅을 생성하고 사용

  • posts 배열과, 카테고리에 따른 필터링을 위해 selectedCategory를 인자로 넘겨준다.
  • 반환 값으로는 observe할 Ref와 posts의 부분 배열인 postsByPage 배열이 반환된다.
const BlogList: React.FC<Props> = ({ posts, selectedCategory }) => { const { targetRef, postsByPage } = useInfiniteScroll({ posts, selectedCategory }); return ( <Wrapper ref={targetRef}> {postsByPage.map( ({ node: { id, fields: { slug }, frontmatter, }, }) => ( <PostItem key={id} link={slug} {...frontmatter} /> ), )} </Wrapper> ); };

useInfiniteScroll 구현

  • page 상태에 따라 렌더링할 post의 개수를 정한다.
    • page 값이 1이면 page(1) * POST_COUNT_BY_PAGE(9) = 9 개의 post가 반환된다.
  • IntersectionOberserver를 활용하여 observe 이벤트 발생 시 page를 1증가 시킨다.
    • page가 증가함에 따라 최대 9개의 새로운 포스트가 추가로 반환된다.
  • observer가 targetRef의 children의 마지막 요소를 관찰하도록 한다.
    • 스크롤을 끝까지 내려서 마지막 요소가 관찰되면 등록한 이벤트 리스너를 수행한다.
  • observer를 매번 disconnect하고 새롭게 observe등록을 하는 이유는 마지막 post까지 렌더링했다면 더 이상 observe할 필요가 없기 때문에 매번 모든 post가 출력됐다면 observe를 등록하지 않도록 하기 위함이다.
  • 마지막으로 selectedCategory가 변경되면 page를 1로 초기화 해주는 작업을 한다.
    • 초기화 하지 않으면 카테고리가 바뀌어도 page가 그대로 유지 되는 현상이 발생한다.
// useInfiniteScroll.tsx import { MutableRefObject, useState, useRef, useEffect, useMemo } from 'react'; import { PostListType } from 'types/post.types'; const POST_COUNT_BY_PAGE = 9; const useInfiniteScroll = ({ posts, selectedCategory }: { posts: PostListType[], selectedCategory: string }) => { const [page, setPage] = useState(1); const targetRef: MutableRefObject<HTMLDivElement | null> = useRef < HTMLDivElement > null; const filteredPosts = useMemo( () => posts.filter( ({ node: { frontmatter: { categories }, }, }) => selectedCategory === 'All' || categories.includes(selectedCategory), ), [selectedCategory], ); const observer = new IntersectionObserver( (entries, observer) => { if (!entries[0].isIntersecting) return; setPage(prev => prev + 1); observer.disconnect(); }, { threshold: 1.0, }, ); useEffect(() => setPage(1), [selectedCategory]); useEffect(() => { if ( !targetRef.current || page * POST_COUNT_BY_PAGE >= filteredPosts.length || targetRef.current.children.length <= 0 ) return; observer.observe(targetRef.current.children[targetRef.current.children.length - 1]); }, [page, selectedCategory]); return { targetRef, postsByPage: filteredPosts.slice(0, page * POST_COUNT_BY_PAGE), }; }; export default useInfiniteScroll;

5️⃣ Fullpage 태블릿 & 모바일 환경 호환

화면 높이 지정 - 100vh

  • 모바일 환경에서의 100vh는 브라우저 상단의 url 영역과 하단의 네비게이션 영역을 포함한다.
  • 따라서 높이를 화면 가득 채우려고 100vh를 사용한다면 실제 화면보다 높이가 크게 잡히는 문제가 발생한다.
  • 따라서 window.innerHeight를 사용하여 실제 화면 높이를 가져온 후 설정해준다.
useEffect(() => { const setScreenSize = () => { const vh = window.innerHeight * 0.01; document.documentElement.style.setProperty('--vh', `${vh}px`); }; setScreenSize(); window.addEventListener('resize', setScreenSize); return () => { window.removeEventListener('resize', setScreenSize); }; }, []);
  • resize 이벤트 발생 시에 화면 크기가 변경되는 것을 고려하여 이벤트를 등록해준다.

  • –vh변수에 계산한 innerHeight 값을 저장한 뒤 다음과 같이 사용한다.

body { height: calc(var(--vh, 1vh) * 100); }
  • –vh가 존재하면 그 값을, 아니면 1vh를 사용한다.

  • 모바일의 경우 가로, 세로 전환을 할 수 있다.

  • 가로, 세로 전환 시 resize 이벤트가 2번 발생하는 경우가 발생했다.

  • 이 문제 때문에 화면 높이가 제대로 잡히지 않는 현상이 발생했고 이를 해결하기 위해 debounce를 적용하여 마지막 이벤트만 수행하도록 했다.

useEffect(() => { const setScreenSize = debounce(() => { const vh = window.innerHeight * 0.01; document.documentElement.style.setProperty('--vh', `${vh}px`); }, 50); setScreenSize(); window.addEventListener('resize', setScreenSize); return () => { window.removeEventListener('resize', setScreenSize); }; }, []);

터치 이벤트 적용

  • 현재 Full Page의 경우 wheel 이벤트만 등록되어 있다.
  • 모바일 환경에서는 wheel보다는 touch를 사용하므로 그에 따른 대안이 필요하다.
  • touch의 방향을 알기 위해 touchstart의 y좌표와 touchend의 y좌표를 비교한다.
  • touchstart의 y좌표가 더 크다면 아래에서 위로 드래그한 것이므로 scollDown()을 실행한다.
  • 반다로 touchend의 y좌표가 더 크다면 위에서 아래로 드래그한 것이므로 scollUp()을 실행한다.
const touchStartY = useRef(0); const touchEndY = useRef(0); useEffect(() => { const touchStartHandler = debounce((e: TouchEvent) => { e.preventDefault(); touchStartY.current = e.changedTouches[0].clientY; }, 50); const touchEndHandler = debounce((e: TouchEvent) => { e.preventDefault(); touchEndY.current = e.changedTouches[0].clientY; if (touchStartY.current < touchEndY.current && currentPage.current > 0) { scrollUp(); } else if (touchStartY.current > touchEndY.current && currentPage.current < maxPageCount) { scrollDown(); } }, 50); outerRef.current?.addEventListener('touchstart', touchStartHandler); outerRef.current?.addEventListener('touchend', touchEndHandler); return () => { outerRef.current?.removeEventListener('touchstart', touchStartHandler); outerRef.current?.removeEventListener('touchend', touchEndHandler); }; }, []);

scroll smooth-behavior

  • 마지막으로 웹 브라우저에서는 다음과 같은 코드가 잘 동작한다.

    outerRef.current?.scrollTo({ top: window.innerHeight * currentPage.current, left: 0, behavior: 'smooth', });
  • 하지만 모바일 브라우저에 따라서는 smooth 동작이 수행되지 않는 경우가 있다.

  • 따라서 다음과 같은 패키지를 설치해준다.

npm install smoothscroll-polyfill --save
  • 그리고 다음과 같이 사용한다.
import smoothscroll from 'smoothscroll-polyfill'; smoothscroll.polyfill();
Thank You for Visiting My Blog, Have a Good Day.
@ 2022 Developer Jun, Powerd By Gatsby.