Dev Thinking
32완료

Effect 없이 해결하기 (기본편) - 계산, 전달, 렌더링으로 충분한 경우

2025-08-27
9분 읽기

리액트 공식문서 기반의 리액트 입문기

들어가며

이전 5-3편에서 useEffect 훅이 컴포넌트와 외부 시스템을 동기화하는 강력한 도구임을 살펴보았습니다. 하지만 useEffect는 마치 강력한 양날의 검과 같아서, 불필요하게 사용될 경우 코드 복잡도를 높이고 예상치 못한 버그를 유발할 수 있습니다. 리액트의 핵심 철학은 UI를 선언적으로 관리하는 것이며, 많은 경우 useEffect 없이도 컴포넌트의 상태, props, 그리고 렌더링 로직만으로 충분히 문제를 해결할 수 있습니다.

이러한 관점에서 볼 때, useEffect는 반드시 필요한 '탈출구(Escape Hatch)'로 남겨두고, 대부분의 로직은 리액트의 선언적인 흐름 안에서 처리하는 것이 좋은 개발 습관이라고 생각합니다. 이번 편에서는 useEffect가 필요하지 않은 다양한 상황들을 구체적인 예시와 함께 살펴보고, 어떻게 Effect 없이도 깔끔하고 효율적으로 코드를 작성할 수 있는지 저의 학습 여정을 공유하고자 합니다.

Effect가 필요하지 않은 경우

useEffect는 컴포넌트의 렌더링 이후에 실행되는 '사이드 이펙트'를 다루는 데 특화되어 있습니다. 하지만 모든 '사이드' 작업이 useEffect를 필요로 하는 것은 아닙니다. 리액트의 선언적인 특성을 활용하면 Effect 없이도 많은 문제들을 우아하게 해결할 수 있습니다. 다음은 Effect가 필요하지 않은 대표적인 상황들과 그 해결 방법들입니다.

1. 렌더링에 필요한 가벼운 파생 값은 state 대신 계산으로 처리하기

컴포넌트의 상태로부터 파생되는 값(Derived State)은 useEffect를 사용해 새로운 상태로 저장할 필요가 없습니다. 대신, 렌더링 과정에서 직접 계산하여 사용하는 것이 훨씬 간결하고 효율적입니다. useEffect로 파생 상태를 만들면 상태 동기화 문제가 발생할 수 있으며, 불필요한 렌더링을 유발할 수 있습니다.

잘못된 접근 (Effect로 파생 상태 관리):

import { useState, useEffect } from 'react';
 
function ProductDisplay({ product }) {
  const [isDiscounted, setIsDiscounted] = useState(false);
 
  useEffect(() => {
    if (product.price < 100) {
      setIsDiscounted(true);
    } else {
      setIsDiscounted(false);
    }
  }, [product.price]); // product.price가 바뀔 때마다 Effect 실행
 
  return (
    <div>
      <h2>{product.name}</h2>
      <p>가격: {product.price}</p>
      {isDiscounted && <p>할인 적용!</p>}
    </div>
  );
}
 
export default ProductDisplay;

올바른 접근 (렌더링 중 계산):

function ProductDisplay({ product }) {
  const isDiscounted = product.price < 100; // 렌더링 중 직접 계산
 
  return (
    <div>
      <h2>{product.name}</h2>
      <p>가격: {product.price}</p>
      {isDiscounted && <p>할인 적용!</p>}
    </div>
  );
}
 
export ProductDisplay;

위 예시처럼, isDiscounted와 같은 파생 값은 product.price에 의존하여 매 렌더링마다 계산되는 것이 가장 효율적입니다. useEffect를 사용하여 별도의 상태로 관리하면 불필요한 로직과 상태 동기화 오버헤드가 발생할 수 있습니다.

2. 값비싼 계산은 useMemo로 메모이제이션하여 렌더링 중 처리하기

