- 발행일
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 호출입니다. 검색 초기화 함수를 만들다가 이름을 지우고 호출만 남긴 것으로 보이는데, 커밋 이력에 경위는 없습니다. 렌더마다 클로저를 하나 만들고 버리는 코드가 몇 주 동안 살아 있었어요.
toggleBookmark와 handleAlertClose는 의존성 배열이 []였습니다. 안에서 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-vitals라 react-hooks/exhaustive-deps가 켜져 있었고, 그 경고를 따라 넣었을 가능성이 큰데 커밋에 남은 이유는 없습니다. 린트를 따르는 것과 그 의존성이 무슨 뜻인지 아는 것은 다른 일이었어요.
검증
- 측정 기록이 없습니다. 커밋에도 문서에도 렌더 횟수나 Profiler 수치가 없어요. "지워도 동작한다"까지만 확인한 커밋으로 읽힙니다.
- 호출 수 19→6, 지운 줄 17·추가 0은 이 글을 쓰면서
git grep -c와 diff로 센 숫자입니다. 당시에는 몇 개를 지우는지도 세지 않았습니다. - 일정 화면의 effect가 렌더마다 도는 건 코드로 읽어 낸 것이고, 그때 실제로 몇 번 돌았는지는 기록이 없습니다.
남은 것 · 한계
- useCallback을 지운 판단은 지금도 맞다고 봅니다. 받는 쪽이 메모이제이션되지 않은 함수를 감싸는 건 비용뿐이에요. 다만 "효과가 없다"와 "측정했더니 없더라"는 다른 말이고, 이 커밋은 앞엣것이었습니다.
[checkAuthStatus]의존성은 5개월 뒤에야 정리됐습니다. 2025년 4월 21일e686745에서useAuth의checkAuth를 다시 useCallback으로 감싸고[getDecryptedCookie, router]를 의존성으로 뒀어요. 그 사이 일정 화면은 렌더마다 인증 검사를 하고 있었습니다. 문제로 드러나지 않은 건 쿠키 읽기가 싸고 토큰이 보통 유효해서 아무 일도 안 일어났기 때문이에요.- 그 뒤 인증 상태를 어디서 확정하는지가 진짜 문제였다는 건 인증이 확정되기 전에 쏜 요청과 로그인이 자꾸 풀리던 서비스에 있습니다. 2024년 11월의 저는 함수 참조를 붙잡느냐 마느냐를 보고 있었고, 봐야 할 건 그 함수가 언제 불리느냐였습니다.
- useState·useEffect를 직접 구현해 보며 클로저와 의존성이 무엇인지 되짚은 건 두 달 뒤였습니다. useState 구현과 useEffect 구현 글은 그 순서로 읽으면 왜 그때 그런 공부를 했는지가 보입니다.
관련 글: 자바스크립트로 useEffect 구현해 보기 · 자바스크립트로 useState 구현해 보기 · 인증이 확정되기 전에 쏜 요청 · 로그인이 자꾸 풀리던 서비스 · JWT exp를 프런트가 읽던 함수를 지웠다 · STOMP 클라이언트를 useState에서 useRef로