항해 플러스 프론트엔드 6기 10주차, Chapter 4-2. 코드 관점의 성능 최적화

드디어 항해 마지막 주차인 10주차가 되었습니다. 시간이 너무 빠른 것 같아요. 이제 수료만 남겨두고 있는 상황인데, 마지막인 만큼 10주차 과제에 대한 회고 내용을 열심히 정리해보겠습니다.


항해 플러스 프론트엔드 6기 10주차, Chapter 4-2. 코드 관점의 성능 최적화

이전 블로그 링크: https://velog.io/@chan9yu/hanghae-plus-wil10

10주차 시작하기

드디어 항해 마지막 주차인 10주차가 되었습니다. 시간이 너무 빠른 것 같아요. 이제 수료만 남겨두고 있는 상황인데, 마지막인 만큼 10주차 과제에 대한 회고 내용을 열심히 정리해보겠습니다.

10주차 알아두면 좋은 사전지식

과제를 시작하기 전에 먼저 알아두면 좋은 사전지식들을 정리해보겠습니다.

React 렌더링 메커니즘

성능 최적화를 진행하기 전에 React의 렌더링 과정을 정리해보면 좋겠죠?

React의 렌더링 3단계

Trigger (트리거)
초기 렌더링은 ReactDOM.render() 또는 createRoot().render() 호출 시 발생하고, 재렌더링은 setState, useState setter, forceUpdate 호출이나 Context 값 변경, 부모 컴포넌트 리렌더링 시 일어납니다.

Render (렌더링)
함수 컴포넌트를 호출하여 JSX를 반환하고, 새로운 가상 DOM 트리를 생성한 다음 이전 가상 DOM과 비교하는 Reconciliation 과정을 거칩니다. 주의할 점은 이 단계에서는 아직 DOM 변경이 없다는 것입니다.

Commit (커밋)
DOM 노드를 추가, 변경, 제거하고 useEffect, useLayoutEffect 등 부수 효과를 실행합니다. componentDidMount, componentDidUpdate 등 라이프사이클 메서드도 이때 실행됩니다.

Reconciliation (재조정) 알고리즘

React는 두 가지 가정을 기반으로 O(n) 복잡도의 휴리스틱 알고리즘을 사용합니다.

첫 번째 가정은 서로 다른 타입의 요소는 서로 다른 트리를 생성한다는 것입니다.
예를 들어 <div><Counter /></div><span><Counter /></span>로 바뀌면 Counter는 완전히 새로 마운트됩니다.

두 번째 가정은 key props로 어떤 자식 요소가 변경되지 않았는지 힌트를 제공할 수 있다는 것입니다. key 없이 렌더링하면 모든 요소가 재생성될 수 있지만, 안정된 key를 사용하면 효율적인 재사용이 가능합니다.

Fiber와 우선순위 기반 렌더링

React 16부터 도입된 Fiber는 렌더링 작업을 중단하고 재개할 수 있습니다. Time Slicing으로 렌더링 작업을 작은 단위로 나누어 실행하고, 사용자 상호작용, 애니메이션, 데이터 fetching 순으로 우선순위를 부여합니다. React 18의 useTransition, useDeferredValue 등이 이런 Concurrent Features에 해당합니다.

React 메모이제이션의 동작 원리

리액트에서 성능 최적화의 핵심인 메모이제이션들을 알아봅시다.

React.memo 얕은 비교 (Shallow Comparison)

React.memo는 기본적으로 얕은 비교를 수행합니다. 객체 키의 개수가 다르면 false를 반환하고, 같다면 각 키의 값을 참조 비교합니다. 문제가 되는 경우는 매번 새 객체나 새 함수를 props로 전달할 때입니다. 이런 경우 memo가 무효화됩니다.

useMemo vs useCallback

useMemo는 계산 비용이 높은 값을 메모이제이션할 때 사용합니다. 복잡한 계산을 포함한 값을 의존성 배열이 바뀔 때만 다시 계산하도록 할 수 있습니다.
useCallback은 함수 참조를 안정화할 때 사용합니다. 이벤트 핸들러 같은 함수를 메모이제이션해서 자식 컴포넌트의 불필요한 리렌더링을 방지할 수 있습니다.

주의할 점은 의존성이 자주 바뀌면 메모이제이션 효과가 없다는 것입니다. 자주 바뀌는 값을 의존성으로 가진 useCallback은 오히려 성능에 도움이 되지 않을 수 있습니다.

10주차 과제

