리액트 개발자라면 누구나 한번쯤은 겪었을...
StrictMode 때문에 useEffect가 두 번 호출되는 문제! 그냥 삭제하면 안되는걸까?

StrictMode 공식문서
공식문서를 보면서 정확히 어떤 일을 하는지 다시 알아보자.
1. 이중 렌더링
리액트는 작성하는 모든 컴포넌트가 순수 함수라고 가정한다. 즉 컴포넌트를 두번씩 실행시켜서 같은 결과를 생성하는지 확인하는 것!
2. Effect 두 번 실행
원래 useEffect의 셋업은 컴포넌트가 마운트 될 때 실행, 클린업은 언마운트될 때 실행된다. 그리고 의존성 배열에 있는 값이 변경되면 둘 다 다시 실행된다. 엄격모드에서는 이런 셋업+클린업 사이클을 한번 더 한다.
3. ref 콜백 두 번 실행
ref콜백이 뭐지... ref는 그냥 객체 넘겨주는거 아님? 궁금해서 타입을 확인해봤다.

오 진짜 콜백도 된다. 공식문서를 보니, 리액트에서 훅은 컴포넌트 최상단에서 호출되어야 해서 콜백 내에서 useRef는 사용 못하니까, Map 객체를 쓰는 방법을 알려주고 있다. 아무튼 ref로 콜백을 넘겨주면 두 번 실행하나보다.
4. deprecated된 API 호출하는지 체크
말그대로 deprecated된 API들 쓰면 워닝 띄운다.
어떻게 한 번만 호출하죠?
문제
useEffect(() => {
if (code) {
loginMutate({ code, lastPath });
}
}, [code, loginMutate, lastPath]);
이제 문제는 명확해졌다. 엄격모드에서는 Effect 싸이클을 두 번 실행시키기 때문에, 여기서 loginMutate도 두 번 실행된다. 문제점은 loginMutate가 POST 요청을 날리고 있어서, 멱등하지 않다는 것이다. 서버에서 리다이렉트 시켜준 라우트에서 code를 가져와 요청을 날리는 식이라, 어떤 사용자 이벤트에 따라 실행되는 것도 아니다.
해결 1. useRef
저 loginMutate 자체를, 같은 code를 받아왔을 경우 클라이언트 단에서 두 번 호출되지 않도록 막아야 한다. 그래서 처음에는 이렇게 렌더링과 무관한 useRef를 활용해서 구현했다.
const isCalled = useRef(false);
useEffect(() => {
if (code && !isCalled.current) {
loginMutate({ code, lastPath });
isCalled.current = true;
}
}, [code, loginMutate, lastPath]);
이런 로직이 한군데 더 있었는데, 캘린더 권한 습득과 관련된 로직이다. 반복은 싫기 때문에 커스텀 훅으로 만들었다.
import { useEffect, useRef } from 'react';
interface UseEffectOnceOptions {
condition?: boolean;
callback: () => void;
}
/**
*
* @param options
* @param options.condition - 콜백을 실행할 조건 (기본값: true)
* @param options.callback - 한 번만 실행할 콜백 함수. useCallback을 사용하여 메모이제이션하는 것이 좋습니다.
*/
export const useEffectOnce = ({ condition = true, callback }: UseEffectOnceOptions) => {
const isCalled = useRef(false);
useEffect(() => {
if (condition && !isCalled.current) {
callback();
isCalled.current = true;
}
}, [condition, callback]);
};
문제1. 의존성 배열 린트가 안된다.
하지만 훅으로 분리하니 의존성 배열을 감지하지 못하는 문제가 있었다. 실제로 리액트 공식문서에서도 커스텀 생명주기 훅을 만들지 말라고 - useEffectOnce - 경고하고 있다. ㅋㅋ.

이를 해결하기 위해 useCallback으로 미리 의존성 배열에 따라 다른 참조를 뱉을 수 있도록 했고, 이 콜백 자체를 의존성 배열에 넣어 해결했다. 그런데 useCallback 사용을 강제할 수 없고, 또 소비자 쪽에서 어쨌든 관련 코드를 작성해야하는 제약이 생기기 때문에 상당히 찝찝했다...
// 이렇게 사용
const loginCache = useCallback(() => {
loginMutate({ code, lastPath });
}, [loginMutate, code, lastPath]);
useEffectOnce({
condition: Boolean(code),
callback: loginCache,
});
문제 2. 공식문서에서 대놓고 하지 말라는데요?
그렇게 추가 자료를 찾던 중, 공식문서에서 개발환경에서 useEffect 두 번 실행 해결하기에 대한 글을 발견했다. 그리고 굉장히 찔리는 부분을 발견했다...


