이번 챕터에서는 성능 최적화를 주제로 두 가지 방향에서 과제를 진행했다.
첫 번째는 웹 페이지의 초기 렌더링 성능을 개선하는 것이었고,
두 번째는 React 컴포넌트 렌더링 성능과 상태 구조 최적화에 집중하는 과제였다.
처음엔 단순히 성능 점수를 높이는 것에만 집중했지만, 과제를 진행하면서
"왜 이 부분에서 비용이 발생하는지",
"렌더링 최적화는 어떤 흐름으로 접근해야 하는지",
"상태 구조는 어떻게 설계해야 이후 최적화가 쉬운지"
등을 더 깊게 고민해볼 수 있는 시간이었다.
이 글에서는 두 가지 최적화 과제를 진행하면서 발견한 문제와 개선 과정을 정리해보려 한다.
CLS (Cumulative Layout Shift)
페이지가 렌더링 중에 요소가 위치를 바꾸는 현상
을 측정하는 지표초기 구조
<section class="hero">
<img class="desktop" src="images/Hero_Desktop.webp" />
<img class="tablet" src="images/Hero_Tablet.webp" />
<img class="mobile" src="images/Hero_Mobile.webp" />
</section>
이슈 원인
img
가 DOM
에 남아 있었고, CSS media query
로 display: none
처리img
를 고려display: none
→ height
공간 계산 안 됨 → 초기 레이아웃이 불안정하게 됨 → CLS
발생img
요소마다 명시적인 크기가 없음 → 브라우저가 이미지 크기를 추정해야 함 → 이미지 로드 시 크기가 변경될 수 있음 → CLS
발생개선 구조
<picture>
<source srcset="images/Hero_Mobile.webp" media="(max-width: 576px)" />
<source srcset="images/Hero_Tablet.webp" media="(max-width: 960px)" />
<img
src="images/Hero_Desktop.webp"
sizes="100vw"
alt="Hero Image"
class="hero-image"
/>
</picture>
<img src="images/vr1.webp" alt="product: Penom Case" width="128" height="128" />
개선 효과
picture + source
구조에서는 브라우저가 조건에 맞는 source
만 선택해서 로드display: none
이 아님, DOM
에서 제외) → 초기 layout 계산이 안정적 → CLS
현상이 사라짐img
요소에 width
와 height
를 명시적으로 설정해 브라우저가 이미지 크기를 추정하지 않도록 함초기 이미지 파일
개선 이미지 파일
개선 효과
초기 이미지 파일
개선 이미지 파일
개선 효과
LCP (Largest Contentful Paint)
문제 상황
Hero Image
가 콘텐츠가 포함된 최대 페인트 요소 (LCP)로 측정됨단계 | 비율 | 시간 |
---|---|---|
TTFB | 7% | 170ms |
로드 지연 | 1% | 20ms |
로드 시간 (다운로드) | 71% | 1760ms |
렌더링 지연 | 21% | 520ms |
개선 내용
Hero Image preload 적용
<link
rel="preload"
as="image"
href="images/Hero_Desktop.webp"
media="(min-width: 961px)"
/>
<link
rel="preload"
as="image"
href="images/Hero_Tablet.webp"
media="(min-width: 577px) and (max-width: 960px)"
/>
<link
rel="preload"
as="image"
href="images/Hero_Mobile.webp"
media="(max-width: 576px)"
/>
Hero Image 구조 개선 (picture + source 사용)
img
+ display: none
방식에서 → <picture> + <source>
구조로 변경source
만 로드하도록 구조 변경 → CLS
및 LCP
개선<picture>
<source srcset="images/Hero_Mobile.webp" media="(max-width: 576px)" />
<source srcset="images/Hero_Tablet.webp" media="(max-width: 960px)" />
<img
src="images/Hero_Desktop.webp"
sizes="100vw"
alt="Hero Image"
class="hero-image"
/>
</picture>
Critical CSS 적용 (aspect-ratio
적용)
aspect-ratio
적용하여 초기 레이아웃 시 height
공간 확보<style>
.hero-image {
width: 100%;
height: auto;
filter: brightness(50%);
aspect-ratio: 2160 / 1005;
}
@media screen and (max-width: 960px) {
.hero-image {
aspect-ratio: 960 / 770;
}
}
@media screen and (max-width: 576px) {
.hero-image {
aspect-ratio: 1 / 1;
}
}
</style>
Cookie Consent JS defer 적용
defer
속성 추가 (HTML 파싱이 끝난 후 JS 실행)main thread block
완화<script
type="text/javascript"
src="//www.freeprivacypolicy.com/public/cookie-consent/4.1.0/cookie-consent.js"
charset="UTF-8"
defer
></script>
지표 | 최초 성능 보고서 | 개선 사후 보고서 |
---|---|---|
LCP | 13.96s | 2.44s |
INP | N/A | N/A |
CLS | 0.011 | 0.011 |
LCP
지표 개선 효과
LCP
지표가 2.44s로 초기 13.96s 대비 82.5% 개선됨Performance (성능) 지표 개선 효과
Performance
지표가 95점으로 개선됨항목 | 개선 전 | 개선 후 | 변화 |
---|---|---|---|
총점 | 65점 | 100점 | +35점 상승 |
First Contentful Paint (FCP) | 0.7초 | 0.6초 | 개선 (0.1초 빠름) |
Largest Contentful Paint (LCP) | 2.5초 | 0.6초 | 개선 (1.9초 빠름) |
Total Blocking Time (TBT) | 110ms | 0ms | 완전 해소 |
Cumulative Layout Shift (CLS) | 0.477 | 0.016 | 안정화 (대폭 개선) |
Speed Index | 1.0초 | 0.6초 | 개선 (0.4초 빠름) |
Hero Image
에 preload
적용 + WebP
변환 + Critical CSS
적용 + JS defer
적용 등의 영향Hero Image
의 paint
시점이 크게 앞당겨짐Hero Image
에 aspect-ratio
적용 + img
구조 변경 (<picture>
+ <source>
사용)Cookie Consent JS
를 defer
처리 → main thread blocking
원인 제거됨FCP
: 0.7초 → 0.6초Speed Index
: 1.0초 → 0.6초preload
+ 레이아웃 안정화 영향 → 초기 렌더링 빠르게 진행됨Lighthouse 기반 최적화가 '페이지 초기 렌더링' 관점이었다면,
이번에는 실제 사용자 상호작용 중 발생하는 렌더링 비용을 줄이는 것이 목표였다.
특히 React Devtool의 Profiler를 활용해 렌더링을 확인해보니, SearchDialog 페이지네이션과 DnD 시스템에서 불필요한 렌더링과 연산 비용이 눈에 띄게 발생하고 있었다.
기존에는 내부에서 잘못된 Promise.all
사용으로fetchAllLectures
함수에서 API 요청이 직렬로 실행되고 있었다.
또한 동일 데이터를 여러 번 요청하는 구조였기 때문에 불필요한 API 요청이 존재했다.
const fetchMajors = () => axios.get<Lecture[]>('/schedules-majors.json');
const fetchLiberalArts = () => axios.get<Lecture[]>('/schedules-liberal-arts.json');
const fetchAllLectures = async () => await Promise.all([
(console.log('API Call 1', performance.now()), await fetchMajors()),
(console.log('API Call 2', performance.now()), await fetchLiberalArts()),
(console.log('API Call 3', performance.now()), await fetchMajors()),
(console.log('API Call 4', performance.now()), await fetchLiberalArts()),
(console.log('API Call 5', performance.now()), await fetchMajors()),
(console.log('API Call 6', performance.now()), await fetchLiberalArts()),
]);
이를 개선하기 위해
useRef
기반 캐싱을 적용하고fetch
로직을 useCallback
으로 감싸주었으며,Promise.all
사용 시 await 제거 → 병렬 요청 가능하도록 변경하였다.
import { useCallback, useRef } from "react";
import { Lecture } from "./types";
import axios from "axios";
export const useLectureFetcher = () => {
const majorsCacheRef = useRef<Lecture[] | null>(null);
const liberalArtsCacheRef = useRef<Lecture[] | null>(null);
const fetchMajors = useCallback(async () => {
if (majorsCacheRef.current) {
return majorsCacheRef.current;
}
const response = await axios.get<Lecture[]>("/schedules-majors.json");
majorsCacheRef.current = response.data;
return majorsCacheRef.current;
}, []);
const fetchLiberalArts = useCallback(async () => {
if (liberalArtsCacheRef.current) {
return liberalArtsCacheRef.current;
}
const response = await axios.get<Lecture[]>("/schedules-liberal-arts.json");
liberalArtsCacheRef.current = response.data;
return liberalArtsCacheRef.current;
}, []);
const fetchAllLectures = useCallback(async () => {
return await Promise.all([
(console.log("API Call 1", performance.now()), fetchMajors()),
(console.log("API Call 2", performance.now()), fetchLiberalArts()),
(console.log("API Call 3", performance.now()), fetchMajors()),
(console.log("API Call 4", performance.now()), fetchLiberalArts()),
(console.log("API Call 5", performance.now()), fetchMajors()),
(console.log("API Call 6", performance.now()), fetchLiberalArts()),
]);
}, [fetchMajors, fetchLiberalArts]);
return { fetchAllLectures };
};
결과적으로
초기 코드에는 해당 컴포넌트에 불필요한 연산이 존재했다.
getFilteredLectures
함수가 매 렌더링마다 실행되며, 내부에서 parseSchedule
이 중복 실행되는 구조였다.
const getFilteredLectures = () => {
const { query = "", credits, grades, days, times, majors } = searchOptions;
return lectures
.filter((lecture) =>
lecture.title.toLowerCase().includes(query.toLowerCase()) ||
lecture.id.toLowerCase().includes(query.toLowerCase())
)
.filter((lecture) => grades.length === 0 || grades.includes(lecture.grade))
.filter((lecture) => majors.length === 0 || majors.includes(lecture.major))
.filter((lecture) => !credits || lecture.credits.startsWith(String(credits)))
.filter((lecture) => {
const schedules = lecture.schedule ? parseSchedule(lecture.schedule) : [];
return days.length === 0 || schedules.some((s) => days.includes(s.day));
})
.filter((lecture) => {
const schedules = lecture.schedule ? parseSchedule(lecture.schedule) : [];
return times.length === 0 || schedules.some((s) =>
s.range.some((time) => times.includes(time))
);
});
};
위 코드처럼 parseSchedule
이 중복 호출되고 있었고, 필터링 연산 또한 렌더링마다 다시 실행되어 페이지네이션 시 성능 저하가 발생했다.
이를 개선하기 위해
parseSchedule
호출 조건을 정리하여 한 번만 호출하도록 수정하고getFilteredLectures
결과를 useMemo
로 메모이제이션 처리하고filteredLectures
, visibleLectures
, allMajors
계산에도 useMemo
를 적용하였다.export const getFilteredLectures = (
lectures: Lecture[],
searchOptions: SearchOption
) => {
const { query = "", credits, grades, days, times, majors } = searchOptions;
return lectures
.filter(
(lecture) =>
lecture.title.toLowerCase().includes(query.toLowerCase()) ||
lecture.id.toLowerCase().includes(query.toLowerCase())
)
.filter((lecture) => grades.length === 0 || grades.includes(lecture.grade))
.filter((lecture) => majors.length === 0 || majors.includes(lecture.major))
.filter((lecture) => !credits || lecture.credits.startsWith(String(credits)))
.filter((lecture) => {
if (days.length === 0 && times.length === 0) {
return true;
}
const schedules = lecture.schedule ? parseSchedule(lecture.schedule) : [];
const matchDay =
days.length === 0 || schedules.some((s) => days.includes(s.day));
const matchTime =
times.length === 0 ||
schedules.some((s) => s.range.some((time) => times.includes(time)));
return matchDay && matchTime;
});
};
const filteredLectures = useMemo(() => {
return getFilteredLectures(lectures, searchOptions);
}, [lectures, searchOptions]);
const lastPage = useMemo(() => {
return Math.ceil(filteredLectures.length / PAGE_SIZE);
}, [filteredLectures]);
const visibleLectures = useMemo(() => {
return filteredLectures.slice(0, page * PAGE_SIZE);
}, [filteredLectures, page]);
const allMajors = useMemo(() => {
return [...new Set(lectures.map((lecture) => lecture.major))];
}, [lectures]);
결과적으로 중복 연산이 제거되고, 필터링 연산이 변경된 경우에만 수행되며
페이지네이션 시 연산 비용이 눈에 띄게 줄어든 구조로 개선되었다.
또한 초기 코드에서는 상위 컴포넌트의 상태 변경 시, 전공 목록(MajorFilterSection
)과 강의 목록(LectureRow
) 모든 요소가 리렌더링되는 문제가 있었다.
대표적인 예시는 전공 필터 토글 시 MajorItem
들이 전부 리렌더링되고, 페이지네이션 시 LectureRow
전체가 다시 렌더링되는 상황이었다.
const allMajors = [...new Set(lectures.map((lecture) => lecture.major))];
const toggleMajor = (major: string) => {
setSearchOptions((prev) => {
const newMajors = prev.majors.includes(major)
? prev.majors.filter((m) => m !== major)
: [...prev.majors, major];
return { ...prev, majors: newMajors };
});
};
문제 원인은
allMajors
가 매 렌더링마다 재생성되고toggleMajor
핸들러도 매번 새로 만들어지며MajorItem
, LectureRow
)가 React.memo
처리 없이 그대로 전달되고 있었기 때문이었다.이를 개선하기 위해
allMajors
를 useMemo
로 메모이제이션toggleMajor
를 useCallback
으로 메모이제이션MajorItem
, LectureRow
등에 React.memo
적용visibleLectures
도 useMemo
적용하여 페이지 변경 시에만 최소 렌더링을 수행하도록 변경했다.// useMemo 적용
const allMajors = useMemo(() => {
return [...new Set(lectures.map((lecture) => lecture.major))];
}, [lectures]);
// useCallback 적용
const toggleMajor = useCallback((major: string) => {
setSearchOptions((prev) => {
const newMajors = prev.majors.includes(major)
? prev.majors.filter((m) => m !== major)
: [...prev.majors, major];
return { ...prev, majors: newMajors };
});
setPage(1);
loaderWrapperRef.current?.scrollTo(0, 0);
}, []);
// React.memo 적용
export const MajorItem = React.memo(({ major, isSelected, onToggle }) => {
return (
<Box key={major}>
<Checkbox
size="sm"
value={major}
isChecked={isSelected}
onChange={() => onToggle(major)}
>
{major.replace(/<p>/gi, " ")}
</Checkbox>
</Box>
);
});
이렇게 개선한 결과
MajorItem
은 변경된 요소만 리렌더링LectureRow
는 새로 추가되는 강의만 렌더링또한 초기 코드에서는 DnD(드래그 앤 드롭) 기능 사용 시, 드래그 시작 시점부터 거의 모든 ScheduleTable
과 DraggableSchedule
요소가 리렌더링되고, 드롭 이후에도 모든 테이블이 리렌더링되는 문제가 있었다.
원인은
ScheduleTable
이 schedulesMap
전체를 의존하고 있었고useDndContext
로 구독하면서 드래그 상태 변화 시마다 테이블 전체가 리렌더링되고DraggableSchedule
역시 별도 메모이제이션 없이 매번 렌더링될 뿐만 아니라handleDragEnd
에서 setSchedulesMap
전체 업데이트로 인해 관련 없는 테이블까지 리렌더링이 발생되는 구조였기 때문이었다.export const ScheduleProvider = ({ children }: PropsWithChildren) => {
const [schedulesMap, setSchedulesMap] =
useState<Record<string, Schedule[]>>(dummyScheduleMap);
return (
<ScheduleContext.Provider value={{ schedulesMap, setSchedulesMap }}>
{children}
</ScheduleContext.Provider>
);
};
const ScheduleTable = ({ tableId, schedules, onScheduleTimeClick, onDeleteButtonClick }) => {
const dndContext = useDndContext();
return (
<TableContainer>
<TableGrid onTimeClick={handleTimeClick} />
{schedules.map((schedule, index) => (
<DraggableSchedule
key={`${schedule.lecture.id}-${index}`}
id={`${tableId}:${index}`}
data={schedule}
/>
))}
</TableContainer>
);
};
이를 개선하기 위해
ScheduleTable
단위로 필요한 tableId
의 데이터만 가져오는 getSchedulesByTableId
함수를 context
에서 제공하여 ScheduleTable
이 전체 schedulesMap
을 직접 구독하지 않도록 변경DraggableSchedule
에 React.memo
적용ScheduleTable
내부에서 useMemo
, useCallback
활용해 렌더 비용 최소화transform
변화에 반응handleDragEnd
에서 updateSchedulesByTableId
함수 사용 → 변경된 테이블만 업데이트하여 관련 없는 테이블 리렌더링 방지하도록 최적화를 진행했다.export const ScheduleProvider = ({ children }: PropsWithChildren) => {
const [schedulesMap, setSchedulesMap] =
useState<Record<string, Schedule[]>>(dummyScheduleMap);
const getSchedulesByTableId = useCallback(
(tableId: string) => {
return schedulesMap[tableId] || [];
},
[schedulesMap]
);
const updateSchedulesByTableId = useCallback((tableId: string, updater) => {
setSchedulesMap((prev) => ({
...prev,
[tableId]: updater(prev[tableId] || []),
}));
}, []);
return (
<ScheduleContext.Provider
value={{
getSchedulesByTableId,
updateSchedulesByTableId,
}}
>
{children}
</ScheduleContext.Provider>
);
};
const ScheduleTableMemo = React.memo(
({ tableId, onScheduleTimeClick, onDeleteButtonClick }) => {
const { getSchedulesByTableId } = useScheduleContext();
const schedules = useMemo(
() => getSchedulesByTableId(tableId),
[getSchedulesByTableId, tableId]
);
return (
<ScheduleTable
tableId={tableId}
schedules={schedules}
onScheduleTimeClick={onScheduleTimeClick}
onDeleteButtonClick={onDeleteButtonClick}
/>
);
}
);
export const DraggableSchedule = React.memo(
({ id, data, bg, onDeleteButtonClick }) => {
// transform 변화만 적용
const transformStyle = useMemo(() => {
return CSS.Translate.toString(transform);
}, [transform]);
return (
<Box
ref={setNodeRef}
style={{
transform: transformStyle,
// 기타 스타일
}}
{...listeners}
{...attributes}
>
{/* 콘텐츠 */}
</Box>
);
}
);
const handleDragEnd = useCallback(
(event: any) => {
const { active, delta } = event;
const [tableId, index] = active.id.split(":");
updateSchedulesByTableId(tableId, (schedules) => {
const updatedSchedules = [...schedules];
updatedSchedules[index] = {
...schedules[index],
day: newDay,
range: newRange,
};
return updatedSchedules;
});
},
[updateSchedulesByTableId]
);
결과적으로
이번 과제를 하면서 성능 최적화라는 걸 여러 각도에서 체감해볼 수 있었다.
Lighthouse 기반 최적화는 처음엔 그냥 점수 올리는 느낌으로 접근했는데, 지표 하나하나를 뜯어보면서 LCP
, CLS
같은 렌더링 안정성이나 초기 로딩 속도가 왜 중요한지 조금 감이 잡혔다.
특히 이미지와 같은 정적 자산을 어떻게 구조적으로 처리해야 성능에 영향이 적을지 고민해본 건 꽤 의미 있었다.
SearchDialog
랑 DnD
쪽에서는 그동안 당연하게 쓰던 useCallback
, useMemo
, React.memo
들에 대해 단순히 “써야 한다”가 아니라 어디서 써야 진짜 효과가 나는지를 직접 느낄 수 있었다.
뿐만 아니라 이를 통해 DnD에서 스케줄 테이블 리렌더링을 잡아가는 과정에서는 성능뿐 아니라 인터랙션 자체가 훨씬 부드러워지는 걸 체감할 수 있었다.
사실 엄청 고난도 기술을 쓴 건 아니지만, 기본적인 설계랑 기초적인 최적화만으로도 결과가 꽤 달라진다는 걸 배운 과제였다. 덕분에 성능 쪽을 좀 더 재미있게 바라볼 수 있는 계기가 됐다.