React의 내장 Hooks를 통한 최적화

John Han·2026년 1월 5일
post-thumbnail

React에는 다양한 Hook이 있다. useState(), useRef(), useEffect()와 같이 자주 사용되는 훅도 있지만, 자주 사용되지 않아서 잊혀져서 사용할 때마다 찾아보게 되는 훅들도 있다(특히 최적화와 관련있는 훅들). 그래서 이번 기회에 React의 훅에 대해서 확실하게 집고 넘어가보려고 한다.

Hook이란? Hook의 등장 배경

React 16.8 버전이 된 후 함수형 컴포넌트가 생기고, 상태 관리와 생명주기 기능을 함수형 컴포넌트에서도 사용하기 위해 Hook이라는 개념이 생겼다. 이전에는 React에서는 상태, 생명주기를 사용하기 위해서는 클래스 컴포넌트를 사용해야했다. 코드가 길고 복잡하고, 악명 높은 this를 사용해야하고, 상태를 state 내부에서 선언해야하는 등 다양한 불편이 있었다.

class Counter extends React.Component {
  state = { count: 0 };
  
  componentDidMount() {
    console.log("mount");
  }

  componentDidUpdate() {
    console.log("update");
  }

  componentWillUnmount() {
    console.log("unmount");
  }

  render() {
    return <button>{this.state.count}</button>;
  }
}

이 외에도 클래스형 컴포넌트를 사용했을 때 구현이 어려운 Concurrent React 설계를 위해 함수형 컴포넌트를 등장시켰고, 클래스형 컴포넌트에서만 가능하던 작업을 함수형 컴포넌트에서도 가능케 하기 위해 Hook이 등장하였다.

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log("mount");
    return () => console.log("unmount");
  }, []);

  return <button>{count}</button>;
}

각 상태는 각자의 역할에 맞게 고유한 이름을 갖을 수 있게 되었고, 기존의 마운트, 업데이트, 언마운트 등 분리된 작업들이 useEffect()를 통해 하나의 Hook 안에서 처리가 가능하게 되었다. 또한 의존성 배열을 통해서 React가 자동으로 prev 비교를 해주기 때문에 렌더링 후처리 과정 또한 편리해졌다.

// 클래스 컴포넌트
componentDidUpdate(prevProps) {
  if (prevProps.userId !== this.props.userId) {
    fetchUser(this.props.userId);
  }
}

// 함수 컴포넌트
useEffect(() => {
  fetchUser(id);
}, [id]);

Hook의 의존성 배열(deps)의 얕은 비교

useEffect(),useCallback(), useMemo() 같은 훅에는 의존성배열이 존재한다.이때 deps가 빈 배열이라면 컴포넌트가 처음 렌더링될 때만 실행된다. 그렇지만 deps에 변경이 있을 경우 재실행(연산)하게 되는데, 이때 React에서는 deps를 대상으로 얕은 비교를 실행한다.

얕은 비교 (Shallow Compare): 객체나 배열 같은 복합 데이터의 값을 비교할 때 내부의 각 요소나 속성을 재귀적으로 비교하지 않고, 참조나 기본 타입 값만 비교한다.

즉 다음과 같은 경우는 의존성 배열이 바뀐 것으로 간주하여, 매번 실행된다.

// value의 타입
// {
//   id: 0
//   name: "John"
// }

export default function Item(value) {
  useEffect(() => {
    console.log(value);
  }, [value])
}

따라서 구조 분해 할당을 통해 필요한 원시 타입 값을 가져와서 사용해야 한다.

export default function Item(value) {
  const { id, name } = value;
  
  useEffect(() => {
    console.log(value);
  }, [id])
}

Ref Hooks

Ref를 사용하면 렌더링에 사용되지 않는 일부 정보들을 보유할 수 있다. State와 달리, Ref는 업데이트 되어도 다시 렌더링 되지 않는다.

useRef를 통해 Ref 사용하기 - DOM 참조하기

아래 코드는 움직일 수 있는 모달을 구현한 코드이다. 해당 모달의 기능은 간단하다 주어진 영역 안에서 자유롭게 이동 가능하며, 모달 밖의 영역을 클릭했을 때 모달을 닫는 기능을 갖고 있다. React는 기본적으로 DOM을 직접적으로 제어할 수 없게 설계됐다. 그렇지만 모달을 클릭한 상태로 위치를 이동시키는 것은 DOM에 접근하는 것이다. 따라서 React에서는 DOM에 접근하기 위해 유일한 통로 ref가 존재한다.

