| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- 알고리즘
- 리액트
- SSE적용방법
- SSE
- 클린코드
- 항해99
- JavaScript
- 탐욕알고리즘
- 그리디
- 백준 반례
- 코테
- SSE 적용 방법
- 백준 19939
- EC2
- 자바스크립트
- 백준
- 프로그래머스
- 테스트코드
- react
- 프론트엔드
- 항해플러스
- next.js
- 2025년회고
- greedy
- 리팩토링
- 코딩테스트
- SSE 후기
- 항해
- jQuery
- 박 터뜨리기
- Today
- Total
공부 및 일상기록
[React] 성능 최적화 하기 본문
이번 과제에서는 드래그 앤 드롭이 존재하는 시간표 프로젝트를 최적화하는 미션이 있었다.
주어진 문제점은 다음과 같았다.
- API 중복 호출 막기
- API 호출 시 병렬로 호출이 안되고 있는데 원인 찾기
- 스케줄 검색과 관련하여 필터들 적용 시 모든 필터 리렌더링 되는 문제 해결하기
- 스케줄 리스트 무한스크롤 시 이전 스크롤까지 리렌더링 되는 문제 해결하기
- 드래그 시 리렌더링 방지하기
- 드롭 시 리렌더링 방지하기
사실 더 적용하려면 더욱 많이 찾아낼 수 있지만 일단 이번 과제는 이정도였다.
그래서 API중복 호출문제와 병렬이 안되는 문제는 비교적 간단하니 코드로 알아보고, 나머지는 리렌더링이 얼마나 심한지 알아보기 위해 React Dev Tools를 사용해서 확인해보았다.
위처럼 관련 없는 컴포넌트들이 리렌더링이 되는 구조였다. 한번 그럼 모든 부분을 차근차근 수정해나가보자!
1. API 호출 최적화
여기에는 크게 두 가지 문제가 존재한다.
첫 번째는 Promise.all 안에 promise만 넘겨야하는데 await를 붙여서 병렬로 실행되지 못하는 문제.
두 번째는 동일 api의 중복 호출이다. (물론 과제 특성상 일부러 같은 api를 여러번 호출시켰고, 그 때 캐싱 전략을 이용하라는 의미였다.)
첫 번째 케이스의 경우 매우 단순한 문제이므로 await만 지워서 해결이 되었다.
그리고 두 번째 케이스의 경우 요청을 캐싱하도록 하였다.
import axios, { AxiosResponse } from "axios";
import { baseUrl } from "../baseUrl";
type CacheEntry = {
promise: Promise<AxiosResponse<unknown>>;
time: number;
};
const cache = new Map<string, CacheEntry>();
const TTL = 5 * 60 * 1000; //5분
export const fetchWithCache = async <T>(
url: string,
options?: { force?: boolean }
): Promise<AxiosResponse<T>> => {
const now = Date.now();
const cached = cache.get(url);
// force 요청이 아니고, 캐시가 존재하며 TTL 이내라면 캐시 반환
if (!options?.force && cached && now - cached.time < TTL) {
console.log("캐시 히트 : ", url);
return cached.promise as Promise<AxiosResponse<T>>;
}
// 그 외 새 요청 실행
console.log("API 호출 : ", url);
const request = axios.get<T>(baseUrl + url);
// 새 요청 캐시 저장
cache.set(url, { promise: request, time: now });
// 새 요청 반환
return request;
};
위 코드와 같이 어떤 url로 api 요청을 받으면 해당 요청이 캐시에 존재하는지 먼저 확인하고, 없다면 새 요청을 실행하고, 또한 url을 key로 하여 캐시에 저장하는 방식을 취했다.
그리고 캐시가 무한히 존재하면 메모리상의 문제 및 최신성의 문제가 결부될 수 있다고 생각하여 일정 시간을 정하고 해당 시간이 지나면 다시 같은 url의 새로운 요청을 받아들일 수 있도록 했다.
사용법은 다음과 같다.
// 실 사용 예시
const fetchMajors = () => fetchWithCache<Lecture[]>("/schedules-majors.json");
const fetchLiberalArts = () =>
fetchWithCache<Lecture[]>("/schedules-liberal-arts.json");
const fetchAllLectures = async () =>
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()),
]);
이제 이렇게 해보면 실행하면 1,2 번째는 다른 요청이므로 새 요청을 하게 되고 나머지 3,4,5,6 번째 요청은 캐시값을 가져와서 요청하게 된다.
2. SearchDialog 최적화 (필터 부분 최적화)
이 곳의 문제점은 여러 컴포넌트가 한 곳에서 작성되었다는 것이였다. 또한 입력폼의 경우 하나의 상태를 조작하여 여러 컴포넌트에 데이터를 뿌려주고 있는 모양이였다.
그래서 시도한 방법은 다음과 같다.
- 컴포넌트를 분리하고 memo로 감싸기
- 분리한 컴포넌트에 모든 값을 객체로 보내지 않고, 필요한 값만 내려주기
- 상태 업데이트 함수는 상태가 변할 때 재정의 되므로 useCallback을 사용해서 메모이제이션 하기
이 정도를 통해 필터의 리렌더링을 다음과 같이 효과적으로 막을 수 있었다.
(무한스크롤 부분 또한 위와 같은 방식으로 최적화가 진행되었다)
처음과 달리 각 필터부분의 상태를 변화시켜도 다른 필터에 영향이 가지 않는것을 볼 수 있고, 리렌더링 시에도 이전에 렌더된 항목들이 다시 렌더되는 문제가 없다는 것을 알 수 있다.
3. 시간표 드래그 앤 드롭
시간표 드래그 앤 드롭 시 상당히 많은 리렌더링이 발생되고 있었다. 원인은 다양했지만 가장 큰 문제는 dndContext의 값 변화가 전체 시간표들을 리렌더링 시키는것이 원인이였다.
그래서 일단 나는 각 시간표들의 컨텍스트를 분리하려고 프로바이더의 위치를 전체 페이지가 아니라 각 시간표를 감싸는 곳으로 옮겼다.
또한 컴포넌트들을 작게 분리하고 memo를 통해 리렌더링이 되지 않도록 만들었다.
그런데 이렇게 하면 드래그의 문제는 해결되지만, 드롭 시 문제는 여전히 발생되었다.
그 이유는 drop은 시간표 자체의 값을 변화시키는데 현재 코드 구조가 context API에서 시간표값을 하나의 상태로 관리했기 때문에 시간표 값이 변화하면 모든 시간표가 리렌더링 되는 형태였다.
그래서 내가 선택한 방법은 외부 스토어를 하나 생성하고, 각 시간표가 외부 시간표를 각자 구독할 수 있도록 하는 방법을 선택했다.
마치 redux의 selector와 유사하게 특정 조각만 구독한다고 생각하면 된다.
// 스토어
import dummyScheduleMap from "../dummyScheduleMap";
import { Schedule } from "../types";
type ScheduleMap = Record<string, Schedule[]>;
// emit할 때 어떤 테이블이 바뀌었는지 알려주기
type Listener = (changedId: string) => void;
/**
* ScheduleStore
* 시간표 데이터를 관리하는 스토어
*/
class ScheduleStore {
private schedulesMap: ScheduleMap = dummyScheduleMap;
private listeners = new Set<Listener>();
/**
* subscribe
* 시간표 데이터 변경 시 호출되는 리스너를 등록
* @param listener 리스너
* @returns 해제 함수
*/
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener); // unsubscribe
};
/**
* getScheduleMap
* 시간표 데이터를 반환
* @returns 시간표 데이터
*/
getScheduleMap = () => this.schedulesMap;
/**
* setScheduleMap
* 시간표 데이터를 업데이트
* @param next 업데이트 함수
*/
setScheduleMap = (
next: ScheduleMap | ((prev: ScheduleMap) => ScheduleMap) // ✅ 두 가지 다 허용
) => {
if (typeof next === "function") {
this.schedulesMap = (next as (prev: ScheduleMap) => ScheduleMap)(
this.schedulesMap
);
} else {
this.schedulesMap = next;
}
this.emit("*");
};
/**
* setTable
* 특정 tableId만 업데이트
*/
setTable = (
tableId: string,
next: Schedule[] | ((prev: Schedule[]) => Schedule[])
) => {
const prevTable = this.schedulesMap[tableId] ?? [];
const newTable = typeof next === "function" ? next(prevTable) : next;
// 전체 객체 새로 만들지 말고, 해당 테이블만 교체
this.schedulesMap[tableId] = newTable;
this.emit(tableId);
};
/**
* deleteTable
* 특정 tableId만 삭제
*/
deleteTable = (tableId: string) => {
const copy = { ...this.schedulesMap };
delete copy[tableId];
this.schedulesMap = copy;
this.emit(tableId);
};
/**
* duplicateTable
* 특정 tableId만 복제
*/
duplicateTable = (tableId: string) => {
const copy = { ...this.schedulesMap };
copy[`${tableId}-${Date.now()}`] = [...copy[tableId]];
this.schedulesMap = copy;
this.emit(tableId);
};
/**
* deleteScheduleItem
*/
deleteScheduleItem = (tableId: string, data: Schedule) => {
this.schedulesMap[tableId] = this.schedulesMap[tableId].filter(
(schedule) =>
schedule.day !== data.day || !schedule.range.includes(data.range[0])
);
this.emit(tableId);
};
/**
* emit
* 시간표 데이터 변경 시 호출되는 리스너 실행
*/
private emit = (changedId: string) => {
this.listeners.forEach((listener) => listener(changedId));
};
}
// 싱글톤 인스턴스 생성
export const scheduleStore = new ScheduleStore();
스토어는 위 와 같은 구조로 만들었다.
import { useSyncExternalStore } from "react";
import { Schedule } from "../types";
import { scheduleStore } from "./schedule.store";
import dummyScheduleMap from "../dummyScheduleMap";
const EMPTY: Schedule[] = [];
// 특정 tableId에 해당하는 schedule만 구독
export function useSchedule(tableId: string): Schedule[] {
return useSyncExternalStore(
(onStoreChange) =>
scheduleStore.subscribe((changedId) => {
if (changedId === "*" || changedId === tableId) {
onStoreChange();
}
}),
() => scheduleStore.getScheduleMap()[tableId] ?? EMPTY
);
}
type ScheduleMap = Record<string, Schedule[]>;
// 시간표 데이터 전체를 구독
export function useSchedulesMap(): ScheduleMap {
return useSyncExternalStore(
scheduleStore.subscribe,
scheduleStore.getScheduleMap,
() => dummyScheduleMap
);
}
그리고 위 훅을 사용해서 구독할 데이터를 tableId값을 통해 받아오도록 했다.
그 결과는 다음과 같이 최적화 되었다.
처음 문제와 달리 드래그 시 최소한의 컴포넌트만 리렌더가 되고, 또한 다른 컴포넌트에 영향을 주지 않는게 보인다.
마찬가지로 드롭 시에도 다른 시간표에 영향을 주지 않는것이 보인다.
느낀점
리렌더링은 크게 부모 컴포넌트의 리렌더링, 상태의 변화, 프롭스의 변화에 의해 발생한다.
따라서 부모가 리렌더링되더라도 자식이 불필요하게 리렌더링되지 않으려면 자식 컴포넌트의 메모이제이션이 필요하다.
하지만 단순히 memo 로 감싼다고 끝이 아니다.
만약 자식 컴포넌트에 새로운 객체/배열/함수를 매번 생성해서 props로 넘겨준다면, 이전 참조와 달라졌다고 판단되어 결국 리렌더링이 다시 일어난다.
그래서 평소 습관처럼 다음과 같은 원칙을 의식하며 코드를 짜는 게 중요하다고 느꼈다:
- props 최소화
- 자식이 진짜로 필요한 값만 넘기기
- 객체를 통째로 넘기지 말고 필요한 필드만 뽑아 전달하기
- props로 넘기는 함수는 useCallback으로 메모이제이션
- 불필요하게 새 함수가 생성되는 걸 방지
- 큰 컴포넌트는 분리하고, 자주 바뀌지 않는 컴포넌트는 memo로 감싸기
- 변경이 잦은 부분과 정적인 부분을 명확히 분리
이번 과제 내내 이 3가지를 계속 의식하면서 최적화를 진행했는데,
실제로 리렌더링을 줄이는 데 효과적이었고, 리액트 렌더링 구조를 좀 더 몸으로 이해할 수 있었다.
'개발 > React' 카테고리의 다른 글
| [React] 무거워진 요청에 SSE를 적용해보자 (프론트엔드편) (0) | 2025.12.08 |
|---|---|
| EC2에 배포한 React가 사용자에게 항상 최신이 보이도록 설정하기 (0) | 2025.11.14 |
| [WIL] 프론트엔드 테스트코드 - 8주차 과제 (2) | 2025.08.30 |
| [WIL] 프론트엔드에서 테스트코드 (1) | 2025.08.30 |
| [React] FSD 폴더구조 공부 후기 (1) | 2025.08.23 |