페이지에서 등록 폼을 만들었다. 목록을 선택하는 MultiChipSelectSearch 이라는 현재 프로젝트 내부의 커스텀 컴포넌트를 썼는데, chip을 3~4개 이상 선택하면 화면이 계속 떨렸다. 1~2개일 때는 괜찮았는데 개수가 늘어나니까 갑자기 깜빡거리기 시작했다.
chip이 많아져서 input 크기를 넘어가게되면, 더보기로 볼 수 있게 개수가 표시되는 구조인데, 그게 표시될 때 무한 렌더링이 발생했다.
이전에 개발하면서 테스트할 때는, 목록 데이터의 길이가 짧아서 위 사항을 테스트하지 못했었던 것이다.
문제는 이게 매 렌더마다 새 객체를 생성한다는 거였다. React Query를 쓰면 캐시가 참조를 안정적으로 유지해주는데, 인라인 리터럴 객체는 그게 안 된다.
changeFiles 새 객체 생성
→ changeFileMap useMemo 재계산
→ fileOptions useMemo 재계산 (항상 새 배열)
→ CheckboxSearchSelectList에 새 options 전달
→ 내부 useFilteredOptions의 useEffect([options]) 실행
→ 상태 변경 → 재렌더 → 무한 루프
이건 useMemo로 감싸서 해결했다. 근데 여전히 떨렸다.
분석 2: ChipsDropLayout의 ResizeObserver 루프
ChipsDropLayout은 칩이 컨테이너를 넘치면 "+N" 버튼을 보여준다. 이걸 계산하려고 useChipsLayout에서 ResizeObserver를 쓴다.
ResizeObserver: Resize(크기 변경) + Observer(관찰자) resize 이벤트와 달리 브라우저는 물론 특정 dom의 크기 변화를 감지할 수 있다.
문제는 아래 로직이였다.
소스
// useChipsLayout.ts
const observer = new ResizeObserver(() => {
if (너비가 5px 이상 바뀌었으면) {
setIsCalculating(true); // 스켈레톤 표시
}
recalculate();
});
동작
isCalculating=true (스켈레톤 표시)
→ DOM 크기 변경
→ ResizeObserver 발동
→ setIsCalculating(true)
→ 스켈레톤 다시 표시 → ...무한 반복
1~2개 칩일 때는 오버플로우가 없어서 이 루프가 안 돌았고, 3개 이상부터는 "+N" 버튼이 생기면서 스켈레톤↔실제 칩이 계속 교체됐다.
이것도 useChipsLayout.ts를 수정해서 ResizeObserver에서는 스켈레톤 없이 재계산만 하도록 바꿨다. 근데 여전히 떨렸다.
원인
flex 레이아웃 미적용
결국 문제는 CSS였다.
// 수정 전
<div style={{ width: '70%' }}>
<MultiChipSelectSearch ... />
</div>
// 수정 후
<div style={{ flex: 1, minWidth: 0 }}>
<MultiChipSelectSearch ... />
</div>
왜 이게 문제였나
Flex 자식은 기본적으로 min-width: auto라서 내용보다 작게 줄어들지 않는다. chip이 많거나 텍스트가 길면 "내용 최소 너비"가 커지고, 그걸 기준으로 레이아웃이 잡힌다.
그래서:
칩 영역 너비가 내용에 따라 계속 바뀌거나
형제 요소(저장소 셀렉트 30%)와 공간을 나누는 과정에서 한 번에 정해지지 않음
ChipsDropLayout의 ResizeObserver가 "컨테이너 너비가 바뀌었다"고 인식
오버플로우 재계산 → "+N" 버튼 표시 여부 변경 → DOM 변경 → 또 너비 바뀜 → ...
해결
flex: 1: 남는 공간을 채우고, 필요하면 줄어들 수 있게 함
minWidth: 0: min-width: auto를 덮어서 내용 최소 너비보다 작게도 줄어들 수 있게 함
그래서 칩 컨테이너의 너비가 "행에서 남은 공간"으로 고정되고, 한 번 계산된 뒤에는 레이아웃이 안정되어 ResizeObserver가 반복해서 호출되지 않았다.
수정 후 CLS
교훈
Flex 레이아웃에서 minWidth: 0은 필수 특히 동적으로 크기가 바뀌는 컴포넌트(칩, 태그 등)를 감쌀 때는 flex: 1, minWidth: 0을 항상 같이 써야 한다.
ResizeObserver 루프는 CSS 문제일 수 있다 컴포넌트 내부 로직만 보지 말고, 부모의 레이아웃 제약도 확인해야 한다.
결국 한 줄 CSS로 해결됐는데, 여기까지 오는 데 몇 시간이 걸렸다. 다음엔 동적 너비 컴포넌트를 쓸 때는 스타일도 확인해보자.