렌더링 시 복잡하고 시간이 오래 걸리는 계산은 useMemo 훅을 사용하여 결과를 메모이제이션하는 것이 효율적입니다. useMemo는 의존성 배열의 값이 변경될 때만 계산을 다시 수행하여 불필요한 재계산을 방지하고 성능을 최적화합니다. useEffect를 사용하여 값비싼 계산 결과를 상태로 저장하는 것은 불필요한 리렌더링과 상태 동기화 오버헤드를 유발하므로 피해야 합니다.

잘못된 접근 (Effect로 값비싼 계산 결과 저장):

import { useState, useEffect } from 'react';
 
function ComplexCalculator({ data }) {
  const [expensiveResult, setExpensiveResult] = useState(0);
 
  useEffect(() => {
    // data가 바뀔 때마다 값비싼 계산 수행
    const result = performExpensiveCalculation(data); // 가상의 값비싼 계산 함수
    setExpensiveResult(result);
  }, [data]);
 
  return <p>계산 결과: {expensiveResult}</p>;
}
 
function performExpensiveCalculation(data) {
  console.log('값비싼 계산 수행...');
  // 실제로는 복잡한 계산 로직이 들어갑니다.
  return data.length * 100;
}
 
export default ComplexCalculator;

useEffect 내부에서 값비싼 계산을 수행하고 그 결과를 상태로 저장하는 방식은 data가 변경될 때마다 Effect를 실행하고, 다시 setExpensiveResult를 호출하여 리렌더링을 유발합니다. 이는 Effect의 본래 목적(외부 시스템과의 동기화)과도 맞지 않으며, 비효율적인 패턴입니다.

올바른 접근 (useMemo 활용):

import { useMemo } from 'react'; // useMemo 훅 import
 
function ComplexCalculator({ data }) {
  const expensiveResult = useMemo(() => {
    console.log('값비싼 계산 수행...');
    // 실제로는 복잡한 계산 로직이 들어갑니다.
    return data.length * 100;
  }, [data]); // data가 바뀔 때만 계산을 다시 수행
 
  return <p>계산 결과: {expensiveResult}</p>;
}
 
function performExpensiveCalculation(data) {
  // 이 함수는 useMemo 내부에서 직접 호출되므로, 외부에서는 사용하지 않을 수 있습니다.
  return data.length * 100;
}
 
export { ComplexCalculator, performExpensiveCalculation };

useMemo를 사용하면 data가 변경될 때만 performExpensiveCalculation 함수가 실행되어 expensiveResult를 다시 계산합니다. 이 방식은 React의 렌더링 과정에서 값비싼 계산을 효율적으로 처리하며, useEffect를 사용하는 것보다 훨씬 간결하고 성능 친화적입니다.

3. 컴포넌트 간 데이터 전달은 props로 처리하기

부모 컴포넌트의 데이터를 자식 컴포넌트로 전달해야 할 때는 useEffect를 통해 상태를 동기화하기보다 props를 사용하는 것이 리액트의 기본 원칙이자 가장 깔끔한 방법입니다. props는 단방향 데이터 흐름을 통해 컴포넌트 간의 관계를 명확하게 하고 예측 가능성을 높여줍니다.

잘못된 접근 (Effect로 props 동기화):

import { useState, useEffect } from 'react';
 
function ChildComponent({ externalValue }) {
  const [internalValue, setInternalValue] = useState(externalValue);
 
  useEffect(() => {
    setInternalValue(externalValue);
  }, [externalValue]);
 
  return <p>내부 값: {internalValue}</p>;
}
 
function ParentComponent() {
  const [value, setValue] = useState('초기값');
 
  return (
    <div>
      <button onClick={() => setValue('새로운 값')}>값 변경</button>
      <ChildComponent externalValue={value} />
    </div>
  );
}
 
export default ParentComponent;

올바른 접근 (props 직접 사용):

function ChildComponent({ externalValue }) {
  return <p>내부 값: {externalValue}</p>;
}
 
function ParentComponent() {
  const [value, setValue] = useState('초기값');
 
  return (
    <div>
      <button onClick={() => setValue('새로운 값')}>값 변경</button>
      <ChildComponent externalValue={value} />
    </div>
  );
}
 
