과제 체크포인트
과제 요구사항
-
배포 후 url 제출 https://yangs1s.github.io/front_6th_chapter4-2/
-
API 호출 최적화(
Promise.all이해) -
SearchDialog 불필요한 연산 최적화
-
SearchDialog 불필요한 리렌더링 최적화
-
시간표 블록 드래그시 렌더링 최적화
-
시간표 블록 드롭시 렌더링 최적화
과제 셀프회고
기술적 성장
이번 과정을 통해서 단순히 알고 있던 개념을 다시 확인하는 수준을 넘어, 실제 코드 속에서 체감하며 재발견할 수 있었습니다.
우선 props에 대해서는, 그동안 단순히 "값만 전달하면 된다"는 정도로만 생각했습니다. 배열, 객체, 함수 같은 props가 매 렌더마다 새로 생성되면 리렌더가 발생한다는 사실까지만 알고 있었죠. 그런데 이번 구현을 통해, 단순히 값이 바뀌는 문제가 아니라 props의 구조와 참조 안정성이 컴포넌트 트리 전체 리렌더링에 직접적인 영향을 준다는 점을 명확히 체감했습니다. 부모 컴포넌트에서 매번 새 함수를 내려주면 자식 컴포넌트가 불필요하게 리렌더링되고, 이런 누적이 성능 저하로 이어진다는 사실을 몸소 겪었습니다.
이를 해결하기 위해 useMemo, useCallback을 활용해 참조를 안정적으로 유지하거나, 아예 store를 도입해서 필요한 부분만 직접 구독하도록 바꿨습니다. 특히 ScheduleTableContainer 같은 컴포넌트에서는 tableId만 props로 넘기고, 실제 schedules는 store에서 구독하도록 구조를 변경해 전체 테이블이 불필요하게 리렌더링되는 문제를 줄일 수 있었습니다. 이 과정을 통해 props를 단순히 값으로만 보는 것이 아니라, 리렌더링 관점에서 어떻게 전달·설계할지까지 고민해야 한다는 점을 새롭게 깨달았습니다.
또한 store를 직접 구현하는 과정도 큰 도움이 되었습니다. class 기반 구조, useSyncExternalStore, 캐시 처리 등을 다시 다루면서 이전에 머릿속으로만 알고 있던 개념들이 실제 코드에서 어떻게 연결되는지 명확히 이해할 수 있었습니다. 단순히 지식을 아는 것과, 구조와 흐름 속에서 직접 구현하며 체감하는 것은 확실히 큰 차이가 있다는 걸 느꼈습니다.
memo안에 비교 함수
과제를 하고, 되돌아보는 시간을 페어팀끼리 가졌는데, 이러한 비교함수가 두번째 인자로 받는다는걸 처음 알았습니다.
interface ScheduleTableContainerProps {
tableId: string;
index: number;
disabledRemoveButton: boolean;
onSearchOpen?: (id: string) => void;
onDuplicate?: (id: string) => void;
onRemove?: (id: string) => void;
}
const ScheduleTableContainer = memo(
(props: ScheduleTableContainerProps) => {
// 컴포넌트 구현
return <div>...</div>;
},
(prevProps, nextProps) => {
// 핵심 데이터만 비교 (함수들은 무시)
return (
prevProps.tableId === nextProps.tableId &&
prevProps.index === nextProps.index &&
prevProps.disabledRemoveButton === nextProps.disabledRemoveButton
);
// onSearchOpen, onDuplicate, onRemove 함수들은 비교하지 않음
}
);
위에 예시를 하나 만들었는데, prevProps,nextProps라는 인자를 받고 이 props들은 본함수에 인자 비교해서 true,false를 리턴으로 받고, true일땐 재사용을 하고 아니면 안하고 이런식의 로직을 가지더라고요. 하지만 불필요한 연산, 그리고 기존에 얕은비교로도 충분히 기능을 하고 있어서 비교적 잘 사용하지 않는다고 합니다.
export function memo<P extends object>(Component: FunctionComponent<P>, equals = shallowEquals) {
// if(equals())
const MemoizedComponent = (props: P) => {
const prevPropsRef = useRef<{ props: P; result: JSX.Element } | null>(null);
// 이전 데이터가 존재할시
const oldProps = prevPropsRef.current && prevPropsRef.current.props;
if (prevPropsRef.current && equals(oldProps, props)) return prevPropsRef.current.result;
else {
const newResult = React.createElement(Component, props);
prevPropsRef.current = { props, result: newResult };
return prevPropsRef.current.result;
}
};
return MemoizedComponent;
}
이게 실제로 1-3을 할때, memo을 직접 구현한건데, 이전에 학습내용에서도 다뤘던 내용이였습니다. 과제를 진행하면서, 이전 과제를 조금 더. 되짚어 볼수있었던거 같습니다.
코드 품질
AS-IS
const ScheduleTables = () => {
// 상태 및 스케줄 목록...
const tableHandlers = useMemo(() => {
const handlers = {};
Object.keys(schedulesMap).forEach(tableId => {
handlers[tableId] = { /* 핸들러들 */ };
});
return handlers;
}, []);
return (
<Flex w="full" gap={6} p={6} flexWrap="wrap">
{scheduleEntries.map(([tableId, schedules], index) => (
<ScheduleTableContainer
{...다른 props}
onScheduleTimeClick={tableHandlers[tableId].onScheduleTimeClick}
onDeleteButtonClick={tableHandlers[tableId].onDeleteButtonClick}
/>
))}
</Flex>
);
};
원래 코드가 useMemo로 핸들러 객체 만들어서 각 테이블에 넣어줬는데, 빈 의존성 배열 때문에 테이블 추가/삭제하면 핸들러가 누락될 수 있었습니다. 게다가 저 스스로도 코드 보기도 이해하고 파악하기 어려웠습니다.
그래서 같은 페어팀 휘린님 아이디어로 아예 외부 스토어 기반으로 갈아엎었습니다
TO-BE
class ScheduleStore {
private schedules = new Map<string, Schedule[]>();
private cachedSchedulesMap: Record<string, Schedule[]> | null = null;
subscribe(tableId: string, callback: () => void) {
// 테이블별 구독
}
updateTable(tableId: string, schedules: Schedule[]) {
this.schedules.set(tableId, schedules);
this.invalidateCache();
this.listeners.get(tableId)?.forEach(callback => callback());
}
});
구현 이유와 간단한 과정.
Map으로 스케줄 데이터 관리하고, 드래그앤드롭이나 테이블/스케줄 복제 및 삭제 같은 기능들을 store에 다 넣어서 기존 함수들을 갈아끼웠습니다. 그래서 SchedulesProvider도 필요 없어져서 과감하게 삭제했습니다. 각 테이블이 독립적으로 store를 구독하게 하니까 필요한 부분만 리렌더링됐습니다.
캐시가 필요성:
처음엔 캐시 없이 구현했는데 "The result of getSnapshot should be cached to avoid an infinite loop" 에러가 계속 나오면서 무한루프 발생했습니다. AI에 확인해보니 useSyncExternalStore가 매번 "스냅샷이 바뀌었다!"라고 판단해서 무한루프가 발생한다고 했습니다. 변환이 있을 때마다 불필요하게 객체를 새로 생성하게 되고 이전과 비교가 달라지면서 리액트에서 리렌더링을 유발하니까, 캐시를 사용하게끔 스토어를 구현하는게 정답이라고 생각해서 invalidateCache를 추가했습니다.
export class SearchInfoStore {
private searchInfo: SearchInfo = null;
private searchListeners = new Set<() => void>();
setSearchInfo(info: SearchInfo) {
this.searchInfo = info;
this.searchListeners.forEach(callback => callback());
}
getSearchInfo() {
return this.searchInfo;
}
subscribeSearch(callback: () => void) {
this.searchListeners.add(callback);
return () => this.searchListeners.delete(callback);
}
}
export const searchInfoStore = new SearchInfoStore();
구현 이유와 간단한 과정.
원래 ScheduleTables 안에 useState로 관리되는 searchInfo를 store로 따로 만들어 관리했습니다. SearchDialog를 열고 닫을 때마다 계속해서 전체적으로 테이블들이 리렌더링되는 부분이 있어서, 차라리 영향을 받지 않게 서로 떼어놓자고 생각했습니다. 그렇게 분리하고 나니까 다이아로그를 열떄, 닫을때도 전체적인 리렌더링이 되는 부분을 해결했습니다.
이러한 과정을 통해서 얻은점.
일단 store을 생각해보지 않은건 아니였습니다. 하지만 다른 사람도 한번 작업을 해본 경험으로는 오히려 이게 좀더 탁월하게 할수있다는 점에서 시도해보고자 했습니다. 기존에 만든건 조금 이해도가 저도 떨어졌다고 생각이 들었습니다. 오히려 조금 시간이 걸리더라도, store을 한번 구현해보고자 했습니다. 처음에는 기존에 만들으셨던 코드를 조금 바탕으로 구현하기 시작했습니다.
만들기 시작하면서 class함수가 어느정도 자연스러워졌다는점, useSyncExternal가 친숙해졌다는점이 상당히 고무적으로 느껴졌습니다. 그리고 아무리 만들어도 이해하지 못하면 내것이 될수 없기에, 100프로 직접 구현은 아니더라도 구현을 하면서 알게 되는점이 인상깊게 느껴졌습니다.
어려웠던 부분이 있다면 ,chapter1에서 store을 직접 useSyncExternalStore로 만들기는 했지만, 까먹기도 했고, 복습을 잘 하지 못해.. 솔직히 처음하는거랑 큰 차이는 없었던거 같습니다. 구현 난이도가 있다고 생각이 들었습니다. 중간중간에 ai도움으로 적절하게 해결하였지만,캐시와 스냅샷 관리, 구독 해제, 참조 동일성 등 개념은 어느정도 이해했지만, 프로젝트 내에서 흐름이나,구조적인 부분은 아직 부족하다고 느꼈습니다.
테이블 추가 및 삭제시 전체 리렌더링
AS-IS
<ScheduleTableContainer tableId={tableId} index={index} disabledRemoveButton={disabledRemoveButton} onSearchOpen={handleSearchOpen} onScheduleUpdate={handleScheduleUpdate} />
문제 및 과정:
추가로 테이블을 복제 삭제를 하였을때도 문제가 새롭게 생겼습니다. 삭제와 복제시 전체적으로 리렌더링이 되었습니다. 원래는 기본적으로 최상위 컴퍼넌트에 아직 함수들이 있었지만 저 함수들도 새롭게 참조가 되고 이게 memo에서 다르게 인식이 되었던거 같습니다.
TO-BE
https://github.com/user-attachments/assets/d9cee9c2-90bd-4b98-b11a-b778e0d58ef8
// ScheduleTables (부모)
<ScheduleTableContainer tableId={tableId} index={index} disabledRemoveButton={disabledRemoveButton} />
전체 모든테이블들이 리렌더가 되는거를 봐서는 ScheduleTableContainer(자식컴퍼넌트에) 저 함수들을 내려도 크게 무방할거 같아서 자식 컴포넌트에서 직접 스토어 쓰도록 바꿨습니다. props는 최소화해야 하는 게 좋고, 로직은 사용하는 곳 가까이에서 이용해야 하는데 그걸 좀 망각했었습니다.
학습 효과 분석
1. props 최적화는 단순 값 전달이 아니라 구조적 설계 문제다:
그냥 값만 내려주는 게 아니라, 참조 안정성을 어떻게 유지할지, 자식 컴포넌트가 어떤 데이터만 받아야 최소 리렌더링이 되는지를 고민해야 한다는 점을 체감.
2. store직접 구현 해보니까 구독단위로 컨텍스트보다 쪼개기 좋았던거 같다.:
useSyncExternalStore 기반 store를 썼는데, tableId별로 필요한 데이터만 구독하게끔 리렌더링 범위를 줄일수 있었음. 구조를 바꿔본건 굉장한 경험으로 큰 소득이였던거 같습니다.
3. 최적화는 기능 추가보다 구조와 흐름 이해가 중요한거 같습니다.
메모이제이션을 가져다가 막 붙이고 이런게 아니라 왜 참조 유지를 고민하는지 왜 이 고민을 하는지에 대해 체감했습니다. props 전달 상태 관리, 리렌더링의 흐름을 이해하고자 코드를 잘 살펴보면서 나 혼자서도 한번 만들어볼수있는 복습을 가져야겠습니다.
과제 피드백
profiler를 이용해서 최적화를 하는건 아직 좀 어렵고 정확하게 단순하게 시간을 줄이는건지
이런런 노란바들을 없애야하는건지 조금 기준이 잘 안서는점이 저는 모호했던거 같고요,
최적화를 진행하면서, 하이라이트를 통해서 전체적으로 리렌더링이 되지 않는 부분을 보면서 아 뭔가 바뀌어가는구나 하는게 느껴져서, 그런점은 너무 좋았던거 같습니다. 그리고 컴퍼넌트의 위치, 프로바이더든 이런 각각 기능을 가진 덩어리들도 존재하는 위치에 따라서 로딩이 느려지나 빨라지냐 하는 점이 저는 되게 재밌게 다가왔던거 같아요.
이번 과제도 정말 고생 많으셨습니다.!
리뷰 받고 싶은 내용
- 이번에 성능 점수를 한번 체크해본다고 라이트하우스를 이용해서 보니까 전부 높은 성능 점수가 나오더라고요 개발환경에선 너무 낮은데, 또 배포후 배포된 사이트는 높은 점수를 받더라고요. 문득 궁금한게 코치님께서도 실무에서는 따로 성능 체크나 이런걸 어떻게 하시나요? 저는 si에서 일하다 보니까 이런거 자체를 잘 해보질 않아서, 최적화 후 성능 테스트는 어떤식으로 하는지 궁금했습니다.
2.최적화를 하는 기준에서 어디까지 최적화를 진행하는지 판단의 기준을 잘 모르겠는데 어떤 기준점이 따로 있을까요? 그리고 최적화를 할때 이런식으로 하지 말아야한다. 아님 이런 방식은 좀 지양해야한다는점이 있으실까요?
- 마지막으로 10주동안 정말 감사했습니다.!
과제 피드백
성진님 고생하셨습니다! 10주동안 정말 고생 많이 하셨어요. 과제도 정말 잘 해주셨고, 구현 과정이랑 이유도 명확하게 잘 작성해주셨네요. Profiler같은 경우에는 색상이나, 작업 표시, 내용들이 정말 구체적이게 나오는 성능 최적화를 하는 관점에서는 지속적이게 만날수 밖에 없는 탭이긴해요! 이 부분에 대해 모호한 점이 있으시다면 꼭 어떤 의미인지 개발자도구 가이드를 참고해서 살펴보시면 너무 좋을것 같습니다.
-
라이트 하우스는 보고서를 돌리다보면 구동하는 환경의 영향을 많이 받아서 일관성있게 점수가 채점이 잘 되지 않는 문제가 있어요. 그래서 보통 CI 환경을 활용하거나 일관되게 측정할 수 있는 환경을 마련하고 결과를 보고서 형태로 관리하게 되는데요. 우리들이 만드는 서비스 환경과 유사한 네트워크 환경, 서버 환경에 맞춰 관리한다면 더 의미있을것 같아요
-
최적화의 기준은 결국 시간과 크기인것 같아요. 시간 복잡도 관점에서 더 깊게 고민을 해보는 것도 있겠지만, 절대적인 시간을 측정해보고 다가가 보는게 거의 다이지 않을가 싶어요. 그런 관점에서 라이트하우스나 웹바이탈같은 도구들은 정말 그런것들을 하기 쉽게해주는 좋은 도구라고 볼 수 있죠. 기준치도 저희보다 훨씬 똑똑한 분들이 만들어주시는 내용이니까요. 이런것들을 기준으로 접근하는게 추후 성과 관리 측면에서도 좋은것 같습니다. 그리고 실무에서는 초기 구현에서는 필요한 만큼만 하고, 추후에 문제가 발생했을 때 최적화하는게 필요한것 같아요. 너무 섣부른 최적화는 만악의 근원이라는 말이 있느니까 꼭 필요할때! 들어가는걸로 하시죠.
저도 너무 감사했고, 앞으로 개발인생 화이팅 하세요! 그럼 내일봬요~