발행일

useCallback 17개를 지운 "최적화" 커밋 — 그리고 같은 커밋에서 새로 만든 문제

useCallback 17개를 지운 "최적화" 커밋

이 글은 2026년 9월에 당시 커밋 이력을 다시 보며 정리한 것입니다. 날짜는 작업한 날 기준입니다.

2024년 11월 7일, TripTune 프런트에 735055d "useCallback 최적화 (#137)"를 올렸습니다. 9개 파일, +169/−169. 제목만 보면 useCallback을 붙여서 렌더를 줄인 커밋 같은데, diff는 반대입니다.

useCallback 이 든 줄  — 지운 것 17, 추가한 것 0
호출 수               — 커밋 전 19(8개 파일) → 커밋 후 6(4개 파일)

지운 17줄은 호출 13개와 import 4줄입니다. 남은 6개는 손대지 않은 파일에 있던 거예요. 그러니까 이 커밋은 "최적화"라기보다 "제가 잘못 쓴 useCallback을 걷어낸 것"에 가깝습니다. 몇 년째 React를 쓰면서도 이 훅을 습관으로 붙이고 있었어요.

무엇을 감싸고 있었나

src/app/Travel/page.tsx에서 지운 것 중 하나입니다.

-  const handleSearch = useCallback(() => {
+  const handleSearch = () => {
     if (searchTerm.trim()) {
       setIsSearching(true);
       setCurrentPage(1);
       refetchSearch();
     } else {
       alert('검색어를 입력해주세요.');
     }
-  }, [searchTerm, setIsSearching, setCurrentPage, refetchSearch]);
+  };

의존성 배열의 setIsSearching·setCurrentPage는 zustand 스토어의 세터입니다. 스토어가 만든 함수라 참조가 바뀌지 않아요. 배열에 넣어 봐야 아무 일도 안 일어납니다. 그리고 handleSearch를 받는 쪽은 버튼의 onClick이라, 참조가 유지된다고 해서 건너뛸 렌더도 없습니다. 받는 쪽이 React.memo도 아니고 다른 훅의 의존성도 아니면 useCallback은 비용만 남습니다.

더 이상한 것도 있었습니다.

-  useCallback(() => {
-    setSearchTerm('');
-    setIsSearching(false);
-    setCurrentPage(1);
-    refetchLocation();
-  }, [setSearchTerm, setIsSearching, setCurrentPage, refetchLocation]);

변수에 담지도 않고 어디서도 부르지 않는 useCallback 호출입니다. 검색 초기화 함수를 만들다가 이름을 지우고 호출만 남긴 것으로 보이는데, 커밋 이력에 경위는 없습니다. 렌더마다 클로저를 하나 만들고 버리는 코드가 몇 주 동안 살아 있었어요.

toggleBookmarkhandleAlertClose는 의존성 배열이 []였습니다. 안에서 setAlertMessage 같은 useState 세터만 쓰니 틀린 건 아닌데, 이것도 받는 쪽이 일반 컴포넌트라 효과가 없기는 마찬가지였습니다.

지우는 김에 한 것

같은 커밋에서 api.ts의 401 처리도 정리했습니다. 로그인 페이지로 보내는 코드가 세 군데에 복붙돼 있던 걸 handleRedirectToLogin(message) 하나로 모았어요. useCallback과는 상관없는 변경인데 한 커밋에 들어갔습니다. 커밋 제목이 내용의 절반만 말하는 셈이에요.

같은 커밋에서 만든 문제

useAuth.ts에서는 세 함수의 useCallback을 벗기면서 훅 안의 effect 의존성을 비웠습니다.

-  const checkAuthStatus = useCallback(async () => {
+  const checkAuthStatus = async () => {
     ...
-  }, [isTokenExpired, showLoginModal]);
+  };

   useEffect(() => {
     checkAuthStatus();
-  }, [checkAuthStatus]);
+  }, []);

여기까지는 앞뒤가 맞습니다. 그런데 같은 커밋에서 일정 화면 두 곳에 이걸 추가했어요.

