Skip to content

Conversation

@jinseoIT
Copy link

@jinseoIT jinseoIT commented Dec 22, 2025

과제 체크포인트

배포 링크

https://jinseoit.github.io/front_7th_chapter4-2/

과제 요구사항

  • 배포 후 url 제출

  • API 호출 최적화(Promise.all 이해)

  • SearchDialog 불필요한 연산 최적화

  • SearchDialog 불필요한 리렌더링 최적화

  • 시간표 블록 드래그시 렌더링 최적화

  • 시간표 블록 드롭시 렌더링 최적화

과제 셀프회고

기술적 성장

새로 학습한 개념
구현 과정에서의 기술적 도전과 해결

1. DND 최적화: 전역 Context의 함정

문제 상황

처음에는 하나의 DndProvider로 모든 시간표를 감싸고 있었습니다. 이로 인해 한 테이블에서 드래그를 시작하면 모든 테이블이 리렌더링되는 심각한 성능 문제가 발생했습니다.

// ❌ 문제가 있던 구조
<DndProvider>
  {tables.map((table) => (
    <ScheduleTable />
  ))}
</DndProvider>

시도한 해결 방법들

  1. 첫 번째 시도: Context 분리

    • DND Context와 Schedule Context를 분리했지만, 여전히 모든 테이블이 리렌더링되는 문제 지속
    • 이유: @dnd-kitDndContext 내부 상태 변경이 모든 하위 컴포넌트에 영향
  2. 두 번째 시도: React.memo 추가

    • ScheduleTableCardReact.memo로 감쌌지만 여전히 리렌더링 발생
    • 이유: props로 전달되는 콜백 함수들이 매번 새로 생성되어 참조가 변경됨
  3. 세 번째 시도: useCallback으로 콜백 안정화

    • 모든 콜백을 useCallback으로 메모이제이션했지만 문제 지속
    • 이유: 근본적으로 DndContext가 전역이라 드래그 시 context 자체가 변경됨

최종 해결책: DndProvider 격리 + 커스텀 비교 함수

각 테이블마다 독립적인 DndProvider를 제공하고, 철저한 메모이제이션 전략을 적용했습니다.

// ✅ 해결: 각 테이블마다 독립적인 DndProvider
<ScheduleTableCard>
  <ScheduleDndProvider tableId={tableId}>
    <ScheduleTable />
  </ScheduleDndProvider>
</ScheduleTableCard>

그리고 ScheduleTableCard에 커스텀 비교 함수를 추가했습니다:

const ScheduleTableCard = memo(
  ({ tableId, schedules, ... }) => { /* ... */ },
  (prevProps, nextProps) => {
    return (
      prevProps.tableId === nextProps.tableId &&
      prevProps.schedules === nextProps.schedules && // 참조 비교!
      prevProps.onScheduleTimeClick === nextProps.onScheduleTimeClick &&
      // ... 모든 props 비교
    );
  }
);

핵심 인사이트

  • schedules === nextProps.schedules 참조 비교가 핵심입니다
  • setSchedulesMap 내부에서 변경되지 않은 테이블은 원본 참조를 유지하도록 구현했습니다
  • 이를 통해 드래그 중인 테이블만 리렌더링되고, 나머지는 완전히 격리됩니다
// ScheduleDndProvider.tsx의 handleDragEnd
const updatedSchedules = targetSchedules.map(
  (schedule, idx) => (idx === scheduleIndex ? { ...schedule, day: newDay, range: newRange } : schedule) // ✅ 원본 참조 유지!
);

return {
  ...prevSchedulesMap,
  [tableId]: updatedSchedules, // 변경된 테이블만 새 참조
};

2. 무한 스크롤 타이밍 이슈: 체계적 디버깅의 중요성

문제 상황

SearchDialog를 처음 열었을 때 무한 스크롤이 동작하지 않았습니다. 하지만 필터를 한 번이라도 변경하면 정상 작동했습니다. 이는 초기 렌더링에만 발생하는 타이밍 문제였습니다.

증상 분석

  • filteredLectures.length > PAGE_SIZE (충분한 데이터 존재)
  • ✅ IntersectionObserver 코드 자체는 정상
  • ❌ 초기 open 시에만 observer가 동작하지 않음
  • ✅ 필터 변경 후에는 정상 동작

