본문으로 건너뛰기
이창준
전체 글

텔링

모달을 닫을 때마다 목록 전체를 다시 불러오던 문제

상세 모달을 열고 닫을 때마다 목록과 즐겨찾기를 다시 조회하던 원인을 네 가지로 나누고, 수정된 항목 한 건만 동기화하도록 바꾼 과정입니다.

  • React
  • 렌더링 최적화
  • 트러블슈팅

텔링은 AI와 대화하며 경험을 정리하고, 그 경험으로 자기소개서와 면접을 준비하는 서비스입니다. (주)크루베이션에서 프론트엔드를 맡는 동안 해결한 문제 중 하나가 경험 목록 화면의 불필요한 재조회였습니다.

사내 코드는 그대로 옮기지 않고, 구조만 남긴 단순화한 예시로 설명합니다.

증상: 모달만 열고 닫았는데 목록 전체가 다시 로딩된다

경험 목록은 폴더별로 항목을 카드나 표로 보여 주는 화면입니다. 항목을 누르면 상세 모달이 열리고, 모달 안에서 제목이나 내용을 고칠 수 있습니다.

문제는 모달을 열 때와 닫을 때마다 목록 전체가 로딩 상태로 바뀌었다가 다시 그려진다는 점이었습니다. 목록 API가 다시 호출됐고, 뒤이어 즐겨찾기 상태 조회도 항목 수만큼 다시 나갔습니다. 항목 하나를 열어 봤을 뿐인데 목록 전체가 흔들렸습니다.

원인: 네 가지가 겹쳐 있었다

클릭부터 화면이 다시 그려질 때까지 어떤 순서로 이어지는지 따라가 보니, 원인이 네 개로 나뉘었습니다.

1. 목록 조회 effect가 모달 상태에 의존했다

변경 전: 목록 조회
useEffect(() => {
  let ignore = false;
  setLoading(true);
 
  fetchItems(folderId)
    .then((list) => {
      if (!ignore) setRows(list);
    })
    .finally(() => setLoading(false));
 
  return () => {
    ignore = true;
  };
}, [folderId, sort, openedItem, refresh]);

의존성 배열에 지금 열려 있는 항목인 openedItem이 들어 있었습니다. 그래서 흐름이 이렇게 이어졌습니다.

  1. 항목을 누른다 → setOpenedItem(item)
  2. openedItem이 바뀌어 effect가 다시 실행된다
  3. 목록 API를 다시 호출하고 rows를 통째로 바꾼다

모달을 닫으면서 setOpenedItem(null)을 할 때도 똑같은 일이 반복됐습니다. 모달을 여닫는 화면 상태가 데이터를 다시 불러오는 조건이 되어 있었습니다.

2. 배열을 통째로 바꾸면서 모든 항목의 참조가 바뀌었다

setRows(list)는 응답으로 받은 새 배열을 그대로 넣습니다. 값이 이전과 같아도 항목 객체는 전부 새로 만들어진 것이라, 항목을 props로 받는 컴포넌트 입장에서는 모두 바뀐 데이터입니다.

3. 즐겨찾기 조회가 rows 배열 자체에 반응했다

useEffect(() => {
  const ids = rows.map((row) => row.id);
  // ids로 즐겨찾기 상태 조회
}, [rows]);

항목이 추가되거나 삭제되지 않고 내용만 바뀌어도 rows의 참조가 바뀌므로 즐겨찾기 조회가 다시 실행됐습니다. 목록 재조회 한 번이 즐겨찾기 조회까지 줄줄이 끌고 오는 구조였습니다.

4. 카드가 부모 컴포넌트 안에서 바로 그려졌다

카드 목록은 부모 컴포넌트 안에서 rows.map(...)으로 JSX를 바로 만들고 있었습니다. 이 구조에서는 부모가 다시 렌더링되면 내용이 그대로인 카드까지 전부 다시 그려집니다.

해결: 원인마다 하나씩 끊었다

모달 상태를 재조회 조건에서 뺐다

가장 먼저 고친 것은 한 줄이었습니다.

}, [folderId, sort, refresh]);

이제 모달을 열고 닫는 것만으로는 목록을 다시 불러오지 않습니다. 삭제처럼 정말 전체를 다시 받아야 할 때만 refresh 신호를 보냅니다.

이 신호도 하나 고쳤습니다. 원래는 setRefresh(true)로 신호를 보냈는데, 한 번 true가 되고 나면 값이 그대로라 두 번째 신호부터는 effect가 실행되지 않을 수 있었습니다. 보낼 때마다 값이 바뀌도록 setRefresh((prev) => !prev)로 바꿨습니다.

닫을 때는 그 항목 하나만 다시 받는다

목록 전체 대신, 방금 닫은 항목의 상세 정보와 즐겨찾기 여부만 다시 조회해서 기존 목록에 합쳤습니다.

모달 닫기
const handleModalClose = useCallback(async () => {
  const closingId = openedItem?.id;
  setOpenedItem(null);
  if (closingId == null) return;
 
  try {
    const [detail, isFavorite] = await Promise.all([
      fetchItemDetail(closingId),
      fetchFavorite(closingId),
    ]);
 
    setRows((prev) => syncRow(prev, detail, folderId));
    setFavorites((prev) =>
      prev[closingId] === isFavorite
        ? prev
        : { ...prev, [closingId]: isFavorite },
    );
  } catch (error) {
    console.error("항목 동기화 실패", error);
  }
}, [openedItem, folderId]);

