과제 체크포인트
과제 요구사항
-
배포 후 url 제출 https://tomatopickles404.github.io/front_6th_chapter4-2/
-
API 호출 최적화(
Promise.all이해) -
SearchDialog 불필요한 연산 최적화
-
SearchDialog 불필요한 리렌더링 최적화
-
시간표 블록 드래그시 렌더링 최적화
-
시간표 블록 드롭시 렌더링 최적화
과제 셀프회고
Lighthouse로 성능을 측정해 본 경험은 있지만, Profiler는 사용 해봤어도 제대로 기능을 제대로 활용하지 못했었습니다. 이번 과제를 통해 profiler를 어떨 때, 어떻게 활용해야 하는지 적절한 쓰임새를 알아가게 되었던 것 같습니다.
기술적 성장
새로 학습한 개념
1. React DevTools Profiler 활용법
언제 사용할까?
- 성능 문제 의심 상황
- 리팩토링 전 후 최적화 비교 (정량적 검증을 위해)
- 복잡한 컴포넌트 트리 분석 (Context, 상태 관리 구조 진단)
어떻게 활용할까?
- 단계별 측정(Record -> 액션 -> Stop)
- Flamegraph에서 문제 지점 찾기 (컴포넌트별 렌더링 시간과 범위 시각화)
- AS-IS / TO-BE 비교
주목해야 할 지표들
-
컴포넌트 색상
- 회색: 리렌더링 안됨 (최적화됨)
- 노란색~빨간색: 렌더링 시간 (긴 시간일수록 빨간색)
-
렌더링 시간
- 개별 컴포넌트 렌더링 시간
- 전체 커밋 시간
- Layout/Passive effects 시간
-
리렌더링 범위
- 예상보다 많은 컴포넌트가 리렌더링되는가?
- 불필요한 컴포넌트까지 영향받는가?
Lighthouse가 전체적인 성능 점수를 제공한다면, Profiler는 React 컴포넌트 레벨의 정밀한 진단을 가능하게 합니다. 이번 Context 최적화 과정에서 "추측이 아닌 수치 기반 최적화"의 중요성을 체감할 수 있었습니다.
2. Promise.all의 이점 분석
기존 코드에서 동일한 API를 중복 호출하는 비효율적인 패턴을 발견하고, 올바른 Promise.all 사용법과 캐싱 메커니즘을 통해 API 호출 성능을 최적화했습니다.
[병렬 처리 vs 순차 처리 비교]
순차 처리 (Sequential)
- 각 API 호출이 이전 호출 완료를 기다림
- 총 시간: API 호출 시간의 합계 (100ms + 100ms = 200ms)
- 네트워크 리소스 비효율적 활용
- 사용자 대기 시간 증가
병렬 처리 (Parallel)
- 모든 API 호출이 동시에 시작
- 총 시간: 가장 오래 걸리는 호출 시간 (max(100ms, 100ms) = 100ms)
- 네트워크 리소스 최대 활용
- 50% 성능 향상으로 사용자 경험 개선
[캐싱과 Promise.all의 시너지 효과]
// 캐싱이 적용된 병렬 처리
const fetchLecturesWithCaching = async () => {
console.log('=== API 호출 시작 ===');
// 첫 번째 호출: 실제 네트워크 요청
const firstCall = await Promise.all([
fetchMajors(), // Cache miss → 네트워크 요청
fetchLiberalArts() // Cache miss → 네트워크 요청
]);
console.log('=== 두 번째 호출 (캐싱 효과) ===');
// 두 번째 호출: 캐시에서 즉시 반환
const secondCall = await Promise.all([
fetchMajors(), // Cache hit → 즉시 반환
fetchLiberalArts() // Cache hit → 즉시 반환
]);
return { firstCall, secondCall };
};
- 첫 번째 호출: 병렬 처리로 네트워크 시간 50% 단축 (200ms → 100ms)
- 두 번째 호출: 캐시 히트로 네트워크 시간 99% 단축 (100ms → 1-2ms)
- 캐시 저장: Promise가 완료되면 결과가 자동으로 캐시에 저장되어 후속 호출에서 즉시 반환
- 메모리 효율성: 동일한 API 중복 호출 시 실제로는 한 번만 네트워크 요청 발생
코드 품질
1. Context를 활용하여 액션과 데이터 레이어 격리
React Context API를 활용하여 데이터와 액션을 분리한 상태 관리 패턴을 적용했습니다.
[문제점]
기존 코드의 { schedulesMap, setSchedulesMap } 객체가 매번 새로 생성되는 문제가 있었습니다.
- 상태 변경 시 액션만 필요한 컴포넌트도 불필요하게 업데이트
- 액션 함수가 재생성 될때마다 전체 트리 리렌더링
[해결 방안]
- 데이터와 액션을 별도의 Context으로 분리하여 관심사를 격리했습니다.
// 데이터 전용 Context
type SchedulesData = {
schedulesMap: SchedulesMap;
};
// 액션 전용 Context
type ScheduleActions = {
setSchedulesMap: React.Dispatch<React.SetStateAction<SchedulesMap>>;
deleteSchedule: (params: TimeInfo & { tableId: string }) => void;
};
const SchedulesDataContext = React.createContext<SchedulesData | null>(null);
const SchedulesActionsContext = React.createContext<ScheduleActions | null>(null);
- 메모이제이션을 활용하여 불필요한 리렌더링을 방지했습니다.
const actions = useMemo(
() => ({ setSchedulesMap, deleteSchedule }),
[setSchedulesMap, deleteSchedule]
);
const data = useMemo(() => ({ schedulesMap }), [schedulesMap]);
- CustomHook으로 사용성을 개선했습니다.
export const useSchedulesData = () => {
const context = React.useContext(SchedulesDataContext);
if (!context) {
throw new Error('useSchedulesData must be used within SchedulesProvider');
}
return context;
};
export const useSchedulesActions = () => {
const context = React.useContext(SchedulesActionsContext);
if (!context) {
throw new Error('useSchedulesActions must be used within SchedulesProvider');
}
return context;
};
[컴포넌트 레이어 격리 예시]
// 데이터만 필요한 컴포넌트
const DataComponent = () => {
const { schedulesMap } = useSchedulesData(); // 액션 변경에 영향받지 않음
return <div>{/* 렌더링 로직 */}</div>;
};
// 액션만 필요한 컴포넌트
const ActionComponent = () => {
const { deleteSchedule } = useSchedulesActions(); // 데이터 변경에 영향받지 않음
return <button onClick={() => deleteSchedule(params)}>삭제</button>;
};
[개선 효과]
AS-IS
- 총 렌더링 시간: 36.9ms
- Render: 36.9ms
- Layout effects: 0.5ms
- Passive effects: 0.9ms
리렌더링된 컴포넌트 수: 46개
TO-BE
- 총 렌더링 시간: 514.4ms → 1.9ms (실제 업데이트 시간)
- Render: 514.4ms (초기 마운트) → 실제 업데이트 시 1.9ms
- Layout effects: 4.7ms → 거의 0ms
- Passive effects: 5ms → 거의 0ms
리렌더링된 컴포넌트 수: 8개 (83% 감소)
Context를 데이터와 액션으로 분리하여 리렌더링 컴포넌트 수를 83% 감소(46개→8개)시키고, 실제 업데이트 성능을 95% 향상(36.9ms→1.9ms)시켰습니다. 이를 통해 불필요한 전체 트리 리렌더링을 방지하고 선택적 업데이트를 구현하여 체감 가능한 수준의 개선이 되었습니다.
2. Observer 추상화
Intersection Observer API를 계층적으로 추상화하여 재사용 가능한 훅을 설계했습니다.
[AS-IS] SearchDialog 컴포넌트에 Intersection Observer와 무한스크롤 로직이 모두 혼재되어 있어 복잡성과 유지보수성이 떨어졌습니다.
const SearchDialog = ({ searchInfo, onClose }: Props) => {
const loaderWrapperRef = useRef<HTMLDivElement>(null);
const loaderRef = useRef<HTMLDivElement>(null);
const [page, setPage] = useState(1);
// 복잡한 필터링 로직
const getFilteredLectures = () => {
// 20줄 이상의 필터링 로직...
};
// Intersection Observer 설정
useEffect(() => {
const $loader = loaderRef.current;
const $loaderWrapper = loaderWrapperRef.current;
if (!$loader || !$loaderWrapper) return;
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting) {
setPage(prevPage => Math.min(lastPage, prevPage + 1));
}
},
{ threshold: 0, root: $loaderWrapper }
);
observer.observe($loader);
return () => observer.unobserve($loader);
}, [lastPage]);
// 200줄 이상의 컴포넌트 로직...
};
[TO-BE] Intersection Observer와 무한스크롤 기능을 각각 커스텀 훅으로 추상화하여 관심사를 분리했습니다.
- Intersection Observer
export function useIntersectionObserver(
callback?: (entry: IntersectionObserverEntry) => void,
options: UseIntersectionObserverOptions = {}
): UseIntersectionObserverReturn {
const { threshold = 0, root = null, rootMargin = '0px', enabled = true } = options;
const ref = useRef<Element>(null);
const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null);
useEffect(() => {
const element = ref.current;
if (!element || !enabled) return;
const observer = new IntersectionObserver(
(entries) => {
const [firstEntry] = entries;
setEntry(firstEntry);
callback?.(firstEntry);
},
{ threshold, root, rootMargin }
);
observer.observe(element);
return () => {
observer.unobserve(element);
observer.disconnect();
};
}, [callback, threshold, root, rootMargin, enabled]);
return { ref, entry, isIntersecting: entry?.isIntersecting ?? false };
}
- 무한 스크롤 전용 Observer 훅
export function useInfiniteScrollObserver({
hasMore,
onLoadMore,
root = null,
threshold = 0,
}: UseInfiniteScrollOptions) {
const handleIntersection = useCallback(
(entry: IntersectionObserverEntry) => {
if (entry.isIntersecting && hasMore) {
onLoadMore();
}
},
[hasMore, onLoadMore]
);
const { ref: intersectionRef, isIntersecting } = useIntersectionObserver(
handleIntersection,
{ threshold, root, enabled: hasMore }
);
return { loaderRef: setRef, isIntersecting };
}
- 무한 스크롤 훅
export function useInfiniteScroll({ items, root }: UseInfiniteScrollOptions) {
const [page, setPage] = useState(1);
const lastPage = useMemo(() => Math.ceil(items.length / PAGE_SIZE), [items.length]);
const visibleItems = useMemo(() => items.slice(0, page * PAGE_SIZE), [items, page]);
const hasMore = page < lastPage;
const handleLoadMore = useCallback(() => {
setPage((prevPage) => Math.min(lastPage, prevPage + 1));
}, [lastPage]);
const { loaderRef } = useInfiniteScrollObserver({
hasMore,
onLoadMore: handleLoadMore,
root,
});
return { page, setPage, lastPage, visibleItems, hasMore, loaderRef, resetPage };
}
[개선 효과]
export const SearchDialog = ({ searchInfo, onClose }: Props) => {
const { lectures, allMajors } = useLectures();
const { searchOptions, filteredLectures } = useSearchOptions(lectures);
const loaderWrapperRef = useRef<HTMLDivElement>(null);
const { visibleItems: visibleLectures, loaderRef } = useInfiniteScroll({
items: filteredLectures,
root: loaderWrapperRef.current,
});
return (
<Modal isOpen={Boolean(searchInfo)} onClose={onClose} size="6xl">
{/* UI 코드만 남음 */}
</Modal>
);
};
- 관심사 분리
- Observer 설정 로직 → useIntersectionObserver
- 무한 스크롤 로직 → useInfiniteScrollObserver
- 페이지 관리 로직 → useInfiniteScroll
- UI 렌더링 → SearchDialog 컴포넌트
- 재사용성
- 테스트 용이
학습 효과 분석
추가 학습 영역으로 Virtual Scrolling을 Intersection Observer 무한스크롤과 함께 구현하면 성능 최적화를 극대화할 수 있을 것이라고 판단했습니다.
Virtual Scrolling 이란? (가상 스크롤링)
화면에 보이는 요소만 실제 DOM에 렌더링하여 성능을 최적화하는 기법입니다. 현재 구현된 무한스크롤과 함께 적용하면 메모리 사용량과 렌더링 성능을 동시에 최적화할 수 있습니다.
[현재 방식의 문제점]
// 문제: 모든 아이템을 DOM에 렌더링
const visibleLectures = filteredLectures.slice(0, page * PAGE_SIZE);
import { FixedSizeList as List } from 'react-window';
function LectureTable({ lectures }: { lectures: Lecture[] }) {
return (
<List
height={600} // 컨테이너 높이
itemCount={lectures.length} // 전체 아이템 수
itemSize={50} // 각 아이템 높이
itemData={lectures} // 데이터 전달
>
{({ index, style, data }) => (
<div style={style}> {/* 위치 계산된 스타일 */}
<LectureItem lecture={data[index]} />
</div>
)}
</List>
);
}
Virtual Scrolling의 핵심 원리
- 윈도우 기법 (Windowing)
// 전체 1000개 강의 중 화면에 보이는 12개만 렌더링
const VISIBLE_ITEMS = Math.ceil(containerHeight / itemHeight); // 12개
const BUFFER_SIZE = 3; // 스크롤 대비 버퍼
const TOTAL_RENDERED = VISIBLE_ITEMS + BUFFER_SIZE * 2; // 18개만 DOM에 존재
- 1000개 아이템이 있어도 실제로는 18개만 DOM에 생성하여 메모리 절약
- 동적 위치 계산
// 스크롤 위치에 따라 렌더링할 아이템 범위 계산
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(startIndex + visibleCount, totalCount);
// 각 아이템의 절대 위치 계산
const itemStyle = {
position: 'absolute',
top: index * itemHeight,
height: itemHeight,
width: '100%'
};
- 스크롤 위치에 따라 어떤 아이템들을 렌더링할지 실시간으로 계산하고, 각 아이템을 원래 위치에 배치
- 스크롤할 때마다 보여줄 아이템들을 다시 계산하고, position: absolute로 원래 자리에 배치하여 자연스러운 스크롤 경험 제공
과제 피드백
- React DevTools을 다뤄보기 좋은 실습이라고 생각했습니다.
- ai 사용하지 않고 하기에도 부담 없는 난이도라고 생각합니다.
- 설계 전반적으로 참조 유지를 꼼꼼하게 파악해야 하는 과제였기 때문에 렌더링 흐름에 대한 이해도가 높아진 것 같습니다.
- 그동안 좋은 과제 만들어주셔서 감사합니다! 덕분에 10주동안 알차고 깊은 경험 했습니다 🥹
리뷰 받고 싶은 내용
- 상황마다 다르겠지만, 실무에서 적절하다고 생각하는 최적화 타이밍은 언제라고 생각하시나요?
- 성능 최적화 작업의 우선순위를 어떻게 정하시는지 궁금합니다!
- 사용자 영향도 vs 개발 비용 균형점
- 어느 정도 성능 개선이 있어야 "의미있는" 최적화로 판단하는지
- 팀 내 성능 최적화 작업 할당 기준
- 팀에서 성능 기준이나 가이드라인을 어떻게 정하고 관리하시는지 궁금합니다!
- 렌더링 시간, 번들 크기 등의 구체적 기준점
- 성능 회귀를 방지하는 팀 프로세스
과제 피드백
지호님~ 이번에도 과제 너무 잘해주셨네요. 이제 마지막 주차네요 ㅎㅎ, 내일 수료식때 인사라도 한 번해요 :)
Q. 상황마다 다르겠지만, 실무에서 적절하다고 생각하는 최적화 타이밍은 언제라고 생각하시나요?
A. 실무에서 성능은 매 과제를 할때마다 디테일하게 측정하진 않습니다. 물론 성능에 민감한 과제의 경우에는 R&D과정에서 성능을 측정하면서 제일 좋은 안을 선택하겠지만 일반적인 경우에는 매번 과제마다 성능을 측정하진 않아요. 기본적으로는 성능을 고려해서 개발을 진행해야하는 것이 당연한 것이고.(경험에 의한 고려죠.) 이후 과제 진행중 혹은 운영중 성능 이슈가 발생해서 성능을 분석해서 문제를 해결하는 경우도 많습니다. 보통 이 경우는 백엔드 API 이슈인 경우도 많죠. 센트리같은 로그 수집도구를 활용해 라이트 하우스 지표는 주기적으로 확인하고 있습니다.
Q. 성능 최적화 작업의 우선순위를 어떻게 정하시는지 궁금합니다!
A. 사실 뭐하나 놓칠 수 없어요. 그런데 FE 성능 최적하는 어느정도 체계화가 되어 있고 이럴땐 이런이런 방법이라는 방안들이 이미 많이 나온 상황입니다. 그래서 대부분 스펙을 보고 이상황에선 성능이슈가 있을 수 있으니 이런이런 기법을 사용해야지 이렇게 각이 나오는 경우가 많죠. 예를들어 긴 리스트의 경우는 가상스크롤을 고려하듯 말입니다. 아주 마이크로한 성능 튜닝은 성능에 민감한 UI를 제외하고는 많이 고려하진 않습니다. 아시다시피 빠르면 빠를 수록 좋은건 당연한 것이지만 5ms 빠르게 하기위해 많은 시간을 쓰는건 불필요 할 수 있다는 것이죵. 과제에서 FE 주어진 시간내에서 가능한 최적화를 구현한다가 맞겠네요.
Q. 에서 성능 기준이나 가이드라인을 어떻게 정하고 관리하시는지 궁금합니다!
A. 렌더링 시간은 라이트하우스 지표로 정해진 것은 있지만 그렇게 까다롭게 보진 않고 있습니다. 번들 크기에 대한 내용보다는 기술적으로 이건 하자고 정하는게 많아요. 예를들면 코드스플리팅을 활용한 레이지로딩은 필수 적용, 트리쉐이킹 필수 적용 이런것들이죠.. 성능 회귀는 UI만의 문제의 경우는 어느정도 테스트 케이스로 커버할 수 있습니다.
수고하셨습니다~