항해플러스 프론트엔드 5기 후기(10주차) - 코드 관점의 성능 최적화

이번 챕터에서는 성능 최적화를 주제로 두 가지 방향에서 과제를 진행했다.
첫 번째는 웹 페이지의 초기 렌더링 성능을 개선하는 것이었고, 두 번째는 React 컴포넌트 렌더링 성능과 상태 구조 최적화에 집중하는 과제였다.
처음엔 단순히 성능 점수를 높이는 것에만 집중했지만, 과제를 진행하면서 "왜 이 부분에서 비용이 발생하는지", "렌더링 최적화는 어떤 흐름으로 접근해야 하는지", "상태 구조는 어떻게 설계해야 이후 최적화가 쉬운지" 등을 더 깊게 고민해볼 수 있는 시간이었다.
이 글에서는 두 가지 최적화 과제를 진행하면서 발견한 문제와 개선 과정을 정리해보려 한다.
💡 Lighthouse 기반 웹 성능 최적화
최초 성능 보고서
lighthouse(CI) 성능 보고서

pagespeed 성능 보고서


주요 개선 내용
1. 대규모 레이아웃 변경 피하기 (CLS) & 오프스크린 이미지 지연
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처리 - 브라우저는 초기 layout 계산 시 모든
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를 명시적으로 설정해 브라우저가 이미지 크기를 추정하지 않도록 함

2. 차세대 형식을 사용해 이미지 제공 & 효율적으로 이미지 인코딩하기


초기 이미지 파일
- 각 이미지 파일은 jpg 및 png 포맷으로 제공
- 파일 포맷 특성상 압축 효율이 낮고, 동일 품질 대비 파일 크기가 큼
개선 이미지 파일
- 모든 이미지 파일을 WebP 포맷으로 변환하여 제공
- WebP는 손실 및 무손실 압축 모두 지원하며, 동일 품질 기준으로 jpg, png 대비 훨씬 더 높은 압축 효율 제공
개선 효과
- WebP 형식은 동일한 이미지 대비 jpg 및 png 형식보다 파일 크기가 더 작음
- 이미지 파일 크기 감소로 네트워크 전송량이 줄어듦
3. 이미지 크기 적절하게 설정하기

초기 이미지 파일
- 각 이미지 파일의 원본 해상도(원본 px 단위)가 렌더링 시 실제 표시 크기에 비해 과도하게 큼
- 브라우저는 필요한 렌더링 크기에 맞게 다운스케일링 처리하지만, 원본 전송량(네트워크 비용)은 그대로 발생
- 프로젝트 구조상 로컬 정적 파일(images 디렉토리 내 저장) 기반으로 관리되어 있어, 초기에는 별도의 사이즈 최적화가 적용되지 않은 상태였음
개선 이미지 파일
- 각 이미지 파일을 실제 렌더링 영역(visible size)을 고려한 적정 해상도로 리사이징
- 만약 CDN 서비스를 도입했다면 이미지 리사이징 API(예: width/quality 파라미터)를 통해 자동 최적화를 적용할 수 있었겠지만, 이번 프로젝트에서는 로컬 파일 기반인 점을 고려해 수동 리사이징 및 WebP 압축 최적화 방식으로 대응
- 결과적으로 렌더링 크기에 적합한 해상도로 이미지를 재생성하여 전송량을 줄임
개선 효과
- 이미지 크기를 줄임으로써 네트워크 전송량 감소 및 렌더링 속도 향상
4. 콘텐츠가 포함된 최대 페인트 요소 (LCP)
LCP (Largest Contentful Paint)
- 페이지가 렌더링 중에 가장 큰 요소가 렌더링 되는 시간을 측정하는 지표
문제 상황
- Lighthouse 분석 결과,
Hero Image가 콘텐츠가 포함된 최대 페인트 요소 (LCP)로 측정됨 - 측정 시 전체 LCP 타이밍: 약 2470ms
- 구성 비율:
| 단계 | 비율 | 시간 |
|---|---|---|
| TTFB | 7% | 170ms |
| 로드 지연 | 1% | 20ms |
| 로드 시간 (다운로드) | 71% | 1760ms |
| 렌더링 지연 | 21% | 520ms |
개선 내용
- Hero Image preload 적용
- Hero Image (Desktop / Tablet / Mobile) 각각에 대해
<link rel="preload">태그 적용 - 브라우저가 이미지를 우선적으로 로드하도록 개선
- Hero Image (Desktop / Tablet / Mobile) 각각에 대해
<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적용)- Hero Image 에
aspect-ratio적용하여 초기 레이아웃 시height공간 확보 - 브라우저가 이미지 크기를 추정하지 않도록 함
- Hero Image 에
<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 실행)- HTML 파싱 후 실행 →
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>
개선 사후 보고서
lighthouse 성능 보고서