export { ChildComponent, ParentComponent };

자식 컴포넌트가 부모로부터 받은 externalValue를 직접 사용하면 되며, 이를 useEffect를 통해 internalValue라는 상태로 다시 저장할 필요가 없습니다. 이는 불필요한 상태 중복과 useEffect 실행을 피할 수 있습니다.

4. 사용자 인터랙션에 대한 로직은 이벤트 핸들러에서 처리하기

사용자의 클릭, 입력 등 직접적인 인터랙션에 응답하는 로직은 useEffect 대신 해당 이벤트 핸들러 내에서 처리해야 합니다. useEffect는 렌더링 이후에 실행되는 동기화 로직에 적합하며, 사용자 인터랙션은 '이벤트'이므로 이벤트 발생 시 즉시 처리하는 것이 직관적이고 효율적입니다.

잘못된 접근 (Effect로 이벤트 반응):

import { useState, useEffect } from 'react';
 
function SaveButton() {
  const [shouldSave, setShouldSave] = useState(false);
 
  useEffect(() => {
    if (shouldSave) {
      console.log('데이터 저장...');
      setShouldSave(false); // 저장 후 플래그 초기화
    }
  }, [shouldSave]);
 
  const handleClick = () => {
    setShouldSave(true);
  };
 
  return <button onClick={handleClick}>저장</button>;
}
 
export default SaveButton;

올바른 접근 (이벤트 핸들러에서 직접 처리):

function SaveButton() {
  const handleClick = () => {
    console.log('데이터 저장...');
    // 필요한 경우, 여기서 상태 업데이트나 API 호출 등을 수행합니다.
  };
 
  return <button onClick={handleClick}>저장</button>;
}
 
export { SaveButton };

사용자 인터랙션(버튼 클릭)으로 인해 발생하는 '저장' 로직은 handleClick 이벤트 핸들러 내에서 직접 수행하는 것이 올바른 접근 방식입니다. shouldSave와 같은 상태를 useEffect의 의존성으로 사용하여 로직을 트리거하는 방식은 불필요한 상태와 Effect 실행을 유발합니다.

5. 상태를 재설정해야 할 때는 key조건부 렌더링 활용하기

폼(Form) 필드나 특정 컴포넌트의 상태를 특정 조건에 따라 초기화하거나 완전히 재설정해야 할 때가 있습니다. 이때 useEffect를 사용하여 수동으로 상태를 리셋하는 대신, 리액트의 key 속성이나 조건부 렌더링을 활용하면 더욱 선언적이고 효율적으로 처리할 수 있습니다.

  • key 속성: key가 변경되면 리액트는 해당 컴포넌트 인스턴스를 이전 것과 다른 것으로 인식하고, 새로운 인스턴스를 마운트하면서 내부 상태를 모두 초기화합니다. 이는 폼 필드처럼 독립적인 상태를 가진 컴포넌트를 완전히 재설정할 때 유용합니다.
  • 조건부 렌더링: 특정 조건에 따라 컴포넌트를 완전히 언마운트하고 다시 마운트함으로써 상태를 초기화할 수 있습니다. 예를 들어, showForm && <MyForm />과 같이 조건부로 폼 컴포넌트를 렌더링하여 showForm 값이 false가 되면 폼이 사라지고, 다시 true가 되면 새로운 상태로 마운트되도록 할 수 있습니다.

잘못된 접근 (Effect로 상태 리셋):

import { useState, useEffect } from 'react';
 
function ResetForm({ userId }) {
  const [name, setName] = useState('');
 
  useEffect(() => {
    // userId가 바뀔 때마다 폼 필드를 초기화
    setName('');
  }, [userId]);
 
  return <input value={name} onChange={e => setName(e.target.value)} placeholder="이름" />;
}
 
function UserProfile() {
  const [activeUserId, setActiveUserId] = useState(1);
 
  return (
    <div>
      <button onClick={() => setActiveUserId(1)}>사용자 1</button>
      <button onClick={() => setActiveUserId(2)}>사용자 2</button>
      <ResetForm userId={activeUserId} />
    </div>
  );
}
 
