지난 포스팅에서 useEffect를 활용해 토큰을 갱신하는 로직을 구현하는 것이 페이지 이동마다 요청을 보내는 방식이라 적절하지 않다고 생각했다고 적었다.
하지만 라우트 가드에서 useEffect를 사용한다고 페이지 이동마다 요청이 실행되는 건 아니다.
의존성 배열을 빈 배열로 설정하면, 새로고할 때만 서버로 요청을 보내도록 할 수 있다.
이에 따라, 로컬 스토리지에 저장한 엑세스 토큰을 다시 axios의 헤더에 설정하도록 변경했다.
또한, 라우트 가드에서 새로고침 시에만 서버로 요청을 보내 엑세스 토큰을 갱신하도록 수정했다.
나는 왜 이런 오해를 했을까?
돌이켜보면, useEffect에 대해서 제대로 이해하지 못했기 때문이었다.
이번 기회에 useEffect를 제대로 정리하며 확실히 이해해보자.
useEffect를 알아보기 전에 사이드 이팩트에 대해 먼저 이해해 보자.
리액트의 컴포넌트에서 사이드 이팩트란, 앱이 정상적으로 동작하기 위해 필요하지만, 현재 컴포넌트의 렌더링 과정에 직접적인 영향을 미치지는 않는 작업을 의미한다.
쉽게 말해, 컴포넌트의 주된 목적은 JSX를 반환하는 것이다.
이 목적과 직접적으로 관련이 없는 작업은 모두 사이드 이팩트라 볼 수 있다.
export default function App() {
navigator.geolocation.getCurrentPosition((position) => {
const { latitude, longitude } = position.coords;
console.log(latitude, longitude);
});
return <div></div>;
}
위 코드에서는 navigator.geolocation.getCurrentPosition()을 실행하여 사용자의 위치 정보를 가져온다.
사용자의 위치를 가져오는 작업은 애플리케이션이 정상적으로 동작하는 데 필요하지만, 컴포넌트의 렌더링과는 직접적인 관련이 없기 때문에 사이드 이팩트다.
즉, 위치 정보를 가져오는 작업은 렌더링을 위한 과정이 아니라 외부 환경(여기서는 브라우저)와 상호 작용에 해당하므로 사이드 이팩트라고 할 수 있다.
import { useState } from "react";
export default function App() {
const [position, setPosition] = useState({ latitude: 0, longitude: 0 });
navigator.geolocation.getCurrentPosition((position) => {
const { latitude, longitude } = position.coords;
setPosition({latitude, longitude});
});
return (
<div>
<p>위도 {position.latitude}</p>
<p>경도 {position.longitude}</p>
</div>
);
}
컴포넌트 내부에 사이드 이팩트가 있는 코드를 실행하는 것이 항상 문제되는 건 아니지만, 위와 같은 코드는 문제가 될 수 있다.
왜냐하면 무한 루프를 야기하기 때문이다.
1. position의 초기값은 { latitude: 0, longitude: 0 }이다.
2. getCurrentPosition()이 실행되면 사용자의 현재 위도와 경도를 가져와 setPosition으로 상태를 변경한다.
3. 상태가 변경되면 컴포넌트가 리렌더링된다.
4. 컴포넌트가 다시 렌더링되면서 getCurrentPosition()이 또 실행된다.
5. 만약 사용자의 위치가 계속 바뀐다면, 이 과정이 끝없이 반복되면서 무한 루프가 발생할 수 있다.
이처럼 사이드 이팩트를 적절히 관리하지 않으면 무한 루프와 같은 문제가 발생할 수 있다.
이러한 문제를 방지하기 위해 useEffect를 사용한다.
useEffect(() => {
navigator.geolocation.getCurrentPosition((position) => {
const { latitude, longitude } = position.coords;
setPosition({latitude, longitude});
});
}, [])
위와 같이 useEffect의 첫 번째 인수로 전달하는 함수 내부에 사이드 이팩트에 해당하는 코드를 작성하면 된다.
기본적으로 함수 내부의 코드는 JSX가 모두 렌더링 된 이후에 실행된다.
하지만 두 번째 인수로 들어가는 배열을 생략한다면 다시 무한 루프에 빠질 위험이 커진다.
의존성 배열에 대해서는 뒤에 자세히 다룰 예정이니 지금은 빈 배열로 해줘야 한다는 것만 알고 넘어가자.
여기서 주의할 점이 있는데, 모든 사이드 이팩트가 useEffect를 필요로 하진 않는다는 점이다.
useEffect의 과한 사용은 좋지 않다.
useEffect 내부 로직은 컴포넌트가 실행된 이후 추가적으로 실행된다는 사실을 잊지 말아야 한다.
import { useState, useEffect } from "react";
export default function App() {
const [position, setPosition] = useState({ latitude: 0, longitude: 0 });
const onClickCapture = () => {
const userLats = JSON.parse(localStorage.getItem("userLats")) || [];
if (!userLats.includes(position.latitude)) {
localStorage.setItem(
"userLats",
JSON.stringify([position.latitude, ...userLats])
);
}
};
useEffect(() => {
navigator.geolocation.getCurrentPosition((position) => {
const { latitude, longitude } = position.coords;
setPosition({latitude, longitude});
});
}, [])
return (
<div>
<p>위도 {position.latitude}</p>
<p>경도 {position.longitude}</p>
<button onClick={onClickCapture}>Capture</button>
</div>
);
}
로컬 스토리지에 사용자의 현재 위도를 저장하는 로직은 JSX 렌더링과 직접적인 연관이 없으므로 사이드 이팩트다.
하지만 getCurrentPosition()과 달리 useEffect로 묶어줄 필요가 없다.
추가로 onClickCapture 함수 내부에 있기 때문에 useEffect를 사용할 수도 없다.
일반 함수 내부에서 사용하는 건 훅 사용 규칙을 어기기 때문이다.
훅 사용 규칙을 떠나, 현재는 로컬 스토리지에 데이터를 저장하는 역할만 하므로 useEffect가 필요하지 않다. 또한, 상태를 변경하더라도 무한 루프에 빠지지 않는다.
왜냐하면 컴포넌트의 라이프 사이클과 무관하게 사용자가 버튼을 클릭했을 때 로직이 실행되기 때문이다.
useEffect는 주로 무한 루프를 방지하거나, 컴포넌트가 최소 한 번 렌더링된 후 실행해야 하는 코드가 있을 때 사용한다.
import { useState, useEffect } from "react";
export default function App() {
const [position, setPosition] = useState({ latitude: 0, longitude: 0 });
const [savedLats, setSavedLats] = useState([]);
const onClickCapture = () => {
const storedLats = JSON.parse(localStorage.getItem("userLats")) || [];
if (!storedLats.includes(position.latitude)) {
const updatedLats = [position.latitude, ...storedLats];
localStorage.setItem("userLats", JSON.stringify(updatedLats));
setSavedLats(updatedLats);
}
};
useEffect(() => {
const storedLats = JSON.parse(localStorage.getItem("userLats")) || [];
setSavedLats(storedLats);
});
useEffect(() => {
navigator.geolocation.getCurrentPosition((position) => {
const { latitude, longitude } = position.coords;
setPosition({ latitude, longitude });
});
}, []);
return (
<div>
{savedLats.map((lat) => (
<p>{lat}</p>
))}
<p>위도 {position.latitude}</p>
<p>경도 {position.longitude}</p>
<button onClick={onClickCapture}>Capture</button>
</div>
);
}
저장한 위도들을 화면에 보여주기 위해 로컬 스토리지에 저장한 위도 배열을 가져와 savedLats를 업데이트 한다고 했을 때, useEffect를 사용하는 건 어찌보면 자연스러워 보인다.
실제로 코드를 실행해도 문제없이 동작한다.
하지만 위 코드는 불필요하게 사용된 useEffect의 예시다.
기존의 getCurrentPosition()과 로컬 스토리지 접근의 차이점은 실행 방식과 결과가 반환되는 시점이다.
getCurrentPosition()은 비동기적으로 동작하며, 호출 즉시 결과를 반환하지 않고 브라우저가 사용자의 위치 정보를 가져오는 과정이 끝나야 콜백 함수가 실행된다.
반면, 로컬 스토리지에 데이터를 가져오는 작업은 동기적으로 실행되며, 데이터를 가져오는데 시간이 걸리지 않는다.
즉, 로컬 스토리지에서 위도 배열을 가져오는 코드는 렌더링 중에도 즉시 값을 가져 올 수 있기 때문에 useEffect를 사용할 필요가 없다.
import { useState, useEffect } from "react";
const storedLats = JSON.parse(localStorage.getItem("userLats")) || [];
export default function App() {
const [position, setPosition] = useState({ latitude: 0, longitude: 0 });
const [savedLats, setSavedLats] = useState(storedLats);
const onClickCapture = () => {
const storedLats = JSON.parse(localStorage.getItem("userLats")) || [];
if (!storedLats.includes(position.latitude)) {
const updatedLats = [position.latitude, ...storedLats];
localStorage.setItem("userLats", JSON.stringify(updatedLats));
setSavedLats(updatedLats);
}
};
useEffect(() => {
navigator.geolocation.getCurrentPosition((position) => {
const { latitude, longitude } = position.coords;
setPosition({ latitude, longitude });
});
}, []);
return (
<div>
{savedLats.map((lat) => (
<p>{lat}</p>
))}
<p>위도 {position.latitude}</p>
<p>경도 {position.longitude}</p>
<button onClick={onClickCapture}>Capture</button>
</div>
);
}
따라서 useEffect를 걷어내고 무한 루프에 빠질 수 있는 setSavedLats를 없앤다.
다음으로 컴포넌트 밖에서 위도 배열을 받아와 savedLats의 초기값으로 넣어줌으로써 해결할 수 있다.
변수를 컴포넌트 바깥에 선언한 이유는 컴포넌트의 라이프 사이클이 아닌 애플리케이션의 라이프 사이클에서 단 한 번만 실행되도록 하기 위해서다.
앱 컴포넌트가 실행될 때마다 로컬 스토리지에서 가져올 필요가 없기 때문이다.
라이프 사이클을 직역하면 생애 주기로 탄생부터 죽음까지를 단계별로 나눠논 것이다.
리액트의 라이프 사이클은 Mount, Update, UnMount 3단계로 구분된다.
라이프 사이클을 잘 알고 있다면 원하는 타이밍에 원하는 작업을 수행하게 할 수 있다.
앞서 살펴 본대로 useEffect란 컴포넌트의 사이드 이팩트를 제어하는 리액트 훅이다.
import { useEffect, useState } from "react";
export default function App() {
const [count, setCount] = useState(0);
const onClickButton = () => {
setCount(count + 1);
console.log(`onClick: ${count}`);
};
useEffect(() => {
console.log(`useEffect: ${count}`);
}, [count]);
return (
<div>
<span>{count}</span>
<button onClick={onClickButton}>+</button>
</div>
);
}
위와 같은 코드가 있다고 했을 때 버튼을 클릭하면 onClickButton, useEffect에서 콘솔에 출력한 결과가 다르게 나온다.

