본문 바로가기
개인 프로젝트/Danmuji04

성능 개선 - 게시글 상세 페이지 LCP 개선

by 학습하는 청년 2025. 6. 7.

요약: LCP 5.04 -> 2.27~3.29 개선

방법

1) 컴포넌트 분할

2) preload와 prefetch 적용 (이미지 있을 때)

3) 렌더링 순서 조정 (모든 데이터를 동시에 -> 게시글 먼저 UI 즉시 업데이트)


게시글 상세 페이지에서 LCP가 5.04~5.74가 나왔다.

해당하는 부분은 이곳이다.

{/* 게시글 본문 */}
<div className="relative mb-8 whitespace-pre-line text-gray-800">
 {user && user.id === post.author_id && (
   <div className="absolute -top-5 right-0 flex gap-2">
     <button
       onClick={handleEditPost}
       className="rounded-md border border-gray-300 px-3 py-1 text-sm text-gray-700 hover:bg-gray-50"
     >
     수정
     </button>
     <button
       onClick={handleDeletePost}
       className="rounded-md border border-red-300 px-3 py-1 text-sm text-red-700 hover:bg-red-50"
     >
     삭제
     </button>
   </div>
 )}
 {renderPostContent(post.content)}
</div>

 

컴포넌트를 분할하니 4.13~4.48 정도로 감소했다.

그럼에도 좋은 수치는 아니다. 코드를 살펴보면 renderContent가 문제인 것인데, Image 컴포넌트를 사용해도 수치가 좋지 않다.

 const renderedContent = useMemo(() => {
  if (!textContent) return null;

  const parts = textContent.split(/__IMAGE_PLACEHOLDER_([^_]*)__/);
  const result = [];
  let imageIndex = 0;

  for (let i = 0; i < parts.length; i++) {
    // 텍스트 파트
    if (i % 2 === 0 && parts[i]) {
      result.push(
        <span key={`text-${i}`} className="whitespace-pre-line">
          {parts[i]}
        </span>
      );
    }
    // 이미지 파트
    else if (i % 2 === 1 && images[imageIndex]) {
      const image = images[imageIndex];
      result.push(
        <div key={`img-${imageIndex}`} className="relative my-4">
          <Image
            src={image.src}
            alt={image.alt}
            width={800}
            height={600}
            className="h-auto max-w-full rounded-lg"
            priority={imageIndex === 0} // 첫 번째 이미지만 우선 로딩
            loading={imageIndex === 0 ? 'eager' : 'lazy'}
            placeholder="blur"
            blurDataURL="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAwIiBoZWlnaHQ9IjYwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZmZmIi8+PC9zdmc+"
            sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 800px"
            style={{
              objectFit: 'contain',
              maxWidth: '100%',
              height: 'auto',
            }}
          />
        </div>
      );
      imageIndex++;
    }
  }

  return result;
}, [textContent, images]);

 

이미지가 있는 게시글

Preload & DNS Prefetch를 통해 이미지를 우선순위( fetchPriority="high" )로 로드하도록 하여, LCP 요소가 빨리 로드되도록 하였다. 또한, dns-prefetch를 추가하여 외부 도메인과의 연결을 미리 설정하여, 이미지 요청 시 DNS 조회 시간을 단축시켰다.

 

상위 컴포넌트에서 첫 번째 이미지를 preload하고 DNS prefetch를 통해 시간을 향상시킬 수 있었다.

// 이미지 preLoad 함수
const preloadFirstImage = useCallback((content: string) => {
  const imageLinkRegex = /!\[([^\]]*)\]\(([^)]+)\)/;
  const match = content.match(imageLinkRegex);

  if (match && match[2]) {
    // 첫 번째 이미지를 preload
    const link = document.createElement('link');
    link.rel = 'preload';
    link.as = 'image';
    link.href = match[2];
    link.fetchPriority = 'high';
    document.head.appendChild(link);

    // DNS prefetch도 추가
    const dnsLink = document.createElement('link');
    dnsLink.rel = 'dns-prefetch';
    dnsLink.href = new URL(match[2]).origin;
    document.head.appendChild(dnsLink);

    // 정리 함수 반환
    return () => {
      document.head.removeChild(link);
      document.head.removeChild(dnsLink);
    };
  }
}, []);

 

이전에는 모든 데이터를 동시에 로드했다면, 게시글 -> 이미지 preload -> 나머지는 백그라운드화 하여 LCP를 감소시켰다.

1. 게시글 로드 -> setPost()를 통해 즉시 UI 업데이트

2. 이미지 preload하여 LCP 최적화

3. 댓글, 관련글은 비동기 처리

정리하면 다음과 같은 렌더링 과정을 거친다.

개선 전 (순차적) 개선 후 (병렬)
1. HTML 파싱
2. CSS/JS 리소스 요청
3. DOM 구축 중 < img> 태그 발견
4. 이미지 URL DNS 조회
5. 서버 연결 설정
6. 이미지 다운로드 시작
7. 이미지 렌더링
1. HTML 파싱
2. 게시글 데이터 로드 (API 응답)
3. 즉시 DNS prefetch 시작 (병렬)
4. 즉시 이미지 preload 시작 (병렬)
5. CSS/JS 리소스 로딩 (병렬)
6. DOM 구축
7. 준비된 이미지 즉시 렌더링

 

이렇게 하니, 이미지가 있는 게시글은 2.81~3.32까지 감소했다.

 

이미지가 없는 게시글은 2.27~3.29까지 감소했다.