첫 번째 시도: 의존성 배열 수정

// ❌ 실패
}, [lastPage, isOpen, lectures.length]); // lectures.length 추가

이론: lectures가 로드되면 observer를 재설정하면 동작할 것이라고 예상했지만, 여전히 동작하지 않았습니다.

두 번째 시도: 수동 체크 로직 추가

// ❌ 실패
useEffect(
  () => {
    requestAnimationFrame(() => {
      const rect = loaderRef.current?.getBoundingClientRect();
      // 수동으로 가시성 체크...
    });
  },
  [
    /* ... */
  ]
);

너무 복잡하고 여전히 타이밍 문제 해결 못함.

세 번째 시도: useEffect 분리

// ❌ 실패
// Effect 1: observer 설정
useEffect(() => {
  /* observer */
}, [lastPage]);

// Effect 2: 초기 로딩 체크
useEffect(() => {
  /* manual check */
}, [isOpen]);

로직이 복잡해지고 여전히 문제 지속.

네 번째 시도: 조건부 렌더링

savage 폴더의 참고 코드를 보고 조건부 렌더링 시도:

// ❌ 실패 (성능 문제)
{
  isDialogOpen && <SearchDialog />;
}

문제: 매번 컴포넌트가 마운트/언마운트되면서 API를 다시 호출하여 느려짐.

체계적 디버깅: Step-by-step 로깅

문제의 근본 원인을 찾기 위해 각 단계별로 console.log를 추가했습니다:

useEffect(() => {
  console.log("🔵 [Step 1] useEffect 실행", { isOpen, lastPage, page });

  if (!isOpen) {
    console.log("❌ [Step 2] isOpen=false, early return");
    return;
  }

  const $loader = loaderRef.current;
  const $loaderWrapper = loaderWrapperRef.current;

  console.log("🔍 [Step 3] ref 체크", {
    hasLoader: !!$loader,
    hasWrapper: !!$loaderWrapper,
  });

  if (!$loader || !$loaderWrapper) {
    console.log("❌ [Step 4] ref 없음, early return");
    return; // ← 여기서 막힘!
  }

  // ...observer 설정
}, [lastPage, isOpen]);

결과 분석

🔵 [Step 1] useEffect 실행 {isOpen: true, lastPage: 4, page: 1}
🔍 [Step 3] ref 체크 {hasLoader: false, hasWrapper: false}
❌ [Step 4] ref 없음, early return

원인 발견: ref가 null이었습니다! Chakra UI Modal의 애니메이션으로 인해 DOM 렌더링이 지연되어, useEffect가 실행될 시점에 아직 ref가 설정되지 않았던 것입니다.

최종 해결책: timeout 내부에서 ref 체크

useEffect(() => {
  if (!isOpen) {
    return;
  }

  let observer: IntersectionObserver | null = null;

  // ✅ 300ms 후에 ref 체크!
  const timeoutId = setTimeout(() => {
    const $loader = loaderRef.current;
    const $loaderWrapper = loaderWrapperRef.current;

    if (!$loader || !$loaderWrapper) {
      return; // 이제는 여기 도달 안 함
    }

    observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          setPage((prevPage) => Math.min(lastPage, prevPage + 1));
        }
      },
      { threshold: 0, root: $loaderWrapper }
    );

    observer.observe($loader);
  }, 300); // ← Modal 애니메이션 대기

  return () => {
    clearTimeout(timeoutId);
    if (observer) {
      observer.disconnect();
    }
  };
}, [lastPage, isOpen, filteredLectures.length]);

핵심 인사이트

  1. 타이밍 문제는 step-by-step 로깅으로 정확히 파악 가능: 어디서 막히는지 정확히 알면 해결책이 명확해집니다
  2. React lifecycle vs Browser rendering vs UI library animation: 세 가지가 복잡하게 얽혀있음을 체감
  3. 실용적인 해결책의 가치: 완벽한 MutationObserver보다 300ms timeout이 더 실용적일 수 있습니다
  4. filteredLectures.length 의존성: 필터 변경 시 observer를 재설정하여 새 데이터셋에 대응