modalRef는 DOM이 생성되기 전 null이다. 그렇지만 DOM이 생기는 순간 ref 속성을 통해 해당 DOM을 modalRef.current에 연결해준다. 따라서 Modal의 이동이 가능해지는 것이다.

import { useEffect, useRef, useState } from "react";

export default function DraggableModal({
  children,
  setOpenModal,
}: {
  children: React.ReactNode;
  setOpenModal: (open: boolean) => void;
}) {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [isDragging, setIsDragging] = useState(false);
  const dragStartPos = useRef({ x: 0, y: 0 });
  const modalRef = useRef<HTMLDivElement>(null);

  // Search Modal 드래그
  useEffect(() => {
    if (!isDragging) return;

    const handleMouseMove = (e: MouseEvent) => {
      const deltaX = e.clientX - dragStartPos.current.x;
      const deltaY = e.clientY - dragStartPos.current.y;
      setPosition((prev) => ({
        x: prev.x + deltaX,
        y: prev.y + deltaY,
      }));
      dragStartPos.current = { x: e.clientX, y: e.clientY };
    };

    const handleMouseUp = () => {
      setIsDragging(false);
    };

    window.addEventListener("mousemove", handleMouseMove);
    window.addEventListener("mouseup", handleMouseUp);

    return () => {
      window.removeEventListener("mousemove", handleMouseMove);
      window.removeEventListener("mouseup", handleMouseUp);
    };
  }, [isDragging]);

  const handleHeaderMouseDown = (e: React.MouseEvent) => {
    if (e.button !== 0) return;
    if (modalRef.current) {
      dragStartPos.current = {
        x: e.clientX,
        y: e.clientY,
      };
      setIsDragging(true);
    }
  };

  return (
    <div
      className="fixed inset-0 flex items-center justify-center z-[9999]"
      onClick={() => setOpenModal(false)}
    >
      <div
        ref={modalRef} // DOM 생성 시 modalRef.current에 연결
        className="flex flex-col w-[750px] h-[480px] shadow-[0_2px_20px_0_#badaff] rounded-2xl border-[1px] border-solid border-[rgba(var(--color-border-quaternary),0.08)] bg-[rgba(255,255,255,0.2)] backdrop-blur-[12px] overflow-hidden"
        style={{
          transform: `translate(${position.x}px, ${position.y}px)`,
        }}
        onMouseDown={handleHeaderMouseDown}
        onClick={(e) => e.stopPropagation()}
      >
        {children}
      </div>
    </div>
  );
}

위 코드를 보면 모달의 위치를 State로 관리한다. 별로 무거운 작업도 아니고, 작동도 잘 되지만 transform(모달 이동)이 발생할 때마다 State position이 변경되고, 동시에 이 모달은 재렌더링된다. (x0, y0)에서 (x1, y1)로 이동하는 모든 순간 렌더링이 되는 것은 실로 비효율적이다. 따라서 이 부분에 대한 최적화가 필요하다.

React Rendering과 Browser Rendering

위의 예시 코드를 useRef()를 통해 최적화하기 앞서 React Rendering과 Browser Rendering의 차이점을 정확하게 집고 넘어갈 필요가 있다.

위 정보들을 통해 position이 변경되는 것은 Browser Rendering의 역할이라는 것을 알 수 있다. 즉 State를 사용하지 않아도 position 변경이 가능하다.

useRef를 통해 Ref 사용하기 - Browser Rendering 트리거

기존의 코드와 변경된 점은 position에 대하여 useRef를 사용한다는 것이다. 그래서 기존 코드에서는 위치가 변경되는 모든 과정 가운데 재렌더링이 발생했다면, 지금은 isDragging의 값이 변경되는 시점에만 재렌더링이 발생한다. 또한 기존에 사용하였던 modalRef를 사용하여 직접적으로 style의 transform 속성을 조작하였다.

import { useEffect, useRef, useState } from "react";

