강신규
[항해 플러스 프론트엔드 코스 5기] 10주차 회고 본문

안녕하세요! 프론트엔드 개발자 강신규입니다.
마지막 주차에는 기본과제와 심화과제 2가지로 나눠져 있는데요.
회고 시작하겠습니다.
기본과제 체크포인트
배포링크
https://front-5th-chapter4-2-basic-theta.vercel.app/
과제 요구사항 만족
- 배포 후 url 제출
- 성능 개선 보고서 작성
- Lighthouse 점수 이해
- Core Web Vital 이해
과제 셀프회고
CORS 에러
과제 최적화와 직접적인 관련은 없지만, 에러를 만났던 상황을 기록하기 위해 정리하였습니다.
에러 상황
index.html 파일을 브라우저에서 열었을 때 CORS 에러가 발생했습니다.
에러 메시지는 익숙했지만 정확한 원인을 몰라 관련 내용을 조사하였습니다.
CORS란?
CORS(Cross-Origin Resource Sharing)는 다른 출처(origin) 간의 리소스 공유를 제한하는 브라우저의 보안 정책입니다.
한 출처(origin)에서 실행 중인 웹 애플리케이션이 다른 출처의 리소스에 접근하려 할 때, 명시적인 허용(CORS 설정)이 없다면 브라우저는 이를 차단합니다.
같은 출처(Same-Origin)의 기준
브라우저는 프로토콜(Protocol), 도메인(Domain), 포트 번호(Port)의 세 가지가 모두 같을 때만 같은 출처(same-origin)로 간주합니다.
예를 들어
http://localhost:8000
http://localhost:3000
위 두 URL은 포트 번호가 다르므로 다른 출처로 간주됩니다.
반대로, 아래 두 URL은 프로토콜, 도메인, 포트 번호가 모두 같기 때문에 같은 출처입니다.
http://localhost:8000/page1.html
http://localhost:8000/page2.html
요약하면
출처 = 프로토콜 + 도메인 + 포트 번호
이 세 요소 중 하나라도 다르면 다른 출처로 인식되어 CORS 정책이 적용됩니다.
브라우저 입장에서 file://singyuKangOne/index.html 과 file://singyuKangTwo/index.html 은 서로 다른 출처로 인식됩니다.
하나의 파일에서 다른 디렉토리의 파일을 불러오면 Cross-Origin 요청이 되며,
이때 CORS 정책에 의해 리소스 요청이 차단되어 에러가 발생합니다.
과제 초기 성능
🎯 Lighthouse 점수
| 카테고리 | 점수 | 상태 |
|---|---|---|
| Performance | 72% | 🟠 |
| Accessibility | 82% | 🟠 |
| Best Practices | 75% | 🟠 |
| SEO | 82% | 🟠 |
| PWA | 0% | 🔴 |
📊 Core Web Vitals (2024)
| 메트릭 | 설명 | 측정값 | 상태 |
|---|---|---|---|
| LCP | Largest Contentful Paint | 14.78s | 🔴 |
| INP | Interaction to Next Paint | N/A | 🟢 |
| CLS | Cumulative Layout Shift | 0.011 | 🟢 |
이미지 리소스 최적화: JPG, PNG → WebP 변경
변경 전 코드
<img class="desktop" src="images/Hero_Desktop.jpg" />
<img class="mobile" src="images/Hero_Mobile.jpg" />
<img class="tablet" src="images/Hero_Tablet.jpg" />
변경 후 코드
<img class="desktop" src="images/Hero_Desktop.webp" />
<img class="mobile" src="images/Hero_Mobile.webp" />
<img class="tablet" src="images/Hero_Tablet.webp" />
기존 JPG 이미지를 WebP 포맷으로 변환하여 적용하였습니다.
WebP는 더 작은 용량으로 비슷한 이미지 품질을 유지할 수 있어 페이지 로딩 속도 향상!
성능 개선 결과
| LCP | Largest Contentful Paint | 9.61s | 🔴 느림 |
|---|---|---|---|
| INP | Interaction to Next Paint | N/A | 🟢 양호 |
| CLS | Cumulative Layout Shift | 0.011 | 🟢 양호 |
LCP (Largest Contentful Paint): 14.7초 → 9.61초로 개선
Lighthouse 성능 점수: 81점 → 96점으로 상승
이미지 포맷 변경만으로도 페이지의 초기 로딩 성능에 직접적인 영향을 미침을 확인할 수 있었습니다.
폰트 최적화
@font-face {
font-family: "Heebo";
src: url("fonts/Heebo-Light.woff2") format("woff");
font-display: swap;
font-weight: 300;
font-style: light;
}
@font-face {
font-family: "Heebo";
src: url("fonts/Heebo-Regular.woff2") format("woff");
font-display: swap;
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "Heebo";
src: url("fonts/Heebo-Medium.woff2") format("woff");
font-display: swap;
font-weight: 600;
font-style: medium;
}
@font-face {
font-family: "Heebo";
src: url("fonts/Heebo-Bold.woff2") format("woff");
font-display: swap;
font-weight: 700;
font-style: bold;
}
기존에는 구글 폰트 링크를 통해 외부에서 폰트를 불러왔으나, 성능 최적화를 위해 폰트를 자체 호스팅하는 방식으로 변경했습니다.
특히, woff2 파일 형식은 ttf 형식에 비해 더 높은 압축률을 제공하여 파일 크기를 줄이고, 로딩 시간을 단축시킵니다.
📊 Core Web Vitals (2024)
| 메트릭 | 설명 | 측정값 | 상태 |
|---|---|---|---|
| LCP | Largest Contentful Paint | 8.78s | 🔴 |
| INP | Interaction to Next Paint | N/A | 🟢 |
| CLS | Cumulative Layout Shift | 0.011 | 🟢 |
이미지 렌더링 방식 변경
기존에는 HTML에서 각 디바이스별 이미지를
태그로 모두 불러온 뒤, CSS에서 display: none으로 보이지 않는 이미지를 숨기는 방식을 사용했습니다.
section.hero img.desktop {
display: none;
}
section.hero img.tablet {
display: initial;
}
- 실제로는 모든 이미지를 로딩하지만, 화면에 보이는 이미지만 렌더링
- 불필요한 이미지도 다운로드
아래와 같이 <picture> 요소를 사용해 미디어 쿼리 기반의 조건부 이미지 로딩으로 구조를 변경했습니다.
<picture>
<source media="(max-width: 576px)" srcset="images/Hero_Mobile.webp" />
<source media="(min-width: 577px) and (max-width: 960px)" srcset="images/Hero_Tablet.webp" />
<img
src="images/Hero_Desktop.webp"
width="900"
height="600"
alt="Hero Desktop"
fetchpriority="high"
loading="eager"
/>
</picture>
브라우저가 현재 화면 크기에 맞는 하나의 이미지만 선택적으로 로드 -> 불필요한 이미지 리소스 낭비가 줄어듦
📊 Core Web Vitals (2024)
| 메트릭 | 설명 | 측정값 | 상태 |
|---|---|---|---|
| LCP | Largest Contentful Paint | 3.38s | 🟠 |
| INP | Interaction to Next Paint | N/A | 🟢 |
| CLS | Cumulative Layout Shift | 0.001 | 🟢 |
| 항목 | 설명 | 변경 전 | 상태 | 변경 후 |
|---|---|---|---|---|
| LCP | Largest Contentful Paint | 8.78초 | 🔴 느림 | 3.38초로 개선 ✅ |
브라우저에 맞는 이미지 최적화로 LCP가 가장 큰폭으로 감소하였습니다
스크립트 로딩 최적화
변경전
<script type="text/javascript" charset="UTF-8">
cookieconsent.run({
...
});
</script>
기존에는 HTML 파싱 도중에 스크립트를 즉시 실행을 하였는데 이로 인해 렌더링이 차단이 됩니다.
<script type="text/javascript" defer>
document.addEventListener("DOMContentLoaded", function () {
cookieconsent.run({
...
});
});
</script>
defer 속성 추가로 HTML 파싱이 끝난 후 스크립트를 실행하도록 변경 -> 초기 화면 표시 속도 개선
📊 Core Web Vitals (2024)
| 메트릭 | 설명 | 측정값 | 상태 |
|---|---|---|---|
| LCP | Largest Contentful Paint | 0.8s | 🟢 |
| INP | Interaction to Next Paint | N/A | 🟢 |
| CLS | Cumulative Layout Shift | 0.001 | 🟢 |
웹 접근성 및 SEO 최적화
meta description 추가
<meta
name="description"
content="최신 VR 헤드셋과 첨단 기술 제품을 만나보세요. 고품질 가상현실 체험을 위한 프리미엄 VR 기기와 액세서리를 합리적인 가격에 제공합니다."
/>
검색 엔진 결과 페이지(SERP)에서 웹페이지 요약문으로 활용
사용자 및 검색 엔진에 페이지 내용을 명확히 전달
모든 이미지 alt 속성 추가
<img
src="images/menu_icon.webp"
alt="menu-icon"
width="24"
height="24"
/>
검색 엔진이 이미지를 인식할 수 있어 SEO 측면에서도 긍정적인 효과
Lighthouse & PageSpeed Insight 측정 결과
Local 환경 – Lighthouse 측정 결과
로컬 환경에서 Chrome DevTools의 Lighthouse로 성능 측정
Local 환경 – PageSpeed Insight 결과
배포 환경 – PageSpeed Insight 결과
심화과제 체크포인트
배포링크
https://front-5th-chapter4-2-advanced-hazel.vercel.app/
과제 요구사항
- 배포 후 url 제출
- API 호출 최적화(
Promise.all이해) - SearchDialog 불필요한 연산 최적화
- SearchDialog 불필요한 리렌더링 최적화
- 시간표 블록 드래그시 렌더링 최적화
- 시간표 블록 드롭시 렌더링 최적화
과제 셀프회고
API 호출 최적화 – 중복 요청 제거 및 병렬 처리 개선
초기상황
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()),
]);
초기 코드에서는 동일한 API(fetchMajors, fetchLiberalArts)를 await 키워드와 함께 여러 번 중복 호출하고 있었습니다.
let majorsCache: Promise<AxiosResponse<Lecture[]>> | null = null;
let liberalArtsCache: Promise<AxiosResponse<Lecture[]>> | null = null;
const getCachedMajors = () => {
if (!majorsCache) {
console.log("전공 데이터 새로 요청", performance.now());
majorsCache = fetchMajors();
} else {
console.log("전공 데이터 캐시 사용", performance.now());
}
return majorsCache;
};
const getCachedLiberalArts = () => {
if (!liberalArtsCache) {
console.log("교양 데이터 새로 요청", performance.now());
liberalArtsCache = fetchLiberalArts();
} else {
console.log("교양 데이터 캐시 사용", performance.now());
}
return liberalArtsCache;
};
export const fetchAllLectures = async () => {
console.log("API 호출 시작", performance.now());
const promises = [
getCachedMajors(), // 1번째 호출 시에만 실제 API 요청
getCachedLiberalArts(), // 1번째 호출 시에만 실제 API 요청
getCachedMajors(), // 캐시된 Promise 재사용
getCachedLiberalArts(), // 캐시된 Promise 재사용
getCachedMajors(), // 캐시된 Promise 재사용
getCachedLiberalArts(), // 캐시된 Promise 재사용
];
return await Promise.all(promises);
};
처음 호출 시에만 API를 요청하고, 이후에는 같은 Promise를 캐시하여 재사용하도록 변경하였습니다.
또한 await를 각 호출 앞에서 제거하고, Promise.all()로 병렬 처리 구현하였습니다.
📉 API 호출 시간
199.89ms → 136.60ms로 약 32% 성능 개선
불필요한 연산 최적화
컴포넌트 분리
3주차 강의에서 컴포넌트가 잘 분리되어 있어야 최적화가 쉬워진다는 내용이 기억나 리팩토링을 다음과같이 먼저 진행하였습니다.
고비용 함수 최적화
const getFilteredLectures = () => {
const { query = "", credits, grades, days, times, majors } = searchOptions;
return lectures
.filter(/* 검색어 기준 필터 */)
.filter(/* 학년 기준 필터 */)
.filter(/* 전공 기준 필터 */)
.filter(/* 학점 기준 필터 */)
.filter(/* 요일 기준 필터 */)
.filter(/* 시간 기준 필터 */);
};
학생이 신청 가능한 시간표의 갯수는 25,677개 입니다.
따라서 getFilteredLectures 함수가 호출될 때마다 이 모든 데이터에 대해 여러 단계의 filter 연산을 수행하게 됩니다.
벌써부터 너무 연산이 많다는 생각이 들지 않나요? 이를 위해 고비용 연산 결과를 캐싱하는 useMemo를 활용했습니다:
const filteredLectures = useMemo(() => {
const { query = "", credits, grades, days, times, majors } = searchOptions;
return lectures
.filter(/* ... */)
// ... 여러 필터 체인
}, [searchOptions, lectures]);
이로인해 searchOptions나 lectures가 변경되지 않으면 필터링 작업을 다시 수행하지 않도록 변경
컴포넌트가 다른 이유로 리렌더링되어도 이미 계산된 결과를 재사용
SearchDialog 최적화
pagination 최적화
제일 아래 스크롤에 도달하게 되면은 다음 페이지 과목을 새로 업데이트하게 되는데 불필요하게 이미 존재하는 테이블 Row 데이터와 테이블 위의 컴포넌트들이 렌더링이 발생하게 됩니다.
이를 위해 테이블을 기준으로 SearchFilters, LectureTable로 나눈후
<VStack spacing={4} align="stretch">
<SearchFilters
searchOptions={searchOptions}
allMajors={allMajors}
onSearchOptionChange={handleSearchOptionChange}
/>
<Text align="right">검색결과: {filteredLectures.length}개</Text>
<LectureTable
visibleLectures={visibleLectures}
onAddSchedule={addSchedule}
loaderRef={loaderRef}
ref={loaderWrapperRef}
/>
</VStack>
useCallback으로 핸들러 메모이제이션 + React.memo를 통해 컴포넌트 메모이제이션하여 최적화를 진행하였습니다.
const handleTimesChange = useCallback(
(times: number[]) => {
onSearchOptionChange("times", times);
},
[onSearchOptionChange]
);
const handleMajorsChange = useCallback(
(majors: string[]) => {
onSearchOptionChange("majors", majors);
},
[onSearchOptionChange]
);
사진에서 보여지는 것처럼 SearchFilters와 이전 컴포넌트들이 렌더링이 발생하지 않은것을 확인할 수 있으며 성능개선이 이루어진것을 확인하실 수 있습니다.
전공데이터 최적화
학년 또는 요일 CheckBox를 누르게 되면은 수많은 전공 데이터들을 다시 렌더링하게 되는 문제가 발생합니다.
위와 마찬가지로 하나의 컴포넌트를 3개의 컴포넌트로 분리하여 memo 작업을 진행하였습니다.
<VStack spacing={4} align="stretch">
<BasicSearchControls
searchOptions={searchOptions}
onSearchOptionChange={onSearchOptionChange}
/>
{/* 시간 + 전공 */}
<HStack spacing={4}>
<TimeSelector
times={searchOptions.times}
onTimesChange={handleTimesChange}
/>
<MajorSelector
majors={searchOptions.majors}
allMajors={allMajors}
onMajorsChange={handleMajorsChange}
/>
</HStack>
</VStack>
DraggableSchedule 최적화
드래그를 통해 수업을 옮기게 되면은 다른 시간표 영역에서도 모두 리렌더링이 발생하여 성능 저하가 발생합니다.
사용자가 수업을 드래그 → DndContext 상태 변경 → active.id 업데이트
DndContext 상태 변경
↓
모든 ScheduleTable 컴포넌트가 리렌더링 (useDndContext 때문에)
↓
각 ScheduleTable 내부의 모든 DraggableSchedule도 리렌더링
이전과 마찬가지로 컴포넌트에 대한 분리를 진행했으며, schedules에 useMemo를 사용해 의존성 변화가 없을때에는 기존값을 사용하도록 하여 불필요한 렌더링을 막았습니다.
const scheduleItems = useMemo(() =>
schedules.map((schedule, index) => ({
key: `${schedule.lecture.id}-${schedule.day}-${schedule.range[0]}`,
id: `${tableId}:${index}`,
data: schedule,
bg: colorMap[schedule.lecture.id],
onDeleteButtonClick: () => onDeleteButtonClick?.({
day: schedule.day,
time: schedule.range[0],
})
}))
, [schedules, tableId, colorMap, onDeleteButtonClick]);
코치님 피드백
지난 10주간 너무 너무 수고 많았어요~ 성능 최적화는 하나의 영역이 아니라 여러 작은 개선점들이 모여 의미 있는 변화를 만들어내는 과정이라, 눈에 띄는 결과를 얻기 위해 신경 써야 할 부분이 정말 많구나 하면서 조금 더 시야가 넓어지는 계기가 되었기를 바랍니다.
React와 JavaScript 코딩뿐만 아니라 개발과 웹 전반에 걸쳐 고려해야 할 요소들이 많다는 것을 체감하는 소중한 경험이 되었길 바랍니다.
이 10주간의 값진 경험이 앞으로의 개발자 커리어에 큰 밑거름이 되기를 진심으로 응원합니다! 앞으로도 화이팅입니다!
'항해 플러스 프론트엔드 5기' 카테고리의 다른 글
| [항해 플러스 프론트엔드 코스 5기] 9주차 회고 (0) | 2025.05.30 |
|---|---|
| [항해 플러스 프론트엔드 코스 5기] 8주차 회고 (0) | 2025.05.30 |
| [항해 플러스 프론트엔드 코스 5기] 7주차 회고 (1) | 2025.05.30 |
| [항해 플러스 프론트엔드 코스 5기] 6주차 회고 (0) | 2025.05.30 |
| [항해 플러스 프론트엔드 코스 5기] 5주차 회고 (0) | 2025.05.30 |