리액트에서 함수형 프로그래밍을 지향하는 이유

chaen·2025년 7월 24일
5

REACT / NEXT.js

목록 보기
22/22
post-thumbnail

리액트(React)는 최근 몇 년간 함수형 프로그래밍(Functional Programming, FP)을 지향하게 되었습니다.

단순한 UI 컴포넌트에서부터 복잡한 어플리케이션 구조까지 함수형 접근법의 장점을 적극적으로 활용합니다.

이번 글에서는 리액트에서 함수형 프로그래밍을 지향하는 이유와 각각의 근거를 체계적으로 정리해보겠습니다.


👍 테스트의 용이성

함수형 프로그래밍의 핵심은 '순수 함수(pure function)'입니다. 순수 함수란 입력이 같으면 항상 같은 출력을 내고, 외부 상태를 변경하지 않는 함수를 말합니다.

함수형 컴포넌트의 테스트 장점

  • 의존성 최소화: 컴포넌트가 외부 상태나 사이드 이펙트에 의존하지 않으면, 그 컴포넌트의 결과는 입력(props)만으로 예측할 수 있습니다.
  • 단위테스트 최적화: mock이나 stub이 불필요한 경우가 많아지고, 테스트 코드가 짧아지고 이해하기도 쉬워집니다.
  • 재사용성 강화: Hooks나 함수형 Helper들을 여러 컴포넌트에 독립적으로 활용할 수 있습니다.

간결한 예시 비교

클래스형 컴포넌트의 테스트:

class UserProfile extends React.Component {
  state = { name: '', loading: false };
  
  async componentDidMount() {
    this.setState({ loading: true });
    const user = await fetchUser(this.props.userId);
    this.setState({ name: user.name, loading: false });
  }
  
  render() {
    return <div>{this.state.loading ? 'Loading...' : this.state.name}</div>;
  }
}

// 테스트 시 lifecycle과 상태를 모두 고려해야 함

함수형 컴포넌트:

function UserProfile({ userId }) {
  const { user, loading } = useUser(userId);
  
  if (loading) return <div>Loading...</div>;
  return <div>{user.name}</div>;
}

// 테스트 시 props만 확인하면 됨 (Hook은 별도 테스트)

함수형 컴포넌트는 로직과 렌더링을 분리할 수 있어서 각각을 독립적으로 테스트할 수 있습니다.

순수 함수 예시:

function formatPrice(price, currency = 'USD') {
  return `${currency} ${price.toFixed(2)}`;
}

// 입력만으로 결과 예측 가능
expect(formatPrice(10.5)).toBe('USD 10.50');
expect(formatPrice(20, 'KRW')).toBe('KRW 20.00');

👍 최적화의 편리함

함수형 프로그래밍에서 데이터는 불변(immutable)하게 다루는 것이 기본입니다.

불변성의 핵심 원리

  • 상태 관리의 불변성: 기존 객체를 직접 변경하지 않고, 새로운 객체를 생성해 상태를 바꿉니다.
  • 참조 비교 효율성(Shallow Compare): 객체의 참조만 비교하므로 최적화가 쉬워집니다.
  • 예측 가능성: 값이 바뀌지 않으므로 예측 가능한 흐름을 유지합니다.

최적화가 쉬워지는 이유

불변성을 지키면 리액트의 최적화 도구들이 매우 효과적으로 동작합니다:

React.memo의 효율적인 동작:

// 불변성을 지킬 때
const TodoItem = React.memo(({ todo, onToggle }) => (
  <div onClick={() => onToggle(todo.id)}>
    {todo.text} {todo.completed ? '✓' : '○'}
  </div>
));

// props가 참조상 같으면 리렌더링 안 됨

useMemo와 useCallback의 정확한 의존성 감지:

function TodoList({ todos, filter }) {
  // todos 배열이 새로 생성될 때만 필터링 재실행
  const filteredTodos = useMemo(() => 
    todos.filter(todo => todo.category === filter), 
    [todos, filter]
  );
  
  // 의존성이 변하지 않으면 함수 재생성 안 됨
  const handleToggle = useCallback((id) => 
    setTodos(prev => prev.map(todo => 
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    )), 
    []
  );
  
  return <div>{/* ... */}</div>;
}