최초 성능 보고서와 비교
| 지표 | 최초 성능 보고서 | 개선 사후 보고서 |
|---|---|---|
| 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점으로 개선됨- 이는 성능 관련 모든 지표가 최적화되어 있음을 의미
- 사용자 경험 향상
pagespeed 성능 보고서


최초 성능 보고서와 비교
| 항목 | 개선 전 | 개선 후 | 변화 |
|---|---|---|---|
| 총점 | 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초 빠름) |
Largest Contentful Paint (LCP) 개선
- 개선 전: 2.5초 → 개선 후: 0.6초
Hero Image에preload적용 +WebP변환 +Critical CSS적용 +JS defer적용 등의 영향Hero Image의paint시점이 크게 앞당겨짐
Cumulative Layout Shift (CLS) 개선
- 개선 전: 0.477 (기준 초과, 매우 나쁨) → 개선 후: 0.016 (안정 영역)
Hero Image에aspect-ratio적용 +img구조 변경 (<picture>+<source>사용)- 레이아웃 시프트가 발생하지 않음
Total Blocking Time (TBT) 개선
- 개선 전: 110ms → 개선 후: 0ms
Cookie Consent JS를defer처리 →main thread blocking원인 제거됨
First Contentful Paint (FCP), Speed Index 개선
FCP: 0.7초 → 0.6초Speed Index: 1.0초 → 0.6초- 이미지 최적화 +
preload+ 레이아웃 안정화 영향 → 초기 렌더링 빠르게 진행됨
🚀 React 렌더링 최적화
Lighthouse 기반 최적화가 '페이지 초기 렌더링' 관점이었다면, 이번에는 실제 사용자 상호작용 중 발생하는 렌더링 비용을 줄이는 것이 목표였다.
특히 React Devtool의 Profiler를 활용해 렌더링을 확인해보니, SearchDialog 페이지네이션과 DnD 시스템에서 불필요한 렌더링과 연산 비용이 눈에 띄게 발생하고 있었다.
SearchDialog 최적화
API 호출 최적화
기존에는 내부에서 잘못된 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 };
};

결과적으로
- 중복 API 요청을 방지하고,
- 동일한 요청은 한 번만 실행되며
- 병렬로 빠르게 응답을 받을 수 있는 구조로 개선되었다.
불필요한 연산 줄이기
초기 코드에는 해당 컴포넌트에 불필요한 연산이 존재했다.
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 렌더링 최적화
또한 초기 코드에서는 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],
);
결과적으로
- 드래그 중에는 필요한 테이블과 스케줄만 리렌더링
- 드롭 시에도 변경된 테이블만 업데이트되어 불필요한 리렌더링 방지
- DnD 전체 UX가 매우 부드럽게 개선되었다.

🧠 회고
이번 과제를 하면서 성능 최적화라는 걸 여러 각도에서 체감해볼 수 있었다.
Lighthouse 기반 최적화는 처음엔 그냥 점수 올리는 느낌으로 접근했는데, 지표 하나하나를 뜯어보면서 LCP, CLS 같은 렌더링 안정성이나 초기 로딩 속도가 왜 중요한지 조금 감이 잡혔다.
특히 이미지와 같은 정적 자산을 어떻게 구조적으로 처리해야 성능에 영향이 적을지 고민해본 건 꽤 의미 있었다.
SearchDialog랑 DnD 쪽에서는 그동안 당연하게 쓰던 useCallback, useMemo, React.memo들에 대해 단순히 “써야 한다”가 아니라 어디서 써야 진짜 효과가 나는지를 직접 느낄 수 있었다.
뿐만 아니라 이를 통해 DnD에서 스케줄 테이블 리렌더링을 잡아가는 과정에서는 성능뿐 아니라 인터랙션 자체가 훨씬 부드러워지는 걸 체감할 수 있었다.
사실 엄청 고난도 기술을 쓴 건 아니지만, 기본적인 설계랑 기초적인 최적화만으로도 결과가 꽤 달라진다는 걸 배운 과제였다. 덕분에 성능 쪽을 좀 더 재미있게 바라볼 수 있는 계기가 됐다.

comments
loading…