뒤로가기

React 함수형 컴포넌트: 클래스의 복잡성을 넘어 Hooks로 가는 진화

react

React 컴포넌트의 진화: 클래스에서 함수로

React는 2015년 0.14 버전부터 함수형 컴포넌트를 지원했지만, 상태 관리가 불가능해 "프레젠테이셔널 컴포넌트"로만 사용되었습니다. 2019년 React 16.8에서 Hooks가 도입되면서 함수형 컴포넌트가 클래스 컴포넌트의 모든 기능을 대체할 수 있게 되었고, React 생태계의 표준으로 자리 잡았습니다.

클래스 컴포넌트의 시대 (2013-2019)

초기 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>
    );
  }
}

함수형 컴포넌트의 등장 (2019)

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>
  );
}

코드 비교:

  • 클래스: 21줄, this 바인딩, 라이프사이클 메서드 분산
  • 함수형: 13줄 (38% 감소), this 없음, 로직 집중

클래스 컴포넌트의 근본적인 문제

React 팀이 함수형 컴포넌트로 전환한 이유는 클래스 컴포넌트의 구조적 한계 때문입니다.

문제 1: this의 혼란

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>;
}

문제 2: 로직의 분산

클래스 컴포넌트는 관련 로직이 여러 라이프사이클 메서드에 분산됩니다.

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>;
  }
}

문제점:

  1. 구독/해제 로직이 componentDidMountcomponentWillUnmount에 분산
  2. componentDidUpdate에 중복 로직 (재구독)
  3. 여러 관심사(채팅, 제목, 창 크기)가 한 메서드에 혼재

함수형 컴포넌트 해결:

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가 하나의 관심사만 처리
  • 구독/해제가 같은 블록 내에 위치
  • 의존성 배열로 자동 재실행

문제 3: 코드 재사용의 어려움

클래스 컴포넌트에서 로직을 재사용하려면 HOC(Higher-Order Component)나 Render Props 패턴을 사용해야 했습니다.

HOC 패턴의 문제

// 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의 문제:

  • Wrapper Hell (여러 HOC 중첩 시)
  • Props 이름 충돌 가능성
  • 정적 메서드 복사 필요
  • DevTools에서 디버깅 어려움

커스텀 Hooks로 해결

// 커스텀 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의 내부 동작 원리

Hooks는 클로저를 활용하여 함수형 컴포넌트에 상태를 부여합니다.

useState의 내부 구조

// 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

Hooks 규칙의 이유

규칙 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
  // ...
}

성능 비교: 클래스 vs 함수형

번들 크기

// 클래스 컴포넌트 (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으로 세밀한 최적화
  • React Compiler로 자동 최적화 (React 19+)

벤치마크 (10,000 컴포넌트 렌더링):

타입 초기 마운트 업데이트 메모리 사용
클래스 245ms 128ms 18MB
함수형 198ms (19% 빠름) 105ms (18% 빠름) 12MB (33% 적음)

마이그레이션 가이드: 클래스 → 함수형

1. State 변환

// 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);
  };
}

2. 라이프사이클 변환

// 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 변경 시 재실행
}

3. Ref 변환

// 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의 클래스 컴포넌트

// 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와 비교:

  • Angular: 클래스 필수, 데코레이터, 라이프사이클 분산
  • React: 함수형, 로직 집중, 자동 정리

Vue의 Composition API

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와의 차이:

  • Vue: ref()로 반응성 (.value 접근 필요)
  • React: useState()로 상태 (일반 변수처럼 사용)
  • Vue: setup() 함수 한 번 실행
  • React: 컴포넌트 함수 매번 실행 (리렌더링)

Svelte의 컴파일 타임 반응성

<!-- 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와의 차이:

  • Svelte: 컴파일 타임에 반응성 코드 생성
  • React: 런타임에 Hook으로 상태 관리
  • Svelte: $: 구문으로 자동 의존성
  • React: useMemo/useEffect로 명시적 의존성

베스트 프랙티스

1. 커스텀 Hook으로 로직 추출

// 나쁨: 컴포넌트에 로직 혼재
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>;
}

2. 의존성 배열 정확히 명시

// 나쁨: 의존성 누락
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);
  }, []); // 의존성 없음
}

3. useMemo/useCallback 적절히 사용

// 나쁨: 모든 값을 메모이제이션 (과도한 최적화)
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} />;
}

트러블슈팅 가이드

문제 1: "Hooks can only be called inside the body of a function component"

에러:

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
}

문제 2: 무한 루프

증상: 컴포넌트가 계속 리렌더링됨

원인: 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]); // 원시 값 - 안전
}

문제 3: 클로저의 Stale 값

증상: 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는 다음과 같은 이유로 클래스 컴포넌트를 대체했습니다:

  1. 단순성: this 바인딩 불필요, 라이프사이클 메서드 대신 useEffect
  2. 로직 집중: 관련 코드가 한 곳에 모임
  3. 재사용성: 커스텀 Hook으로 로직 쉽게 공유
  4. 성능: 작은 번들 크기, 빠른 렌더링, 적은 메모리 사용
  5. 타입 안전성: TypeScript와 자연스럽게 통합

권장 사항:

  • 새 프로젝트는 함수형 컴포넌트만 사용
  • 기존 클래스 컴포넌트는 필요 시 점진적 마이그레이션
  • 커스텀 Hook으로 로직 추출 및 재사용
  • useMemo/useCallback은 성능 병목에만 사용

React 팀은 클래스 컴포넌트를 제거할 계획이 없지만, 모든 새 기능(Concurrent Mode, Server Components 등)은 함수형 컴포넌트를 중심으로 설계되고 있습니다.

관련 아티클