10주차 과제는 시간표 제작 서비스의 성능 최적화였습니다.
잘 만들어진 것 같지만 성능 문제가 있는 애플리케이션을 최적화하는 과제였어요.

현재 성능 문제점들
  • 수업 검색 모달에서 페이지네이션(무한 스크롤)이 느림
  • 똑같은 API를 계속 호출
  • 드래그/드롭 시 모든 컴포넌트가 리렌더링
  • 시간표가 많아질수록 렌더링이 기하급수적으로 느려짐
기본과제
  • API 호출 최적화 (Promise.all + 캐싱)
  • SearchDialog 불필요한 연산/렌더링 최적화
심화과제
  • DnD 시스템 드래그/드롭 시 렌더링 최적화

어떻게 구현했을까?

API 호출 최적화

먼저 API 호출 부분을 최적화했는데, 기존 코드에서 문제점을 발견했어요. Promise.all을 사용하는 곳을 보니 인자로 받는 배열값들이 다 await으로 되어있었습니다.

// 직렬 실행되는 잘못된 Promise.all 사용  
const fetchAllLectures = async () =>  
	await Promise.all([  
		(console.log("API Call 1", performance.now()), await fetchMajors()),  
		(console.log("API Call 2", performance.now()), await fetchLiberalArts())  
		// ... 6번의 중복 호출  
	]);  

위 코드가 왜 문제가 될까요?

MDN 문서

에 따르면 Promise.all은 Promise 객체들의 배열을 받아서 모든 Promise가 완료될 때까지 기다립니다. 그런데 await
Promise를 기다려서 그 결과값을 반환하는 연산자예요. 즉 await fetchMajors()는 Promise가 아니라 이미 완료된 결과값을
반환합니다.

실제로는 첫 번째 API 완료까지 대기한 후 그 다음에 두 번째 API를 시작하게 되어서, Promise.all에는 이미 완료된 결과값들만 전달됩니다. 결국 각 API 호출이 순차적으로 실행되고 Promise.all에는 이미 완료된 값들이 전달되어서 병렬 처리의 의미가 사라지는 거죠.

반면에 await를 제거하면 각 함수가 즉시 Promise를 반환하면서 실행을 시작하기 때문에 동시에 실행됩니다. 간단히 말해서 Promise.all의 인자로는 "실행 중인 Promise들"이 들어가야 하는데, await를 사용하면 "이미 완료된 결과값들"이 들어가게 되어서 병렬 처리가 불가능해지는 겁니다.

그리고 두 번째 문제가 남았는데요. 총 6번의 API를 호출하는데 같은 fetchMajors를 3번, fetchLiberalArts를 3번 호출하는 구조더라구요. 결국 같은 응답을 받을 API인데 중복으로 호출해서 4번의 불필요한 호출을 하는 게 문제였습니다.

이 부분은 간단하게 클로저를 이용한 캐싱 처리로 개선할 수 있을 것 같아서 LectureService라는 클래스를 만들어서 구현해주었어요.

export class LectureService {  
	private majorsCache: Promise<AxiosResponse<Lecture[]>> | null = null;  
	private liberalArtsCache: Promise<AxiosResponse<Lecture[]>> | null = null;

	public async getMajorLectures() {  
		if (!this.majorsCache) {  
			this.majorsCache = this.fetchMajorLectures();  
		}  
		return this.majorsCache;  
	}

	public async getLiberalArtsLectures() {  
		if (!this.liberalArtsCache) {  
			this.liberalArtsCache = this.fetchLiberalArtsLectures();  
		}  
		return this.liberalArtsCache;  
	}

	public async getAllLectures() {  
		const startTime = performance.now();  
		console.log("API 호출 시작: ", startTime);

		const results = await Promise.all([  
			(console.log("API Call 1", performance.now()), this.getMajorLectures()),  
			(console.log("API Call 2", performance.now()), this.getLiberalArtsLectures()),  
			(console.log("API Call 3", performance.now()), this.getMajorLectures()),  
			(console.log("API Call 4", performance.now()), this.getLiberalArtsLectures()),  
			(console.log("API Call 5", performance.now()), this.getMajorLectures()),  
			(console.log("API Call 6", performance.now()), this.getLiberalArtsLectures())  
		]);

		const endTime = performance.now();  
		console.log("모든 API 호출 완료: ", endTime);  
		console.log("API 호출에 걸린 시간(ms): ", endTime - startTime);

		return results.flatMap((result) => result.data);  
	}

	public clearCache() {  
		this.majorsCache = null;  
		this.liberalArtsCache = null;  
	}

