Context-Query: React 상태 관리 라이브러리 개발 여정
Context API와 React Query의 장점을 결합한 최적화된 상태 관리 솔루션
React는 2015년 0.14 버전부터 함수형 컴포넌트를 지원했지만, 상태 관리가 불가능해 "프레젠테이셔널 컴포넌트"로만 사용되었습니다. 2019년 React 16.8에서 Hooks가 도입되면서 함수형 컴포넌트가 클래스 컴포넌트의 모든 기능을 대체할 수 있게 되었고, React 생태계의 표준으로 자리 잡았습니다.
초기 React는 클래스 컴포넌트를 중심으로 설계되었습니다.
// React 초기 클래스 컴포넌트 (2013)
class Counter extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
this.increment = this.increment.bind(this); // this 바인딩 필수
}
increment() {
this.setState({ count: this.state.count + 1 });
}
componentDidMount() {
console.log('마운트됨');
}
componentWillUnmount() {
console.log('언마운트됨');
}
render() {
return (
<div>
<p>카운트: {this.state.count}</p>
<button onClick={this.increment}>증가</button>
</div>
);
}
}Hooks 도입 후 동일한 컴포넌트를 함수로 작성:
// React Hooks 함수형 컴포넌트 (2019)
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('마운트됨');
return () => console.log('언마운트됨');
}, []);
return (
<div>
<p>카운트: {count}</p>
<button onClick={() => setCount(count + 1)}>증가</button>
</div>
);
}코드 비교:
this 바인딩, 라이프사이클 메서드 분산this 없음, 로직 집중React 팀이 함수형 컴포넌트로 전환한 이유는 클래스 컴포넌트의 구조적 한계 때문입니다.
JavaScript의 this 바인딩은 복잡하고 실수하기 쉽습니다.
class BadExample extends React.Component {
state = { message: 'Hello' };
handleClick() {
// Error: Cannot read property 'setState' of undefined
this.setState({ message: 'Clicked' });
}
render() {
// this가 바인딩되지 않음!
return <button onClick={this.handleClick}>클릭</button>;
}
}
// 해결 방법 1: constructor에서 바인딩
class Solution1 extends React.Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
// ...
}
// 해결 방법 2: 화살표 함수 (클래스 필드)
class Solution2 extends React.Component {
handleClick = () => {
this.setState({ message: 'Clicked' });
}
// ...
}
// 해결 방법 3: 인라인 화살표 함수 (매번 재생성)
class Solution3 extends React.Component {
render() {
return <button onClick={() => this.handleClick()}>클릭</button>;
}
}함수형 컴포넌트 해결:
function GoodExample() {
const [message, setMessage] = useState('Hello');
// this가 없음 - 바인딩 불필요
const handleClick = () => {
setMessage('Clicked');
};
return <button onClick={handleClick}>클릭</button>;
}클래스 컴포넌트는 관련 로직이 여러 라이프사이클 메서드에 분산됩니다.
class ChatRoom extends React.Component {
state = { isOnline: null };
componentDidMount() {
// 구독 로직
ChatAPI.subscribe(this.props.roomId, this.handleStatusChange);
// 다른 초기화 로직
document.title = `Room: ${this.props.roomId}`;
// 또 다른 구독
WindowAPI.addEventListener('resize', this.handleResize);
}
componentDidUpdate(prevProps) {
// roomId가 변경되면 재구독
if (prevProps.roomId !== this.props.roomId) {
ChatAPI.unsubscribe(prevProps.roomId, this.handleStatusChange);
ChatAPI.subscribe(this.props.roomId, this.handleStatusChange);
}
// 제목 업데이트
document.title = `Room: ${this.props.roomId}`;
}
componentWillUnmount() {
// 구독 해제 (로직이 멀리 떨어짐)
ChatAPI.unsubscribe(this.props.roomId, this.handleStatusChange);
WindowAPI.removeEventListener('resize', this.handleResize);
}
handleStatusChange = (status) => {
this.setState({ isOnline: status.isOnline });
}
handleResize = () => {
// ...
}
render() {
return <div>{this.state.isOnline ? '온라인' : '오프라인'}</div>;
}
}문제점:
componentDidMount와 componentWillUnmount에 분산componentDidUpdate에 중복 로직 (재구독)함수형 컴포넌트 해결:
function ChatRoom({ roomId }) {
const [isOnline, setIsOnline] = useState(null);
// 관련 로직이 한 곳에 모임
useEffect(() => {
const handleStatusChange = (status) => {
setIsOnline(status.isOnline);
};
ChatAPI.subscribe(roomId, handleStatusChange);
return () => ChatAPI.unsubscribe(roomId, handleStatusChange);
}, [roomId]); // roomId 변경 시 자동 재구독
// 제목 업데이트 로직 분리
useEffect(() => {
document.title = `Room: ${roomId}`;
}, [roomId]);
// 창 크기 로직 분리
useEffect(() => {
const handleResize = () => { /* ... */ };
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return <div>{isOnline ? '온라인' : '오프라인'}</div>;
}장점:
useEffect가 하나의 관심사만 처리클래스 컴포넌트에서 로직을 재사용하려면 HOC(Higher-Order Component)나 Render Props 패턴을 사용해야 했습니다.
// HOC로 마우스 위치 추적
function withMousePosition(Component) {
return class extends React.Component {
state = { x: 0, y: 0 };
handleMouseMove = (e) => {
this.setState({ x: e.clientX, y: e.clientY });
};
componentDidMount() {
window.addEventListener('mousemove', this.handleMouseMove);
}
componentWillUnmount() {
window.removeEventListener('mousemove', this.handleMouseMove);
}
render() {
return <Component {...this.props} mouse={this.state} />;
}
};
}
// 사용
class MouseTracker extends React.Component {
render() {
return <div>마우스 위치: {this.props.mouse.x}, {this.props.mouse.y}</div>;
}
}
export default withMousePosition(MouseTracker);HOC의 문제:
// 커스텀 Hook
function useMousePosition() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (e) => {
setPosition({ x: e.clientX, y: e.clientY });
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);
return position;
}
// 사용
function MouseTracker() {
const { x, y } = useMousePosition(); // 간단한 재사용
return <div>마우스 위치: {x}, {y}</div>;
}
// 여러 Hook 조합도 자연스러움
function ComplexComponent() {
const mouse = useMousePosition();
const [count, setCount] = useState(0);
const user = useCurrentUser();
const theme = useTheme();
// Wrapper Hell 없이 여러 로직 조합
return <div>...</div>;
}Hooks는 클로저를 활용하여 함수형 컴포넌트에 상태를 부여합니다.
// React 내부 (단순화)
let componentHooks = [];
let currentHookIndex = 0;
function useState(initialValue) {
const hookIndex = currentHookIndex;
// 첫 렌더링 시 상태 초기화
if (componentHooks[hookIndex] === undefined) {
componentHooks[hookIndex] = initialValue;
}
const setState = (newValue) => {
componentHooks[hookIndex] = newValue;
rerender(); // 리렌더링 트리거
};
return [componentHooks[hookIndex], setState];
}
function rerender() {
currentHookIndex = 0; // 인덱스 리셋
render(); // 컴포넌트 재실행
}동작 예시:
function Counter() {
const [count, setCount] = useState(0); // Hook 0
const [name, setName] = useState('Alice'); // Hook 1
return <div>{count} - {name}</div>;
}
// 첫 렌더링
// currentHookIndex = 0
// useState(0) → componentHooks[0] = 0, currentHookIndex = 1
// useState('Alice') → componentHooks[1] = 'Alice', currentHookIndex = 2
// setCount(5) 호출 후 리렌더링
// currentHookIndex = 0 (리셋)
// useState(0) → componentHooks[0] = 5, currentHookIndex = 1
// useState('Alice') → componentHooks[1] = 'Alice', currentHookIndex = 2규칙 1: 최상위에서만 호출
// 나쁨: 조건문 안에서 Hook 호출
function Bad({ condition }) {
if (condition) {
const [state, setState] = useState(0); // Hook 순서가 변경됨!
}
const [count, setCount] = useState(0);
// ...
}
// 렌더링 1 (condition = true):
// Hook 0: state
// Hook 1: count
// 렌더링 2 (condition = false):
// Hook 0: count (이전 state 위치에 count가 들어감 - 버그!)
// 좋음: 최상위에 모든 Hook
function Good({ condition }) {
const [state, setState] = useState(0);
const [count, setCount] = useState(0);
if (condition) {
// state를 여기서 사용
}
}규칙 2: React 함수에서만 호출
// 나쁨: 일반 함수에서 Hook 호출
function useFetch(url) {
const [data, setData] = useState(null);
// ...
}
function regularFunction() {
useFetch('/api'); // Error: Hook은 React 함수에서만!
}
// 좋음: 커스텀 Hook 또는 컴포넌트에서 호출
function MyComponent() {
const data = useFetch('/api'); // OK
// ...
}// 클래스 컴포넌트 (minified)
class Counter extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
this.increment = this.increment.bind(this);
}
// ... (약 500바이트)
}
// 함수형 컴포넌트 (minified)
function Counter() {
const [count, setCount] = useState(0);
// ... (약 200바이트)
}결과: 함수형 컴포넌트가 약 60% 작은 번들 크기
클래스 컴포넌트:
this 바인딩 검사함수형 컴포넌트:
useMemo/useCallback으로 세밀한 최적화벤치마크 (10,000 컴포넌트 렌더링):
| 타입 | 초기 마운트 | 업데이트 | 메모리 사용 |
|---|---|---|---|
| 클래스 | 245ms | 128ms | 18MB |
| 함수형 | 198ms (19% 빠름) | 105ms (18% 빠름) | 12MB (33% 적음) |
// Before (클래스)
class Example extends React.Component {
state = {
count: 0,
name: 'Alice',
};
updateCount = () => {
this.setState({ count: this.state.count + 1 });
};
updateName = (newName) => {
this.setState({ name: newName });
};
}
// After (함수형)
function Example() {
const [count, setCount] = useState(0);
const [name, setName] = useState('Alice');
const updateCount = () => {
setCount(count + 1);
};
const updateName = (newName) => {
setName(newName);
};
}// Before (클래스)
class UserProfile extends React.Component {
state = { user: null };
componentDidMount() {
this.fetchUser(this.props.userId);
}
componentDidUpdate(prevProps) {
if (prevProps.userId !== this.props.userId) {
this.fetchUser(this.props.userId);
}
}
componentWillUnmount() {
this.cancelRequest();
}
fetchUser(userId) {
fetch(`/api/user/${userId}`).then(/* ... */);
}
}
// After (함수형)
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false;
fetch(`/api/user/${userId}`)
.then(res => res.json())
.then(data => {
if (!cancelled) setUser(data);
});
return () => {
cancelled = true; // cleanup
};
}, [userId]); // userId 변경 시 재실행
}// Before (클래스)
class TextInput extends React.Component {
inputRef = React.createRef();
focus = () => {
this.inputRef.current.focus();
};
render() {
return <input ref={this.inputRef} />;
}
}
// After (함수형)
function TextInput() {
const inputRef = useRef(null);
const focus = () => {
inputRef.current.focus();
};
return <input ref={inputRef} />;
}// Angular (클래스 기반)
@Component({
selector: 'app-counter',
template: `
<div>
<p>카운트: {{ count }}</p>
<button (click)="increment()">증가</button>
</div>
`
})
export class CounterComponent implements OnInit, OnDestroy {
count = 0;
subscription: Subscription;
ngOnInit() {
// 초기화 로직
this.subscription = interval(1000).subscribe(() => {
this.count++;
});
}
ngOnDestroy() {
// 정리 로직 (분리됨)
this.subscription.unsubscribe();
}
increment() {
this.count++;
}
}React Hooks와 비교:
Vue는 React Hooks에 영감을 받아 Composition API를 도입했습니다.
// Vue 3 Composition API
import { ref, onMounted, onUnmounted } from 'vue';
export default {
setup() {
const count = ref(0);
let intervalId;
const increment = () => {
count.value++;
};
onMounted(() => {
intervalId = setInterval(() => {
count.value++;
}, 1000);
});
onUnmounted(() => {
clearInterval(intervalId);
});
return { count, increment };
}
};React와의 차이:
ref()로 반응성 (.value 접근 필요)useState()로 상태 (일반 변수처럼 사용)setup() 함수 한 번 실행<!-- Svelte (런타임 없음) -->
<script>
let count = 0;
$: doubled = count * 2; // 반응형 선언
function increment() {
count++;
}
</script>
<div>
<p>카운트: {count}</p>
<p>2배: {doubled}</p>
<button on:click={increment}>증가</button>
</div>React와의 차이:
$: 구문으로 자동 의존성useMemo/useEffect로 명시적 의존성// 나쁨: 컴포넌트에 로직 혼재
function UserProfile() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/user')
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, []);
if (loading) return <div>로딩 중...</div>;
if (error) return <div>에러: {error.message}</div>;
return <div>{user?.name}</div>;
}
// 좋음: 커스텀 Hook으로 분리
function useUser() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/user')
.then(res => res.json())
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, []);
return { user, loading, error };
}
function UserProfile() {
const { user, loading, error } = useUser(); // 재사용 가능
if (loading) return <div>로딩 중...</div>;
if (error) return <div>에러: {error.message}</div>;
return <div>{user?.name}</div>;
}// 나쁨: 의존성 누락
function Bad() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1); // count를 참조하지만 의존성 없음
}, 1000);
return () => clearInterval(timer);
}, []); // Warning: count가 의존성 배열에 없음
}
// 좋음 1: 의존성 추가
function Good1() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, [count]); // count 변경 시 타이머 재생성
}
// 좋음 2: 함수형 업데이트
function Good2() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1); // 최신 값 참조
}, 1000);
return () => clearInterval(timer);
}, []); // 의존성 없음
}// 나쁨: 모든 값을 메모이제이션 (과도한 최적화)
function Bad() {
const value1 = useMemo(() => 1 + 2, []);
const value2 = useMemo(() => 'hello', []);
const onClick = useCallback(() => {}, []);
// 단순 계산은 메모이제이션 불필요
}
// 좋음: 비싼 계산만 메모이제이션
function Good({ items }) {
// 복잡한 계산
const expensiveValue = useMemo(() => {
return items.reduce((acc, item) => acc + heavyComputation(item), 0);
}, [items]);
// 자식 컴포넌트에 전달할 함수
const handleClick = useCallback(() => {
doSomething(expensiveValue);
}, [expensiveValue]);
return <ChildComponent onClick={handleClick} />;
}에러:
Error: Invalid hook call. Hooks can only be called inside of the body of a function component.
원인: Hook을 일반 함수나 클래스에서 호출
해결:
// 나쁨
function helper() {
const [state, setState] = useState(0); // Error!
}
// 좋음: 커스텀 Hook으로 만들기
function useHelper() {
const [state, setState] = useState(0);
return [state, setState];
}
function Component() {
const [state, setState] = useHelper(); // OK
}증상: 컴포넌트가 계속 리렌더링됨
원인: useEffect 의존성 배열 문제
해결:
// 나쁨: 의존성 배열에 객체/배열
function Bad() {
const [data, setData] = useState(null);
useEffect(() => {
fetch('/api').then(res => res.json()).then(setData);
}, [{ id: 1 }]); // 매번 새 객체 - 무한 루프!
}
// 좋음: 원시 값만 의존성
function Good({ userId }) {
const [data, setData] = useState(null);
useEffect(() => {
fetch(`/api/user/${userId}`).then(res => res.json()).then(setData);
}, [userId]); // 원시 값 - 안전
}증상: useEffect 내에서 오래된 상태 값 참조
해결:
// 나쁨: 클로저가 오래된 값 캡처
function Bad() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // 항상 0 출력
}, 1000);
return () => clearInterval(timer);
}, []); // count가 의존성에 없음
}
// 좋음: Ref로 최신 값 참조
function Good() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
useEffect(() => {
countRef.current = count; // 항상 최신 값 저장
}, [count]);
useEffect(() => {
const timer = setInterval(() => {
console.log(countRef.current); // 최신 값 출력
}, 1000);
return () => clearInterval(timer);
}, []);
}React 함수형 컴포넌트와 Hooks는 다음과 같은 이유로 클래스 컴포넌트를 대체했습니다:
this 바인딩 불필요, 라이프사이클 메서드 대신 useEffect권장 사항:
useMemo/useCallback은 성능 병목에만 사용React 팀은 클래스 컴포넌트를 제거할 계획이 없지만, 모든 새 기능(Concurrent Mode, Server Components 등)은 함수형 컴포넌트를 중심으로 설계되고 있습니다.
Context API와 React Query의 장점을 결합한 최적화된 상태 관리 솔루션
React lazy와 Next.js dynamic의 SSR 동작 차이와 최적의 코드 스플리팅 전략
Incremental Rendering과 Time Slicing을 통한 React의 비동기 렌더링 엔진 분석