Project/Project01.MailBuddy

React 전역 상태 디버깅: selectedItem null 오류 해결

develop_mii 2025. 11. 21. 11:58
문제 상황
Cannot read properties of null (reading 'type')
TypeError: Cannot read properties of null (reading 'type')
    at onEdit (CalendarWriteForm.js:xx)
  • Memo 영역에서 “날짜 없는 일정”을 선택하고 → “수정” 버튼을 누르면
    CalendarWriteForm 팝업이 열리고 , 기존 제목/내용/장소 등은 잘 채워진다.
  • 그런데 수정 후 “수정” 버튼을 누르면 저장이 안 되고 에러가 난다.

 

# 콘솔 에러

Cannot read properties of null (reading 'type')
TypeError: Cannot read properties of null (reading 'type')
    at onEdit (CalendarWriteForm.js:xx)

=> 즉, selectedItem.type을 읽으려는데 그 순간 selectedItem이 null이라는 뜻.

    수정 팝업도 그 데이터로 잘 떴는데, 저장하려고 하면 갑자기 selectedItem이 사라진다?

 

 

현재  SelectedItem 사용중인 곳들 확인
  • Memo
    • 날짜 없는 일정 / 시간 없는 일정 목록을 보여줌
    • “수정” 버튼 클릭 시
      → ScheduleContext의 selectedItem, isEdit, popupOpen 등을 세팅
  • CalendarWriteForm
    • 작성/수정 폼
    • ScheduleContext의 selectedItem, isEdit을 보고
      • 값 채워 넣기
      • 수정/저장 API 호출
  • CalendarList
    • 선택한 날짜의 “오늘의 일정” 목록
    • 백엔드에서 가져온 일정들을
      → splitAndSortEvents로 정렬/분리하고
      → 제일 빠른 일정을 자동으로 selectedItem으로 설정하는 로직이 있음
  • ScheduleContext
    • 전역 상태: selectedDate, selectedItem, isEdit, sortedDailyEvents 등 저장
    • 모든 컴포넌트가 여기 값을 보고/바꾸면서 움직임

문제의 핵심 → selectedItem을 여러 컴포넌트가 건드린다는 점

 

 

문제를 해결해보자

 

처음 에러는 이 부분에서 발생

if (selectedItem.type === "summary") {
  await updateAiSchedule(selectedItem.id, formValue);
} else if (selectedItem.type === "local") {
  await updateUserSchedule(selectedItem.id, formValue);
}

 

 

그래서 가장 먼저 한 일은:

  1. selectedItem이 null인지 체크하는 방어 코드 추가
  2. type이 없을 수도 있으니 기본값("summary") 지정
const onEdit = async (e) => {
  e.preventDefault();
  setError("");

  if (!formValue.title || !formValue.eventTime) {
    setError("제목/시간을 입력하세요.");
    return;
  }

  // 1) selectedItem이 null이면 바로 종료
  if (!selectedItem) {
    console.error("수정할 일정이 없습니다.", selectedItem);
    setError("수정할 일정 정보를 찾을 수 없습니다. 다시 시도해 주세요.");
    return;
  }

  console.log(selectedItem, "타입넣기전");

  // 2) type이 없으면 기본값 summary
  const itemType = selectedItem.type ?? "summary";

  console.log(selectedItem, "타입넣은후");

  try {
    if (itemType === "summary") {
      await updateAiSchedule(selectedItem.id, formValue);
      alert("AI 일정 수정 완료");
    } else if (itemType === "local") {
      await updateUserSchedule(selectedItem.id, formValue);
      alert("일정 수정 완료");
    } else {
      await updateAiSchedule(selectedItem.id, formValue);
      alert("일정 수정 완료");
    }

    dispatch({ type: "SET_ISEDIT", payload: false });
    dispatch({ type: "SET_POPUPOPEN", payload: false });
  } catch (err) {
    console.error(err);
    setError("수정 실패");
  } finally {
    trigger();
  }
};

 

구현하면 “selectedItem이 null일 때” 에러가 터지지는 않고, 에러 메시지를 직접 보여줄 수 있게 된다.

버튼을 눌러보니 "수정할 일정이 없습니다. null" 로그

 

 

electedItem이 언제 null이 되는가?

 

어느 순간에 selectedItem이 null로 바뀌는지 찾아서 해결해야함 →  로그 찍어보기 

 

