YoonLog 개발 이슈 & 성능 개선

By Yoon
·
thumbnail
YoonLog를 개발하면서 겪었던 이슈와 성능 개선에 대한 내용입니다 - 블로그 개발 후기 (5)

글을 시작하며

여태까지 YoonLog를 개발하면서 발생한 이슈들과 성능 개선을 어떻게 했는지 이야기해 보려고 합니다.

발생한 이슈들

1. 배포 환경에서 한글 제목 클릭 시 다운로드 되는 현상

블로그 개발 시 slug를 글 작성 시 title과 동일하게 유지하고 싶었습니다.
하지만, 한글로 title을 작성하는 경우 slug도 한글로 반영되기 때문에 인코딩, 디코딩 과정이 필요합니다.

URL을 가져오는 모든 코드에 인코딩, 디코딩 과정을 추가하니, 코드의 일관성이 없어지고 어디서 인코딩 해야 할지 디코딩을 해야 할지 파악하기가 어려워졌습니다.
또한 배포 환경에서 한글 제목 클릭 시 페이지 이동이 되지 않고, 다운로드 받아지는 버그까지 생겼습니다.

따라서, slug의 로직을 다음과 같이 수정했습니다.
slug 입력은 처음 글 작성 시에만 가능하며, title과 별개로 영어와 숫자만 입력할 수 있게 했습니다.


2. 특정 게시글 해시 태그로 뒤로가기 안 되는 현상

