Effect는 렌더링 자체에 의해 발생하는 부수 효과를 말합니다. 버튼 클릭이나 폼 제출 같은 특정 이벤트가 아니라, 컴포넌트가 화면에 나타나는 것만으로 실행되는 작업을 의미합니다.
Effect는 컴포넌트 렌더링 후 화면 업데이트가 완료된 시점에 실행됩니다. 이 타이밍이 React가 관리하지 않는 외부 시스템, 즉 브라우저 API, 서버, 외부 라이브러리 같은 것들과 React 컴포넌트를 동기화하기에 적절하다고 공식 문서에서는 설명합니다.
외부 시스템?
React에 제어되지 않는 시스템들을 의미합니다. 예를 들어 브라우저 API(타이머, 웹 스토리지 등), 서버 연결, 써드 파티 라이브러리 등이 외부 시스템에 해당합니다.
프로그래밍에서 부수 효과(side effect)란 함수가 결괏값을 반환하는 것 외에 외부 세계에 어떤 영향을 주는 것을 말합니다. 예를 들어 함수가 전역 변수를 수정하거나, 파일에 데이터를 쓰거나, 네트워크 요청을 보내거나, 콘솔에 로그를 출력하는 것들이 모두 부수 효과입니다.
React에서는 컴포넌트 함수가 JSX를 반환하는 것이 주 목적입니다. React는 컴포넌트가 순수 함수처럼 동작하길 기대합니다. API 호출, DOM 조작, 구독 설정 같은 작업들은 부수 효과에 해당합니다.
React 컴포넌트의 순수성?
React는 작성하는 모든 컴포넌트가 순수 함수라고 가정합니다. 순수 함수란 자신의 일에만 집중하는 함수를 말하는데, 호출되기 전에 존재했던 객체나 변수를 변경하지 않고, 같은 입력에 대해 항상 같은 결과를 반환합니다. 컴포넌트로 치면 같은 props와 state가 주어졌을 때 항상 같은 JSX를 반환해야 한다는 의미입니다.
컴포넌트를 순수하게 유지하면서도 부수 효과는 처리해야 하는 상황, 이 딜레마를 해결하는 것이 바로 Effect입니다. 부수 효과를 렌더링 과정과 분리해서, 렌더링이 완료된 후에 안전하게 실행할 수 있게 해줍니다. 이렇게 하면 컴포넌트는 순수성을 유지하면서도 필요한 부수 효과를 처리할 수 있습니다.
가족 단체 채팅방에 들어가는 상황을 생각해봅시다. 채팅방 화면이 렌더링되면 자동으로 채팅 서버에 접속해야 하고, 채팅방을 나가서 채팅방 화면이 사라지면 자동으로 서버 연결을 해제해야 합니다. 클릭이나 입력 같은 사용자 행동 없이도 렌더링만으로 서버 접속이 일어나는데, 이것이 바로 부수 효과입니다.
페이지가 렌더링되면 브라우저 탭의 제목을 현재 시간으로 계속 업데이트하는 기능을 생각해봅시다. 컴포넌트가 화면에 나타나면 1초마다 제목이 바뀌어야 하고, 컴포넌트가 사라지면 타이머를 정리해야 합니다. 이 역시 사용자의 어떤 이벤트도 필요 없이 페이지가 화면에 나타나기만 하면 실행됩니다.
useEffect는 Effect를 구현하는 React Hook입니다. Effect가 외부 시스템과 동기화하는 데 사용되기 때문에, useEffect를 “외부 시스템과 컴포넌트를 동기화하는 Hook”이라고도 표현합니다.
렌더링 후 실행할 부수 효과 코드를 useEffect의 콜백 함수에 작성하면, React가 화면을 그린 다음 해당 코드를 실행합니다. 간단한 예시를 보겠습니다.
useEffect(() => {
// 렌더링이 완료된 후 실행
console.log('페이지가 렌더링되었습니다');
// 외부시스템(채팅 서버)와 연결
const connection = connectToChatServer();
// cleanup 함수: 컴포넌트가 사라질 때 실행
return () => {
connection.disconnect();
};
});
이렇게 useEffect를 사용하면 렌더링 타이밍에 맞춰 외부 시스템과의 동기화를 안전하게 처리할 수 있습니다. React는 컴포넌트가 화면에 나타날 때 Effect를 실행하고, 사라질 때는 cleanup 함수를 실행해서 정리 작업을 수행합니다.
React 컴포넌트는 단순히 화면을 그리는 것 이상의 작업이 필요할 때가 많습니다. What 목차에서 나온 내용에서 외부 시스템, 즉 서버, 브라우저 API, 외부 라이브러리 같은 것들과 컴포넌트를 연결해야 하는 상황이 생깁니다.
예를 들어 채팅 애플리케이션을 만든다고 생각해봅시다. 채팅방 컴포넌트가 화면에 나타나면 자동으로 채팅 서버에 접속해야 하고, 사용자가 채팅방을 나가면 서버 연결을 끊어야 합니다. 또 다른 예로, 쇼핑몰에서 상품 상세 페이지를 보는 상황을 생각해봅시다. 상품 페이지가 렌더링되면 자동으로 해당 상품이 “최근 본 상품” 목록에 추가되어 있습니다. 사용자가 “추가” 버튼을 누를 필요 없이 페이지를 보는 것만으로 자동으로 저장됩니다.
이런 작업들의 공통점은 사용자가 버튼을 클릭하거나 무언가를 입력하는 행동과는 관계없이, 컴포넌트가 화면에 나타나는 것 자체가 트리거가 된다는 점입니다. React에서는 이렇게 컴포넌트의 상태(화면에 있음/없음)에 따라 외부 시스템(서버, 브라우저 API 등)의 상태도 함께 맞춰주는 것을 ‘동기화’라고 표현합니다. 그렇다면 이런 동기화 작업을 어디에 작성해야 할까요?
컴포넌트의 렌더링 코드, 즉 JSX를 반환하는 함수 본문에 직접 작성하면 어떨까요? return 문 바로 위에 서버 접속 코드를 넣으면, 컴포넌트가 실행될 때 그 코드도 함께 실행될 것입니다.
function ChatRoom() {
// connection은 채팅 서버와의 연결을 관리하는 객체라고 가정
connection.connect(); // ❌
return <div>채팅방</div>;
}
하지만 이 방법은 큰 문제가 있습니다. What 섹션에서 배웠듯이 React는 컴포넌트가 순수 함수여야 한다고 가정합니다. 순수 함수는 같은 입력에 항상 같은 출력을 반환하고, 외부 세계에 영향을 주지 않아야 합니다. 그런데 서버에 접속하는 것은 외부 세계에 영향을 주는 부수 효과입니다. 이는 컴포넌트의 순수성을 깨뜨립니다.
더 심각한 문제는 React가 컴포넌트를 여러 번 렌더링할 수 있다는 점입니다. 개발 모드에서는 의도적으로 두 번 렌더링하기도 하고, 상태가 변경되거나 부모 컴포넌트가 리렌더링되면 이 컴포넌트도 다시 렌더링됩니다. 렌더링될 때마다 서버 접속이 반복되면 이미 연결되어 있는데도 새로운 연결을 계속 만들게 됩니다. 게다가 컴포넌트가 화면에서 사라질 때 이 연결들을 정리할 방법도 없습니다. 연결이 쌓이면서 메모리 낭비가 발생하고 서버에도 부담을 주게 됩니다.
그렇다면 이벤트 핸들러에 작성하면 어떨까요? 이벤트 핸들러는 사용자의 특정 행동에 반응해서 실행되는 코드입니다. 렌더링이 이미 끝나고 화면이 다 그려진 후에 실행되기 때문에, 렌더링의 순수성과는 관계가 없습니다. 그래서 이벤트 핸들러 안에서는 서버에 데이터를 보내거나 상태를 변경하는 등의 부수 효과를 자유롭게 처리할 수 있습니다.
function ChatRoom() {
return (
<button onClick={() => connection.connect()}>
입장하기
</button>
);
}
이 코드는 문법적으로 문제가 없지만 우리가 원하는 동작을 구현할 수 없습니다. 우리는 사용자가 버튼을 클릭하면 접속하는 게 아니라, 채팅방 화면이 나타나면 자동으로 접속하길 원합니다. 이벤트 핸들러는 사용자의 특정 행동에만 반응하기 때문에, 컴포넌트가 화면에 나타나는 것과 같은 생명주기 이벤트를 처리할 수 없습니다.
채팅방 컴포넌트가 렌더링되면 자동으로 서버에 접속하고 싶은데, 렌더링 코드에 넣으면 순수성을 위반하고, 이벤트 핸들러에 넣으면 자동 실행이 안 됩니다. 이 딜레마를 해결하는 것이 바로 useEffect입니다.
useEffect를 사용하면 렌더링의 순수성을 유지하면서도, 렌더링이 완료된 후에 부수 효과를 실행할 수 있습니다.
React의 처리 순서를 보면 이해가 쉽습니다:
이 순서 덕분에 여러 가지 장점이 생깁니다.
useEffect를 사용하는 이유를 한 문장으로 정리하면, React 컴포넌트의 순수성을 유지하면서도 외부 시스템과 안전하게 동기화하기 위해서입니다. 렌더링 코드에 부수 효과를 넣으면 순수성이 깨지고 예측 불가능한 동작이 발생할 수 있습니다. 이벤트 핸들러는 사용자 행동에만 반응하므로 컴포넌트의 생명주기와 연결된 자동 동기화를 구현할 수 없습니다. useEffect는 이 두 가지 한계를 모두 극복하면서, 렌더링이 완료된 후 적절한 타이밍에 외부 시스템과의 동기화를 처리할 수 있게 해줍니다.
useEffect(setup, dependencies?)
useEffect는 두 개의 매개변수를 받습니다.
첫 번째 매개변수인 setup 함수는 Effect의 로직이 포함된 함수입니다. 이 함수는 컴포넌트가 DOM에 추가된 후(마운트된 후)에 React가 실행합니다.
setup 함수 내부에서는 렌더링이 완료된 후 실행할 부수 효과 코드를 작성합니다. 위에서 설명했던 채팅 서버에 연결하는 작업도 setup 함수 내부에 작성합니다.
cleanup 함수는 Effect를 정리하는 함수로, 컴포넌트가 사라질 때 실행됩니다. setup 함수는 선택적으로 cleanup 함수를 반환할 수 있습니다. 컴포넌트가 DOM에서 제거 되기 전(언마운트되기 전)에 React가 이 cleanup 함수를 실행합니다. cleanup 함수에서는 연결 해제, 타이머 정리, 이벤트 리스너 제거 같은 정리 작업을 수행합니다.
useEffect(() => {
// setup: 부수 효과 실행
const connection = connectToServer();
// cleanup 함수 반환
return () => {
connection.disconnect();
};
}, []);
두 번째 매개변수는 setup 함수 코드 내부에서 참조되는 모든 반응형 값들의 목록입니다. 반응형 값에는 props, state, 그리고 컴포넌트 본문에 직접 선언된 모든 변수와 함수가 포함됩니다.
dependencies 배열은 선택사항이지만, 생략하거나 어떤 값을 넣느냐에 따라 Effect가 실행되는 시점이 달라집니다.
빈 배열 []을 전달하는 경우
useEffect(() => {
console.log('컴포넌트가 마운트됐을 때 한 번만 실행');
}, []);
빈 배열을 전달하면 컴포넌트가 마운트될 때만 Effect가 실행됩니다. 리렌더링이 발생해도 Effect는 다시 실행되지 않습니다. 초기 설정이나 한 번만 실행하면 되는 작업에 사용합니다.
dependencies를 생략하는 경우
useEffect(() => {
console.log('컴포넌트가 렌더링될 때마다 실행');
});
dependencies를 생략하면 컴포넌트가 렌더링될 때마다 Effect가 실행됩니다. 매번 리렌더링 후에 Effect가 실행되므로 주의해서 사용해야 합니다.
특정 값들을 배열에 전달하는 경우
useEffect(() => {
console.log('userId가 변경될 때와 마운트될 때 실행');
fetchUserData(userId);
}, [userId]);
// ——
useEffect(() => {
console.log('userId 또는 postId가 변경될 때 실행');
}, [userId, postId]);
배열에 특정 값을 전달하면 컴포넌트가 마운트될 때와 해당 값이 변경되어 리렌더링될 때 Effect가 실행됩니다. 여러 값을 넣을 수도 있으며, 배열 안의 값 중 하나라도 변경되면 Effect가 다시 실행됩니다.
컴포넌트가 화면에 나타날 때 서버에서 데이터를 가져와야 할 때 사용합니다.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let ignore = false;
fetch(`https://api.example.com/users/${userId}`)
.then(response => response.json())
.then(data => {
if (!ignore) {
setUser(data);
}
});
return () => {
ignore = true;
};
}, [userId]);
return <div>{user?.name}</div>;
}
userId가 변경될 때마다 해당 사용자의 데이터를 가져옵니다ignore 플래그는 race condition을 방지합니다. userId가 빠르게 변경될 때 이전 요청의 응답이 나중에 도착해도 무시됩니다race condition?
여러 비동기 작업이 경쟁하듯 실행될 때, 완료 순서가 예상과 달라서 잘못된 결과가 나오는 상황을 말합니다. 예를 들어 userId가 1에서 2로 바뀌었는데, userId 1의 API 응답이 userId 2의 응답보다 늦게 도착하면 화면에는 엉뚱하게 userId 1의 데이터가 표시됩니다.
지도, 차트 같은 외부 라이브러리를 React 컴포넌트와 연결할 때 사용합니다.
function MapComponent({ latitude, longitude }) {
const mapRef = useRef(null);
const mapInstanceRef = useRef(null);
// 지도 초기화
useEffect(() => {
mapInstanceRef.current = new MapLibrary.Map(mapRef.current, {
center: { lat: latitude, lng: longitude }
});
return () => {
mapInstanceRef.current.destroy();
};
}, []);
// 위치 업데이트
useEffect(() => {
mapInstanceRef.current?.setCenter({ lat: latitude, lng: longitude });
}, [latitude, longitude]);
return <div ref={mapRef} style={{ width: '100%', height: '400px' }} />;
}
latitude, longitude가 변경되면 지도의 중심 위치를 업데이트합니다React강의를 듣다가 ‘useEffect 훅은 컴포넌트 렌더링의 부수 효과를 표현할 때 사용한다‘는 설명을 들었는데, 부수 효과가 정확히 무엇인지 이해하기 어려워서 이렇게 정리하게 되었습니다.
글을 정리하면서 세 가지를 이해할 수 있었습니다.
React 컴포넌트는 순수 함수여야 합니다. 같은 props와 state가 주어지면 항상 같은 JSX를 반환해야 합니다. 컴포넌트의 본래 목적은 화면을 그리는 것이고, 이 렌더링과 직접 관련되지 않은 모든 작업들은 부수 효과라고 말합니다.
React가 제어하지 않는 시스템들, 서버, 브라우저 API, 외부 라이브러리 같은 것들을 외부 시스템이라고 합니다. 컴포넌트가 화면에 나타나면 이런 외부 시스템과 연결하고, 사라지면 연결을 끊어야 합니다. 컴포넌트의 상태와 외부 시스템의 상태를 맞추는 것을 외부 시스템과의 동기화라고 합니다.
useEffect는 컴포넌트의 렌더링 순수성을 유지하면서도 부수 효과를 안전하게 실행하고, cleanup 함수로 정리 작업까지 처리할 수 있게 해주기 때문에 사용한다는 것을 배웠습니다.
데이터 패칭이라는 작업은 부수 효과에 해당하기 때문에 useEffect훅을 사용하지는 이해할 수 있었습니다. React 컴포넌트는 순수하게 화면을 그리는 일에 집중하고, 그 외의 모든 부수 효과는 useEffect로 처리하도록 코드를 작성해야겠습니다.