이번 챕터에서는 성능 최적화를 주제로 두 가지 방향에서 과제를 진행했다.
첫 번째는 웹 페이지의 초기 렌더링 성능을 개선하는 것이었고,
두 번째는 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에서 스케줄 테이블 리렌더링을 잡아가는 과정에서는 성능뿐 아니라 인터랙션 자체가 훨씬 부드러워지는 걸 체감할 수 있었다.
사실 엄청 고난도 기술을 쓴 건 아니지만, 기본적인 설계랑 기초적인 최적화만으로도 결과가 꽤 달라진다는 걸 배운 과제였다. 덕분에 성능 쪽을 좀 더 재미있게 바라볼 수 있는 계기가 됐다.