export default function DraggableModal({
  children,
  setOpenModal,
}: {
  children: React.ReactNode;
  setOpenModal: (open: boolean) => void;
}) {
  const modalRef = useRef<HTMLDivElement>(null);
  const [isDragging, setIsDragging] = useState(false);

  const dragStartPos = useRef({ x: 0, y: 0 });
  const positionRef = useRef({ x: 0, y: 0 });

  const applyTransform = () => {
    const el = modalRef.current;
    if (!el) return;
    const { x, y } = positionRef.current;
    el.style.transform = `translate(${x}px, ${y}px)`;
  };

  // Search Modal 드래그
  useEffect(() => {
    if (!isDragging) return;

    const handleMouseMove = (e: MouseEvent) => {
      const deltaX = e.clientX - dragStartPos.current.x;
      const deltaY = e.clientY - dragStartPos.current.y;

      positionRef.current = {
        x: positionRef.current.x + deltaX,
        y: positionRef.current.y + deltaY,
      };

      dragStartPos.current = { x: e.clientX, y: e.clientY };

      applyTransform();
    };

    const handleMouseUp = () => {
      setIsDragging(false);
    };

    window.addEventListener("mousemove", handleMouseMove);
    window.addEventListener("mouseup", handleMouseUp);

    return () => {
      window.removeEventListener("mousemove", handleMouseMove);
      window.removeEventListener("mouseup", handleMouseUp);
    };
  }, [isDragging]);

  const handleHeaderMouseDown = (e: React.MouseEvent) => {
    if (e.button !== 0) return; // 좌클릭만 허용
    if (modalRef.current) {
      dragStartPos.current = {
        x: e.clientX,
        y: e.clientY,
      };
      setIsDragging(true);
    }
  };

  return (
    <div
      className="fixed inset-0 flex items-center justify-center z-[9999]"
      onClick={() => setOpenModal(false)}
    >
      <div
        ref={modalRef}
        className="flex flex-col w-[750px] h-[480px] shadow-[0_2px_20px_0_#badaff] rounded-2xl border-[1px] border-solid border-[rgba(var(--color-border-quaternary),0.08)] bg-[rgba(255,255,255,0.2)] backdrop-blur-[12px] overflow-hidden"
        onMouseDown={handleHeaderMouseDown}
        onClick={(e) => e.stopPropagation()}
      >
        {children}
      </div>
    </div>
  );
}

Performance Hooks

React에서는 성능을 재렌더링 성능을 최적화하기 위해 사용하는 훅들이 있다. 이러한 훅은 최적화 도구이지 기본 문법이 아니다. 또한 이러한 훅을 사용하는 것 자체만으로도 비용이 있기 때문에 무분별한 사용은 오히려 코드 가독성을 낮추고, 단순 연산에 비해 비용이 더 많이 든다.

useMemo() - 동일한 값의 재연산 방지하기

useMemo()를 사용하면 비용이 많이 드는 계산 결과를 캐시할 수 있다. useMemo()는 파리미터로 콜백 함수와 의존성 배열을 받는다. 콜백 함수의 반환 값이 변수가 갖는 값이 되며, 의존성 배열이 변했을 때 기존 캐싱된 값을 무효하고 다시 계산을 한다.

const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);

useCallback() - 동일한 함수 생성 방지하기

useCallback()을 사용하면 함수 정의를 최적화된 컴포넌트에 전달하기 전에 캐시할 수 있다. 말이 추상적인데, 보다 직관적으로 설명하자면 함수는 Pure JS 문법이고, JS에서는 하나의 값이다. 따라서 재렌더링이 될 때마다 새로운 함수를 생성을 한다.

아래 코드를 예시로 들자면, Parent()의 props에 변경이 생겨 재렌더링 되었다. 이때 Parent() 내부에
CircleAvatar 컴포넌트 역시 props에 변경이 생겨서 재렌더링되며, 선언된 함수 fetchData() 역시 새롭게 생성된다. 그렇지만 새로고침을 위한 Child 컴포넌트 또한 렌더링이 되는 것은 불필요한 상황이다. 즉 자식이 렌더를 스킵할 수 있는 구조이다. 그렇지만 React에서는 얕은 비교를 하기 때문에 Child 컴포넌트는 fetchData가 바뀐 것으로 간주하고 재렌더링을 한다.

function Parent(user) {
  const fetchData = async () => { ... };

  return (
    <div>
      <CircleAvatar source={user.profileImg} />
      <Child onRefresh={fetchData} />;
      {/* ... */}
    </div>
  );
}

이러한 상황에서 기존의 fetchDatauseCallback을 사용하여 부모인 Parent가 재렌더링 되어도 함수가 다시 생성되는 것을 방지하여, 자녀인 Child가 재렌더링 되는 것을 방지할 수 있다.

const fetchData = useCallback(async () => { ... }, [userId]);