Q. 이미지가 없는데, prefetch와 preload의 영향을 받았을까?

아니다. 이전에는 모든 데이터 로딩 완료 -> 전체 UI 렌더링 이었다. 이를 개선하여,

게시글 로딩 완료 -> 게시글만 먼저 렌더링하여 개선되었다.

 

이미지가 없는 글에서는 이전에는 모든 데이터를 동시에 기다렸다면, 상세 정보를 먼저 렌더링하고 부가 기능들을 비동기로 처리함으로써 렌더 블로킹을 제거하였다.

useEffect(() => {
  const fetchPostData = async () => {
    try {
      setIsLoading(true);
      
      // 게시글 상세 정보 조회
      const numericPostId = parseInt(postId, 10);
      if (isNaN(numericPostId)) {
        router.push('/community?error-post-not-found');
        return;
      }

      // 게시글 조회
      const postData = await fetchPostById(numericPostId);
      if (!postData) {
        router.push('/community?error=post-not-fount');
        return;
      }

      setPost(postData);
      setIsLiked(postData.is_liked || false);
      setLikesCount(postData.likes_count || 0);

      // 댓글 조회
      const commentsData = await fetchCommentsByPostId(numericPostId);
      setComments(commentsData);

      // 관련 게시글 조회
      const relatedPostsData = await fetchRelatedPosts(numericPostId);
      setRelatedPosts(relatedPostsData);

      // 북마크 상태 조회
      if (user) {
        const bookmarked = await isPostBookmarked(numericPostId);
        setIsBookmarked(bookmarked);
      }
    } catch (error) {
      console.error('게시글 데이터 로드 실패:', error);
      showToast('게시글을 불러오는데 실패했습니다.', 'error');
      router.push('/community');
    } finally {
      setIsLoading(false);
    }
  };

  if (postId) {
    fetchPostData();
  }
}, [postId, router, showToast, user]);

================================
  // 개선 코드
useEffect(() => {
  const fetchPostData = async () => {
    try {
      setIsLoading(true);
      const numericPostId = parseInt(postId, 10);

      if (isNaN(numericPostId)) {
        router.push('/community?error=post-not-found');
        return;
      }

      // 1. 게시글 먼저 로드
      const postData = await fetchPostById(numericPostId);
      if (!postData) {
        router.push('/community?error=post-not-found');
        return;
      }

      setPost(postData);

      // 2. 첫 번째 이미지 preload (게시글 설정 직후)
      let cleanupPreload: (() => void) | undefined;
      if (postData.content) {
        cleanupPreload = preloadFirstImage(postData.content);
      }

      // 3. 전역 상태 초기화 (비동기로 처리)
      if (user) {
        Promise.all([
          initializePostLike(numericPostId),
          initializePostBookmark(numericPostId),
        ]).catch(console.error);
      }

      // 4. 댓글과 관련 게시글은 백그라운드에서 로드
      Promise.all([
        fetchCommentsByPostId(numericPostId),
        fetchRelatedPosts(numericPostId),
      ])
      .then(([commentsData, relatedPostsData]) => {
         setComments(commentsData);
        setRelatedPosts(relatedPostsData);
      })
      .catch(console.error);

      // 정리 함수 등록
      return cleanupPreload;
    } catch (error) {
      console.error('게시글 데이터 로드 실패:', error);
      showToast('게시글을 불러오는데 실패했습니다.', 'error');
      router.push('/community');
    } finally {
      setIsLoading(false);
    }
  };

  if (postId) {
    const cleanup = fetchPostData();

    // cleanup 함수 반환
    return () => {
      cleanup?.then((cleanupFn) => cleanupFn?.());
    };
  }
}, [
  postId,
  router,
  showToast,
  user,
  initializePostLike,
  initializePostBookmark,
  preloadFirstImage,
]);

 

스켈레톤 UI 적용

LCP가 Largest Contentful Paint이므로 가장 큰 요소를 스켈레톤 UI가 먼저 나타나도록 하면, 감소하지 않을까? 라는 생각이 들었다.

<div className="animate-pulse mobile:mb-4 tablet:mb-6 laptop:mb-8">
  <div className="mb-4 rounded bg-gray-200 mobile:h-8 tablet:h-9"></div>
  <div className="mb-6 h-10 rounded bg-gray-200"></div>
  <div className="mb-8 h-[480px] rounded bg-gray-200"></div>
</div>

 

스켈레톤 UI를 적용해도 사실상 효과가 없었다. 심지어는 증가하는 경우도 있었고, 대체로 2.43~3.24로 나타났다.

 

정규분포식으로 인한 문제?

가장 큰 문제는 render delay 였다. 해당 게시글의 본문에서 나타나는 문제였다.

<div className="prose prose-lg max-w-none">{renderedContent}</div>

 

해당 코드에는 정규분포식이 포함되어 있는데, 복잡한 연산이라 그런 것 같다. 이를 어떻게 개선하면 좋을지 아직 모르겠어서 잠시 뒤로 미뤄둬야 겠다.

 


ps. 작업을 하면서 CLS 수치도 개선했다.

'개인 프로젝트 > Danmuji04' 카테고리의 다른 글

리팩토링 과정 & 오류  (0) 2025.07.16
Jotai 적용 (Knowledge, Study Section)  (0) 2025.07.11
프로젝트 기획  (1) 2025.07.11
Jotai 적용 (Course, Community Section)  (0) 2025.06.11
Supabase SQL 보관  (0) 2025.02.10

댓글