	private fetchMajorLectures() {  
		return axios.get<Lecture[]>("/schedules-majors.json");  
	}

	private fetchLiberalArtsLectures() {  
		return axios.get<Lecture[]>("/schedules-liberal-arts.json");  
	}  
}  

이렇게 Lectures API 호출에 대한 로직을 하나의 클래스로 묶어서 캡슐화하고 응집도를 높이면서 최적화 작업을 진행했습니다. 그렇다면 사용하는 쪽에서는 훨씬 간단하고 직관적인 코드로 정리할 수 있겠죠?

기존에는 20여 줄의 복잡한 API 호출 로직이 있었는데, 이제는 단 1줄로 간소화되었습니다.

const SearchDialog = ({ searchInfo, onClose }: Props) => {  
	const [lectures, setLectures] = useState<Lecture[]>([]);

	useEffect(() => {  
		LectureService.getInstance().getAllLectures().then(setLectures);  
	}, []);

	// ... 나머지 로직은 비즈니스 로직에만 집중  
};  

LectureService를 만들면서 얻은 이점들을 정리해보면, 먼저 관심사 분리가 되었습니다. UI 컴포넌트에서 복잡한 API 로직이 제거되고 비즈니스 로직과 데이터 페칭 로직이 명확하게 분리되었습니다. 코드 가독성도 향상되어서 컴포넌트가 "무엇을" 하는지에 집중할 수 있게 되었어요.

여기서 핵심 포인트는 Promise 자체를 캐시하여 동시 호출 시에도 안전하고 6번에서 2번으로 실제 HTTP 호출을 감소시켰다는 것입니다. 이렇게 하면 첫 번째 호출이 완료되기 전에 같은 API를 또 호출해도 중복 요청이 발생하지 않고 자연스러운 메모이제이션 효과를 얻을 수 있어요.

SearchDialog 컴포넌트 분리와 렌더링 격리

기존에는 하나의 거대한 SearchDialog에서 모든 검색 옵션을 관리했는데, 이를 독립적인 컴포넌트들로 분리했습니다.

각각 독립적으로 메모이제이션된 컴포넌트들을 만들어서 분리했어요.

// 각각 독립적으로 메모이제이션된 컴포넌트들  
export const MemoizedQueryInput = memo(QueryInput);  
export const MemoizedCreditSelect = memo(CreditSelect);  
export const MemoizedGradeSelect = memo(GradeSelect);  
export const MemoizedDaySelect = memo(DaySelect);  
export const MemoizedTimeSelect = memo(TimeSelect);  
export const MemoizedMajorSelect = memo(MajorSelect);  

메인 컴포넌트에서는 useMemo로 필터링 결과를 캐싱하고 useCallback으로 핸들러를 메모이제이션했습니다.

export function SearchDialog({ searchInfo, onClose }: SearchDialogProps) {  
  // useMemo로 필터링 결과 캐싱  
  const filteredLectures = useMemo(() => {  
    // 복잡한 필터링 로직  
  }, [lectures, searchOptions]);

  // useCallback으로 핸들러 메모이제이션  
  const handleQueryChange = useCallback(  
    (value: string) => changeSearchOption("query", value),  
    [changeSearchOption]  
  );

  return (  
    <Modal>  
      <HStack spacing={4}>  
        <MemoizedQueryInput value={searchOptions.query} onChange={handleQueryChange} />  
        <MemoizedCreditSelect value={searchOptions.credits} onChange={handleCreditsChange} />  
      </HStack>  
    </Modal>  
  );  
}  

거대한 컴포넌트를 분리하는 것만으로도 컴포넌트의 리렌더링을 격리하여 불필요한 다른 컴포넌트들의 리렌더링 전파를 막을 수 있습니다.

Chakra UI Component에서 JSX Element로 변경

가장 의외였던 최적화였습니다. 대량 데이터 렌더링에서 UI 라이브러리 컴포넌트 오버헤드가 이렇게 클 줄 몰랐습니다.

Chakra UI 컴포넌트를 사용했을 때는 220ms가 걸렸는데, HTML 요소를 직접 사용하니 40ms로 줄어들었어요.

// Before: Chakra UI 컴포넌트 사용 (220ms)  
<Table size="sm" variant="striped">  
  <Tbody>  
    {visibleLectures.map((lecture, index) => (  
      <Tr key={`${lecture.id}-${index}`}>  
        <Td width="100px">{lecture.id}</Td>  
        <Td width="50px">{lecture.grade}</Td>  
        <Td width="200px">{lecture.title}</Td>  
        <Td width="50px">{lecture.credits}</Td>  
        <Td width="80px">  
          <Button size="sm" colorScheme="green" onClick={() => addSchedule(lecture)}>  
            추가  
          </Button>  
        </Td>  
      </Tr>  
    ))}  
  </Tbody>  
