목차
1️⃣ 리액트에선 과정보단 결과다
2️⃣ 3초 뒤의 this.state는 과연 믿을 만한가?
3️⃣ 클래스 컴포넌트에서는 함수가 데이터 흐름에서 소외된다
4️⃣ 클래스 컴포넌트의 라이프사이클은 '의도'보다는 '타이밍'에 집중한다
5️⃣ Hooks는 합성과 재사용성을 극대화한다
6️⃣ 클래스 컴포넌트는 미래가 없다
1️⃣ 리액트에선 과정보단 결과다
React는 처음부터 지금까지 선언형 UI를 지향해왔다. 중요한 건 어떻게 그 목적에 도달했는가가 아니라, 무엇을 보여주고 싶은가다. "과정보다 결과가 중요하다"는 철학 아래, 복잡한 절차를 감추고 UI를 단지 상태의 결과로 표현하는 방향으로 발전해왔다.
그런데 클래스 컴포넌트는 이러한 철학에 조금 부합하지 않는다. 다음 두 코드는 결과적으로 같은 UI를 표시하지만, 두 번째는 render 메서드, this binding 등 "과정"을 독자에게 먼저 강요한다.
// 함수형 선언: 결과 중심
function Hello({ name }) {
return <h1>Hello, {name}</h1>;
}
// 클래스형 선언: 과정 개입
class Hello extends React.Component {
render() {
return <h1>Hello, {this.props.name}</h1>;
}
}
React 16.8에서 Hooks 도입을 전환점으로, React는 클래스 컴포넌트에서 함수 컴포넌트로 점진적으로 전환했는데, 그 이유 중 일부분을 이번 글에서 다뤄보려고 한다.
2️⃣ 3초 뒤의 this.state는 과연 믿을 만한가?
stale state 주의!!
2.1. 클래스 컴포넌트로 만든 이상한 Counter
버튼을 클릭하면 3초 후에 count가 찍히는 Counter 컴포넌트가 있다. 버튼을 빠르게 5번 클릭한다면, 콘솔에 어떤게 찍혀야 할까?
우리가 기대하는건 로그가 "클릭 시점의 값", 그러니까 3초 후에 1,2,3,4,5가 순서대로 찍히는걸 예상한다.
하지만 이상하게도, 이 카운터를 class 컴포넌트로 만들면 예상대로 동작하지 않는다.
class Counter extends React.Component {
state = { count: 0 };
handleClick = () => this.setState(({ count }) => ({ count: count + 1 }));
componentDidMount() {
setTimeout(() => console.log(this.state.count), 3000);
}
render() {
return <button onClick={this.handleClick}>{this.state.count}</button>;
}
}
시나리오
1) 버튼을 빠르게 5번 누른다 → count = 5
2) 3초 후 콘솔 출력: 5 (항상 최신값)
왜그럴까?
클래스 컴포넌트는 this.state 라는 참조를 통해 항상 최신 상태만 가리킨다. 그래서 클래스 구조에서 1,2,3,4,5가 찍히는 동작을 구현한다면, 별도 변수가 필요하거나, setTimeout 콜백 안에 클로저를 수동으로 만들어야 한다.
2.2. 함수 컴포넌트로 만들어보면?
반면 함수 컴포넌트는 렌더 스코프마다 클로저로 상태 스냅숏을 캡처한다.
c.f) 렌더링에 대한 더 자세한 이해는 Dan Abramov의 useEffect 가이드에 너무나도 잘 설명이 되어있다!
함수 컴포넌트에서 동일한 카운터를 만들어보자.
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setTimeout(() => console.log(count), 3000);
return () => clearTimeout(id);
}, [count]); // ← 클로저가 매 렌더 스냅숏을 캡처
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
결과는 예상대로 1,2,3,4,5가 찍힌다.
왜그럴까?
useEffect 콜백은 렌더 당시의 count 값을 클로저에 봉인한다. 나중에 실행돼도 변하지 않으므로 “버튼을 누른 순간”의 상태를 정확히 기록하는 것이다.
항목 | 클래스 this.state (참조) | 함수 state (클로저 캡처) |
메모리 위치 | 인스턴스 객체 내부 값 | 렌더 스택 프레임 변수 |
값 고정 여부 | 렌더 사이에도 최신값으로 갱신 | 렌더 단위 스냅숏으로 불변 |
비동기 안전성 | ❌ stale state 위험 | ✅ 렌더 스코프 안전 |
해결 전략 | 별도 변수, setState callback, useRef 혼용 | 기본 동작이 안전 |
이렇듯 클래스 컴포넌트는 전통적으로 타임아웃에서 잘못된 값을 가져오는 문제에 시달리고 있다. class와 함수의 이 미묘한 차이가 타임아웃·이벤트 리스너·비동기 호출에서 치명적인 버그를 만든다.
2.3. 함수 컴포넌트에서 최신 상태를 참조하는 방법
함수 컴포넌트에서 좀전에 클래스 컴포넌트 예시처럼 최신 상태를 참조해야 할 때는, useRef를 사용하면 된다.
const countRef = useRef(0);
useEffect(() => { countRef.current = count; });
즉, “최신값”과 “스냅숏” 양쪽 니즈를 모두 충족할 수 있다.
3️⃣ 클래스 컴포넌트에서는 함수가 데이터 흐름에서 소외된다
3.1. props로 내려간 함수도 데이터다
React는 “모든 것이 prop으로 흐른다”는 단방향 데이터 흐름을 강조한다. prop으로 내려가는 숫자·문자열뿐 아니라 함수 역시 ‘데이터’로 취급해야 의존성 그래프가 완성된다. 하지만 클래스 메서드는 인스턴스에 영구적으로 바인딩되며 레퍼런스(identity)가 변하지 않는다. 이 특성은 React Fiber가 “의존성이 없다”고 잘못 판단하게 만들고, 결과적으로 데이터 흐름에서 함수가 소외되어버린다.
3.2. 문제 재현: Parent → Child fetchData
Parent에 있는 fetchData 함수를 Child 컴포넌트에 prop으로 내려주는걸 전통적인 클래스 컴포넌트로 만들어보자. fetchData는 Parent의 상태에 의존하고 있다.
// 부모 컴포넌트
class Parent extends React.Component {
state = { query: 'react' };
fetchData = () => {
const url =
'https://hn.algolia.com/api/v1/search?query=' + this.state.query;
return fetch(url).then(r => r.json());
};
render() {
return <Child fetchData={this.fetchData} />;
}
}
// 자식 컴포넌트
class Child extends React.Component {
componentDidMount() {
this.props.fetchData(); // 최초 1회 호출
}
componentDidUpdate(prev) {
if (this.props.fetchData !== prev.fetchData) {
this.props.fetchData(); // 절대 호출 안 됨 ‼️
}
}
render() {
return null;
}
}
겉보기에 문제가 없어 보이지만, 실제로는 큰 문제가 숨어 있다. (힌트: fetchData는 class의 메서드이다)
클래스의 메서드는 인스턴스의 this에 묶여 있고, 리액트는 이 fetchData 함수가 상태에 의존하는지 판단할 수 없다. 즉, 상태가 바뀌어도 이 함수 자체는 변하지 않기 때문에, componentDidUpdate에서 함수 비교는 항상 false를 반환한다.
this.props.fetchData === prevProps.fetchData // 항상 true
즉, 위 if절이 false가 되는 일은 없으므로,
결국 Child 컴포넌트는 부모의 상태가 바뀌어도 fetchData를 재호출하지 못한다!!
🚨 props로 전달된 함수가 실제로 상태에 의존하고 있는데도 React Fiber가 “의존성이 없다”고 잘못 판단
문제를 우회하기 위해선 어쩔 수 없이 query를 prop으로 넘겨야 한다. query 상태가 Child에서 직접적으로 사용되지 않고 우리는 this.props.fetchData 함수만 필요한 것인데, "차이"를 비교하기 위해 온갖 다른 데이터들도 전달해줘야 하는 것이다.
컴포넌트 간의 캡슐화가 깨졌고, 관심사가 흐려졌다.
그리고 더 나아가서는 리액트의 데이터 흐름 모델과도 어긋난다. 리액트는 "함수도 값이다"라고 말하지만, 클래스 컴포넌트는 이 방향성과 충돌한다.
요약
- this.fetchData는 클래스 인스턴스가 살아 있는 한 동일 레퍼런스
- Parent state query가 'vue'로 바뀌어도 Child는 변화를 감지하지 못한다
- 결국 서버에서 오래된 결과를 쓰거나, query prop을 억지로 내려야 한다 → 캡슐화 파괴
3.3. 왜 React Fiber가 함수를 투명 인간 취급하나?
단계 | Fiber 내부 동작 | 클래스 메서드 영향 |
① 리렌더 트리거 | Parent setState → 새 VDOM 생성 | fetchData 레퍼런스 동일 |
② Reconciliation | props shallow equal? True → Child skip | Child fiber No Update |
③ Commit | Child 생략 – effect 훅·라이프사이클 호출 안 됨 | 데이터 흐름 끊김 |
의존성 결정은 “참조가 달라졌는가?”가 기준이다. 클래스 메서드는 ‘데이터’이면서 변화 없는 상수로 간주된다.
3.4. 함수형으로 해결해보자: 레퍼런스에 의미 부여하기
① useCallback + deps array
function Parent() {
const [query, setQuery] = useState('react');
const fetchData = useCallback(() => {
const url =
'https://hn.algolia.com/api/v1/search?query=' + query;
return fetch(url).then(r => r.json());
}, [query]); // query 바뀌면 새 함수로 재생성
return <Child fetchData={fetchData} />;
}
- 함수 레퍼런스가 query 변경 때마다 갱신 → Child effect 재실행
② 커스텀 훅으로 관심사 분리
function useSearch(query) {
return useQuery(['hn', query], () =>
fetch(`https://hn.algolia.com/api/v1/search?query=${query}`)
.then(r => r.json())
);
}
function Parent() {
const [query] = useState('react');
const result = useSearch(query);
return <Child result={result} />;
}
- Child는 “데이터 결과”만 받으므로 캡슐화가 유지되고, 테스트도 쉽다.
- 이 접근은 SRP·성능·테스트·버그 감소 네 마리 토끼를 잡는다.
- React 코드베이스에서 “함수도 변하는 데이터”라는 관점을 잊지 말아야 한다.
4️⃣ 클래스 컴포넌트의 라이프사이클은 '의도'보다는 '타이밍'에 집중한다
클래스 컴포넌트에서 이펙트를 실행하려면 다음 세 가지 라이프사이클 메서드를 다뤄야 한다:
- componentDidMount
- componentDidUpdate
- componentWillUnmount
이렇게 라이프사이클을 직접 다루는건 어떤걸 할건지의 의도보다는 언제 어떻게 실행할건지의 타이밍을 기반으로 구성된 것이다.
예를 들어, 어떤 API를 호출하고 결과를 저장하고, 나중에 컴포넌트가 사라질 때 타이머를 정리하려면 다음처럼 코드를 작성해야 한다:
class Search extends React.Component {
state = { query: 'react', data: [] };
componentDidMount() {
this.fetchData(); // 1. 마운트 시 호출
this.timer = setInterval(() => // 2. 주기적 폴링
this.fetchData(), 5000);
}
componentDidUpdate(prev) {
if (prev.state.query !== this.state.query) {
this.fetchData(); // 3. query 바뀔 때 호출
}
}
componentWillUnmount() {
clearInterval(this.timer); // 4. 정리
}
fetchData() { /* ... */ }
render() { /* ... */ }
}
이런 방식은 타이밍 중심으로 로직이 분산되고, 관심사 분리도 흐트러지며, 코드의 추적과 유지보수를 어렵게 만든다.
- 로직 파편화 : fetchData 호출 위치가 세 곳
- 중복·누락 위험 : cleanup 놓치면 메모리 누수
- 타이밍 의존 버그 : 폴링 도중 query 바뀌면 race condition 발생
함수 컴포넌트는 useEffect를 통해 관련된 로직을 하나의 블록 안에서 관리할 수 있어 훨씬 명확하다.
function Search() {
const [query, setQuery] = useState('react');
const [data, setData] = useState([]);
useEffect(() => {
let isStale = false;
const fetchData = async () => {
const res = await fetch(`/api?query=${query}`);
if (!isStale) setData(await res.json());
};
fetchData(); // 1. 최초 & query 변경 시
const id = setInterval(fetchData, 5000); // 2. 폴링
return () => { // 3. 정리
isStale = true;
clearInterval(id);
};
}, [query]); // 의도: query 의존
}
- 의도 중심 : “query가 바뀌면 이펙트를 다시 실행” 한 문장
- 타이밍·정리 한곳 : return cleanup 함수
- race condition 방지 : isStale 플래그로 오래된 응답 무시
관점 | 클래스 라이프사이클 | useEffect |
로직 위치 | Mount·Update·Unmount 분산 | 한 블록에 통합 |
의존성 선언 | 수동 if 문·prevProps 비교 | deps array로 선언 |
cleanup | 별도 메서드(componentWillUnmount) | return 함수 |
race condition 회피 | 개발자 책임(플래그, abort) | effect 스코프·closure 활용 |
동시성(React 18) | 자동 batching 미적용 부분 있음 | 자동 적용 + transition API 결합 용이 |
5️⃣ Hooks는 합성과 재사용성을 극대화한다
5.1. 로직도 컴포넌트처럼 조립할 수 없을까?
React 16.8 Hooks가 발표되자 가장 먼저 떠오른 질문은 “상태 관리 로직을 컴포넌트 밖으로 꺼낼 수 있다면?”이었다. Custom Hook은 그걸 실현하기 위해 등장했고, 로직을 재사용 가능한 모듈로 만들었다.
- UI = 컴포넌트 조립
- 로직 = 훅 조립
5.2. HOC / Render Props 의 복잡성
“with with with…”로 시작하던 2017년의 JSX
클래스에서 공통 로직을 재사용하려면 HOC(Higher‑Order Component) 나 Render Props를 쌓는 수밖에 없었다.
// HOC 3단 체인
export default withError(
withRouter(
withAuth(UserList) // ① 인증 확인
) // ② 라우팅 정보 주입
); // ③ 에러 경계
- JSX 깊이 증가 : DevTools 트리가 ‘Wrapper’ 노드로 뒤덮임
- 추적 난이도 : 실제 렌더 노드가 어디인지 한눈에 파악 불가
- 타입 전파 문제 : Generic Props가 외피마다 재정의 → TS 지옥
Render Props도 형태만 다를 뿐, 중첩이 깊어지면 동일한 고통을 줬다.
<Data>{data => (
<Theme>{theme => (
<Auth>{user => (
<Table data={data} theme={theme} user={user} />
)}</Auth>
)}</Theme>
)}</Data>
5.3. Hooks로 평탄화
Hooks 패턴은 로직을 훅으로, UI는 JSX로 명확히 분리하면서 트리를 평탄화했다.
function UserList() {
const { user } = useAuth(); // ① 인증
const { data } = useQuery('users'); // ② 데이터
const theme = useTheme(); // ③ 테마
return <Table data={data} theme={theme} user={user} />;
}
결과적으로, Hooks는 “언제·어디서 로직을 끌어올까?”라는 고민을 훅 내부로 숨기고, 컴포넌트에는 “어떤 결과를 보여 줄까?”만 남겼다.
추가적으로, 훅은 모듈 생태계 확장을 가속했다. 단일 함수 형태라 패키징과 npm 배포 장벽이 낮고, Storybook · CodeSandbox 같은 도구로 훅 실행 결과를 즉시 시각화할 수 있어 공유·검증 속도가 엄청 빨라졌다.
6️⃣ 클래스 컴포넌트는 미래가 없다
6.1. Technical Debt
Technical Debt(기술 부채) = ‘지금 편하려고 택한 설계’가 시간이 지나며 새 기능·성능 목표와 충돌해 내야 하는 이자.
React 팀이 함수형을 밀어붙인 이유는 클래스 구조 자체가 다음 단계로 가기에 구조적 한계를 드러냈기 때문이다.
항목 | 클래스 설계 | 확장 장애 요인 |
상태 보관 | 상태가 인스턴스 안 (this.state) | 화면을 잠깐 멈췄다가 이어 그리는 동시성 기능과 충돌(스냅숏 분리 불가) |
메서드 바인딩 | this.handle = this.handle.bind(this) | 같은 레퍼런스가 계속 유지 → React가 “얘는 안 바뀌네?” 하고 의존성에서 제외 |
생명주기 | Mount/Update/Unmount 분산 | “언제?” 로직이 분산돼 자동 배칭·우선순위 제어 넣기 어려움 |
상속 대신 혼합 | 상속 X, HOC/RenderProps 사용 | 로직 합성 깊이 ∞ → 컴파일러·AI 분석 난이도↑ |
요약하자면, 클래스는 객체 지향 + 명령형 타이밍으로 설계됐다. 쉽게 말해 클래스 구조엔 "타이밍·바인딩·참조"가 뒤엉켜 있어,
React가 시도하는 “작업 잘게 쪼개기·중간에 끊기·우선순위 붙이기” 같은 최신 전략과 맞지 않다.
6.2 그래도.. 클래스로도 만들 수 있지 않나?
① startTransition()― 우선순위 기반 상태 업데이트
startTransition 은 “사용자 입력이 체감되는 작업”과 “계산량은 크지만 시급하지 않은 작업”을 분리한다.
// 함수형
const handleType = (v) => {
setInput(v); // 고우선 UI
startTransition(() => {
setFiltered(filter(v)); // 저우선 렌더
});
};
Transition 큐는 작업 단위 스냅숏을 저장했다가 브라우저가 한가해졌을 때 이어서 처리하는 방식으로 동작한다.
클래스로 하려면?
1. setState마다 작업 우선순위 태그
2. 배칭 큐 직접 관리
3. this.state가 중간에 바뀌지 않도록 세 군데에서 가드 코드…
import React, { unstable_batchedUpdates, startTransition } from 'react';
class SearchBox extends React.Component {
state = { input: '', filtered: [] };
handleType = (v) => {
// 1) UI 즉시 반영
this.setState({ input: v });
// 2) 저우선 업데이트를 직접 큐 관리
startTransition(() => {
unstable_batchedUpdates(() => {
this.setState({ filtered: expensiveFilter(v) });
});
});
};
render() {
return (
<input
value={this.state.input}
onChange={e => this.handleType(e.target.value)}
/>
);
}
}
– unstable_batchedUpdates 같은 저수준 API 호출
– this 바인딩 유지 · 큐 동기화 코드를 직접 관리해야 한다.
할 수는 있지만, 코드가 늘고, React 버전이 바뀌면 다시 맞춰야 한다.
② Suspense for Data— throw Promise 패턴과의 궁합
함수 컴포넌트와 훅은 “렌더 중 throw promise → Suspense 경계에서 로딩 UI 대체” 패턴을 그대로 쓸 수 있다.
함수 컴포넌트 (자연스러운 Suspense)
// 서버 쿼리를 promise‑return 함수로 래핑
function fetchPost(id) {
return fetch(`/api/post/${id}`).then((r) => r.json());
}
function Post({ id }) {
const post = use(fetchPost(id)); // 준비 전이면 Promise throw
return <article>{post.title}</article>;
}
export default function Page() {
return (
<Suspense fallback={<Spinner />}>
<Post id={1} />
</Suspense>
);
}
클래스 컴포넌트 (메서드 분산)
class Post extends React.Component {
state = { status: 'loading', post: null, error: null };
componentDidMount() {
fetch(`/api/post/${this.props.id}`)
.then(r => r.json())
.then((post) => this.setState({ status: 'success', post }))
.catch((err) => this.setState({ status: 'error', error: err }));
}
componentDidCatch(error) { // Suspense 경계 겹치면 충돌 위험
this.setState({ status: 'error', error });
}
render() {
const { status, post, error } = this.state;
if (status === 'loading') return <Spinner />;
if (status === 'error') return <ErrorView err={error} />;
return <article>{post.title}</article>;
}
}
export default function Page() {
return (
<ErrorBoundary>
<Post id={1} />
</ErrorBoundary>
);
}
함수형은 use() 한 줄과 Suspense 경계로 끝나지만, 클래스형은
- componentDidMount 안에서 fetch 수행
- componentDidCatch 로 Promise를 감지
- 로컬 state로 로딩/에러/완료 Switch 렌더
이렇게 여러 메서드로 분산되고, Suspense 경계와 Error Boundary가 겹치면 재렌더 순서를 따로 조정해야 한다.
“클래스 전용 Best Practice”가 존재하지 않는다.
6.3 Server Components · Streaming SSR — 함수형과의 시너지
① 직렬화(Serialize) 해야함
Server Component는 컴파일 단계에서 의존 없는 순수 함수로 변환되고, 실행 결과(React Element Tree)는 JSON 스트림으로 직렬화되어 전달된다.
- 필수 조건 : props와 내부 상태가 모두 (1) 직렬화 가능, (2) side‑effect free.
- 클래스 장애 요인 : 메서드·getter·private field는 직렬화가 불가능하거나 의미가 사라진다. 컴파일러가 안전성을 보장할 방법이 없다.
② 재‑입력(Re‑entrancy)이 안전해야 함
Streaming SSR 은 HTML 청크를 보내다가 데이터·CPU가 준비되면 나머지를 이어서 만든다.
Fiber Scheduler는 “작업 스택 스냅숏”을 저장해 두는데
요구 사항 | 함수 컴포넌트 | 클래스 컴포넌트 |
렌더 일관성 | 클로저로 상태 스냅숏 고정 | 인스턴스 state가 변경될 수 있음 |
중단 시점 복구 | 순수 함수 재호출로 동일 결과 | side‑effect 위치 추적 필요 |
Partial Hydration | 각 청크가 독립 | 라이프사이클 순서 맞추기 어려움 |
결국, 클래스 코드는 “전체 페이지 완성 후 스트림” 방식으로 돌아가거나, client side 전용으로 격리되어 Streaming SSR의 빠른 LCP·INP 이점을 온전히 누리지 못한다.
6.4 결론: 비용‑편익을 계산하면 클래스보다는 함수다.
- Concurrent Renderer·Server Components·Compiler는 함수형 전제를 기반으로 설계됐다.
- 같은 기능을 클래스용으로 우회 구현할 때 드는 인력·성능·품질 비용이 급격히 커진다.
- 라이브러리·도구·교육 자료가 함수형을 디폴트로 삼아 계속 쌓인다.
따라서 “클래스 컴포넌트는 미래가 없다”의 말은 "불가능하다"는 말이 아니라, 새 기능을 누릴 때마다 ‘우회 장치’를 만들어야 하는 구조적 채무(technical debt)를 뜻한다.
그래서 선택지는 두 가지 뿐이다.
1) 지속적으로 늘어나는 호환층을 감당하며 과거 설계를 유지하거나,
2) 한 번의 전환 비용을 지불하고 미래 기능을 기본값으로 받아들이는 코드베이스로 갈아타거나.
새 기능을 쓸 때마다 “클래스 호환 어댑터”를 만드는 비용이, 함수형으로 갈아타는 초기 투자보다 더 비싸기 때문에, React 커뮤니티와 대다수 기업들은 이미 두 번째 길을 택했다.
'Frontend > React,Next' 카테고리의 다른 글
리액트의 렌더링 전략: CSR > SSR > Suspense in SSR > RSC (1) | 2024.11.21 |
---|---|
[Next.js] Data Mutation: Server Actions (1) | 2024.07.16 |
React의 최적화 전략 (0) | 2024.04.11 |
React의 원칙: Immutable Data Pattern (4) | 2024.04.11 |
프로젝트 5개 하고 돌아보는 React를 쓰는 이유 (3) | 2024.04.04 |