서비스의 첫인상을 결정하는 홈화면, 이번 개편을 통해 저희는 사용자에게 더 풍부하고 유용한 콘텐츠를 제공하고자 했습니다. 수강현황 부터 추천강의까지, 사용자는 중요한 정보를 한눈에 파악하고 원하는 학습을 더 빠르게 시작할 수 있게 되었죠.
그러나 콘텐츠가 풍부해진 만큼 대가도 따랐습니다. 레이아웃 시프트, 답답한 로딩 속도, 그리고 사용자를 혼란스럽게 하는 UI 업데이트라는 '방해 요소'들이 완벽한 첫인상을 가로막고 있었습니다.
이 글은 이 세 가지 문제를 단계별로 해결하며 웹 성능의 핵심 지표인 CLS와 LCP를 개선해나간 저의 경험을 담은 가이드입니다.
먼저 글에 들어가기 앞서, 웹 성능을 측정하는 주요 지표인 Web Vitals에 대해 알아보겠습니다. Web Vitals는 구글이 실제 사용자 경험을 측정하기 위해 제안한 표준화된 지표 모음입니다. 이 중에서도 가장 중요한 세 가지를 Core Web Vitals라고 부르며, 이는 모든 웹사이트가 좋은 사용자 경험을 제공하기 위해 집중해야 할 핵심 요소입니다.
지표 | 측정 항목 | ✅ 좋음 | ⚠️ 개선 필요 | ❌ 나쁨 |
---|---|---|---|---|
LCP (Largest Contentful Paint) | 주요 콘텐츠가 사용자에게 보이는 시간 (로딩 성능) | ≤ 2.5s | 2.5s ~ 4.0s | > 4.0s |
INP (Interaction to Next Paint) | 사용자 입력 후 시각적 반응까지 걸린 시간 (반응성) | ≤ 200ms | 200ms ~ 500ms | > 500ms |
CLS (Cumulative Layout Shift) | 페이지 로딩 중 예기치 않은 레이아웃 이동 정도 (시각적 안정성) | ≤ 0.1 | 0.1 ~ 0.25 | > 0.25 |
참고: Google Web.dev – Core Web Vitals
이 지표들은 단순히 숫자에 그치지 않고, 사용자 만족도에 직접적인 영향을 미칩니다. 특히 이 글에서는 로딩 속도(LCP)와 시각적 안정성(CLS)이라는, 종종 상충 관계에 놓이는 두 지표를 함께 개선해나간 경험에 초점을 맞추고자 합니다.
홈 화면 개편을 시작하며 마주한 장벽은 바로 예측 불가능성이었습니다. 저희 서비스는 B2B로, 고객사마다 개인화된 데이터를 바탕으로 홈 화면이 구성됩니다. 고객사 설정, 사용자별 학습 데이터에 따라 추천/필수 강의, 공지사항 등 노출되는 콘텐츠가 모두 달랐죠.
예를 들어, 사용자마다 다음과 같이 완전히 다른 화면을 마주하게 됩니다.
사용자 A: [필수강의 큐레이션] [공지사항 5개] [최근강의] [추천강의 1]
사용자 B: [수강신청 독려] [추천강의 1] [추천강의 2]
사용자 C: [공지사항 1개] [최근강의]
데스크탑 | 모바일 |
---|---|
![]() | ![]() |
LCP 1.55s/CLS 0.68 | LCP 1.75s/CLS 1.24😱 |
이처럼 데이터 유무에 따라 모듈이 생기거나 사라지는 일이 잦았고, 이는 레이아웃 시프트를 유발하는 완벽한 조건이었습니다. 실제로 개선 전 CLS(Cumulative Layout Shift) 지표를 측정했을 때, 일부 사용자에게서는 1이 넘는 최악의 수치가 확인되었습니다.
가장 먼저 시도한 것은 페이지 전체를 단 하나의 <Suspense>
로 감싸는 전략입니다. 모든 데이터가 준비될 때까지 전체 화면 로딩 스피너를 보여줍니다.
// HomePage.tsx
const HomePageContent = () => {
// 모든 데이터를 한 번에 fetching
return (
<>
<NoticeSection />
<RecentCoursesSection />
<BannerSection />
</>
);
}
const HomePage = () => (
<Layout>
<Suspense fallback={<PageSpinner />}>
<HomePageContent />
</Suspense>
</Layout>
);
데스크탑 |
---|
![]() |
LCP 3.25s😱/CLS 0.00 |
결과 분석
CLS (👍): 예상대로 이 방법은 CLS 문제에 완벽한 해답이었습니다. 모든 데이터가 준비된 후에 한 번에 화면이 그려지므로, 로딩 과정에서 레이아웃 시프트가 전혀 발생하지 않아 CLS 0.00을 달성할 수 있었습니다.
LCP (👎): 하지만 안정성을 얻은 대가는 혹독했습니다. 페이지 내 가장 느린 API 하나가 전체 렌더링을 막는 병목 지점이 되면서, 사용자는 아무런 콘텐츠도 볼 수 없었습니다. 그 결과 LCP는 3.25초로 오히려 악화되었습니다.
하나의 문제를 해결했지만, 그 반작용으로 더 큰 문제를 만든 셈입니다. 이 경험은 저에게 "사용자의 시간을 희생시켜 얻은 안정성이 과연 가치 있는가?"라는 중요한 질문을 던졌습니다.
1단계에서 발생한 LCP 저하 문제를 해결하기 위해, 가장 직관적인 다음 단계로 나아갔습니다. 페이지 전체를 기다리는 대신, 각 UI 모듈을 개별 <Suspense>
로 감싸는 것이었죠. 로딩이 빠른 모듈은 즉시 나타날 테니 LCP는 자연스럽게 개선될 것이라 기대했습니다.
물론, CLS 문제도 잊지 않았습니다. 각 모듈의 fallback
으로 고정된 높이의 스켈레톤 UI를 미리 그려주면, 데이터가 채워질 때 레이아웃이 밀리는 현상을 방지할 수 있을 거라 생각했습니다.
// HomePage.tsx
const HomePage = () => {
return (
<Layout>
<Suspense fallback={<NoticeSkeleton height="100px" />}>
<NoticeSection />
</Suspense>
<Suspense fallback={<RecentCoursesSkeleton height="250px" />}>
<RecentCoursesSection />
</Suspense>
<Suspense fallback={<BannerSkeleton height="250px" />}>
<BannerSection />
</Suspense>
</Layout>
);
};
데스크탑 | 모바일 |
---|---|
![]() | ![]() |
데스크탑 환경에서는 데이터가 없는 경우에도 모듈이 노출되어 안정적입니다. | 모바일에서는 데이터가 없는 경우 모듈 자체가 렌더링 되지 않아 레이아웃 시프트가 발생합니다.😣 |
결과 분석
이 시도를 통해 저는 더 근본적인 문제에 직면했습니다. 사용자가 가진 데이터에 따라 CLS 경험에 '빈부격차' 가 발생하는 것이었죠. 고정 높이 스켈레톤은 '데이터가 있을 것'이라는 전제 하에서만 유효한 반쪽짜리 해결책이었습니다.
1단계의 LCP 문제와 2단계의 CLS 문제를 모두 겪으며, 절충안을 찾아야 했습니다. 전체 데이터를 기다리는 것은 비효율적이고, 사용자별 데이터 편차 때문에 표준 레이아웃을 잡는 것도 불가능했죠. 그래서 사용자가 처음 마주하는 화면 상단(Above-the-fold) 과 하단 영역을 분리하여 로딩하는 전략을 세웠습니다.
이 방법은 하단의 콘텐츠가 상단의 중요 콘텐츠보다 상대적으로 느리게 로드된다는 저희 서비스의 특성 때문에 시도해볼 수 있었습니다.
// HomePage.tsx
const HomePage = () => {
return (
<Layout>
{/* 상단 콘텐츠 */}
<Suspense fallback={<UpperContentSkeleton />}>
<UpperContent />
</Suspense>
{/* 하단 콘텐츠 */}
<Suspense fallback={<LowerContentSkeleton />}>
<LowerContent />
</Suspense>
</Layout>
);
};
데스크탑 |
---|
![]() |
LCP 2.70s/CLS 0.08 |
새로운 문제 재진입 시 UI 업데이트 |
---|
![]() |
결과 분석
LCP & CLS (부분적인 성공 👍): 초기 로딩 시 LCP와 CLS는 이전 단계들보다 훨씬 양호한 수준으로 개선되었습니다. 드디어 균형점을 찾는 듯했습니다.
새로운 문제: 페이지 재진입 시 UI 불안정성 : 하지만 초기 로딩 문제를 해결했다고 생각한 순간, SPA 환경의 또 다른 복병이 수면 위로 드러났습니다. 바로 페이지 재진입 시의 UI 업데이트 문제였습니다.
저는 다른 페이지에 갔다 홈으로 돌아올 때, 캐시된 stale
데이터를 먼저 보여주어 빠른 화면 전환을 유도했습니다. 문제는 그 이후였습니다. 백그라운드 리페칭이 완료되자 UI가 뒤늦게 '깜빡' 하며 바뀌는 현상이 발생했고, 이는 사용자에게 큰 혼란을 주었습니다.
그렇다고 리페칭 시 스켈레톤 UI를 보여주자니, 데이터 유무에 따라 높이가 달라져 재진입 과정에서 CLS가 다시 발생하는 딜레마에 빠졌습니다. 초기 로딩 성능을 잡았지만, 이제는 화면 전환 시의 사용자 경험이라는 또 다른 차원의 문제를 해결해야 했습니다.
3단계에서 마주한 '페이지 재진입' 문제는 제게 중요한 깨달음을 주었습니다. 문제의 근본 원인은 컴포넌트가 렌더링된 이후에 데이터를 가져오는 방식(Fetch-on-Render)에 있었습니다. 이 방식은 항상 시각적 불안정성의 위험을 안고 있었죠.
그래서 저는 데이터 로딩의 패러다임을 바꾸기로 했습니다. 컴포넌트가 렌더링되기 이전에 데이터를 미리 가져오는 프리페치(Prefetch) 전략을 도입한 것입니다.
react-router-dom
의 loader
를 활용하여, 홈('/')
으로 라우팅될 때 상단 영역에 필요한 데이터를 미리 로드하고, 준비가 된 뒤에야 홈 컴포넌트를 마운트하는 흐름을 설계했습니다.
이렇게 하면 Fallback UI가 그려질 일이 없어지고, 데이터 요청 시점도 앞당겨져 더 빠른 로딩을 기대할 수 있습니다. 물론, LCP를 위해 홈 화면 전체가 아닌, 레이아웃 시프트에 영향을 주는 상단 영역의 데이터만 프리페치하도록 범위를 한정했습니다.
// router.ts
import { queryClient } from './queryClient';
export const homeLoader = async () => {
// 상단에 필요한 모든 데이터를 미리 가져온다.
await Promise.all([
queryClient.prefetchQuery({ queryKey: ['notice'], ... }),
queryClient.prefetchQuery({ queryKey: ['recent-courses'], ... }),
...
]);
return null;
};
데스크탑 | 모바일 |
---|---|
![]() | ![]() |
LCP 1.35s/CLS 0.00 | LCP 1.39s/CLS 0.00 |
새로운 문제 페이지 전환 시 가장 느린 api 응답 400-600ms 대기 |
---|
![]() |
결과 분석
LCP & CLS (해결 👍): 데이터 페칭 시점을 컴포넌트 렌더링 이전으로 앞당겨, 기존 방식보다 훨씬 빠르게 데이터를 준비할 수 있게 되면서 LCP가 대폭 개선되었습니다. 또한, 데이터가 100% 준비된 상태에서 렌더링되므로 CLS 문제가 원천적으로 차단되었습니다.
리마운트 안정성 (해결 👍): 데이터가 항상 준비된 상태로 렌더링되므로, 재진입 시의 깜빡임이나 CLS 문제도 완벽하게 해결되었습니다.
남은 과제: 최초 홈 화면 마운트 시에는 문제가 없었지만, loader
가 상단의 모든 API(느린 API 포함)를 기다려야 했기에, 다른 페이지에서 홈으로 돌아오는 페이지 전환 속도가 느리게 느껴지는 문제가 남았습니다.
마지막으로 4단계에서 발견한 '페이지 전환 경험'까지 최적화하기 위해, 사용자의 방문 맥락을 고려하는 전략을 도입했습니다. 모든 방문을 동일하게 취급하는 대신, '첫 방문' 과 '재방문' 의 경험을 분리하여 접근한 것입니다.
저의 가설은 이렇습니다.
첫 방문 (새로고침, 로그인 직후): 사용자는 약간의 초기 로딩을 감수하더라도, 완벽하고 안정적인 첫인상을 기대한다.
재방문 (다른 페이지에서 이동): 사용자는 이미 서비스에 익숙하므로, 무엇보다 즉각적인 반응 속도와 부드러운 화면 전환을 기대한다.
이 가설에 따라, 재방문 시에는 빠른 데이터만 프리페치하고 느린 데이터는 이전 캐시를 먼저 보여주어 페이지 전환 속도를 극대화했습니다. 하지만 여기서 마지막 문제가 발생했습니다. 캐시된 stale
데이터가 백그라운드에서 업데이트될 때, UI가 또다시 '깜빡'이며 바뀌는 현상이었습니다.
이 마지막 '깜빡임'을 잡기 위해, 저는 낙관적 업데이트(Optimistic Update) 를 적용했습니다. 서버 응답을 기다리지 않고, 사용자의 최근 활동(예: 다른 페이지에서 본 강의)을 기반으로 UI를 미리 업데이트하여 시각적 불일치의 가능성 자체를 제거했습니다.
// router.ts
export const homeLoader = async () => {
// 캐시 유무로 첫 방문/재방문 구분
const isInitialLoad = !queryClient.getQueryData(['recent-courses']);
const coreQueries = [
queryClient.prefetchQuery({ queryKey: ['notice'], ... }),
// ... 빠른 API들
];
if (isInitialLoad) {
// 첫 방문: 느린 API까지 모두 포함하여 prefetch
await Promise.all([
...coreQueries,
queryClient.prefetchQuery({ queryKey: ['recent-courses'], ... }), // 느린 API
]);
} else {
// 재방문: 빠른 핵심 API만 prefetch
await Promise.all(coreQueries);
}
return null;
};
홈 화면 재 진입 시 |
---|
![]() |
개발환경 기준, 가장 느린 api 응답속도가 1초가 넘어도 페이지 전환은 문제 없습니다!
최근 수강 강의 업데이트 시 |
---|
![]() |
낙관적 업데이트를 통해 UI 깜빡임을 해소하였습니다.
최종 결과 분석
데스크탑 뷰 개선 전 | 개선 후 |
---|---|
![]() | ![]() |
LCP 1.65s/CLS 0.68 | LCP 1.35s/CLS 0.00 |
모바일 뷰 개선 전 | 개선 후 |
---|---|
![]() | ![]() |
LCP 1.75s/CLS 1.24 | LCP 1.39s/CLS 0.00 |
개선 후 프로덕션 |
---|
![]() |
LCP 0.58-0.8s/CLS 0.00 👍 |
LCP, CLS, 전환 안정성 모두 해결 👍: 이 전략을 통해 저는 세 가지 목표를 모두 달성할 수 있었습니다. 첫 방문 시에는 완벽한 첫인상을, 재방문 시에는 낙관적 업데이트 덕분에 어떤 상황에서도 시각적 불안정성이 없는 즉각적인 페이지 전환을 경험하게 만들었습니다. 😊
남은 과제 (감수해야 할 비용..): 이 수준의 최적화는 필연적으로 높은 구현 복잡도를 동반합니다. 사용자의 방문 맥락을 파악하고, 캐시 상태를 관리하며, 낙관적 업데이트 로직까지 동적으로 제어하는 것은 상당한 수준의 이해도와 복잡한 코드를 요구했습니다.
지금까지의 경험을 바탕으로, 각 최적화 전략의 장단점과 추천 대상을 아래와 같이 정리해 보았습니다. 물론, 이 표는 저의 경험을 바탕으로 한 상대적인 지표이며 모든 서비스에 절대적인 기준이 될 수는 없습니다.
단계 | LCP | CLS | 문제점 | 추천 대상 |
---|---|---|---|---|
1. 전체 Suspense | 최악 | 최상 | 긴 로딩 시간 | 데이터 양이 적은 페이지 |
2. 모듈별 Suspense | 최상 | 나쁨 | 레이아웃 시프트 가능성 | 모듈 크기가 고정된 페이지 |
3. Above the fold 분리 | 양호 | 양호 | 리페칭 시 CLS, 그룹 병목 | 대부분의 동적 페이지 (간단하고 좋은 출발점) |
4. Loader Prefetch | 최상 | 최상 | 페이지 전환 속도 저하 | 첫 로딩 경험이 중요한 SPA |
5. 조건부 Prefetch+낙관적 업데이트 | 최상 | 최상 | 높은 구현 복잡도 | 중요한 api 속도가 느린 경우 |
모든 서비스가 5단계까지 나아갈 필요는 없습니다. 어떤 서비스는 3단계에서 충분히 '좋은' 사용자 경험을 제공할 수 있고, 어떤 서비스는 4단계의 페이지 전환 속도 저하가 문제가 되지 않을 수도 있습니다. 또한, 각 단계별 전략을 서비스의 특정 페이지나 기능에 맞게 부분적으로 조합하는 것도 좋은 방법입니다.
중요한 것은 각 전략의 명확한 트레이드오프를 이해하고, 우리 서비스의 특성과 사용자의 기대를 고려하여 가장 적절한 수준의 최적화를 적용하는 것입니다.
처음엔 "이렇게 하면 될 거야!"라고 생각했던 접근법이 계속해서 예상치 못한 문제에 부딪혔습니다. 각 전략에는 명확한 장단점이 존재했고, 이는 기술적인 트레이드오프를 이해하고 우리가 처한 상황과 목표에 맞는 최적의 균형점을 찾아가는 과정이 얼마나 중요한지를 깨닫게 해주었습니다. 자칫 기술적 완벽성만 추구하다 유지보수가 어려운 코드를 낳는 오버엔지니어링에 빠질 수 있다는 것도 배울 수 있었습니다.
"모든 사용자를 만족시킬 수 없다면, 대다수의 사용자를 만족시키자"는 원칙을 세웠습니다. '데이터가 없는 신규 사용자 vs 있는 기존 사용자'처럼 상충하는 케이스를 마주했을 때, 가설에 의존하기보다 실제 사용자 지표를 확인하며 데이터 기반의 의사결정을 내렸습니다. 이는 최적의 타협점을 찾는 데 큰 도움이 되었습니다.
이번 최적화 작업은 혼자서는 절대 불가능했습니다. 로딩 정책을 정하며 디자이너와 사용자 경험에 대해 깊이 논의했고, API 분리가 필요할 때는 명확한 근거를 가지고 백엔드 개발자에게 요청했습니다. 문제의 원인과 기대효과를 투명하게 공유했을 때, 팀원들은 단순한 '요청'이 아닌 '공동의 목표'로 인식하고 흔쾌히 협력해 주었습니다. 이 과정을 통해 작업자가 먼저 문제점을 제시하고 적극적으로 협조를 구하는 것이 얼마나 중요한지 다시 한번 느꼈습니다.
Suspense
, TanStack Query
, React Router
는 각자 강력한 도구지만, 함께 사용될 때 발생하는 미묘한 상호작용을 이해하는 것이 중요했습니다. 특히 staleTime과 loader의 동작 방식을 깊이 파고들어 '페이지 재진입 시 UI 불안정성' 문제를 해결했던 경험은, 라이브러리를 단순히 '사용하는 것'과 '제대로 활용하는 것'의 차이를 깨닫게 해주었습니다.
이번 홈 화면 개선 프로젝트는 단순히 웹 표준 지표를 맞추는 것을 넘어, 사용자 경험의 본질에 대해 깊이 고민하고 동료들과 함께 성장하는 계기가 되었습니다. 사용자가 아무런 불편함 없이, 자연스럽고 쾌적하게 서비스를 이용할 수 있도록 만드는 것, 앞으로도 개발자로서 계속 추구하고 싶은 목표입니다.