개요
남에게 폐 끼치는 게 정말 싫다. 하지만 살다 보면 그럴 일이 참 많은데, 내가 지금부터 작성할 실수(?) 또한 그런 일이었다.
바로바로 서버에 초당 10,000건의 API 요청을 보내서 서버를 터뜨려버리기!
많은 실수들이 중첩되어서 생긴 문제였지만, 이번 포스팅에서는 useEffect 실수 부분만을 살펴보며 React의 useEffect란 무엇이며 어떨 때 호출되는지에 대해 알아보자.
* 참고로 해당 글은 독자가 useEffect를 아주 간단하게는 알고 있다는 전제 하에 작성되었다.
상황
Socket.IO를 이용해 실시간으로 둘 중 하나의 옵션을 골라 베팅할 수 있는 실시간 베팅 서비스를 구현하며 생긴 일이다.
컴포넌트가 쉴새없이 리렌더링 된다. 하지만 해당 페이지에서 useEffect를 사용해 Socket.IO를 연결하고 있으므로 저렇게 수많은 리렌더링이 일어나더라도 소켓 연결은 초기에 단 한 번만 수립되어야 한다. 내가 생각하는 이론 상으로는 그게 맞는데, 왜인지 그렇지 않았다.
사실 애초에 저렇게 많이 리렌더링 되면 안 된다. 이 부분에 대해서도 추후 다뤄볼 예정이다.
쫀쪼는 나 하나인데 실시간으로 쫀쪼가 미친 듯이 증식하고 있는 진풍경을 볼 수 있다. 이렇게 1초도 채 안 된 시점에 만 명이 넘는 쫀쪼가 생겨났고, 이는 곧 만 개가 넘는 API 요청을 서버에 날렸음을 의미한다. 결국 서버가 홀랑 터져버렸다. 원인을 명확히 정의 내리기 전에 우선 useEffect란 무엇인지 먼저 알아보자.
React의 useEffect란?
useEffect는 생각보다 단순하다. 컴포넌트가 렌더링 될 때마다(=함수형 컴포넌트가 호출될 때마다) 본인의 의존성에 있는 값을 체크하여 의존성의 값이 이전과 다른 게 하나라도 있으면 콜백 함수를 실행하는 메커니즘을 갖고 있으며 컴포넌트의 어떤 값에 의존하여 부수 효과를 실행하기 위해 사용된다. 만약 의존성 배열이 비어있다면 초기 렌더링 시 한 번만 실행되지만, 이는 권장되지 않으므로 넘어가도록 하자.
useEffect 안의 콜백 함수가 실행될 때
먼저 이 친구의 동작 원리를 확인하기 위해 먼저 간단한 코드 하나를 살펴보자.
const App = () => {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount((prevCount) => prevCount + 1);
};
useEffect(() => {
console.log("useEffect 실행:", count);
}, [count]);
return (
<div>
<div>Current Value: {count}</div>
<button onClick={handleClick}>+</button>
</div>
);
};
위의 코드에서는 + 버튼을 누를 때마다 count의 상태값이 1씩 증가한다.
이 때 useEffect는 어떻게 본인의 의존성 배열 안에 있는 count가 버튼을 누를 때마다 업데이트된다는 것을 알고 콜백 함수를 실행하는 걸까? 앞서 말했듯 useEffect는 상당히 단순해서 count가 언제 어떻게 업데이트되었는가는 모른다. 다만 의존성 배열에 있는 값(count)이 이전 렌더링 시점과 달라졌는지 여부를 기반으로 동작한다. 더 정확히 설명하자면 count가 변경되면 App()이 다시 호출 되고, App()이 다시 호출되고 useEffect()가 호출될 때, count가 이전의 값과 다른 것을 알게 되어 콜백 함수를 실행한다.
이를 그림으로 나타내면 다음과 같다.
useEffect의 클린업 함수가 실행될 때
앞서 작성한 코드에 클린업 함수 부분만 넣어봤다.
const App = () => {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount((prevCount) => prevCount + 1);
};
useEffect(() => {
console.log("useEffect 실행:", count);
// 클린업 함수
return () => {
console.log("클린업 실행, 이전 propValue:", count);
};
}, [count]);
return (
<div>
<div>Current Value: {count}</div>
<button onClick={handleClick}>+</button>
</div>
);
};
흔히 아는 useEffect에 대한 지식에 근거하면, 컴포넌트가 언마운트 될 때 클린업 함수 안의 console.log가 실행된다.
하지만 아직 컴포넌트가 두 눈 부릅뜨고 똑똑히 살아있는데도 useEffect의 클린업 함수가 실행되고 있다. 이게 어떻게 된 일일까?
심지어 자세히 보면 클린업 함수가 실행된 이후에 useEffect의 콜백함수가 실행된다.
이는 useEffect가 실행될 때, 이전의 클린업 함수가 존재한다면 해당 클린업 함수를 실행한 뒤에 콜백을 실행하기 때문에 발생하는 일이다.
클린업 함수는 잘 살아있다가 새로운 콜백 함수가 실행되기 전에 이전 콜백 함수에서 등록한 부수효과를 정리(클린업)한다.
그렇다면 컴포넌트가 언마운트 될 때 클린업 함수가 작동한다는 것은 틀린 이야기일까?
컴포넌트가 언마운트 될 때도 역시 클린업 함수가 동작한다. React는 컴포넌트가 언마운트되기 직전에 useEffect의 클린업 함수를 실행하는데, 이 동작은 의존성 배열 값의 변경과는 무관하게 컴포넌트 자체가 DOM에서 사라질 때 항상 작동한다. 둘 다 부수효과 정리라는 점에서는 동일하다.
이를 통해 useEffect를 통해 이벤트를 등록할 때 클린업을 통해 지워야 하는 이유를 알 수 있다. 새로이 이벤트를 등록하기 이전에 기존의 이벤트리스너를 제거해 주는 역할을 해주기 때문이다.
useEffect가 의존성 배열의 값을 비교하는 방식
useEffect는 의존성 배열의 이전 값과 현재 값을 비교하여 다를 때 콜백 함수를 실행한다고 했다. 여기서 중요한 것은 이전 값과 현재 값을 얕은 비교 한다는 점이다. 의존성 배열에 들어간 값 자체끼리 비교하거나, 객체나 배열일 경우 참조값(메모리 주소)를 비교한다. 깊은 비교 시 객체나 배열의 모든 속성을 재귀적으로 비교하므로 데이터 크기가 클수록 성능에 부담을 주기 때문에, React는 빠른 렌더링과 성능 유지를 위해 얕은 비교를 사용한다고 한다. React에서는 상태를 변경할 때 새로운 참조값을 가지는 객체나 배열을 생성하는 패턴(불변성)을 권장하므로 얕은 비교만으로도 값이 변경되었는지 판단할 수 있는 경우가 많다.
내 문제의 원인
앞서 useEffect에 대해 알아보았다. 이제 앞선 상황에서 내가 마주한 문제의 원인을 살펴보겠다.
1. socket 선언부가 잘못되었다.
React의 함수형 컴포넌트는 렌더링 될 때마다 컴포넌트 함수를 다시 호출한다.
나는 아래와 같이 함수형 컴포넌트 안에 socket 선언부와 socket 사용 코드를 함께 두었다.
const socket = useSocketIO({
url: "/api/betting",
// 소켓 연결 코드 (생략)
});
useEffect(() => {
if (!socket) return;
socket.on("timeover", handleTimeOver);
return () => {
socket.off("timeover");
};
}, [socket, handleTimeOver]);
useEffect(() => {
if (!socket) return;
// 소켓 사용 코드 (생략)
return () => {
socket.off("fetchBetRoomInfo");
};
}, [socket]);
이렇게 되면 socket이 매번 다시 생성되고 이전과 똑같은 socket 객체 객체를 참조하더라도 참조값이 다르기에 useEffect 콜백이 실행되고 매번 새로운 socket 연결이 만들어진다. 이는 앞서 살펴봤던 것처럼 useEffect가 의존성 배열의 값이 이전과 다른지 비교할 때 얕은 비교를 사용하기 때문이다.
이를 해결하기 위해 useRef를 사용하여 소켓 객체를 한 번만 생성해주었다.
// useSocketIO
const socketRef = React.useRef<Socket>();
const [socketState, setSocketState] = React.useState<SocketState>({
isConnected: false,
isReconnecting: false,
reconnectAttempt: 0,
error: null,
});
const optionsRef = React.useRef(options);
React.useEffect(() => {
optionsRef.current = options;
}, [options]);
2. 클린업에서 소켓 연결 해제를 해주지 않았다.
위와 같이 소켓 객체를 한 번만 생성하도록 변경했음에도 불구하고 쫀쪼가 누적되는 현상이 있었다. 이는 클린업에서 소켓 이벤트 리스너 제거는 했지만 소켓 연결 해제를 해주지 않았기에, 기존 연결에 계속해서 새로운 연결이 누적되었기 때문이다. 이는 서버 과부하로 이어졌다.
따라서 클린업 함수에 socket.disconnet()를 호출해 새로이 소켓이 연결되었을 때 기존의 소켓 연결을 해제하도록 수정했다.
마치며
사실 내 문제 해결에는 useEffect에 대해 새로이 알게 된 모든 내용이 다 들어가지는 않는다. 하지만 이번 기회에 useState와 양대산맥을 이루는 useEffect에 대해 더 잘 알아보자는 생각에 함께 정리해 봤다. 특히 useEffect의 클린업 함수가 컴포넌트가 언마운트 될 때만 실행되는 게 아니라 새로이 연결될 때마다 실행된다는 점을 모르고 useEffect를 남발하며 사용 중이었던 것이 상당히 아쉬웠다. 하나를 알더라도 제대로 알고 쓰는 쫀쪼 되자.
참고
- [도서] 모던 리액트 Deep Dive