Child가 React.memo가 아니거나, props로 내려가지 않는 함수라면 useCallback은 대부분 의미가 없다.

실제 사용 예시

SearchModal은 위에서 봤던 DraggableModal의 children으로 전달된다. DraggableModal은 마우스가 클릭되는 시점과 이동이 종료되는 시점에 재렌더링이 되는데, 이 두 시점에 자식 컴포넌트인 SearchModal 또한 재렌더링이 된다.

// SearchModal.tsx
export default function SearchModal({
  setOpenSearch,
}: {
  setOpenSearch: (open: boolean) => void;
}) {
  const [searchQuery, setSearchQuery] = useState("");
  const debouncedSearchQuery = useDebounce(searchQuery, 300);

  const { data: notes } = useQuery<Note[]>({
    queryKey: ["notes", debouncedSearchQuery],
    queryFn: () => noteRepo.getNoteByQuery(debouncedSearchQuery),
    enabled: searchQuery.length > 1,
  });

  // ...

  return (
    <DraggableModal setOpenModal={setOpenSearch}>
      {/* Header */}
      <div className="flex items-center justify-between p-4 border-b-[1.5px] border-solid border-[rgba(var(--color-border-quaternary),0.08)] cursor-move flex-shrink-0">
        ...
      </div>
      {/* content */}
      <section className="flex flex-col w-full flex-1 overflow-y-scroll custom-scrollbar px-4 py-3 gap-3">
        <SearchResult
          type="chat"
          title="Chats"
          data={chatThreads}
          searchQuery={searchQuery}
          setOpenSearch={setOpenSearch}
        />
        ...
      </section>
      {/* footer */}
      <div className="px-4 py-[10px] border-t-[1.5px] border-solid border-[rgba(var(--color-border-quaternary),0.08)] flex items-center justify-between flex-shrink-0">
        ...
      </div>
    </DraggableModal>
  );
}

따라서 자식 SearchResult 컴포넌트 또한 재렌더링이 되며 다음과 같은 페인 포인트가 존재한다.

  • highlightText()가 아이템마다 매번 RegExp를 새로 생성한다 (map 안에서 계속)
  • 렌더 부분이 길어서 가독성이 떨어진다
// SearchResult.tsx
export default function SearchResult({
  type,
  title,
  data,
  searchQuery,
  setOpenSearch,
}: {
  type: "chat" | "note";
  title: string;
  data: SearchResultData;
  searchQuery: string;
  setOpenSearch: (open: boolean) => void;
}) {
  const navigate = useNavigate();

  const highlightText = (text: string, query: string) => {
    if (!query || query.length === 0) return text;

    const regex = new RegExp(
      `(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`,
      "gi"
    );
    const parts = text.split(regex);

    return parts.map((part, index) =>
      regex.test(part) ? (
        <span key={index} className="text-primary">
          {part}
        </span>
      ) : (
        part
      )
    );
  };

  return (
    <div>
      <p className="font-noto-sans-kr font-medium text-[12px] text-text-secondary mb-2">
        {title}
      </p>
      {data && data.length > 0 ? (
        data.map((item) => (
          <div
            onClick={() => {
              navigate(`/${type}/${item.id}`);
              setOpenSearch(false);
            }}
            key={item.id}
            className="w-full group cursor-pointer flex flex-col items-start gap-2.5 hover:bg-search-item-hover rounded-[10px] p-3"
          >
            <p className="font-noto-sans-kr font-medium text-[14px]">
              {highlightText(item.title, searchQuery)}
            </p>
            <p className="text-[12px] text-text-secondary line-clamp-1 group-hover:line-clamp-2">
              {highlightText(
                type === "chat"
                  ? (item as ChatThread).messages[0].content
                  : (item as Note).content,
                searchQuery
              )}
            </p>
          </div>
        ))
      ) : (
        <div className="w-full flex items-center justify-center py-1">
          <p className="text-[14px] font-medium">{`No ${title} found`}</p>
        </div>
      )}
    </div>
  );
}