그렇지만 억울했다. 계속 예시를 저런 가역적인 로직으로 들고 있는데, 나의 POST 요청은 비가역적이다. 그리고 공식문서의 물건을 구매하는 POST 요청과 다르게, 사용자 이벤트가 아닌 컴포넌트의 생명주기에 따라 fetch 요청을 해야 한다. 도움이 하나도 안된다.
문제 3. 심지어 이건 버그라면서 고칠 거라는데요?
공식문서에서 왜 ref를 사용하지 말라고 했는지 알 수 있는 이슈를 발견했다.
여기서 말하고자 하는 핵심은, "useRef()의 반환값이 StrictMode에서 유지되는 건 버그이다"
리액트 18 릴리즈 공식문서에는 "StrictMode에서는 컴포넌트를 자동으로 언마운트/마운트 시킨다"라고 적혀 있다.

그러면 useRef 값도 참조가 파괴되어야 하는데 그렇지 않아 혼란스럽다. 이런 내용이다. 실제로 컴포넌트가 언마운트 되었다가 다시 마운트되면 참조든 뭐든 다 초기화되기 때문이다. (재호출일 뿐인 리렌더링과 다름)
심지어 내가 구현한 것과 같은 내용도 언급되어 있다. 왜냐하면 이게 진짜 "버그"로 취급된다면, 언젠가 고쳐질 것이기 때문에 하지 말아야 할 안티패턴이기 때문. 실제로 리액트 팀도 여기에 수긍하고 버그라고 인정했으며, 공식문서에 하지 말라고 적어둔 것이다.

해결 2. 어 useEffect 안해...
그런데 굳이 이걸 useEffect 내부에서 처리해야 할까? 이 컴포넌트가 라우팅의 단위가 되는 컴포넌트인 것에 힌트를 얻었다. 컴포넌트 단위가 아니라 라우팅이 되었을 때 딱 한번만 실행시키려면, 다른 방법도 있다. 라우트 레벨에서 처리하는 것. 이렇게 하면 컴포넌트의 생명주기로부터 자유로워진다. 여기서 useMutation을 사용할 수 없으므로 리액트 컴포넌트 외부에서 사용하는 커스텀 뮤테이션 함수를 만들어 처리했다.
import { createFileRoute } from '@tanstack/react-router';
import { jwtMutation } from '@/features/login/api/mutations';
import { getLastRoutePath } from '@/utils/route';
type SearchWithCode = {
code?: string;
};
export const Route = createFileRoute('/oauth/redirect/login/')({
beforeLoad: async ({ search }: { search: SearchWithCode }) => {
const lastPath = getLastRoutePath();
const { loginMutate } = jwtMutation();
if (search.code) {
await loginMutate({ code: search.code, lastPath });
}
},
});
결론
You might not need an Effect 글을 보면 생각보다 Effect로 처리하지 않아도 되는 것들이 많다. 다음에도 개발하다가 useEffect 내부 코드가 두 번 실행되는 경우가 있다면, StrictMode를 탓하지 말고 코드를 의심해 봐야겠다. StrictMode는 리액트가 추구하는 순수성을 지키기 위해 중요한 부분이라는 생각이 들었다.
StrictMode 켜고 나서도 요청이 한 번만 보내지는 것을 확인하며 마무리!!

'TIL > React' 카테고리의 다른 글
| [240331] 모던 리액트 딥 다이브 스터디 3주차 - (2) children props (1) | 2024.03.31 |
|---|---|
| [240327] 모던 리액트 딥 다이브 스터디 3주차 - (1) useContext (0) | 2024.03.27 |
| [240320] 리액트의 렌더링, 렌더링 최적화, 메모이제이션 (0) | 2024.03.20 |
| [240207] 리액트의 가상 돔(Virtual DOM) (1) | 2024.02.07 |
| [240205] 리액트 라이프사이클, useEffect (0) | 2024.02.05 |