잘못된 방식 vs 올바른 방식:

// ❌ 직접 수정 - 최적화 도구들이 변화를 감지하지 못함
function badUpdate() {
  const newTodos = todos;
  newTodos[0].completed = true;
  setTodos(newTodos); // 참조가 같아서 리렌더링 안 됨
}

// ✅ 새로운 객체 생성 - 최적화 도구들이 정확히 동작
function goodUpdate() {
  setTodos(prev => prev.map((todo, index) => 
    index === 0 ? { ...todo, completed: true } : todo
  ));
}

👍 변화 감지의 효율성

불변성을 지키면 참조 비교(===)만으로 상태 변화 감지가 가능해집니다.

참조 비교의 성능상 이점

깊은 비교 vs 참조 비교:

// ❌ 깊은 비교 - 모든 속성을 재귀적으로 확인 (느림)
function deepEqual(obj1, obj2) {
  // 객체의 모든 key-value를 비교해야 함
  return JSON.stringify(obj1) === JSON.stringify(obj2);
}

// ✅ 참조 비교 - 메모리 주소만 확인 (빠름)
function shallowEqual(obj1, obj2) {
  return obj1 === obj2; // 한 번의 비교로 끝
}

리액트의 변화 감지 메커니즘

리액트는 상태가 변했는지 확인할 때 Object.is() (거의 ===와 동일)를 사용합니다:

function Counter() {
  const [count, setCount] = useState(0);
  const [user, setUser] = useState({ name: 'John', age: 25 });
  
  // ✅ 원시값은 자동으로 불변성 보장
  const increment = () => setCount(prev => prev + 1);
  
  // ✅ 객체는 새로 생성해야 변화 감지됨
  const updateAge = () => setUser(prev => ({ ...prev, age: prev.age + 1 }));
  
  // ❌ 이렇게 하면 변화가 감지되지 않음
  const wrongUpdate = () => {
    user.age += 1;
    setUser(user); // 같은 참조이므로 리렌더링 안 됨
  };
}

대용량 데이터에서의 효율성

function DataTable({ items }) {
  // items가 참조상 같으면 정렬을 다시 하지 않음
  const sortedItems = useMemo(() => {
    console.log('정렬 실행!'); // 언제 실행되는지 확인 가능
    return [...items].sort((a, b) => a.name.localeCompare(b.name));
  }, [items]);
  
  return (
    <table>
      {sortedItems.map(item => (
        <tr key={item.id}>
          <td>{item.name}</td>
          <td>{item.price}</td>
        </tr>
      ))}
    </table>
  );
}

이처럼 불변성을 유지하면, 복잡한 데이터 구조에서도 변화 감지가 O(1) 시간에 이루어져 성능상 큰 이점을 얻을 수 있습니다.


👍 메모이제이션과 함수형의 관계

메모이제이션(Memoization)은 같은 입력에 대해 계산을 반복하지 않고 캐시된 값을 반환하는 최적화 기법입니다. 함수형 프로그래밍은 순수 함수(pure function)를 전제로 하기에, 메모이제이션이 매우 효과적입니다.

함수형 프로그래밍과 찰떡인 이유

  • 순수 함수는 항상 동일한 입력에 대해 동일한 출력을 반환 → 캐시하기 최적
  • 부작용이 없어서 캐시된 결과값을 재사용해도 문제 없음
  • 대표적으로 useMemo, useCallback 같은 훅도 이 개념에 기반

예시 - useMemo 최적화

function ProductList({ products, searchTerm }) {
  // searchTerm이나 products가 바뀌지 않으면 필터링을 다시 하지 않음
  const filteredProducts = useMemo(() => {
    console.log('필터링 실행!');
    return products.filter(p => 
      p.name.toLowerCase().includes(searchTerm.toLowerCase())
    );
  }, [products, searchTerm]);
  
  return <div>{filteredProducts.map(/* ... */)}</div>;
}

순수하지 않은 함수는 메모이제이션하기 어려움:

// ❌ 순수하지 않음 - 외부 상태에 의존
let discount = 0.1;
function calculatePrice(price) {
  return price * (1 - discount); // 외부 변수 참조
}

