
Intro
React Hooks가 공개된 이후, 현대의 React 애플리케이션에서는 함수 컴포넌트가 사실상의 표준으로 자리 잡았습니다.
이 변화는 자연스럽게 하나의 질문을 떠올리게 합니다.
“Hooks의 등장은 class 문법과 객체지향 접근의 몰락을 의미하는가?”
이 질문은 자주 “React가 class를 버렸다”는 인상으로 단순화되곤 합니다.
그러나 React의 선택이 JavaScript의 class 문법 자체를 부정한 결정이었는지,
아니면 UI 컴포넌트라는 특정 문제 영역에서 더 비용 효율적인 모델을 채택한 것이었는지는 분리해서 살펴볼 필요가 있습니다.
이 글에서는 먼저 React가 왜 Hooks를 도입했는지, 그 선택이 어떤 문제를 해결하기 위한 것이었는지를 살펴봅니다.
이후, 그 결론을 애플리케이션 전반—특히 도메인 로직 설계에까지 확장하는 것이 타당한지에 대해 효용성 관점에서 class 문법을 검토해보겠습니다.
React는 왜 Hook을 만들었을까
React Hooks의 도입 배경은 레거시 문서에 비교적 명확히 정리되어 있습니다.
상태 로직을 재활용할 수 있다.
class 컴포넌트에서는 상태 로직을 재사용하기 위해 HOC나 render props와 같은 패턴이 필요했습니다.
이들은 강력했지만, 컴포넌트 트리를 복잡하게 만들고 추론 비용을 증가시켰습니다.
Hooks는 상태 로직을 컴포넌트 구조와 분리된 단위로 재사용할 수 있도록 합니다.
const useToggle = () => {
const [ value, setValue ] = useState(false);
const toggle = () => setValue( old => !old );
return {
value,
toggle
}
}
상태와 그에 대한 업데이트 규칙이 하나의 함수로 묶이면서, 재사용 비용이 크게 낮아졌습니다.
라이프사이클 메소드는 관심사 분리가 어렵다.
class 컴포넌트에서는 componentDidMount, componentDidUpdate, componentWillUnmount와 같은 시점 중심 API를 기준으로 로직을 배치해야 했습니다.
class Profile extends React.Component<{ userId: string }> {
componentDidMount() {
// 관심사 1: DOM 사이드 이펙트
document.title = `User ${this.props.userId}`;
// 관심사 2: 데이터 패칭
fetch(`/api/users/${this.props.userId}`);
// 관심사 3: 이벤트 구독
window.addEventListener("resize", this.handleResize);
}
componentDidUpdate(prevProps: { userId: string }) {
if (prevProps.userId !== this.props.userId) {
// 관심사 1 반복
document.title = `User ${this.props.userId}`;
// 관심사 2 반복
fetch(`/api/users/${this.props.userId}`);
}
}
componentWillUnmount() {
// 관심사 3의 정리 로직
window.removeEventListener("resize", this.handleResize);
}
handleResize = () => {};
render() {
return null;
}
}
이 구조에서는 서로 다른 관심사가 동일한 lifecycle 메소드 안에서 섞이기 쉽고, 같은 관심사가 여러 메소드에 흩어지게 됩니다.
Hooks는 이 문제를 관심사 중심으로 재구성합니다.
function Profile({ userId }: { userId: string }) {
// 관심사 1: 타이틀 관리
useEffect(() => {
document.title = `User ${userId}`;
}, [userId]);
// 관심사 2: 데이터 패칭
useEffect(() => {
fetch(`/api/users/${userId}`);
}, [userId]);
// 관심사 3: 이벤트 구독 + 정리
useEffect(() => {
const handleResize = () => {};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
return null;
}
class 컴포넌트에서 중요한 축이 “언제 실행되는가”였다면, Hooks에서는 “무엇을 관리하는가”가 중심이 됩니다.
이는 UI 상태 관리라는 문제 영역에서 추론 비용을 크게 줄였습니다.
this 바인딩 문제
class 컴포넌트에서는 JavaScript의 this 바인딩 규칙으로 인해 추가적인 문법적 처리가 필요했습니다.
class Counter extends React.Component {
state = { count: 0 };
increment() {
this.setState({ count: this.state.count + 1 });
}
render() {
return <button onClick={this.increment}>+</button>;
}
}
이를 해결하기 위해 bind나 public class field 패턴을 사용해야 했습니다.
class Counter extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
this.increment = this.increment.bind(this);
}
increment() {
this.setState({ count: this.state.count + 1 });
}
render() {
return <button onClick={this.increment}>+</button>;
}
}
다만 이는 Hooks 도입의 결정적 원인이라기보다는, class 기반 컴포넌트가 가지는 추가적인 인지·유지 비용 중 하나로 보는 것이 타당합니다.
Hooks의 선택은 “class 문법의 부정”이었을까?
React는 class 컴포넌트가 안고 있던 문제를 해결하기 위해 Hooks를 도입했습니다.
이는 UI 컴포넌트라는 특수한 실행 환경에서 더 비용 효율적인 모델을 선택한 결과입니다.
여기서 중요한 점은, 이 결론을 JavaScript 전체 설계 영역으로 확장할 수 있는가입니다.
즉,
“React 컴포넌트에서 class 사용이 줄어든 것이,
애플리케이션의 다른 영역에서도 class 문법이 효용을 잃었다는 의미인가?”
이 질문에 답하기 위해, UI 컴포넌트가 아닌 도메인 로직 설계를 살펴보겠습니다.
도메인 모듈 구현 방식 비교
비교 조건
- 내부 상태 캡슐화
- 명시적 초기화
- 확장 가능성
- 상태 업데이트 규칙의 표현
- 다중 인스턴스 지원 여부
1. 클로저 기반 구현
function createAccount(initialBalance: number) {
let balance = initialBalance;
function validateWithdraw(amount: number) {
if (amount <= 0) throw new Error("invalid amount");
if (balance < amount) throw new Error("insufficient");
}
return {
getBalance() {
return balance;
},
setBalance(value: number) {
if (value < 0) throw new Error("invalid balance");
balance = value;
},
withdraw(amount: number) {
validateWithdraw(amount);
balance -= amount;
},
};
}
- 장점: 강력한 캡슐화, 간단한 구현
- 비용:
- 파생 상태 표현이 컨벤션에 의존
- 구조 확장이 어려움
- 캡슐화가 암묵적임
ES module 기반 구현
let balance = 0;
function validateWithdraw(amount: number) {
if (amount <= 0) throw new Error("invalid amount");
if (balance < amount) throw new Error("insufficient");
}
function getBalance() {
return balance;
}
function setBalance(value: number) {
if (value < 0) throw new Error("invalid balance");
balance = value;
}
function withdraw(amount: number) {
validateWithdraw(amount);
balance -= amount;
}
export { getBalance, setBalance, withdraw }
- 장점: 간단한 public/private 구분
- 비용:
- 싱글톤 구조로 고정
- 초기화 시점이 암묵적
- “이 파일이 하나의 상태를 관리한다”는 의도가 명시적이지 않음
class 문법 구현
class Account {
#balance: number;
constructor(initialBalance: number) {
this.#balance = initialBalance;
}
#validateWithdraw(amount: number) {
if (amount <= 0) throw new Error("invalid amount");
if (this.#balance < amount) throw new Error("insufficient");
}
get balance() {
return this.#balance;
}
set balance(value: number) {
if (value < 0) throw new Error("invalid balance");
this.#balance = value;
}
withdraw(amount: number) {
this.#validateWithdraw(amount);
this.#balance -= amount;
}
}
- 장점:
- 초기화, 상태, 행위의 경계가 언어 차원에서 명시됨
- 다중 인스턴스 생성이 자연스러움
- getter/setter를 통한 읽기·쓰기 규칙 표현
- 타입 시스템과의 결합이 명확
- 비용:
- 단순 로직에는 상대적으로 구조가 무거울 수 있음
도메인 객체가 내부 상태를 관리하고, 계산된 결과를 명시적 API를 통해 노출하며,
여러 인스턴스로 독립적 상태를 유지해야 하는 경우, class 문법은 여전히 비용 대비 효율적인 선택지입니다.
class 문법
class Account {
#balance: number;
constructor(initialBalance: number) {
this.#balance = initialBalance;
}
// 명시적 상태 조회 (read boundary)
get balance() {
return this.#balance;
}
// 상태 변경 규칙을 강제 (write boundary)
set balance(value: number) {
if (value < 0) {
throw new Error("잔액은 음수가 될 수 없습니다.");
}
this.#balance = value;
}
deposit(amount: number) {
this.balance += amount;
}
withdraw(amount: number) {
this.balance -= amount;
}
}
바운더리가 명시적이다
Account.balance
Account.deposit(100)
파생 상태 표현이 자연스러움
class Person {
firstName: "Peter"
lastName: "Parker"
// ✅ 파생 상태 처리
get fullName(){
reutrn `${this.firstName} ${this.lastName}`
}
}
명시적 캡슐화
- 구현과 인터페이스의 분리가 명시적이다
- private 필드: 내부 구현을 위해 필요하고 외부 사용시 숨겨야 하는 데이터
- public 필드: 외부 공개되어야 하는 인터페이스
// ✅ 외부에 공개되어야 하는 정보
interface IProfile {
name: string
}
class Profile implements IProfile {
public name = "Peter"
// ✅ 내부 구현상 필요한 데이터
private userId = 123
// ✅ 내부 구현을 위한 메소드 명시, 어떤 책임을 가지는지 어떤 의존성이 있는지 class 바운더리로 명시적
private helperFunction = () => { // 내부 구현을 위한 메소드 }
}
제가 제시한 조건처럼 객체가 내부 상태를 관리하고 계산된 결과를 외부에 공개 API를 통해 제공한다라는 상황에서는 class 문법의 표현이 가장 적절했다고 생각합니다.
마무리
React에서 class 컴포넌트 사용이 줄어든 이유는, class 문법이 구시대적이기 때문이 아니라
UI 상태 관리라는 문제 영역에서 Hooks가 더 적합한 추상화였기 때문입니다.
이 선택을 근거로, 애플리케이션 전반—특히 도메인 로직 영역—에서
class 문법의 효용까지 부정하는 것은 과도한 일반화에 가깝습니다.
문제의 성격에 따라, 어떤 추상화가 가장 낮은 비용으로 의도를 전달할 수 있는지를 판단하는 것이 중요합니다.
그 관점에서 class 문법은 여전히 유효한 도구 중 하나입니다.
참고
https://ko.legacy.reactjs.org/docs/hooks-intro.html
Hook의 개요 – React
A JavaScript library for building user interfaces
ko.legacy.reactjs.org
'react' 카테고리의 다른 글
| 컴포넌트에서 서버 데이터 가져오기 (0) | 2026.01.05 |
|---|---|
| 왜 내 코드의 Effect가 실행됐을까? (0) | 2025.12.29 |
| 함수 컴포넌트와 클로저 (0) | 2025.12.23 |
| React에서 상태를 초기화하는 기준 (0) | 2025.12.22 |
| useEffect, 의존성 배열 lint를 무시하면 안되는 이유 (0) | 2025.12.19 |