코드 품질

특히 만족스러운 구현

  • DND Provider 분리: 각 테이블마다 독립적인 ScheduleDndProvider를 갖도록 설계하여 격리된 상태 관리를 구현한 점이 만족스럽습니다. 이를 통해 드래그 시 다른 테이블은 전혀 영향받지 않습니다.
  • 메모이제이션 전략: ScheduleTableCard에서 참조 비교 기반의 커스텀 비교 함수를 사용하여, 실제 데이터가 변경될 때만 리렌더링되도록 최적화했습니다.

코드 설계 관련 고민과 결정

  • IntersectionObserver 타이밍 처리: 처음엔 복잡한 수동 체크 로직을 추가하려 했으나, 결국 300ms timeout만으로 해결하는 단순한 방법을 선택했습니다. "완벽한 해결책"보다 "실용적인 해결책"이 더 나을 때가 있다는 것을 배웠습니다.
  • 조건부 렌더링 vs isOpen prop: 처음엔 savage 폴더의 조건부 렌더링 방식을 따라했으나, 매번 API를 호출하는 문제가 있어서 원래의 isOpen prop 방식으로 되돌렸습니다. 성능과 사용자 경험 사이의 트레이드오프를 고려한 결정이었습니다.

학습 효과 분석

가장 큰 배움이 있었던 부분

  • 체계적인 디버깅: 무한 스크롤 문제를 해결하면서 각 단계별로 console.log를 추가하여 정확히 어느 지점에서 문제가 발생하는지 파악하는 방법을 배웠습니다. "ref가 null이다" → "DOM 렌더링이 늦어진다" → "Modal 애니메이션 때문이다" 순으로 원인을 좁혀가는 과정이 인상적이었습니다.
  • 브라우저 렌더링 타이밍: React의 useEffect, Chakra UI Modal의 애니메이션, 브라우저의 DOM 렌더링 순서가 복잡하게 얽혀있다는 것을 체감했습니다.

실무 적용 가능성

  • DND Provider 격리 패턴은 대시보드나 칸반 보드 같은 복잡한 UI에서 바로 활용할 수 있을 것 같습니다.
  • IntersectionObserver를 활용한 무한 스크롤은 거의 모든 리스트 UI에 적용 가능한 보편적인 패턴입니다.
  • 배포 환경 경로 문제는 실무에서도 자주 겪을 수 있는 이슈이므로, 이번 경험이 매우 유용할 것 같습니다.

과제 피드백

좋았던 부분

  • DND와 성능 최적화를 동시에 고민해볼 수 있는 실전적인 과제였습니다.
  • 배포까지 포함되어 있어서 개발 환경과 프로덕션 환경의 차이를 체감할 수 있었습니다.

어려웠던 부분

  • 무한 스크롤 초기 동작 이슈는 과제 요구사항에 명시되어 있지 않았는데, 디버깅하는데 시간이 많이 소요되었습니다. 하지만 덕분에 깊이 있는 학습이 가능했습니다.

리뷰 받고 싶은 내용

메모이제이션 적용 기준과 판단

무분별한 메모이제이션은 오히려 성능을 악화시킬 수 있다는 것을 알고 있습니다. 메모리 오버헤드와 비교 연산 비용이 추가되기 때문입니다. 하지만 이번 DND 최적화 케이스에서는 메모이제이션 없이는 문제를 해결할 수 없었습니다.

// 이번 과제에서 필수적이었던 메모이제이션
- React.memo + 커스텀 비교 함수 (ScheduleTableCard)
- useCallback (각종 이벤트 핸들러)
- useMemo (filteredLectures, visibleLectures)

질문:

  • 코치님께서는 어떤 기준으로 "이 경우엔 메모이제이션이 필요하다"고 판단하시는지 궁금합니다
  • 실무에서 메모이제이션을 적용할 때 사용하시는 구체적인 지표나 신호가 있으신가요?
    • 예: 특정 횟수 이상 리렌더링 발생 시
    • 예: React DevTools Profiler에서 특정 수치 초과 시
    • 예: 사용자가 체감할 수 있는 성능 저하 발생 시

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant