이번 프로젝트에서 어려웠던 점 중 하나는 리액트 내부의 코드 실행 순서를 제대로 이해하지 못하고 있다는 것이었는데,
그로 인해 겪은 시행착오 덕에 여러가지를 배울 수 있었다. 나름 유의미했던 러닝포인트 하나를 기록해두고자 한다.
우리 프로젝트는 사용자가 작업실을 만들고 그 안에서 플레이하는 형식이기 때문에, API 통신이나 소켓 통신을 활용하는 많은 기능에서 사용자가 어떤 작업실에서 요청을 하는지를 식별해야 했다. 때문에 spaceId(해당 작업실의 UUID)를 요청시 함께 주는 방식으로 설계하였다.
그런데 간헐적으로 HTTP 통신과 소켓 통신을 이용하는 몇몇 기능들이 동작하지 않는 문제가 발생하였고, 확인해보니 spaceId에 계속 null 값이 담겨서 서버에 가는 현상이 있었다. 당시 내 딴에는 분명히 spaceId를 상태에 저장하고 prop으로 전달했고 여기에 문제가 없을 줄 알았기 때문에 처음에는 당황했다.
사용자가 특정 작업실에 입장하면, useEffect를 활용하여 마운트시 useParams를 이용하여 spaceId를 받아와 상태 업데이트하고 이를 자식 컴포넌트에서 활용하는 로직을 구성했었다. 따라서 부모의 useEffect 내부 함수가 작동해야 상태가 업데이트되고, 자식도 올바른 상태값을 활용할 수 있는 것이다.
그런데 부모의 useEffect가 실행되기 전에 자식의 코드가 먼저 실행되면서, 아직 상태가 업데이트되기 전이므로 null값이 들어가있었던 것이었다.
확인을 해보니 대충 [부모 코드 실행 -> useEffect 건너 뜀 -> 자식 코드 실행 -> useEffect 내부 코드 실행 -> 상태 업데이트되면서 재렌더링 -> 부모 코드 실행 -> 자식 코드 실행]의 순서로 동작하고 있었다. 밑줄 친 useEffect를 건너 뛴다는 사실을 알지 못했었다.
처음 useEffect를 접할 당시에 단순히 라이프사이클을 제어하는 용도, 즉 '마운트 시 실행할 코드, 업데이트시 실행할 코드, 혹은 클린업 함수를 설정하여 언마운트시 실행할 코드를 지정한다' 정도로만 이해를 했었고, 때문에 나는 useEffect 내부에 빈 의존성 배열을 넣으면 마운트되자마자 가장 먼저 코드가 실행될 것이라고 단순히 생각했었다.
useEffect가 포함된 코드의 실행 순서를 직관적으로 확인하기 위해 테스트를 해보면 다음과 같다.
[부모 컴포넌트]
import React, {useState, useEffect} from 'react'
import TestPageChild from './TestPageChild'
const TestPage = () => {
const [testNumber, setTestNumber] = useState(0);
console.log("[부모, useEffect 상단] 코드 진입");
// setTestNumber("여기에서 업데이트하면 무한 렌더링");
useEffect(() => {
console.log("[부모, useEffect 내부] 코드 실행");
setTestNumber(1);
},[])
console.log(`[부모, useEffect 하단] testNumber: ${testNumber}`);
return (
<>
<TestPageChild testNumber={testNumber}/>
</>
)
}
export default TestPage
[자식 컴포넌트]
import React, { useEffect } from 'react'
const TestPageChild = ({testNumber}) => {
console.log(`[자식, useEffect 상단] 코드 진입`);
console.log(`[자식, useEffect 상단] prop으로 받은 testNumber: ${testNumber}`);
useEffect(() => {
console.log(`[자식, useEffect 내부] prop으로 받은 testNumber: ${testNumber}`)
}, [])
console.log(`[자식, useEffect 하단] prop으로 받은 testNumber: ${testNumber}`);
return (
<></>
)
}
export default TestPageChild
이러한 구성의 컴포넌트들을 실행시키고 콘솔을 확인해보면, 다음과 같다.
[콘솔]
[부모, useEffect 상단] 코드 진입
[부모, useEffect 하단] testNumber: 0
[자식, useEffect 상단] 코드 진입
[자식, useEffect 상단] prop으로 받은 testNumber: 0
[자식, useEffect 하단] prop으로 받은 testNumber: 0
[자식, useEffect 내부] prop으로 받은 testNumber: 0
[부모, useEffect 내부] 코드 실행
[부모, useEffect 상단] 코드 진입
[부모, useEffect 하단] testNumber: 1
[자식, useEffect 상단] 코드 진입
[자식, useEffect 상단] prop으로 받은 testNumber: 1
[자식, useEffect 하단] prop으로 받은 testNumber: 1
위에서 말한 [부모 코드 실행 -> useEffect 건너 뜀 -> 자식 코드 실행 -> useEffect 내부 코드 실행 -> 상태 업데이트되면서 재렌더링 -> 부모 코드 실행 -> 자식 코드 실행] 의 순서이다.
왜 이런 일이 벌어질까?
잠깐이나마 공부를 해보니, 내가 리액트에서 useEffect를 도입한 철학에 대해서는 무지했다는 걸 알게되었다.
우선 useEffect 사용의 목적은 단순히 라이프사이클 제어 뿐만이 아니라, 사이드 이펙트 관리도 있다.
React의 컴포넌트는 순수 함수로서의 특성을 가지며, 이론적으로는 입력(Props와 State)이 같다면 출력(DOM)도 항상 같아야 합니다. 하지만 실제 애플리케이션에서는 네트워크 요청, DOM 조작, 타이머 설정 등과 같이 컴포넌트의 순수 함수적 특성을 벗어나는 작업이 필요할 수 있습니다.
이런 작업들은 사이드 이펙트로 간주되며, useEffect 훅을 사용하여 관리됩니다. useEffect는 사이드 이펙트를 컴포넌트 내부에서 선언적으로 관리할 수 있게 해줍니다. 즉, 컴포넌트의 렌더링 결과에 영향을 주거나 렌더링 결과에 의존하는 작업들을 useEffect를 사용해 처리할 수 있습니다.
useEffect는 사이드 이펙트를 처리하는 용도로 도입되었지만, 실제로는 함수형 컴포넌트에서 클래스 컴포넌트의 라이프사이클 메소드를 대체하는 역할도 수행합니다. 따라서 useEffect는 사이드 이펙트 관리와 라이프사이클 제어 두 가지 목적으로 모두 사용될 수 있습니다.
'의존설 배열에 상태들을 포함시키고, 해당 상태 변화에 따라 useEffect 내부가 실행된다' 라는 걸 그냥 외우기만 했었는데,
이러한 동작 구조는 '선언적인 사이드 이펙트 관리' 를 위함인 것이라는 점을 새삼 알 수 있다.
컴포넌트 렌더링 결과에 영향을 주거나, 렌더링 결과에 의존하는 작업들을 관리하는 것이므로 렌더링과 동시에 실행되기보다는 별도로 관리되는 것이 맞을 것이다. 따라서 리액트는 컴포넌트 함수를 실행하면서 마주치는 useEffect 호출을 순차적으로 등록하고, 모든 렌더링 작업이 끝난 후에 이를 실행한다.
Q. 근데 왜 자식의 useEffect가 더 먼저 실행되지? 기준이 뭘까?
React의 공식 문서나 커뮤니티 가이드라인에 따르면, 부모와 자식 컴포넌트 간의 useEffect 실행 순서는 보장되지 않는다고 한다. 즉, React는 내부적으로 효율적인 방식으로 useEffect를 스케줄링하기 때문에, 개발자가 이 순서를 예측하거나 의존해서는 안 된다.
React | 컴포넌트의 생명주기, useEffect (2) | 2024.01.05 |
---|---|
React | State(상태) 관리, Props 전달의 기본 개념 (0) | 2024.01.02 |
React | 리액트 프로젝트의 기본 실행구조 분석 (1) | 2024.01.02 |