위의 코드는 다음과 같이 리펙토링 작업이 가능하다

  • 기존의 highlightText를 문자 그대로 바꿔주는 함수 escapeRegExp와 렌더를 담당하는 함수 renderHighlightParts로 분리하고, util함수로 분리해준다.
  • 기존의 SearchResult.tsx의 map에서 렌더링되던 긴 렌더부분을 재사용 가능한 컴포넌트로 분리한다.
  • highlight가 되야하는 부분은 searchQuery가 바뀌지 않는 한 불변해야 한다. 따라서 useMemo를 사용해서, 재연산을 방지한다. (highlight 되야하는 부분이 재연산이 되면, map 부분도 재연산이 될 것이고 검색 결과가 많을 경우 이 비용이 비쌀 수가 있다)
  • SearchResultItem은 자신이 클릭됐을 때 실행해야하는 함수를 파라미터로 받는다. 해당 함수는 부모인 SearchResult에서 선언됐다. Modal이 재렌더링될 때 SeacrhResult 또한 재렌더링 되고, SearchResult 내부에 선언된 함수는 새롭게 생긴다. 이때useCallbackReact.memo()를 사용하여서 data는 안 변하고, 모달 이동 과정 중에 발생하는 리렌더링에서 함수 참조 값의 불일치로 SearchResultItem이 렌더링 되는 것을 방지한다.
// Util 함수로 분리
function escapeRegExp(s: string) {
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function renderHighlightParts(text: string, regex: RegExp | null) {
  if (!regex) return text;

  const parts = text.split(regex);
  return parts.map((part, idx) =>
    idx % 2 === 1 ? (
      <span key={idx} className="text-primary">
        {part}
      </span>
    ) : (
      part
    )
  );
}

// SearchResultItem으로 렌더 부분 재사용 컴포넌트로 분리
const SearchResultItem = React.memo(function SearchResultItem({
  id,
  title,
  preview,
  onClick,
  highlightRegex,
}: {
  id: string;
  title: string;
  preview: string;
  onClick: (id: string) => void;
  highlightRegex: RegExp | null;
}) {
  return (
    <div
      onClick={() => onClick(id)}
      className="w-full group cursor-pointer flex flex-col items-start gap-2.5 hover:bg-search-item-hover rounded-[10px] p-3"
    >
      <p className="font-noto-sans-kr font-medium text-[14px]">
        {renderHighlightParts(title, highlightRegex)}
      </p>
      <p className="text-[12px] text-text-secondary line-clamp-1 group-hover:line-clamp-2">
        {renderHighlightParts(preview, highlightRegex)}
      </p>
    </div>
  );
});

// SearchResult.tsx
export default function SearchResult({
  type,
  title,
  data,
  searchQuery,
  setOpenSearch,
}: {
  type: "chat" | "note";
  title: string;
  data: SearchResultData;
  searchQuery: string;
  setOpenSearch: (open: boolean) => void;
}) {
  const navigate = useNavigate();

  const highlightRegex = useMemo(() => {
    const q = searchQuery.trim();
    if (!q) return null;
    return new RegExp(`(${escapeRegExp(q)})`, "gi");
  }, [searchQuery]);

  const onClickItem = useCallback(
    (id: string) => {
      navigate(`/${type}/${id}`);
      setOpenSearch(false);
    },
    [navigate, setOpenSearch, type]
  );

  return (
    <div>
      <p className="font-noto-sans-kr font-medium text-[12px] text-text-secondary mb-2">
        {title}
      </p>

      {data && data.length > 0 ? (
        data.map((item) => {
          const preview =
            type === "chat"
              ? (item as ChatThread).messages?.[0]?.content ?? ""
              : (item as Note).content ?? "";

          return (
            <SearchResultItem
              key={item.id}
              id={item.id}
              title={item.title}
              preview={preview}
              onClick={onClickItem}
              highlightRegex={highlightRegex}
            />
          );
        })
      ) : (
        <div className="w-full flex items-center justify-center py-1">
          <p className="text-[14px] font-medium">{`No ${title} found`}</p>
        </div>
      )}
    </div>
  );
}

마무리하며...

React를 사용하여 개발한지 꽤 많은 시간이 흘렀지만 아직도 완벽하게 알고 있지 않다. React의 내장 훅들를 사용하여 커스텀훅을 만들어 UI와 로직의 분리로 코드 가독성을 올리고, Hook을 필요로 하는 로직을 재사용할 수 있는 등 다양한 시도를 할 수 있다. 다음번에는 커스텀 훅에 대하여 더 깊게 알아보고, 글로 남겨보는 시간을 가져봐야겠다.

참고한 공식문서
https://ko.react.dev/reference/react/hooks
https://ko.react.dev/reference/react/memo#skipping-re-rendering-when-props-are-unchanged

profile
Hello World!

0개의 댓글