</Table>

// After: HTML 요소 직접 사용 (40ms)  
<Table size="sm" variant="striped">  
  <Tbody>  
    {visibleLectures.map((lecture, index) => (  
      <tr key={`${lecture.id}-${index}`}>  
        <td width="100px">{lecture.id}</td>  
        <td width="50px">{lecture.grade}</td>  
        <td width="200px">{lecture.title}</td>  
        <td width="50px">{lecture.credits}</td>  
        <td width="80px">  
          <button onClick={() => addSchedule(lecture)}>  
            추가  
          </button>  
        </td>  
      </tr>  
    ))}  
  </Tbody>  
</Table>  

UI 라이브러리 컴포넌트들이 매번 theme 계산, props 처리, hooks 실행 등의 오버헤드를 가진다는 걸 알 수 있었습니다. 특히 100개 행에서 각 행마다 8개의 컴포넌트가 있으니 총 800번의 함수 호출이 HTML 요소로는 단순한 DOM 요소 생성으로 바뀌는 차이가 컸습니다.

하지만 기존 Chakra UI에서 제공하는 스타일은 없어지게 되는데, 이 부분은 상위 컴포넌트인 Table에 스타일을 위임해서 동일한 스타일을 보여줄 수 있도록 개선했습니다.

<Table  
	size="sm"  
	variant="striped"  
	sx={{  
		"& tbody tr:nth-of-type(odd)": {  
			backgroundColor: "gray.100"  
		},  
		"& td": {  
			fontSize: "sm",  
			padding: "8px 12px"  
		},  
		"& button": {  
			fontSize: "sm",  
			backgroundColor: "green.500",  
			color: "white",  
			padding: "4px 12px",  
			borderRadius: "md",  
			border: "none",  
			cursor: "pointer",  
			transition: "background-color 0.2s",  
			"&:hover": {  
				backgroundColor: "green.600"  
			}  
		}  
	}}
>  
	<tbody>  
		{visibleLectures.map((lecture, index) => (  
			<tr key={`${lecture.id}-${index}`}>  
				<td style={{ width: "100px" }}>{lecture.id}</td>  
				<td style={{ width: "50px" }}>{lecture.grade}</td>  
				<td style={{ width: "80px" }}>  
					<button onClick={() => addSchedule(lecture)}>추가</button>  
				</td>  
			</tr>  
		))}  
	</tbody>  
</Table>  

이렇게 하면 Chakra UI의 스타일링 시스템은 유지하면서도 반복되는 컴포넌트의 오버헤드는 제거할 수 있습니다. 결과적으로는 약 80% 성능 향상을 달성할 수 있었습니다. 몇 백 번의 컴포넌트 함수 호출이 단순한 DOM 요소 생성으로 바뀌는 차이가 컸어요.

Context Provider 범위 축소하기

가장 중요했던 부분입니다. ScheduleDndProvider의 위치를 App 레벨에서 각 테이블 레벨로 이동시켰습니다.

// Before: App 레벨 Provider  
function App() {  
  return (  
    <ChakraProvider>  
      <ScheduleProvider>  
        <ScheduleDndProvider>  
          {/* 모든 테이블이 영향받음 */}  
          <ScheduleTables />  
        </ScheduleDndProvider>  
      </ScheduleProvider>  
    </ChakraProvider>  
  );  
}

// After: 테이블별 개별 Provider  
function ScheduleCard({ tableId, schedules, ... }) {  
  return (  
    <Stack>  
      <ScheduleDndProvider tableId={tableId} schedules={schedules}> {/* 범위 축소 */}  
        <ScheduleTable {...props} />  
      </ScheduleDndProvider>  
    </Stack>  
  );  
}  

기존에는 한 테이블을 드래그해도 모든 테이블의 모든 컴포넌트가 리렌더링되었는데, 이제는 드래그하는 테이블 내부의 컴포넌트들만 리렌더링됩니다. 이렇게 Context 범위를 축소하는 것만으로도 렌더링 범위를 크게 줄일 수 있다는 점을 배웠습니다.

그래서 결과는

목표했던 성능 최적화를 모두 달성할 수 있었습니다.

기본과제: SearchDialog API 호출, 연산, 렌더링 최적화 완료
심화과제: DnD 시스템 드래그/드롭 시 렌더링 최적화 완료