export default UserProfile;

올바른 접근 (key 활용):

import { useState } from 'react';
 
function ResetForm() {
  const [name, setName] = useState('');
 
  return <input value={name} onChange={e => setName(e.target.value)} placeholder="이름" />;
}
 
function UserProfile() {
  const [activeUserId, setActiveUserId] = useState(1);
 
  return (
    <div>
      <button onClick={() => setActiveUserId(1)}>사용자 1</button>
      <button onClick={() => setActiveUserId(2)}>사용자 2</button>
      {/* key prop을 사용하여 userId가 바뀔 때마다 ResetForm 컴포넌트를 재마운트 */}
      <ResetForm key={activeUserId} />
    </div>
  );
}
 
export { ResetForm, UserProfile };

ResetForm 컴포넌트에 key={activeUserId}를 부여함으로써, activeUserId가 변경될 때마다 리액트는 이전 ResetForm 인스턴스를 파괴하고 새로운 인스턴스를 마운트합니다. 이 과정에서 name 상태는 자동으로 ''로 초기화됩니다. 이 방식은 useEffect를 사용하여 수동으로 상태를 리셋하는 것보다 훨씬 선언적이고 리액트의 작동 방식에 부합합니다.

6. Effect는 오직 외부 시스템과의 동기화에만 사용하기

useEffect의 가장 핵심적인 역할은 React 컴포넌트와 React 외부 시스템 간의 동기화입니다. 여기서 '외부 시스템'이란 브라우저 DOM, 네트워크, 구독 서비스, 서드파티 라이브러리 등을 의미합니다. useEffect는 이러한 외부 시스템의 상태를 React 컴포넌트의 상태와 일치시키거나, 외부 시스템에 특정 작업을 지시할 때 사용해야 합니다.

  • 외부 시스템 상태와 React 상태 동기화: document.title을 컴포넌트 상태에 따라 업데이트하거나 (5-3편 예제), 이벤트 리스너를 등록/해제하는 것 등이 이에 해당합니다.
  • 외부 시스템에 작업 지시: useRef를 통해 얻은 DOM 요소에 focus() 메서드를 호출하거나, 비디오를 play()하는 것 (5-2편 예제) 등이 이에 해당합니다.

useEffect가 React 내부의 상태나 props만을 가지고 로직을 처리하는 데 사용된다면, 이는 Effect의 오용일 가능성이 높습니다. Effect는 React의 렌더링 로직을 벗어나 외부와 소통하는 통로이므로, 그 역할에 충실하게 사용하는 것이 중요하다고 생각합니다.

Effect 없이 코드를 작성하며 배우는 React의 핵심 가치