1. Memo – 수정 클릭 시

const editMemo = (memo) => {
  console.log("[Memo/editMemo] SET_SELECTED_ITEM 디스패치 전, memo:", memo);

  dispatch({
    type: "SET_SELECTED_ITEM",
    payload: memo,
  });

  console.log("[Memo/editMemo] SET_SELECTED_ITEM 디스패치 완료");

  dispatch({ type: "SET_ISEDIT", payload: true });
  dispatch({ type: "SET_ISWRITE", payload: false });
  dispatch({ type: "SET_POPUPOPEN", payload: true });
};

 

2. ScheduleReducer – SET_SELECTED_ITEM 처리할 때

case "SET_SELECTED_ITEM": {
  console.log(
    "[ScheduleReducer] SET_SELECTED_ITEM 호출 ",
    "\n  기존:", state.selectedItem,
    "\n  새로:", action.payload
  );
  return {
    ...state,
    selectedItem: action.payload,
  };
}

 

3. CalendarWriteForm – 렌더될 때마다 확인

useEffect(() => {
  console.log("[CalendarWriteForm] 렌더",
    "\n  isEdit:", isEdit,
    "\n  selectedItem:", selectedItem,
    "\n  selectedDate:", selectedDate
  );
}, [isEdit, selectedItem, selectedDate]);

 

 

# 로그결과 👉 여기까지 완벽히 정상

[Memo/editMemo] SET_SELECTED_ITEM 디스패치 완료

[ScheduleReducer] SET_SELECTED_ITEM 호출 
  기존: [] 
  새로: {id: 6, title: '...', ...}

[ScheduleReducer] SET_ISEDIT: true
[ScheduleReducer] SET_POPUPOPEN: true

[CalendarWriteForm] 렌더 
  isEdit: true 
  selectedItem: {id: 6, ...}
  ...

 

 

어딘가에서 SET_SELECTED_ITEM을 payload: null로 또 호출 확인

→ 그래서 Memo에서 선택한 일정이 한 번은 잘 들어갔다가, 곧바로 null로 덮어씌워지고 있었다.

[ScheduleReducer] SET_SELECTED_ITEM 호출 
  기존: {id: 6, ...} 
  새로: null   //여기서 선택 일정이 날아감

[CalendarWriteForm] 렌더 
  isEdit: true 
  selectedItem: null

 

 

 

진짜 원인

 

CalendarList의 “자동 선택” 로직

→ SET_SELECTED_ITEM을 검색해보니, CalendarList에 이런 useEffect가 있었다

useEffect(() => {
  // 아직 데이터가 없으면 패스
  if (!dailyEvents && !summaryDailyEvents) return;

  const { sortedDailyEvents, unsortedDailyEvents } = splitAndSortEvents(
    dailyEvents.map((event) => ({ ...event, type: "local" })),
    summaryDailyEvents.map((event) => ({ ...event, type: "summary" })),
    "DailyEvents"
  );

  // 정렬/ 날짜, 시간 미정 리스트 저장
  dispatch({ type: "SET_SORTED_DAILY_EVENTS", payload: sortedDailyEvents });
  dispatch({
    type: "SET_UNSORTED_DAILY_EVENTS",
    payload: unsortedDailyEvents,
  });

  // 제일 빠른 일정 자동 선택
  if (sortedDailyEvents.length > 0) {
    dispatch({
      type: "SET_SELECTED_ITEM",
      payload: sortedDailyEvents[0],
    });
  } else {
    if (!isEdit) {
      dispatch({ type: "SET_SELECTED_ITEM", payload: null }); // ★ 문제 부분
    }
  }
}, [dailyEvents, summaryDailyEvents, dispatch]);

 

※ 여기서 중요한 포인트:

  1. sortedDailyEvents.length === 0 이고
  2. 여기서 사용하는 isEdit은 useRefresh()의 isEdit (ScheduleContext의 isEdit과는 다른 값)
  3. Memo에서 수정 모드(ScheduleContext.isEdit = true)를 켜더라도 CalendarList 입장에서는 isEdit이 false일 수 있다.

그 결과:

  • Memo에서 수정 → selectedItem = { id: 6, ... }로 잘 들어감
  • 하지만 그날에 정렬된 일정 배열이 비어 있는 타이밍에
    CalendarList의 useEffect가 실행되면서 dispatch({ type: "SET_SELECTED_ITEM", payload: null });
  • 코드를 한번더 보냄 → 아까 로그에  null

