HomeFeedAbout

블로그 Feed 성능 개선 1 - 클라이언트에서 서버로

김성현 2주 전
next리펙토링포트폴리오

취준 때 만들었던 웹 포폴의 코드가 너무 골치 아파서 퇴근하고 틈틈히 리펙토링을 하고 있습니다. 이번 주말에는 웹 포트폴리오의 Feed 페이지를 개선해봤어요.

피드에선 무슨 일이

일단 Feed에선 제가 Velog에 올린 포스트들을 그대로 보여줍니다.

다크 모드도 되고,, 당시에 나름 열심히 만들었습니다 ㅋㅋ

graphQL로 velog 포스트를 가져온 후 마크다운을 파싱하고, 파싱이 완료되면 Velog 스타일을 입혀주고 dangerouslySetInnerHTML을 통해 HTML을 주입하는 방식으로 구현했어요.

당시에는 별다른 이유 없이 커스텀 훅을 만들어보고 싶어서 useVelogStyle 같은 커스텀 훅을 만들어 ‘use client’를 붙여 사용했어요.

당시 짰던 코드

그리고 무한 스크롤로 불러온 포스트 목록을 저장해서 보여주기 위해 전역 상태 관리 라이브러리 jotai를 사용했어요.

Post 상세 페이지

'use client';

import { useAtomValue } from 'jotai';
import { useParams } from 'next/navigation';
import { useEffect } from 'react';
import { VelogPost } from '#velog/components/VelogPost';
import { getPostBySlug } from '#stores/post';
import Loading from './loading';
	
export default function DetailPage() {
  useEffect(() => window.scrollTo(0, 0), []);

  const { id } = useParams<{ id: string }>();
  const getPost = useAtomValue(getPostBySlug);
  const post = getPost(id);

  if (!post) return <Loading />;

  // 들어가도 끝이 없네
  return <VelogPost post={post} />;
}

VelogPost를 들어가보면 이런 코드들이 숨어져있습니다..

'use client';

export function VelogPost({ post }: { post: PostType }) {
  const [styledContent, setStyledContent] = useState('');
  const { addStyleAsync } = useVelogStyle();

  useEffect(() => {
    addStyleAsync(post.body)
      .then((res) => setStyledContent(res))
      .catch((err) =>
        console.error('포스트 스타일 적용 실패: ', err),
      );
  }, [post.body, addStyleAsync]);

  return (
    <Post>
      <Post.Title title={post.title} />
      <Post.Description
        author="김성현"
        postedAt={getRelativeDays(post.released_at)}
      />
      <Post.Tags tags={post.tags} />
      <Post.Content body={styledContent} />
    </Post>
  );
}

useVelogStyle

마크다운을 파싱해서 Velog 스타일을 입혀주는 코드인데, 딱봐도 많은 일들을 할 것 같슴니다,,

// useVelogStyle.tsx
const LANGUAGE_MAPPING: Record<string, string> = {
  tsx: 'ts',
  jsx: 'js',
};

const HEADING_CLASS: Record<number, string> = {
  1: styles.postHeading1,
  2: styles.postHeading2,
  3: styles.postHeading3,
  4: styles.postHeading4,
  5: styles.postHeading5,
  6: styles.postHeading6,
};