onClickButton에서 출력한 결과는 count에서 1 증가한 값이 아닌 이전 값이 나온다.
이유는 setCount가 비동기로 작동하기 때문이다.
위와 같은 이유때문에 count가 변경된 시점에 무언가를 하고 싶다면 useEffect의 의존성 배열에 count를 넣어서 처리할 수 있다.
의존성 배열은 선택 사항으로 각각의 의존성들은 Object.is로 이전 값과 비교한다.
useEffect(() => {
// 마운트 될 때 실행할 로직
}, []);
앞서 마운트 시점은 최초 렌더링 시점이라고 얘기했다.
최초에 렌더링되는 시점에 무언가 실행하고 싶다면 의존성 배열에 빈 배열을 넣어주면 된다.
앞에 최초가 붙은 이유는 리렌더링할 때는 실행되지 않기 때문이다.
import { useEffect, useState } from "react";
export default function App() {
const [count, setCount] = useState(0);
const onClickButton = () => {
setCount(count + 1);
};
useEffect(() => {
console.log("mount!!!");
}, []);
return (
<div>
<span>{count}</span>
<button onClick={onClickButton}>+</button>
</div>
);
}
따라서 위와 같이 작성하면 count값이 변경되어 리렌더링 되더라도 콘솔에 mount!!!는 최초에 한 번만 출력된다.
라우트 가드에서 useEffect를 사용해서 서버에 요청을 보내면 페이지 이동마다 요청을 보낼 거라고 오해했던 이유가 마운트 시점과 리렌더링을 헷갈렸기 때문이다.
기본적으로 리액트에서 컴포넌트가 리렌더링되는 시점은 props와 state가 변경되거나 부모 컴포넌트가 리렌더링 될 때이다.
하지만 useEffect의 의존성 배열에 빈 배열을 넣어준다면 count라는 상태가 변경되어 리렌더링 되더라도 다시 실행되지 않는다.
useEffect(() => {
// 업데이트마다 실행할 로직
});
업데이트 시점이란 리렌더링 시점을 의미한다.
만약 의존성 배열을 넣지 않는다면 리렌더링할 때마다 실행된다.
부모로 부터 받은 props의 값이 변해서 리렌더링 될 때, 내부적으로 사용하는 상태값이 변경돼서 리렌더링될 때만 어떤 로직을 수행하고 싶으면 의존성 배열에 해당 props와 state를 넣어주면 된다.
useEffect(() => {
// 클린업, 정리 함수
return () => {};
}, [])
언마운트 시점은 컴포넌트가 화면에서 사라지는 순간을 의미한다.
useEffect의 콜백 함수 안에서 반환하는 함수를 클린업 또는 정리 함수라고 부른다.