“자동으로 오늘의 첫 일정 선택해주는 로직”이, Memo에서 선택한 일정을 덮어버리고 있었다

 

 

해결 방법

 

수정 모드 / 이미 선택된 상태에서는 자동으로 건드리지 않기

  • “자동 선택”은
    • 수정 중이 아닐 때
    • 아직 어떤 일정도 선택되지 않았을 때
      → 이 두 조건을 만족할 때에만 동작해야 한다.
  • 진짜 수정 모드 여부는 useRefresh()가 아니라
    **ScheduleContext의 isEdit**을 써야 한다
// 수정전
const {
  selectedItem,
  selectedDate,
  summaryDailyEvents,
  dailyEvents,
  sortedDailyEvents,
} = useScheduleState();

const { trigger, refreshCount, isEdit } = useRefresh();


//수정후
const {
  selectedItem,
  selectedDate,
  summaryDailyEvents,
  dailyEvents,
  sortedDailyEvents,
  isEdit,  // 수정 여부 가져오기 이동
} = useScheduleState();

const { trigger, refreshCount } = useRefresh();

 

 

 

자동 선택 useEffect에 조건 추가

useEffect(() => {
  // 아직 데이터가 없으면 패스
  if (!dailyEvents && !summaryDailyEvents) return;

  const {
    sortedDailyEvents: sorted,   // 지역 변수 이름 겹치지 않도록 변경
    unsortedDailyEvents,
  } = splitAndSortEvents(
    (dailyEvents || []).map((event) => ({ ...event, type: "local" })),
    (summaryDailyEvents || []).map((event) => ({ ...event, type: "summary" })),
    "DailyEvents"
  );

  // 정렬/ 날짜, 시간 미정 리스트 저장
  dispatch({ type: "SET_SORTED_DAILY_EVENTS", payload: sorted });
  dispatch({
    type: "SET_UNSORTED_DAILY_EVENTS",
    payload: unsortedDailyEvents,
  });

  // 1) 수정 모드일 땐 selectedItem 건들지 않기
  if (isEdit) {
    console.log("[CalendarList] isEdit=true, 자동 선택/초기화 스킵");
    return;
  }

  // 2) 이미 선택된 일정이 있으면 그대로 두기
  if (selectedItem) {
    console.log("[CalendarList] selectedItem 이미 있음 → 자동 선택 스킵");
    return;
  }

  // 3) 아무것도 선택 안되어 있을 때만 기본값 세팅
  if (sorted.length > 0) {
    dispatch({
      type: "SET_SELECTED_ITEM",
      payload: sorted[0],
    });
  } else {
    dispatch({ type: "SET_SELECTED_ITEM", payload: null });
  }
}, [dailyEvents, summaryDailyEvents, isEdit, selectedItem, dispatch]);

 

 

정리 

 

  • 전역 상태를 여러 컴포넌트가 “자동으로” 수정하면 충돌이 나기 쉽다.
    • Memo에서 selectedItem을 설정해 놓았는데
    • CalendarList의 자동 선택 로직이 다시 덮어버리면서 버그 발생
  • “자동 기본값 세팅” 로직에는 항상 조건을 달자.
    • 수정 모드인지(isEdit)
    • 이미 선택된 값이 있는지(selectedItem) 체크한 뒤에만 자동 세팅
  • 디버깅할 때는 “시간 순서대로 로그 찍기”가 최고다.
    • Memo → reducer → CalendarWriteForm 렌더 → 다시 reducer
    • 순서대로 로그를 찍어보니
      “어디에서 null로 덮어쓰는지”가 정확히 보였다.
  • 같은 이름의 상태(isEdit)가 여러 Context에 있으면 혼란을 부른다.
    • 이번에도 useRefresh()의 isEdit vs ScheduleContext의 isEdit가 섞여 있었다.
    • “수정 모드”와 관련된 건 한 Context에서만 관리하는 게 안전하다.
  • 폼 쪽에서도 방어 코드는 항상 유용하다.
    • selectedItem이 null일 수 있다는 가정 하에 방어 로직을 넣어두면
    • 이런 문제를 디버깅할 때도 “죽지 않고” 에러 메시지를 우리가 통제할 수 있다