syncRow는 수정된 항목 하나만 새 객체로 바꾸고, 나머지 항목은 기존 객체를 그대로 둡니다. 수정하면서 다른 폴더로 옮겨진 항목은 지금 보고 있는 목록에서 뺍니다.

수정된 항목만 교체
function syncRow(rows: Row[], detail: ItemDetail, folderId: FolderId) {
  const index = rows.findIndex((row) => row.id === detail.id);
  if (index === -1) return rows;
 
  if (isRealFolder(folderId) && detail.folderId !== folderId) {
    return rows.filter((row) => row.id !== detail.id);
  }
 
  const next = rows.slice();
  next[index] = { ...rows[index], ...toRow(detail) };
  return next;
}

즐겨찾기 상태도 값이 같으면 이전 객체를 그대로 돌려줍니다. 새 객체를 만들지 않아야 이 상태를 쓰는 컴포넌트가 괜히 다시 렌더링되지 않습니다.

즐겨찾기 조회는 id 구성이 바뀔 때만

rows 배열 대신 id를 이어 붙인 문자열을 의존성으로 삼았습니다. 문자열은 값으로 비교되므로, 항목 내용만 바뀌고 구성이 그대로면 effect가 다시 실행되지 않습니다.

const rowIdsKey = useMemo(
  () => rows.map((row) => row.id).join(","),
  [rows],
);
 
useEffect(() => {
  const ids = rowIdsKey ? rowIdsKey.split(",").map(Number) : [];
  // ids로 즐겨찾기 상태 조회
}, [rowIdsKey]);

카드를 memo 컴포넌트로 분리했다

카드를 memo로 감싼 별도 컴포넌트로 빼서, props가 같으면 부모가 다시 렌더링돼도 카드는 건너뛰게 했습니다.

const ItemCard = memo(function ItemCard({
  row,
  isFavorite,
  onOpen,
  onToggleFavorite,
}: ItemCardProps) {
  // ...
});

그런데 memo만 붙여서는 효과가 없었습니다. 부모가 렌더링될 때마다 새로 만들어지는 함수를 props로 넘기면 memo는 매번 props가 바뀌었다고 판단합니다. 그래서 카드에 넘기는 함수를 useCallback으로 고정했습니다.

즐겨찾기 토글에는 문제가 하나 더 있었습니다. 현재 즐겨찾기 상태를 읽어 낙관적 업데이트를 해야 해서 함수가 자주 새로 만들어지는데, 카드에는 같은 참조를 넘기고 싶었습니다. 그래서 최신 함수는 ref에 담아 두고, 카드에는 그 ref를 호출만 하는 고정된 함수를 넘겼습니다.

const toggleFavoriteRef = useRef(toggleFavorite);
toggleFavoriteRef.current = toggleFavorite;
 
const onToggleFavorite = useCallback((id: number) => {
  void toggleFavoriteRef.current(id);
}, []);

결과

동작변경 전변경 후
모달 열기목록 전체 재조회목록 재조회 없음
모달 닫기목록 전체 재조회, 즐겨찾기 재조회수정한 항목 1건만 조회
카드 보기에서 항목 수정모든 카드 다시 렌더링바뀐 카드만 다시 렌더링

React Profiler로 한 번 측정했을 때 목록 컴포넌트의 렌더링 시간은 15.7ms에서 13.5ms로 약 14% 줄었습니다. 솔직히 절대 시간 차이는 크지 않습니다. 이 작업에서 더 중요했던 건 불필요한 네트워크 요청과 목록이 흔들리는 현상을 없앤 것, 그리고 항목이 많아질수록 차이가 커지는 구조로 바꾼 것이었습니다.

적용한 뒤에는 타입 검사를 통과하는지, 모달을 닫을 때 목록 전체를 다시 조회하지 않는지 확인했습니다.

돌아보며

작업 기록에는 이 문제를 이렇게 정리해 두었습니다.

전체 목록을 다시 받아서 해결하려고 하지 말고, 수정된 항목만 로컬 상태에 정교하게 반영하자.

그리고 effect의 의존성 배열은 이 값이 바뀌면 부수 효과를 다시 실행해야 하는지로 따져야 한다는 걸 배웠습니다. 모달이 열려 있는지 같은 화면 상태와, 폴더나 정렬처럼 데이터를 다시 불러와야 하는 조건은 섞으면 안 됩니다.

다시 한다면 바꾸고 싶은 점도 있습니다.

  • ref를 갱신하는 위치: 렌더링 중에 ref.current를 바꾸는 방식은 React 문서에서 권하지 않습니다. 지금이라면 effect 안에서 갱신하겠습니다.
  • 표 보기: 이 작업에서는 카드 보기와 모바일 목록에만 memo를 적용했고, 데스크톱 표 보기의 행까지는 연결하지 못했습니다.
  • 서버 상태 관리: 이 화면은 목록을 useState로 직접 관리했습니다. React Query로 관리했다면 같은 전략을 setQueryData로 캐시 안의 해당 항목만 바꾸는 방식으로 더 짧게 쓸 수 있습니다.
queryClient.setQueryData<Row[]>(["items", folderId], (old) =>
  old?.map((row) =>
    row.id === detail.id ? { ...row, ...toRow(detail) } : row,
  ),
);