YoonLog 개발 이슈 & 성능 개선

글을 시작하며
여태까지 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
이 특정 게시글 해시로 되어있을 때, 다른 페이지를 Header
의 Link
로 구현된 NavBar
를 통해 이동했다가, 뒤로가기를 하면 페이지가 정상적으로 바뀌지 않는 현상이 나타났습니다.
a
태그는 html
전체가 reload
되고, 페이지가 완전히 새로고침 됩니다.
Next.js
의 Link
태그는 SEO
에 유리하고, 페이지를 다시 로드하지 않고 SPA 동작처럼 "보이게" 만듭니다.
a
태그를 사용하면, 페이지가 정상적으로 바뀌지 않는 현상을 방지할 수 있지만, 깜빡임 현상이 생기고, 좋은 UX를 주지 못한다고 생각했습니다.
따라서 Link 태그를 유지하고, popstate
이벤트에 윈도우의 현재 URL
을 가져와서, 해당 URL
로 router
를 통해 페이지를 시켰습니다.
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
태그를 검사하여 렌더링합니다.
수정 전 코드는 다음과 같습니다.
- Markdown 컴포넌트를 MDContent 컴포넌트에서 직접 Dynamic import 합니다.
- MDContent가 마운트되면 onLoad 함수를 실행합니다.
- 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 결과
First Load JS
는 웹 페이지가 처음 로드될 때 실행되는 JS
코드를 의미합니다.
First Load JS
가 작으면 빠른 로딩 속도와 페이지가 사용자의 상호작용에 더 빠르게 응답할 수 있습니다.
또한, SEO
는 웹 페이지의 로딩 속도를 고려하기 때문에 반드시 First Load JS
개선이 필요했습니다.
Dynamic Import
는 Code 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 결과
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 개선 초심자 가이드
- 구글 서치 콘솔에 등록
- sitemap 제작 및 제출
- robots.txt
- meta tag
- 중복 URL 제거
결과
- Mac OS 환경에서의 Light House 결과입니다.
- 홈
글을 마치며
이번 개발 블로그를 직접 풀 스택으로 개발하면서 한층 더 성장했다고 느꼈습니다.
Next.js의 강력한 기능들을 직접 사용해 보고 배웠습니다.
Drag and Drop, 다크 모드, DropDown 등 직접 구현해 보는 것도 재밌었습니다.
좋은 UX를 주기 위해, 끊임없이 고민했습니다.
시행착오도 많았지만, 배운 점도 많은 값진 시간이었습니다.
지금까지 YoonLog를 개발하면서 겪었던 이슈와 성능 개선에 대한 내용입니다.
읽어주셔서 감사합니다.