// ✅ 순수함 - 모든 입력이 매개변수로 전달됨
function calculatePrice(price, discount = 0) {
  return price * (1 - discount);
}

이처럼 함수형의 "불변성 + 순수성"은 메모이제이션의 전제조건과 일치하기 때문에, 두 개념은 서로를 강화해줍니다.


👍 상태 관리와 가독성

함수형 컴포넌트와 Hook(useState, useReducer)을 활용하면 상태 관리 코드가 간결해집니다.

useReducer로 복잡한 로직 처리

function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, { id: Date.now(), text: action.text, completed: false }];
    case 'TOGGLE_TODO':
      return state.map(todo => 
        todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
      );
    case 'DELETE_TODO':
      return state.filter(todo => todo.id !== action.id);
    default:
      return state;
  }
}

function TodoApp() {
  const [todos, dispatch] = useReducer(todoReducer, []);
  
  return (
    <div>
      <button onClick={() => dispatch({ type: 'ADD_TODO', text: 'New Todo' })}>
        Add Todo
      </button>
      {/* ... */}
    </div>
  );
}

커스텀 Hook으로 로직 분리

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const saved = localStorage.getItem(key);
    return saved ? JSON.parse(saved) : initialValue;
  });
  
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);
  
  return [value, setValue];
}

// 여러 컴포넌트에서 재사용 가능
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const [language, setLanguage] = useLocalStorage('language', 'ko');
  
  return <div>{/* 설정 UI */}</div>;
}

이렇게 하면 UI 렌더링 로직과 데이터 로직을 명확히 분리할 수 있어 가독성이 높아집니다.


📌 결론

리액트가 함수형 프로그래밍을 채택하는 이유는 코드의 명확성, 테스트 용이성, 최적화와 변화 감지의 효율성 때문입니다.

  • 테스트 코드 작성이 쉬워지고 - 순수 함수는 입력과 출력만 확인하면 됨
  • 상태 관리가 직관적으로 되며 - Hook을 통한 로직 분리와 재사용
  • 성능 최적화가 예측 가능하게 이루어집니다 - 불변성 기반의 참조 비교

함수형 패러다임은 컴포넌트를 모듈화하고, 복잡도를 제어하는 데 효과적인 전략입니다.


💭 프론트엔드 vs 백엔드에서의 패러다임 선택

저의 주관적인 판단입니다

프론트엔드(React)는 UI 렌더링, 상태 변화 감지, 최적화, 테스트 등을 효율적으로 하기 위해 함수형 패러다임이 적합한 것 같습니다

  • 순수성 + 불변성 → 상태 추적, 리렌더링 제어에 유리

반면, 백엔드(Node.js, Java 등)에서는 성능 이슈나 리소스 관리, 프로세스 흐름 제어 등에서 객체지향(OOP)이 실용적이라고 생각합니다.

  • 순수함수 기반으로 데이터 흐름을 만들 경우, 성능보다 복잡성이 커지는 경우도 있음
구분프론트엔드 (React 등)백엔드 (Node.js, Java 등)
주요 고려 요소UI 렌더링 최적화, 상태 변화 추적성능, 리소스 관리, 트랜잭션 흐름
적합한 패러다임함수형 프로그래밍객체지향 프로그래밍
장점- 테스트 용이
- 불변성으로 상태 변화 추적
- 컴포넌트 재사용성
- 클래스 기반 구조로 책임 명확화
- 상태 유지, 연결 중심의 설계
- 복잡한 로직 구조화 용이
단점- 복잡한 연산 흐름에선 불편
- 무분별한 hook 남용 시 가독성 저하
- 테스트 시 상태 분리 어려움
- 리렌더링 최적화 구조는 복잡

참고 자료

1개의 댓글

comment-user-thumbnail
2025년 7월 25일

네, 리액트에서 함수형 프로그래밍을 지향하는 이유와 그 근거를 아주 체계적으로 잘 정리해주셨습니다. 특히 테스트 용이성, 최적화, 변화 감지 효율성, 그리고 KFC Customer Satisfaction Survey - Welcome 상태 관리와 가독성 측면에서의 이점을 명확한 예시와 함께 설명하여 이해도를 높였습니다.

답글 달기