구체적인 성능 개선 수치
  • API 호출: 6번 → 2번 (67% 감소)
  • 테이블 렌더링: 220ms → 40ms (80% 개선)
  • DnD 렌더링: 전체 앱 → 개별 테이블만

배포 링크

10주차 KPT 회고

Keep

React DevTools Profiler를 통한 성능 측정

이번 과제에서 가장 큰 배운 점은 실제 성능 문제를 수치로 확인하고 개선하는 경험이었습니다. 그동안은 "느린 것 같다"는 주관적 느낌에 의존했는데, React DevTools Profiler를 사용하면서 명확한 수치로 문제를 파악할 수 있었어요.

Promise.all 이해

기존에는 Promise.all을 단순히 "여러 개 API를 동시에 호출하는 것" 정도로만 이해했는데, 배열 안에서 await를 사용하면 순차 실행된다는 함정을 직접 경험했습니다. 또한 단순한 결과 캐싱이 아닌 Promise 자체를 캐싱하는 방식으로 동시 호출 시에도 안전한 캐싱 시스템을 구현할 수 있었습니다.

Context 범위 최적화

ScheduleDndProvider 위치 변경만으로 렌더링 범위를 극적으로 줄일 수 있다는 걸 배웠습니다. Context Provider의 위치가 성능에 이렇게 직접적인 영향을 미칠 줄 몰랐어요. "전역 상태 관리 = Provider를 최상위에"라는 고정관념에서 벗어나서, 필요한 범위에서만 Context를 제공하는 것이 얼마나 중요한지 알게 되었습니다.

Problem

메모이제이션의 적정 수준 판단 어려움

이번 과제를 진행하면서 가장 고민이 되었던 부분은 "어느 정도까지 메모이제이션을 해야 할까?"였습니다. 모든 컴포넌트에 React.memo를 붙이고, 모든 함수에 useCallback을 적용하고, 모든 계산에 useMemo를 사용했는데... 이게 과연 적절한 수준인지 확신이 서지 않았습니다. 메모이제이션도 메모리 비용이 들고, 코드 복잡성도 증가시키는데 어떤 기준으로 선별해야 할지 여전히 고민입니다.

실무에서의 적용 가능성

과제에서는 성능 문제가 명확하게 드러나는 극단적인 상황이었는데, 실무에서는 이런 명확한 성능 병목이 항상 존재하지 않을 수 있다는 생각이 들었습니다. 팀에서 "성능 최적화를 해야 한다"고 했을 때, 어떤 순서로 접근하고 어느 수준까지 진행해야 하는지에 대한 가이드라인이 필요할 것 같습니다.

Try

고급 최적화 기법 학습

이번에는 기본적인 React 최적화에 집중했지만, 앞으로는 더 고급 기법들을 학습해보고 싶습니다.

React 18의 Concurrent Features (Suspense, useDeferredValue 등), 가상화(Virtualization)를 통한 대량 데이터 렌더링, Web Workers를 활용한 메인 스레드 부하 분산, Bundle 분석과 Code Splitting 최적화 같은 것들을 더 깊이 있게 공부해보려고 합니다.

마무리

10주차는 정말 배움이 많았던 주차였습니다. 그동안 "성능 최적화"라고 하면 막연하게 어려운 것이라고 생각했는데, 실제로 문제를 분석하고 단계별로 해결해보니까 체계적으로 접근할 수 있는 영역이라는 걸 알게 되었습니다.

특히 이론과 실제의 차이를 많이 느꼈습니다. 책에서 읽은 React.memo, useMemo, useCallback 같은 개념들이 실제 애플리케이션에서는 어떻게 적용되는지, 어떤 상황에서 효과가 있고 어떨 때는 오히려 부작용이 있는지를 직접 경험할 수 있었습니다.

그리고 성능 최적화는 정답이 없다는 것도 배웠습니다. 상황에 따라, 팀의 우선순위에 따라 다른 선택을 할 수 있고, 그 트레이드오프를 이해하고 합리적인 판단을 내리는 것이 중요하다는 걸 느꼈어요.

드디어 항해가 끝나가는데... 10주 동안 정말 많은 걸 배웠던 것 같습니다. 마지막까지 목요일 밤샘은 변하지 않았지만, 그만큼 깊이 있게 공부할 수 있었던 시간이었어요. 앞으로도 이런 실무에서 바로 써먹을 수 있는 깊이 있는 학습을 계속해나가고 싶습니다.

댓글