마크다운으로 작성된 게시글은 h1, h2, h3 태그에 해시(#)가 붙습니다.
사이드바의 목차나 본문 h 태그들 클립 아이콘을 클릭하면 해당 위치로 스크롤이 됩니다.
하지만 Header 때문에 Header offset만큼 제외한 위치로 스크롤을 이동시켜야 했습니다.
따라서 hashchange 이벤트로 h 태그들의 위칫값을 구하고, offset만큼 빼주었습니다.
좋은 UX를 주기 위해, 스크롤 효과를 부드럽게 주고 싶어 window.scrollTo의 옵션으로 behavior: smooth를 주었지만, 원하는 대로 스크롤이 되지 않았습니다.
구글링 결과 html 태그에 "scroll-behavior: smooth" CSS를 넣어주니 원하는 스크롤 효과가 구현되었습니다.
따라서, RootLayout html"scroll-behavior: smooth" 속성을 넣어주었습니다.

하지만, 문제는 현재 URL이 특정 게시글 해시로 되어있을 때, 다른 페이지를 HeaderLink로 구현된 NavBar를 통해 이동했다가, 뒤로가기를 하면 페이지가 정상적으로 바뀌지 않는 현상이 나타났습니다.
a 태그는 html 전체가 reload 되고, 페이지가 완전히 새로고침 됩니다.
Next.jsLink 태그는 SEO에 유리하고, 페이지를 다시 로드하지 않고 SPA 동작처럼 "보이게" 만듭니다.
a 태그를 사용하면, 페이지가 정상적으로 바뀌지 않는 현상을 방지할 수 있지만, 깜빡임 현상이 생기고, 좋은 UX를 주지 못한다고 생각했습니다.
따라서 Link 태그를 유지하고, popstate 이벤트에 윈도우의 현재 URL을 가져와서, 해당 URLrouter를 통해 페이지를 시켰습니다.
scroll 옵션은 hashchange에서 사용한 스크롤 위치로 가야 하므로 false로 해주었습니다.

// app/_components/ScrollToHash.tsx
"use client";

import { useRouter } from "next/navigation";
import { useEffect } from "react";

export default function ScrollToHash({ offset }: { offset: number }) {
  const router = useRouter();

  useEffect(() => {
    const handlePopState = (e: PopStateEvent) => {
      const targetWindow = e.target as Window;
      const url = targetWindow.location.href;
      router.push(url, { scroll: false });
    };

    const handleHashChange = () => {
      const hash = window.location.hash;
      const hTagId = decodeURI(hash.slice(1));
      const h = document.getElementById(hTagId);
      if (!h) {
        console.error("Element with id '" + hTagId + "' not found.");
        return null;
      }
      const rect = h.getBoundingClientRect();
      const height = rect.top + window.scrollY - offset;

      window.scrollTo({ top: height });
    };

    window.addEventListener("hashchange", handleHashChange);
    window.addEventListener("popstate", handlePopState);

    return () => {
      window.removeEventListener("hashchange", handleHashChange);
      window.removeEventListener("popstate", handlePopState);
    };
  }, [router, offset]);

  return null;
}

3. 사이드바가 그려지지 않는 현상

사이드바는 MDContent가 마운트 된 후에 h 태그를 검사하여 렌더링합니다.

수정 전 코드는 다음과 같습니다.

  1. Markdown 컴포넌트를 MDContent 컴포넌트에서 직접 Dynamic import 합니다.
  2. MDContent가 마운트되면 onLoad 함수를 실행합니다.
  3. PostSection 전체를 리 렌더링시켜서, SideBar를 렌더링한다.
"use client";

import "@uiw/react-md-editor/markdown-editor.css";
import "@uiw/react-markdown-preview/markdown.css";
import { useEffect } from "react";
import dynamic from "next/dynamic";
import useTheme from "@/app/_context/ThemeContext/useTheme";

type Props = {
  source: string;
  onLoad?: VoidFunction;
};

const Markdown = dynamic(() => import("@uiw/react-markdown-preview"));

export default function MDContent({ source, onLoad }: Props) {
  const { isDarkMode } = useTheme();

  useEffect(() => {
    onLoad?.();
  }, [onLoad]);

  return (
    <div data-color-mode={isDarkMode ? "dark" : "light"}>
      <Markdown
        source={source}
        style={{ backgroundColor: "var(--backGround)" }}
      />
    </div>
  );
}
// app/_components/PostSection/index.tsx
"use client";

import SideBar from "../SideBar";
import { useCallback, useState } from "react";
import MDContent from "../MDContent";

export default function PostSection({ content }:{content: string}) {
 
  const [isLoaded, setIsLoaded] = useState(false);

  const handleLoad = useCallback(() => {
    setIsLoaded(true);
  }, []);

  return (
    <section>
      <MDContent
        source={content}
        onLoad={handleLoad}
      />
      {isLoaded && <SideBar />}
    </section>
  );
}

하지만 예상과 달리 사이드바는 렌더링 되지 않습니다.
과정을 살펴보면,

PostSection render -> MDContent render (Markdown 컴포넌트 제외, Markdown 번들 가져오기 시작) -> useEffect run -> onLoad run -> PostSection re-render -> SideBar render -> (MarkDown 번들 가져오기 끝) -> MarkDown render

결국, 사이드바가 마크다운보다 먼저 렌더링 되어 화면에 보이지 않습니다.

따라서, 마크다운을 사이드바보다 먼저 렌더링 되게 수정했습니다.
수정 후 코드는 다음과 같습니다.

StaticMDContent 컴포넌트를 dynamic으로 가져오는 DynamicMDContent 컴포넌트를 만듭니다.
따라서, StaticMDContent 안의 모든 import문을 dynamic으로 가져옵니다. (마크다운 라이브러리 포함)

PostSection render -> (MarkDown 번들 가져오기) -> DynamicMDContent render -> MarkDown render -> useEffect run -> onLoad run -> PostSection re-render -> SideBar render

마크다운 컴포넌트의 렌더링과 useEffect의 실행 순서를 보장시키기 위해 useEffect가 포함된 컴포넌트 부모 컴포넌트(MDContent) 전체를 dynamic import 해주었습니다.

사이드바가 마크다운보다 나중에 렌더링 되면서 화면에 보이게 됩니다.

// app/_components/MDContent/StaticMDContent.tsx
"use client";

import "@uiw/react-md-editor/markdown-editor.css";
import "@uiw/react-markdown-preview/markdown.css";
import { useEffect } from "react";
import Markdown from "@uiw/react-markdown-preview";
import useTheme from "@/app/_context/ThemeContext/useTheme";

type Props = {
  source: string;
  onLoad?: VoidFunction;
};

export default function StaticMDContent({ source, onLoad }: Props) {
  const { isDarkMode } = useTheme();

  useEffect(() => {
    onLoad?.();
  }, [onLoad]);

  return (
    <div data-color-mode={isDarkMode ? "dark" : "light"}>
      <Markdown
        source={source}
        style={{ backgroundColor: "var(--backGround)" }}
      />
    </div>
  );
}
// app/_components/MDContent/DynamicMDContent.tsx
import dynamic from "next/dynamic";

const DynamicMDContent = dynamic(() => import("./StaticMDContent"));

export default DynamicMDContent;

// app/_components/PostSection/index.tsx
"use client";

import SideBar from "../SideBar";
import { useCallback, useState } from "react";
import DynamicMDContent from "../MDContent/DynamicMDContent";

export default function PostSection({ content }:{content: string}) {

  const [isLoaded, setIsLoaded] = useState(false);

  const handleLoad = useCallback(() => {
    setIsLoaded(true);
  }, []);

  return (
    <section>
      <DynamicMDContent
        source={content}
        onLoad={handleLoad}
      />
      {isLoaded && <SideBar />}
    </section>
  );
}

성능 개선하기

1. Dynamic Import로 번들 사이즈 줄이기

  • Static Import 결과

스크린샷 2024-03-12 오후 3.33.25.png

First Load JS는 웹 페이지가 처음 로드될 때 실행되는 JS 코드를 의미합니다.
First Load JS가 작으면 빠른 로딩 속도와 페이지가 사용자의 상호작용에 더 빠르게 응답할 수 있습니다.
또한, SEO는 웹 페이지의 로딩 속도를 고려하기 때문에 반드시 First Load JS 개선이 필요했습니다.

Dynamic ImportCode Splitting을 위한 동적인 모듈 로딩을 지원하는 기능입니다.

Code Splitting은 애플리케이션의 번들을 분할하여 초기 로딩 시 필요한 부분만 로드하여 성능을 최적화하는 기술 중 하나입니다.

컴포넌트, 라이브러리, 페이지 등을 필요할 때 비동기적으로 로드할 수 있어, 초기 로딩 시에 필요하지 않은 코드를 늦게 로딩하여 초기 로딩 속도를 향상시킵니다.
또한, 최초 빌드 시 포함되지 않습니다.

따라서, Dynamic Import를 이용해 번들 사이즈를 줄였습니다.

// app/_components/MDContent/DynamicMDContent.tsx
import dynamic from "next/dynamic";

const DynamicMDContent = dynamic(() => import("./StaticMDContent"));

export default DynamicMDContent;
  • Dynamic Import 결과

스크린샷 2024-03-12 오후 3.30.25.png


2. FOUT 줄이기

Next.js를 사용하면 next/font를 사용할 수 있습니다.
이는 개인정보 보호 및 성능 향상을 위해 폰트(사용자 정의 글꼴 포함)를 자동으로 최적화하고 외부 네트워크 요청을 제거합니다. 하지만 StyleX 라이브러리 사용으로 Babel 컴파일러를 사용하게 되면서, next/font를 사용할 수 없게 됐습니다.

처음 선택한 폰트는 Noto Serif Korean입니다.
폰트 크기를 동일하게 맞춰도, 각각의 폰트가 기본적으로 가지고 있는 크기가 조금씩 다르기 때문에 서로 다른 폰트를 교체하는 과정에서 Layout Shift가 발생했습니다. (이를 FOUT, Flash Of Unstyled Text라고 합니다.)
Noto Serif Korean 폰트가 로드되기 전에 기본적으로 보여주는 폰트와 로드된 폰트의 크기가 달라서 이미지가 아래로 밀려나는 Layout Shift가 발생하고 있는 것을 확인했습니다.

따라서, 다른 폰트로 교체해 FOUT를 줄여 성능을 개선했습니다.
교체된 폰트는 Noto Sans Korean입니다.


3. Light House

  • Accessibility 개선 79 -> 95
  • 개선 사항
  • Buttons do not have an accessible name
    -> button 태그 속성에 aria-label 추가
  • Links do not have a discernible name
    -> Link 태그 속성에 aria-label 추가
  • Heading elements are not in a sequentially-descending order
    -> h2 태그 다음에 h4 태그 대신 h3 태그로 변경
  • Lists do not contain only <li> elements and script supporting elements (<script> and )
    -> ul 태그 사용시 li 태그 사용

4. SEO

  • 방문자와 트래픽을 더 늘리고 싶다면 검색엔진 최적화 (SEO: Search Engine Optimization)가 필요합니다.
  • 다음 글을 참고하여 SEO를 개선했습니다. SEO 개선 초심자 가이드
  1. 구글 서치 콘솔에 등록
  2. sitemap 제작 및 제출
  3. robots.txt
  4. meta tag
  5. 중복 URL 제거

결과

  • Mac OS 환경에서의 Light House 결과입니다.

스크린샷 2024-03-11 오후 5.22.58.png

스크린샷 2024-03-11 오후 5.15.49.png

스크린샷 2024-03-11 오후 5.15.15.png

글을 마치며

이번 개발 블로그를 직접 풀 스택으로 개발하면서 한층 더 성장했다고 느꼈습니다.
Next.js의 강력한 기능들을 직접 사용해 보고 배웠습니다.
Drag and Drop, 다크 모드, DropDown 등 직접 구현해 보는 것도 재밌었습니다.
좋은 UX를 주기 위해, 끊임없이 고민했습니다.
시행착오도 많았지만, 배운 점도 많은 값진 시간이었습니다.

지금까지 YoonLog를 개발하면서 겪었던 이슈와 성능 개선에 대한 내용입니다.

읽어주셔서 감사합니다.

YoonLog 깃허브 레포