useEffect 없이 리액트의 선언적 특성과 기본적인 훅들을 활용하여 문제를 해결하는 방식은 다음과 같은 장점들을 가집니다.

  1. 예측 가능성 및 단순성 향상: useEffect는 비동기 작업, 외부 시스템과의 상호작용 등 리액트의 제어 흐름을 벗어나는 '사이드 이펙트'를 다루는 도구입니다. Effect 없이 상태와 props만으로 로직을 처리할 때는 컴포넌트의 동작이 훨씬 예측 가능해지며, 코드의 흐름이 단순해져 이해하기 쉬워진다는 장점이 있다고 생각합니다.
  2. 불필요한 리렌더링 및 상태 동기화 감소: useEffect로 파생 상태를 만들거나, 사용자 인터랙션에 직접 반응하는 대신 상태를 설정하는 등의 잘못된 사용은 불필요한 리렌더링을 유발하고 상태 동기화 문제를 발생시킬 수 있습니다. Effect 없이 문제를 해결하면 이러한 오버헤드를 줄여 애플리케이션의 성능을 자연스럽게 최적화할 수 있습니다.
  3. React의 선언적 철학 강화: 리액트는 '무엇을 그릴지'를 선언하면 '어떻게 그릴지'는 리액트가 처리하는 선언적 UI를 지향합니다. Effect 없이 문제를 해결하려는 노력은 이러한 리액트의 핵심 철학을 더욱 강화하며, 개발자가 UI의 최종 상태에 집중할 수 있도록 돕는다고 생각합니다. 이는 명령형 코드의 양을 줄여 코드의 의도를 더욱 명확하게 만듭니다.
  4. useEffect의 본질적인 역할 이해 증진: Effect 없이 해결할 수 있는 경우를 명확히 이해함으로써, useEffect가 정말로 필요한 상황, 즉 'React 컴포넌트와 외부 시스템 간의 동기화'라는 본질적인 역할에 더욱 집중할 수 있게 됩니다. 이는 useEffect를 무분별하게 사용하는 것을 방지하고, 각 훅의 목적에 맞는 올바른 활용법을 익히는 데 중요한 통찰을 제공해 줄 것이라고 생각합니다.
  5. 디버깅 용이성: useEffect 내부는 비동기적이거나 외부 시스템과 상호작용하기 때문에 디버깅이 복잡해질 수 있습니다. Effect 없이 상태와 props만으로 로직을 처리하는 코드는 동기적으로 동작하고 예측 가능성이 높아 디버깅이 훨씬 용이하다는 장점이 있습니다. 이는 개발 과정에서 발생하는 시간과 노력을 절약하는 데 기여할 수 있습니다.

요약

이전 5-3편에서 useEffect 훅의 기본적인 역할과 외부 시스템과의 동기화 중요성을 살펴보았습니다. 이번 5-4편 (기본편)에서는 useEffect가 필요하지 않은 기본적인 상황들을 깊이 있게 탐구하며, 리액트의 선언적 특성을 활용하여 Effect 없이도 문제를 깔끔하고 효율적으로 해결하는 방법들을 알아보았습니다. useEffect는 강력한 도구이지만, 그 사용은 신중해야 하며, 많은 경우 props, state, 이벤트 핸들러, key 속성 등으로 충분히 대체 가능함을 확인할 수 있었습니다.

Effect 없이 문제를 해결하는 연습은 리액트의 핵심 철학을 더욱 깊이 이해하고, 불필요한 복잡성을 줄이며, 코드의 예측 가능성과 성능을 향상시키는 데 큰 도움이 될 것이라고 생각합니다. 다음은 이번 편의 핵심 내용들입니다.

  • 파생 값: state 대신 렌더링 중 직접 계산하여 사용합니다. (가벼운 경우)
  • 값비싼 파생 값: useMemo를 활용하여 렌더링 중 효율적으로 계산합니다.
  • 데이터 전달: 컴포넌트 간 데이터는 props를 통해 전달하는 것이 원칙입니다.
  • 사용자 인터랙션: 클릭, 입력 등의 직접적인 반응은 이벤트 핸들러 내에서 처리합니다.
  • 상태 재설정: key 속성이나 조건부 렌더링을 활용하여 컴포넌트 상태를 효율적으로 초기화합니다.
  • Effect의 본질: 오직 외부 시스템과의 동기화에만 Effect를 사용합니다. 5-6편에서 의존성 배열을 통한 Effect의 생명주기 관리를 심층적으로 다룹니다.

useEffect를 적재적소에 사용하는 것은 React 개발의 중요한 역량입니다. Effect가 필요하지 않은 경우를 명확히 구분함으로써, 우리는 더욱 견고하고 유지보수하기 쉬운 애플리케이션을 만들 수 있을 것이라고 생각합니다. 이 가이드가 useEffect에 대한 올바른 이해를 돕고, 더 나은 React 개발 습관을 형성하는 데 기여할 수 있기를 바랍니다.

다음 5-5편에서는 useCallback, React.memo 및 '값비싼 계산'을 어떻게 판단하고 측정할지에 대한 심층적인 내용을 다룰 예정입니다. 효율적인 React 애플리케이션 개발을 위한 여정에 함께해 주셔서 감사합니다.

참고문서

– [Effect가 필요하지 않은 경우 – React]