// src/app/Schedule/page.tsx, src/app/Schedule/[scheduleId]/page.tsx
+  const { checkAuthStatus } = useAuth();
+
+  useEffect(() => {
+    checkAuthStatus();
+  }, [checkAuthStatus]);

checkAuthStatus는 이제 useCallback이 없으니 렌더마다 새 함수입니다. 그걸 의존성에 넣었으니 이 effect는 렌더마다 돕니다. 그 화면의 state가 바뀔 때마다 쿠키를 읽고 토큰 만료를 검사해요. 게다가 useAuth 훅 자체가 마운트 때 checkAuthStatus()를 한 번 부르고 있어서, 화면 쪽 호출은 처음부터 중복이었습니다.

header.tsx도 같은 모양입니다. 5분마다 도는 setInterval[checkAuthStatus, router] 의존성의 effect 안에 뒀으니, 렌더마다 interval을 지우고 새로 걸었어요.

한쪽에서는 "이 함수는 참조가 바뀌어도 상관없다"고 useCallback을 지우고, 다른 쪽에서는 "이 함수의 참조가 바뀌면 다시 실행해라"고 의존성에 넣은 겁니다. 한 커밋 안에서 두 규칙이 충돌하고 있었는데, 그때는 못 봤습니다. 프로젝트 린트가 next/core-web-vitalsreact-hooks/exhaustive-deps가 켜져 있었고, 그 경고를 따라 넣었을 가능성이 큰데 커밋에 남은 이유는 없습니다. 린트를 따르는 것과 그 의존성이 무슨 뜻인지 아는 것은 다른 일이었어요.

검증

  • 측정 기록이 없습니다. 커밋에도 문서에도 렌더 횟수나 Profiler 수치가 없어요. "지워도 동작한다"까지만 확인한 커밋으로 읽힙니다.
  • 호출 수 19→6, 지운 줄 17·추가 0은 이 글을 쓰면서 git grep -c와 diff로 센 숫자입니다. 당시에는 몇 개를 지우는지도 세지 않았습니다.
  • 일정 화면의 effect가 렌더마다 도는 건 코드로 읽어 낸 것이고, 그때 실제로 몇 번 돌았는지는 기록이 없습니다.

남은 것 · 한계

  • useCallback을 지운 판단은 지금도 맞다고 봅니다. 받는 쪽이 메모이제이션되지 않은 함수를 감싸는 건 비용뿐이에요. 다만 "효과가 없다"와 "측정했더니 없더라"는 다른 말이고, 이 커밋은 앞엣것이었습니다.
  • [checkAuthStatus] 의존성은 5개월 뒤에야 정리됐습니다. 2025년 4월 21일 e686745에서 useAuthcheckAuth를 다시 useCallback으로 감싸고 [getDecryptedCookie, router]를 의존성으로 뒀어요. 그 사이 일정 화면은 렌더마다 인증 검사를 하고 있었습니다. 문제로 드러나지 않은 건 쿠키 읽기가 싸고 토큰이 보통 유효해서 아무 일도 안 일어났기 때문이에요.
  • 그 뒤 인증 상태를 어디서 확정하는지가 진짜 문제였다는 건 인증이 확정되기 전에 쏜 요청로그인이 자꾸 풀리던 서비스에 있습니다. 2024년 11월의 저는 함수 참조를 붙잡느냐 마느냐를 보고 있었고, 봐야 할 건 그 함수가 언제 불리느냐였습니다.
  • useState·useEffect를 직접 구현해 보며 클로저와 의존성이 무엇인지 되짚은 건 두 달 뒤였습니다. useState 구현useEffect 구현 글은 그 순서로 읽으면 왜 그때 그런 공부를 했는지가 보입니다.

관련 글: 자바스크립트로 useEffect 구현해 보기 · 자바스크립트로 useState 구현해 보기 · 인증이 확정되기 전에 쏜 요청 · 로그인이 자꾸 풀리던 서비스 · JWT exp를 프런트가 읽던 함수를 지웠다 · STOMP 클라이언트를 useState에서 useRef로