export function useVelogStyle() {
  const addStyleAsync = useCallback(async (markdown: string) => {
    if (typeof window === 'undefined' || !markdown) return markdown;

    const decodedMarkdown = markdown
      .replace(/</g, '<')
      .replace(/>/g, '>')
      .replace(/"/g, '"')
      .replace(/&/g, '&')
      .replace(/'/g, "'")
      .replace(/'/g, "'")
      .replace(///g, '/');

    const renderer = new marked.Renderer();

    renderer.code = ({ text, lang }: Tokens.Code) => {
      const language =
        (lang && LANGUAGE_MAPPING[lang]) || lang || 'ts';

      const highlighted = Prism.languages[language]
        ? Prism.highlight(text, Prism.languages[language], language)
        : text;

      return `
        <pre class="${styles.postPreBlock}">
          <code class="${styles.postCodeInPre} language-${language}">
${highlighted}
          </code>
        </pre>
      `;
    };
    
    
    // ... 여기까지 ㅠㅠ

이렇게 Velog 포스트를 가져오기 위해 필요한 컴포넌트, 훅, 유틸만 거의 5개는 된 것 같아요. 이런식으로 구현하면 코드가 복잡할 뿐만 아니라 앱 번들에 marked, prismjs같은 무거운 라이브러리들이 포함되어 성능에 좋지 않고, LCP도 증가하게 됩니다.

🏃‍♂️ 리펙토링 시작

그래서 몇 달간 방치해두었던 코드들을 하나씩 리펙토링하기 시작했습니다.

1. 클라이언트에서 서버 컴포넌트로

우선 마크다운, 스타일링하는 로직들을 서버 컴포넌트에서 처리했습니다.

서버 컴포넌트는 서버에서만 실행되기 때문에 더 이상 앱 번들에 marked, prismjs같은 무거운 라이브러리가 포함되지 않아요. Next Bundle Analyzer로 분석해보니 번들 크기가 90KB 이상 감소했습니다.

<, >같은 문자열을 구분할 수 있도록, 직접 매칭시키기보단 he 라이브러리를 통해 디코딩 해주었습니다.

// feed/(detail)/page.tsx

// 서버 컴포넌트로 만들기
export default async function PostDetailPage({ params }: PageProps) {
  const { id } = await params;
  const post = await getPostBySlug({ slug: id });

  if (!post) {
    return notFound();
  }

  const decodedBody = he.decode(post?.body); // 디코딩
  const parsed = parseMarkdown(decodedBody); // 마크다운 parsing
  const sanitized = sanitizeHtml(parsed); // sanitize

  return (
    <Post>
      <Post.Title>{post.title}</Post.Title>
      <Post.Description>
        <span className={author}>김성현</span>{' '}
        <span className={postedAt}>
          {getRelativeDays(post.released_at)}
        </span>
      </Post.Description>
      <Post.Tags>
        <Tag tags={post.tags} />
      </Post.Tags>
      <Post.Content className={vmarkdown}>{sanitized}</Post.Content>
    </Post>
  );
}

2. 불필요한 추상화 없애기

추상화가 때론 좋기도 하지만, 실무를 경험하면서 과도한 추상화는 비용이라는 것을 느꼈습니다.

따라서 아래와 같이 한 depth 더 들어가지 않고,

return <VelogPost post={post} />;

차라리 바로 구조가 보이도록 수정했어요.

return (
  <Post>
    <Post.Title>{post.title}</Post.Title>
    <Post.Description>
      <span className={author}>김성현</span>{' '}
      <span className={postedAt}>
        {getRelativeDays(post.released_at)}
      </span>
    </Post.Description>
    <Post.Tags>
      <Tag tags={post.tags} />
    </Post.Tags>
    <Post.Content className={vmarkdown}>{sanitized}</Post.Content>
  </Post>
);

3. useInfiniteScroll 사용하기

상세 페이지는 서버 컴포넌트로 충분하지만, 무한 스크롤을 통해 포스트의 제목들을 보여줘야 하는 List 페이지는 클라이언트 컴포넌트로 구현했어요. 그리고 단순히 포스트들을 저장하기 위한 전역 상태 관리는 과하다고 생각해서 jotai를 좀 없애고 싶었어요.

graphQL을 통해 리스트 페이지에서는 title, createdAt 같은 필요한 것만 받아오고, 상세 페이지에서는 title, body 같이 무거운 데이터를 따로 받아오긴 했지만요.

그래서 없애고,, 이번에 알게 된 useInfiniteQuery와 IntersectionObserver를 조합해서 구현했습니다.

Feed 데이터를 패칭하는 부분도 개선했는데, 다음 게시글에서 소개할게요!

// feed/(list)/page.tsx
'use client';

export default function Feed() {
  const { data, fetchNextPage, isFetchingNextPage, hasNextPage } =
    useInfinitePostQuery({ username: 'oilater' });

  const handleIntersect = useCallback(() => {
    if (hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);

  const { observeRef } = useInfiniteScroll({
    onIntersect: handleIntersect,
    rootMargin: '800px',
  });

  const posts = data?.pages.flatMap((page) => page.posts) || [];

  if (!posts.length) {
    return <ListSkeleton />;
  }

  return (
    <div className={listWrapper}>
      {posts.map((post) => (
        <ListRow
          key={post.id}
          post={post}
          link={`/feed/${post.url_slug}`}
        />
      ))}
      <div className={observeContainer} ref={observeRef} />
    </div>
  );
}

4. fallback UI 최대한 비슷하게 맞추기

Next JS의 layout.tsx, loading.tsx를 활용해 Feed list가 로딩 중일 때 나오는 fallback UI를 최대한 비슷하게 맞춰줬어요.

Medium의 디자인을 참고했습니다.

성과

열시미 리펙토링을 한 뒤 아래와 같은 성과를 얻었습니다! 🎉

  1. 서버 컴포넌트를 활용해서 클라이언트 번들 사이즈 감소 (marked, prismjs, jotai → 약 90KB 이상)

  2. 불필요한 커스텀 훅, 유틸, 컴포넌트 4개 이상 제거

  3. 깔끔해진 로직

  4. fallback UI로 자연스럽게 피드 보여주기

어려웠던 점

하지만, 어려웠던 점이 한 가지 있었습니다.

파싱된 마크다운에 스타일을 어떻게 입혀야 할까?

파싱된 마크다운에 스타일을 입히기가 어려웠어요. HTML로 파싱한 이후에 css를 붙여 스타일을 적용하려고 했는데 생각보다 어렵더라구요. 그래서 marked의 renderer를 통해 파싱하는 과정에서 className을 주입해주었고 해당 className에 매칭될 globalStyle을 만들어놨는데, 별로 만족스럽지 않더라구요. 더 좋은 방법이 있다면 알려주시면 감사하겠습니다 ㅠ

export function parseMarkdown(
  markdown: string | null | undefined,
): string {
  if (!markdown) return '';
  const markedInstance = new Marked();

  markedInstance.use({
    renderer: {
      image({ href, title, text }) {
        const titleAttr = title ? ` title="${title}"` : '';
        return `<img class="velog_image" src="${href}" alt="${text}"${titleAttr} />`;
      },

      codespan({ text }) {
        return `<code class="velog_code">${text}</code>`;
      },

      code({ text, lang }) {
        const langKey = lang?.toLowerCase() || 'typescript';
        const language = PRISM_LANGUAGE_MAP[langKey] || langKey;

        const highlighted = Prism.languages[language]
          ? Prism.highlight(text, Prism.languages[language], language)
          : text;

        return `<pre class="velog_preBlock"><code class="velog_codeInPre velog_code language-${language}">${highlighted}</code></pre>\n`;
      },

      paragraph({ tokens }) {
        const content = this.parser.parseInline(tokens);
        if (content.trim().startsWith('<img')) {
          return `${content}\n`;
        }
        return `<p class="velog_paragraph">${content}</p>\n`;
      },

  // ...

  return markedInstance.parse(markdown, {
    gfm: true,
    breaks: true,
    async: false,
  }) as string;
}

다음엔 피드 페이지의 LCP를 낮추는 과정을 기록해보겠습니다!

결론

시간이 좀 널널해지면 포트폴리오 UX/UI를 개편할 생각입니다. 이제는 포폴보단 블로그처럼 글들이 주 컨텐츠가 되도록 수정하려고 해요. 또 Velog 포스트를 가져오는 방식 대신 MDX를 활용해보려고 합니다.

그래도 이번 리펙토링은 의미 있고 좋은 개선 경험이었고, 앞으로도 틈틈히 개선해보려고 합니다.

읽어주셔서 감사합니다!