요약: 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 |
댓글