🌀 React 컴포넌트 라이프사이클: 구조적이고 깊이 있는 이해
목표: React 컴포넌트 생명주기를 구조적으로 해부하고, 함수형 컴포넌트 중심의 실무 적용 전략까지 도출
🔍 개요: React에서 "라이프사이클"이란?
React는 선언적(declarative) UI를 컴포넌트 단위로 구성하며, 각 컴포넌트는 마치 유기체처럼 자신의 생존 주기를 가집니다. 이 주기는 크게 **탄생(Mounting) → 갱신(Updating) → 소멸(Unmounting)**의 세 가지 핵심 단계로 나뉘며, 사용자의 인터랙션, 데이터의 변화, 라우팅 변경 등 다양한 외부 요인에 의해 전이(transition)됩니다.
이 "라이프사이클"을 정확히 이해하는 것은 React 애플리케이션의 결정론적(deterministic) 동작을 보장하고, 다음과 같은 핵심 아키텍처적 이점을 제공합니다.
- 💡 부작용(Side-Effect)의 정확한 제어: 네트워크 요청, DOM 조작, 구독(subscription) 설정/해제 등 비순수(impure) 연산의 실행 시점 및 범위를 명확히 정의하여 예측 불가능한 동작을 방지합니다. 이는 **순수 함수(pure function)**와 **참조 투명성(referential transparency)**을 지향하는 함수형 프로그래밍 패러다임과 일맥상통합니다.
- 🚀 성능 최적화 (불필요한 리렌더 방지): React의 Virtual DOM과 Reconciliation 알고리즘의 동작 원리를 이해하고, 불필요한 렌더링 사이클을 사전에 차단함으로써 애플리케이션의 반응성(responsiveness)을 향상시킵니다. 이는 **시간 복잡도(time complexity)**와 공간 복잡도(space complexity) 측면에서 효율적인 UI 렌더링을 의미합니다.
- 🗑 메모리 누수(Memory Leak) 및 비정상 동작 방지: 컴포넌트가 소멸될 때 할당된 리소스를 적절히 해제하여, 애플리케이션의 장기적인 안정성과 성능을 보장합니다. 이는 운영체제의 자원 관리(resource management) 개념과 유사하며, 가비지 컬렉션(garbage collection)만으로는 해결하기 어려운 명시적 자원 해제가 필요한 경우에 특히 중요합니다.
- 🚨 에러 포착 및 복구 처리 가능: 런타임 에러 발생 시 애플리케이션 전체가 중단되는 것을 방지하고, 사용자에게 의미 있는 피드백 및 대체 UI(Fallback UI)를 제공하여 견고성(robustness)을 높입니다. 이는 예외 처리(exception handling) 메커니즘의 한 형태로 볼 수 있습니다.
🧱 1. 준비: 컴포넌트 생성 및 초기 상태 설정 (Instantiation & Initialization)
이 단계는 컴포넌트 인스턴스가 생성되고, 최초 렌더링을 위한 내부 상태(state)가 초기화되는 과정입니다.
- 이론적 관점: 이는 객체 지향 프로그래밍(OOP)에서의 객체 인스턴스화(instantiation) 및 멤버 변수 초기화에 해당합니다. 함수형 프로그래밍에서는 클로저(closure)를 통한 상태 유지와 렉시컬 환경(lexical environment) 설정에 비유할 수 있습니다.
📌 클래스형 컴포넌트
constructor(props) {
super(props); // 부모 클래스인 React.Component의 생성자 호출
this.state = { count: 0 }; // 초기 상태 설정
this.handleClick = this.handleClick.bind(this); // 메서드 바인딩 (this 컨텍스트 유지)
}
- 특징:
- constructor는 컴포넌트가 마운트되기 전에 단 한 번 호출됩니다.
- super(props)를 호출하여 this.props를 사용할 수 있게 합니다.
- 핵심 원칙: 이 단계에서는 사이드 이펙트를 발생시키면 안 됩니다. 상태 초기화 및 this 바인딩과 같은 순수한 초기화 작업만 수행해야 합니다. 네트워크 요청이나 DOM 접근은 이 시점에서 불가능합니다.
- ES6 클래스 필드 문법을 사용하면 constructor 내부의 state 정의와 메서드 바인딩을 간소화할 수 있습니다.
📌 함수형 컴포넌트
const MyFunctionalComponent = (props) => {
const [count, setCount] = useState(0); // useState Hook으로 상태 정의
// ... 컴포넌트 본문
};
- 특징:
- 명시적인 constructor는 없지만, 컴포넌트 함수의 본문 자체가 실행 컨텍스트(execution context)가 됩니다.
- useState, useReducer 등의 React Hooks를 사용하여 상태를 정의하고 관리합니다. 이 Hooks들은 컴포넌트가 렌더링될 때마다 호출되지만, React 내부적으로는 이전에 렌더링된 상태를 기억하는 메모이제이션(memoization) 기법이 적용되어 있습니다.
- 렉시컬 클로저(Lexical Closure): 함수형 컴포넌트의 상태 변수(count)와 상태 업데이트 함수(setCount)는 해당 컴포넌트 함수의 렉시컬 환경에 캡처되어 컴포넌트가 리렌더링되어도 동일한 상태를 참조할 수 있도록 합니다.
- ✅ 복잡한 계산은 useMemo로 메모이제이션하여 렌더 최적화 가능:
이는 동적 계획법(Dynamic Programming)의 개념과 유사하게, 동일한 입력에 대해 이전에 계산된 결과를 캐싱하여 불필요한 재계산을 방지합니다.TypeScript
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]); // 의존성 배열 [a, b]가 변경되지 않는 한, 이전 계산 결과 재사용
⛰ 2. 마운트 단계 (Mounting)
컴포넌트 인스턴스가 생성된 후, 실제 DOM에 처음으로 삽입되는 순간입니다. 이 단계는 초기 렌더링 및 필요한 초기 설정을 수행하기에 가장 적합한 시점입니다.
- 컴퓨터 과학적 관점: 시스템이 초기 부팅되거나, 애플리케이션이 처음으로 시작될 때 수행되는 초기화 루틴과 유사합니다. 리소스 할당 및 외부 시스템과의 첫 통신이 이루어집니다.
⏱ 실행 시점
| componentDidMount() | useEffect(() => { /* ... */ }, []) |
- componentDidMount(): 컴포넌트가 DOM에 마운트된 직후 한 번 호출됩니다.
- useEffect(() => { /* ... */ }, []): 의존성 배열([])이 비어 있으면, 컴포넌트가 마운트될 때 한 번만 실행되고, 이후 리렌더링 시에는 다시 실행되지 않습니다. (클래스형의 componentDidMount와 유사)
✅ 주로 수행하는 작업
- 비동기 API 호출 (ex: 초기 데이터 로딩): 백엔드 서버로부터 필요한 초기 데이터를 가져옵니다. 이 시점에서 데이터를 가져오는 것은 UI가 사용자에게 보인 직후이므로, 사용자 경험 측면에서 지연을 최소화합니다.
- WebSocket, 타이머 (setInterval, setTimeout), 이벤트 리스너 등록: DOM에 직접 접근하거나 외부 시스템과 연결되는 구독(subscription) 및 주기적인 작업을 설정합니다.
- 서드파티 라이브러리 초기화 (Chart.js, Mapbox 등): DOM 요소에 기반한 외부 라이브러리 인스턴스를 생성하고 초기화합니다.
✅ 함수형 예시: 비동기 데이터 로딩 및 클린업 대비
useEffect(() => {
const controller = new AbortController(); // 네트워크 요청 취소를 위한 AbortController
const fetchData = async () => {
try {
const response = await fetch("/api/data", { signal: controller.signal });
const data = await response.json();
// 데이터 처리 로직
} catch (error) {
if (error.name === 'AbortError') {
console.log('Fetch aborted');
} else {
console.error('Fetch error:', error);
}
}
};
fetchData();
return () => {
// 클린업 함수: 컴포넌트 언마운트 시 또는 의존성 변경 시 (재실행 전) 호출
controller.abort(); // 마운트 해제 시 비동기 요청 취소
console.log("Cleanup for mount effect executed.");
};
}, []); // 빈 의존성 배열: 마운트 시 한 번만 실행
- useEffect의 반환 함수 (Cleanup Function): useEffect 훅이 반환하는 함수는 컴포넌트가 언마운트될 때 또는 다음 useEffect가 실행되기 전에 이전 효과(effect)를 정리(cleanup)하는 데 사용됩니다. 이는 리소스 누수 방지 및 비동기 작업의 안정성을 보장합니다.
🔄 3. 업데이트 단계 (Updating)
컴포넌트의 props 또는 state가 변경될 때 발생합니다. React는 이 변화를 감지하여 Virtual DOM을 효율적으로 조작하고 실제 DOM에 최소한의 변경만을 반영하여 렌더링 성능을 최적화합니다. 이 과정은 **재조정(Reconciliation)**이라고 불립니다.
- 컴퓨터 과학적 관점: 이는 데이터베이스의 트랜잭션(transaction) 처리 후 데이터 뷰를 갱신하거나, 캐시 일관성(cache coherence)을 유지하는 과정과 유사합니다. 변경 감지(change detection) 및 효율적인 갱신 알고리즘이 핵심입니다.
🔁 주요 상황 (트리거)
- state 변경 (클래스형: setState, 함수형: useState, useReducer의 Setter 함수)
- 부모 컴포넌트가 전달한 props 변경
- forceUpdate() (클래스형, 가급적 지양)
- context API를 통한 전역 상태 변화
⏱ 실행 시점 (순서)
| static getDerivedStateFromProps(nextProps, prevState) | (직접 대응 없음, useState 및 useEffect로 대체) |
| shouldComponentUpdate(nextProps, nextState) | React.memo(), useMemo(), useCallback() |
| render() | (함수형 컴포넌트 본문 실행) |
| getSnapshotBeforeUpdate(prevProps, prevState) | (직접 대응 없음) |
| componentDidUpdate(prevProps, prevState, snapshot) | useEffect(() => { /* ... */ }, [deps]) |
- getDerivedStateFromProps: props 변경에 따라 state를 동기화해야 할 때 사용되는 static 메서드입니다. state를 반환하거나 null을 반환하여 state 변경이 없음을 알립니다. 사이드 이펙트는 여기서 금지됩니다.
- shouldComponentUpdate: 컴포넌트의 리렌더링 여부를 결정하는 중요한 최적화 지점입니다. true를 반환하면 렌더링을 진행하고, false를 반환하면 렌더링을 건너뜁니다. PureComponent와 유사하게 얕은 비교(shallow comparison)를 수행합니다.
- componentDidUpdate: 컴포넌트가 업데이트된 후 DOM에 변경 사항이 반영된 직후 호출됩니다. props 또는 state 변경에 따른 부작용을 처리하기에 적합합니다.
- getSnapshotBeforeUpdate: render()가 호출된 후 DOM이 업데이트되기 직전에 호출됩니다. DOM에 접근하여 특정 정보를(예: 스크롤 위치) 스냅샷으로 저장하여 componentDidUpdate로 전달할 수 있습니다.
✅ 함수형 예시: useEffect를 이용한 props 또는 state 변경 감지
const UserProfile = ({ userId }) => {
const [userData, setUserData] = useState(null);
useEffect(() => {
console.log("userId가 변경됨:", userId);
// userId가 변경될 때마다 사용자 데이터 새로 가져오기
const fetchUserData = async () => {
// ... API 호출 로직
setUserData(data);
};
if (userId) { // userId가 유효할 때만 fetch
fetchUserData(userId);
}
}, [userId]); // userId가 의존성 배열에 포함되어 변경될 때마다 이 Effect 재실행
// ... 렌더링 로직
};
- useEffect의 의존성 배열 (Dependency Array): useEffect의 두 번째 인자인 의존성 배열은 해당 Effect가 언제 다시 실행될지를 결정합니다. 배열 내의 값 중 하나라도 이전 렌더링 이후 변경되었다면 Effect는 재실행됩니다. 빈 배열([])은 마운트 시 한 번만 실행됨을 의미하며, 의존성 배열을 생략하면 매 렌더링마다 실행됩니다.
✅ 최적화 팁 (함수형 컴포넌트 중심)
| React.memo() | Props 변경 여부에 따라 컴포넌트 렌더링 결정 (고차 컴포넌트, HOC) | 메모이제이션(Memoization): 컴포넌트의 props가 이전과 동일하면 렌더링을 건너뛰고 이전에 렌더링된 결과를 재사용합니다. 이는 캐싱(Caching) 전략의 일종입니다. <br/>참조 동등성(Referential Equality): 객체/배열/함수 타입의 props는 얕은 비교를 수행하므로, 새로운 참조가 생성되면 변경된 것으로 간주됩니다. |
| useMemo() | 복잡한 계산된 값 메모이제이션 | 동적 계획법(Dynamic Programming), 캐싱(Caching): 특정 입력에 대해 이전에 계산된 결과를 저장하여 동일한 입력에 대한 불필요한 재계산을 방지합니다. 의존성 배열 내 값이 변경될 때만 다시 계산합니다. |
| useCallback() | 콜백 함수 재생성 방지 → 자식 컴포넌트 불필요한 렌더 트리거 방지 | 함수형 메모이제이션(Functional Memoization): 함수 자체의 참조가 변경되지 않도록 메모이제이션합니다. 이는 React.memo로 감싸진 자식 컴포넌트에 콜백 함수를 props로 전달할 때 특히 중요합니다. 함수의 참조가 변경되면 React.memo는 이를 변경으로 인식하여 자식 컴포넌트를 리렌더링하기 때문입니다. |
예시:
const MyChildComponent = React.memo(({ onClick }) => {
console.log("Child Component Rendered");
return <button onClick={onClick}>Click Me</button>;
});
const MyParentComponent = () => {
const [count, setCount] = useState(0);
// useCallback으로 handleClick 함수 메모이제이션
// count가 변경될 때만 새로운 함수를 생성
const handleClick = useCallback(() => {
setCount(prevCount => prevCount + 1);
}, []); // 의존성 배열이 비어있으므로, 컴포넌트 마운트 시 한 번만 함수 생성
return (
<div>
<p>Count: {count}</p>
<MyChildComponent onClick={handleClick} />
<button onClick={() => setCount(count + 1)}>Parent Render Trigger</button>
</div>
);
};
위 예시에서 handleClick을 useCallback으로 감싸지 않았다면, MyParentComponent가 리렌더링될 때마다 handleClick 함수는 새로운 참조를 가지게 되고, MyChildComponent는 비록 count 상태와 관련이 없더라도 React.memo임에도 불구하고 불필요하게 리렌더링됩니다.
🗑 4. 언마운트 단계 (Unmounting)
컴포넌트가 DOM에서 완전히 제거될 때 실행됩니다. 이 단계는 마운트 단계에서 설정했던 모든 리소스(타이머, 이벤트 리스너, 구독 등)를 해제하고 부작용을 제거하여 메모리 누수를 방지하는 데 필수적입니다.
- 컴퓨터 과학적 관점: 운영체제에서 프로세스가 종료될 때 할당된 모든 자원을 반환하거나, 동적으로 할당된 메모리를 해제하는 것과 유사합니다. Garbage Collection만으로는 해결되지 않는 외부 리소스(파일 핸들, 네트워크 소켓 등)의 명시적 해제가 중요합니다.
⏱ 실행 시점
| componentWillUnmount() | useEffect(() => { return () => { /* ... */ } }, []) |
- componentWillUnmount(): 컴포넌트가 DOM에서 완전히 제거되기 직전에 호출됩니다. 이 메서드 내에서 setState를 호출하는 것은 아무런 의미가 없습니다.
- useEffect의 클린업 함수: useEffect 훅이 반환하는 함수는 컴포넌트가 언마운트될 때 호출됩니다. 또한, 의존성 배열의 값이 변경되어 다음 Effect가 실행되기 전에도 이전 Effect의 클린업 함수가 먼저 호출됩니다. 이는 componentWillUnmount뿐만 아니라 componentDidUpdate의 특정 경우까지 커버하는 유연성을 제공합니다.
✅ 사용 예시: 타이머 해제 및 이벤트 리스너 제거
useEffect(() => {
const id = setInterval(() => console.log("tick"), 1000); // 타이머 설정
window.addEventListener("resize", handleResize); // 이벤트 리스너 등록
return () => {
clearInterval(id); // 컴포넌트 제거 시 타이머 해제
window.removeEventListener("resize", handleResize); // 이벤트 리스너 제거
console.log("컴포넌트 제거됨: 모든 리소스 해제 완료");
};
}, []); // 빈 의존성 배열: 마운트 시 한 번 설정, 언마운트 시 한 번 해제
🚨 해제해야 할 리소스 예시
- 타이머: setInterval, setTimeout으로 설정된 주기적 또는 지연 실행 함수
- 이벤트 리스너: window.addEventListener, document.addEventListener, 특정 DOM 요소에 추가된 이벤트 리스너
- 웹소켓 연결: new WebSocket()으로 생성된 연결
- 구독(Subscription): RxJS Observable, Redux Store의 subscribe 등 외부 데이터 소스 구독
- 서드파티 인스턴스: Chart.js 차트, Mapbox 지도 등 DOM에 직접 부착되거나 외부 리소스를 사용하는 라이브러리 인스턴스
주의: 클린업 함수는 해당 useEffect의 deps 배열에 있는 값이 변경될 때에도 호출됩니다. 이 특성을 이용하면 props나 state의 변화에 따라 기존의 구독을 해지하고 새로운 구독을 시작하는 등의 동적인 리소스 관리가 가능합니다.
🧨 5. 에러 처리 단계 (Error Boundary)
컴포넌트 렌더링, 생성자, 또는 라이프사이클 메서드 내부에서 발생한 JavaScript 에러를 포착하여 애플리케이션 전체가 중단되는 것을 방지하고, 사용자에게 의미 있는 대체 UI (Fallback UI)를 제공합니다.
- 컴퓨터 과학적 관점: 이는 운영체제의 세그멘테이션 오류(Segmentation Fault)나 애플리케이션의 크래시(Crash)를 방지하기 위한 강력한 예외 처리 메커니즘입니다. AOP(Aspect-Oriented Programming)의 개념처럼, 핵심 로직 외부에 에러 핸들링 로직을 분리하여 시스템의 복원력(resilience)을 높입니다.
⏱ 지원: 클래스형에서만 공식 지원
Error Boundary는 React v16부터 도입되었으며, 현재는 클래스형 컴포넌트에서만 구현할 수 있는 특정 라이프사이클 메서드를 통해 공식적으로 지원됩니다.
메서드 설명
| getDerivedStateFromError(error) | 상태 변경용 static 메서드 |
| componentDidCatch(error, info) | 에러 로그 전송, 트래킹 등 부수 효과 처리 가능 |
| static getDerivedStateFromError(error) | 에러 발생 시 호출되는 static 메서드입니다. 반환하는 객체는 컴포넌트의 state를 업데이트하여 에러 상태를 UI에 반영하는 데 사용됩니다. 사이드 이펙트는 금지됩니다. 이 메서드의 목적은 에러가 발생했을 때 다음 렌더링에서 fallback UI를 보여줄 수 있도록 상태를 업데이트하는 것입니다. | componentDidCatch(error, info) | 자식 컴포넌트 트리에서 발생하는 JavaScript 에러를 포착합니다. 이 메서드는 에러 로그 전송, 트래킹 서비스에 에러 정보 전송 등 부수 효과(side effect) 처리를 수행하기에 적합합니다. info 객체에는 componentStack 키를 통해 어떤 컴포넌트에서 에러가 발생했는지 스택 정보가 제공됩니다. |
✅ 예시: ErrorBoundary 클래스 구현
import React, { Component } from 'react';
class ErrorBoundary extends Component {
// ① getDerivedStateFromError: 에러 발생 시 state를 업데이트하여 fallback UI를 렌더링하도록 준비
static getDerivedStateFromError(error) {
// 다음 렌더링에서 fallback UI를 보여주기 위해 상태를 업데이트합니다.
return { hasError: true };
}
// ② componentDidCatch: 에러 로깅 등 부수 효과 처리
componentDidCatch(error, info) {
// 에러 리포팅 서비스 (예: Sentry, Bugsnag)에 에러 정보를 전송
console.error("Error Boundary caught an error:", error, info);
// logErrorToService(error, info); // 실제 서비스 호출
}
state = { hasError: false }; // 초기 에러 상태
render() {
if (this.state.hasError) {
// 에러 발생 시 대체 UI (Fallback UI)를 렌더링
return (
<div style={{ padding: '20px', border: '1px solid red', color: 'red' }}>
<h2>🚨 Something went wrong.</h2>
<p>We're sorry for the inconvenience. Please try again later.</p>
</div>
);
}
// 에러가 없으면 일반 자식 컴포넌트를 렌더링
return this.props.children;
}
}
// 사용 예시
const App = () => {
return (
<ErrorBoundary>
{/* 이 안에 있는 어떤 컴포넌트에서든 에러가 발생하면 ErrorBoundary가 포착 */}
<MyProblematicComponent />
<MyNormalComponent />
</ErrorBoundary>
);
};
- 함수형 컴포넌트에서의 에러 처리: 함수형 컴포넌트 자체에서는 Error Boundary를 구현할 수 없습니다. 대신, 위 예시처럼 Error Boundary 클래스 컴포넌트로 감싸서 사용해야 합니다. React 공식 문서에 따르면, Hooks는 현재 Error Boundary 기능을 제공하지 않습니다.
🧭 전체 생명 주기 요약 도식 (함수형 컴포넌트 중심)
React 애플리케이션의 렌더링 흐름을 함수형 컴포넌트와 Hooks 관점에서 요약하면 다음과 같습니다.
[컴포넌트 정의 및 초기화 (useState, useReducer)]
↓
[마운트 단계 (Mounting)]
→ 컴포넌트 본문 첫 실행
→ useEffect(() => { /* Side Effects */ }, []) (의존성 배열 비어있음)
↓
[업데이트 단계 (Updating)]
→ Props 또는 State 변경 감지 (Reconciliation 시작)
→ 컴포넌트 본문 재실행 (새로운 JSX 반환)
→ React.memo(), useMemo(), useCallback()을 통한 렌더링 최적화
→ useEffect(() => { /* Side Effects */ }, [deps]) (의존성 변경 시 재실행)
↓
[언마운트 단계 (Unmounting)]
→ 컴포넌트 DOM에서 제거
→ useEffect cleanup function (이전 Effect의 반환 함수 호출)
↓
[에러 처리 단계 (Error Handling)]
→ ErrorBoundary (클래스형 컴포넌트로 상위에서 감싸야 함)
✅ 마무리: 생명주기를 이해함으로써 얻는 실질적 이점
| 🔍 예측 가능한 렌더링 흐름 | 언제 어떤 작업이 실행될지 명확히 알 수 있음 |
| 🧹 자원 관리 | 이벤트 리스너, 타이머 등을 정리하여 메모리 누수 방지 |
| 🚀 성능 최적화 | 불필요한 리렌더링 방지 → 빠른 UI |
| 🧱 코드 구조화 | 관심사 분리 → 컴포넌트 분리 기준 명확 |
| 🧯 안정성 향상 | 예외 상황 대응 → 전체 앱 중단 방지 |
'코드의 해부학' 카테고리의 다른 글
| WAS는 절대 혼자 두지 마라! 웹 서버와의 치명적 사랑 이야기 (4) | 2025.06.05 |
|---|---|
| Jenkins: 개발자에게 자유를! 자동화가 선사하는 여유로운 삶. (4) | 2025.06.05 |
| Monitoring & Logging : 시스템은 거짓말하지 않는다 (2) | 2025.06.04 |
| MSA(마이크로서비스 아키텍처) : 개발팀의 꿈인가? 아니면 PM의 악몽인가? (0) | 2025.06.04 |
| IaC(Infrastructure as Code) : 코드로 빚어내는 '운영의 연금술' (0) | 2025.06.04 |