부수 효과(Side Effect)란 무엇인가?

부수 효과(Side Effect)란 무엇인가?

지난 글에서는 사이드 프로젝트 “아이두”를 만들면서 마주한 추상화에 대해 다뤘는데,
이번 주제는 부수 효과입니다.

실제로 아이두를 개발하면서 부수 효과를 어떻게 분리하고 관리할지 많이 고민했습니다.

프런트엔드 애플리케이션은 본질적으로 부수 효과와 함께 동작합니다.
사용자의 입력을 받고, 화면에 데이터를 렌더링하고, 서버와 통신하는 것.
이 모든 것이 부수 효과입니다.

React를 처음 배울 때 “useEffect를 남용하지 말라”는 말을 한 번쯤 들어봤을 것입니다.
그 이유가 부수 효과 때문이라는 것도 어렴풋이 알고 있지만,
정작 부수 효과가 무엇인지, 왜 관리가 필요한지를 명확히 알고 쓰는 경우는 많지 않습니다.

이 글에서는 부수 효과의 개념을 정리하고, 부수 효과를 어떻게 관리해야 하는지 살펴보겠습니다.


아이두 - 친구와 함께 하는 AI 투두 서비스

본격적인 글에 앞서, 아이두(Aido)가 궁금하다면 iPhone, Android, Mac, Tablet 등 다양한 기기에서 호환 가능하니 한 번 써보시면 좋을 것 같습니다.

친구들과 함께 할 일을 관리하며 서로 자극을 주고받을 수 있는 AI 할일 관리 서비스인데 혹여나 주변에 같이 쓸 친구가 없다면 초대 코드 WNTQJEEE로 들어와서 함께 자극을 나누며 성장하면 좋을 것 같습니다!

아이두 앱 화면 1아이두 앱 화면 2아이두 앱 화면 3아이두 앱 화면 4

App Store | Google Play | 아이두(Aido) 공식 웹사이트 | 공식 인스타그램


부수 효과(Side Effect)

다음 두 함수를 비교해보겠습니다.

function add(a, b) {
  return a + b;
}ts
let count = 0;

function addAndCount(a, b) {
  count = count + 1;
  return a + b;
}ts

add는 두 수를 받아서 더한 값을 돌려줍니다. 그게 전부입니다. 같은 인자를 넣으면 항상 같은 결과가 나오고, 함수 바깥의 세계에는 아무 영향도 주지 않습니다. 이런 함수를 순수 함수라고 합니다. 같은 입력에 항상 같은 출력을 반환하고, 외부 상태를 변경하지 않는 함수입니다.

반면 addAndCount는 덧셈 결과를 돌려주면서 전역 스코프의 count를 슬쩍 바꿔놓습니다. 호출할 때마다 count가 1씩 올라가니까, 이 함수를 몇 번 호출했느냐에 따라 바깥 세상의 상태가 달라집니다.

이렇게 함수가 인자를 받고 값을 반환하는 것 외에 벌어지는 모든 일을 부수 효과라고 합니다. 외부 변수를 바꾸거나, 콘솔에 로그를 찍거나, API를 호출하거나, DOM을 조작하는 것 모두 부수 효과입니다.

일상에 비유하면 이렇습니다. 카페에서 아메리카노를 주문하면 아메리카노를 받는 것, 그게 기대하는 결과입니다. 그런데 바리스타가 커피를 만들면서 옆 테이블의 물컵도 치우고, 매장 음악도 바꿔버린다면 어떨까요?

매튜는 커피만 주문했을 뿐인데 매장 상태가 바뀌어 버립니다. 이것이 부수 효과입니다. 순수 함수는 커피만 만들어서 돌려주는 바리스타고, 부수 효과를 가진 함수는 커피를 만들면서 매장 이곳저곳을 건드리는 바리스타인 셈입니다.

이제 좀 더 현실적인 예시로 살펴보겠습니다. 간단한 Todo 앱에서 할 일 목록을 관리하는 함수 두 개가 있습니다.

let todos = [];

function addTodo(title) {
  todos.push({ id: todos.length + 1, title, done: false });
}

function clearTodos() {
  todos = [];
}ts

addTodo는 외부 변수 todos에 항목을 추가하고, clearTodostodos를 비웁니다.
두 함수 모두 자기 바깥에 있는 상태를 변경합니다. 이것이 부수 효과입니다.

호출 순서와 횟수가 중요하다

부수 효과가 있는 함수는 언제, 몇 번 호출하느냐에 따라 결과가 완전히 달라집니다.

addTodo("장보기");
addTodo("운동하기");
clearTodos();
// todos = []ts
clearTodos();
addTodo("장보기");
addTodo("운동하기");
// todos = [{ id: 1, ... }, { id: 2, ... }]ts

같은 함수를 같은 횟수만큼 호출했는데, 순서만 바꿨을 뿐 결과가 완전히 다릅니다.

부수 효과의 영향은 여기서 그치지 않습니다. todos를 읽어서 화면에 보여주는 함수가 있다고 해보겠습니다.

function getTodoSummary() {
  return `할 일이 ${todos.length}개 남았다.`;
}ts

이 함수는 자기 자신은 todos를 변경하지 않지만, addTodoclearTodos가 언제 호출됐느냐에 따라 반환값이 달라집니다.
부수 효과를 가진 함수가 외부 상태를 바꾸면, 그 상태에 의존하는 다른 함수까지 영향을 받는 것입니다.

부수 효과는 전염된다

여기가 핵심입니다. 부수 효과를 가진 함수를 사용하는 함수도 부수 효과를 갖게 됩니다.

function addDefaultTodos() {
  addTodo("장보기");
  addTodo("운동하기");
}ts

addDefaultTodos는 직접 todos를 건드리지 않습니다. 하지만 내부에서 addTodo를 호출하기 때문에 addDefaultTodos 역시 부수 효과를 가진 함수가 됩니다.

이 전염성이 왜 문제일까요? 부수 효과를 가진 함수가 낮은 수준의 모듈에 있으면, 그 함수를 사용하는 모든 상위 모듈로 부수 효과가 퍼져나갑니다.
작은 유틸 함수 하나에 부수 효과가 숨어 있으면, 그걸 쓰는 함수, 그 함수를 또 쓰는 함수까지 줄줄이 부수 효과를 갖게 되는 것입니다.


부수 효과를 관리하는 방법

그렇다면 부수 효과를 어떻게 다뤄야 할까요? 핵심은 부수 효과를 가진 코드와 순수한 코드를 분리하는 것입니다.

무엇이 부수 효과이고 무엇이 아닌지 명확하게 나눠두면, 코드를 읽을 때 “이 부분은 외부에 영향을 줄 수 있겠구나”라고 빠르게 판단할 수 있습니다.

Todo 앱으로 돌아가서, 남은 할 일의 상태를 화면에 보여주는 함수를 만들어보겠습니다.

function displayTodoStatus() {
  const $status = document.getElementById("todo-status");
  const incomplete = todos.filter((todo) => !todo.done);

  if (incomplete.length === 0) {
    $status.textContent = "모든 할 일을 완료했다!";
  } else {
    $status.textContent =
      `${incomplete.length}개 남음. 다음 할 일: ${incomplete[0].title}`;
  }
}ts

이 함수 안에는 두 가지 종류의 코드가 섞여 있습니다.

  • 부수 효과: document.getElementById로 DOM 요소를 조회하고, textContent로 DOM을 직접 수정합니다.
  • 순수한 로직: todos에서 미완료 항목을 필터링하고 상태 문자열을 만듭니다. 같은 입력이면 항상 같은 출력이 나옵니다.

문제는 이 두 가지가 하나의 함수에 뒤섞여 있다는 것입니다. displayTodoStatus가 순수 함수가 아니기 때문에, DOM의 id가 바뀌거나 다른 코드가 todo-status 요소를 조작하면 예상치 못한 결과가 생길 수 있습니다.

예를 들어, 다른 개발자가 HTML의 id를 변경했다고 해보겠습니다.

<p id="status"></p> <!-- todo-status에서 status로 변경됨 -->html
function displayTodoStatus() {
  const $status = document.getElementById("todo-status"); 
  const incomplete = todos.filter((todo) => !todo.done);

  // $status가 null이므로 런타임 에러 발생
  if (incomplete.length === 0) {
    $status.textContent = "모든 할 일을 완료했다!"; 
  } else {
    $status.textContent =
      `${incomplete.length}개 남음. 다음 할 일: ${incomplete[0].title}`; 
  }
}ts

DOM에 의존하는 부수 효과와 순수한 로직이 한 함수에 섞여 있기 때문에, DOM 변경 하나로 함수 전체가 망가집니다. 순수한 로직까지 함께 깨지는 것입니다.

순수한 부분을 분리하기

해결 방법은 간단합니다. 상태 텍스트를 만드는 순수한 로직을 별도의 함수로 꺼내면 됩니다.

// 순수 함수: 같은 todos를 넣으면 항상 같은 문자열이 나온다
function getTodoStatusText(todos) {
  const incomplete = todos.filter((todo) => !todo.done);

  if (incomplete.length === 0) {
    return "모든 할 일을 완료했다!";
  }

  return `${incomplete.length}개 남음. 다음 할 일: ${incomplete[0].title}`;
}ts

이제 displayTodoStatus는 이렇게 바뀝니다.

// 부수 효과를 가진 함수: DOM 조회 + DOM 수정
function displayTodoStatus() {
  const $status = document.getElementById("todo-status");
  $status.textContent = getTodoStatusText(todos);
}ts

달라진 점이 보이시나요? displayTodoStatus를 읽을 때 더 이상 어떤 조건 분기로 텍스트를 만드는지 신경 쓸 필요가 없습니다. “상태 텍스트를 만들어서 DOM에 넣는구나”라는 흐름만 파악하면 됩니다.

분리의 효과를 정리하면 다음과 같습니다.

  • getTodoStatusText순수 함수입니다. 테스트하기 쉽고, 어디서든 안전하게 재사용할 수 있습니다.
  • displayTodoStatus부수 효과를 가진 함수입니다. 하지만 코드가 짧아졌기 때문에 어떤 부수 효과가 발생하는지 한눈에 파악할 수 있습니다.
  • 순수한 로직이 분리되었기 때문에, 부수 효과가 있는 코드의 양 자체가 줄어듭니다.

Functional Core & Imperative Shell

지금까지 살펴본 “순수한 코드와 부수 효과를 가진 코드를 분리하는 방법”은 Functional Core & Imperative Shell(이하 FCIS)이라는 패턴으로 일반화할 수 있습니다.

이 패턴의 아이디어는 단순합니다. 순수한 로직을 안쪽에, 부수 효과를 바깥쪽에 배치하는 것입니다.

FCIS 패턴 구조도: 안쪽에 Functional Core(순수 함수, 비즈니스 로직), 바깥쪽에 Imperative Shell(DOM 조작, API 호출, 이벤트 핸들링)이 감싸는 구조
FCIS 패턴 구조도: 안쪽에 Functional Core(순수 함수, 비즈니스 로직), 바깥쪽에 Imperative Shell(DOM 조작, API 호출, 이벤트 핸들링)이 감싸는 구조

각 부분의 역할을 살펴보겠습니다.

Imperative Shell (바깥쪽)

부수 효과가 필요한 모든 작업을 담당합니다.

  • 외부로부터 입력을 받습니다 (사용자 이벤트, API 응답 등)
  • 외부에 출력을 보냅니다 (DOM 업데이트, API 호출 등)
  • 전체 흐름을 지휘하고 조정합니다

Functional Core (안쪽)

순수 함수로만 이루어진 영역입니다.

  • 비즈니스 로직과 데이터 변환을 처리합니다
  • 같은 입력에 항상 같은 출력을 반환합니다
  • 외부 세계에 대해 아무것도 모릅니다

프런트엔드에서의 FCIS 흐름

이 패턴을 프런트엔드의 일반적인 동작 흐름에 대입하면 다음과 같습니다.

프런트엔드 FCIS 흐름도: 사용자 이벤트 → Imperative Shell → Functional Core → Imperative Shell → 화면 반영의 단방향 흐름
프런트엔드 FCIS 흐름도: 사용자 이벤트 → Imperative Shell → Functional Core → Imperative Shell → 화면 반영의 단방향 흐름

Imperative Shell → Functional Core → Imperative Shell로 이어지는 단방향 흐름입니다. Todo 앱으로 예를 들면 이렇습니다.

  1. 사용자가 “할 일 추가” 버튼을 클릭한다 → Imperative Shell이 이벤트를 받는다
  2. 새로운 Todo 데이터를 만들고, 목록을 정렬한다 → Functional Core가 순수하게 처리한다
  3. 결과를 DOM에 반영한다 → Imperative Shell이 화면을 업데이트한다

순수 함수를 흐름의 안쪽에 위치시키면, 부수 효과의 전염성을 자연스럽게 차단할 수 있습니다.

그렇다면 이 패턴을 React에서는 어떻게 적용할 수 있을까요?


React에서의 부수 효과

React에서 순수한 컴포넌트란 무엇일까요?

React는 같은 입력에 대해 항상 같은 JSX를 반환하는 컴포넌트를 순수하다고 봅니다. 여기서 입력이란 props, state, context를 말합니다.

// 순수한 컴포넌트: 같은 props를 넣으면 항상 같은 JSX가 나온다
function TodoItem({ title, done }) {
  return (
    <li>
      {title} - {done ? "완료" : "미완료"}
    </li>
  );
}tsx

이런 순수한 컴포넌트는 React.memo를 통한 메모이제이션 같은 성능 최적화가 가능해집니다. 입력이 같으면 출력도 같다는 보장이 있으니, 불필요한 리렌더링을 건너뛸 수 있는 것입니다. 즉, React에서 부수 효과 관리는 코드 품질뿐만 아니라 성능과도 직결됩니다.

컴포넌트의 순수성을 깨뜨리는 것들

그렇다면 어떤 것들이 컴포넌트를 순수하지 않게 만들까요? 크게 두 방향으로 나눌 수 있습니다.

외부에 영향을 주는 경우 (부수 효과)

렌더링 과정에서 컴포넌트 바깥의 세계를 변경하는 경우입니다.

function TodoPage() {
  const [todos, setTodos] = useState([]);

  useEffect(() => {
    fetchTodos().then(setTodos); // 부수 효과: API 호출
  }, []);

  const handleAdd = async (title) => {
    await createTodo(title); // 부수 효과: API 호출
    // ...
  };

  // ...
}tsx

useEffect에서의 API 호출, 이벤트 핸들러에서의 API 호출 모두 부수 효과에 해당합니다.

외부로부터 영향을 받는 경우 (참조 투명성 훼손)

컴포넌트가 props 외의 외부 값에 의존하는 경우입니다.

function TodoCount() {
  // 외부 상태에 의존: Context에서 값을 읽어온다
  const { todos } = useContext(TodoContext);

  return <p>할 일이 {todos.length}개 남았다.</p>;
}tsx

이 컴포넌트는 props가 없는데도 렌더링 결과가 달라집니다. TodoCount를 사용하는 쪽에서는 내부를 들여다보지 않는 한, 어떤 외부 상태에 의존하는지 알기 어렵습니다.

Context와 전역 상태의 함정

Context나 Redux store는 사용 방식에 따라 순수성에 미치는 영향이 달라집니다.

props drilling을 피하기 위해 Context를 사용하는 경우, 이는 props 전달 방식을 대체하는 것이므로 순수성을 크게 해치지 않습니다.

// props drilling 대체 용도: 순수성을 크게 해치지 않는다
function TodoApp() {
  const [filter, setFilter] = useState("all");

  return (
    <FilterContext.Provider value={filter}>
      <TodoList />
    </FilterContext.Provider>
  );
}tsx

하지만 여러 컴포넌트가 제약 없이 Context의 상태를 읽고 쓰기 시작하면 이야기가 달라집니다.

// 여러 곳에서 자유롭게 상태를 읽고 쓰면 추적이 어려워진다
function TodoInput() {
  const { addTodo } = useContext(TodoContext); // 쓰기
  // ...
}

function TodoStats() {
  const { todos } = useContext(TodoContext); // 읽기
  // ...
}

function TodoFilter() {
  const { setFilter } = useContext(TodoContext); // 쓰기
  // ...
}tsx

어디서 상태를 변경하고, 어디서 읽는지 추적하기 어려워집니다. 복잡도가 커지면 이 컴포넌트들은 독립적인 컴포넌트 집합이 아니라 하나의 거대한 컴포넌트처럼 엉켜버립니다. 이 문제는 Redux store 등 다른 전역 상태 관리도 마찬가지입니다.

이벤트 핸들러와 Effect

이벤트 핸들러와 useEffect도 컴포넌트의 순수성에 영향을 줄 수 있습니다. 콜백이 선언만 되어 있고 실제로 외부와 상호작용하지 않는다면 순수성에 영향이 없습니다.

// 부수 효과 없음: 컴포넌트 내부 상태만 변경한다
function TodoInput() {
  const [value, setValue] = useState("");

  return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}tsx

위 컴포넌트는 내부 state만 변경하기 때문에 순수합니다. 하지만 같은 TodoInput이라도 외부와 상호작용하는 순간 부수 효과를 갖게 됩니다.

// 부수 효과 있음: API 호출 + 부모 상태 변경
function TodoInput({ onAdd }) {
  const [value, setValue] = useState("");

  const handleSubmit = async () => {
    await createTodo(value);
    onAdd();
  };

  // ...
}tsx

결국 이벤트 핸들러나 Effect의 존재 여부가 곧 부수 효과의 존재 여부와 일치하는 경우가 많습니다.

React에서 FCIS 적용하기

앞서 바닐라 JS에서 displayTodoStatusgetTodoStatusText를 분리했던 것처럼, React 컴포넌트에서도 같은 원칙을 적용할 수 있습니다.

먼저, 모든 로직이 하나의 컴포넌트에 뒤섞인 경우를 보겠습니다.

function TodoPage() {
  const [todos, setTodos] = useState([]);

  useEffect(() => {
    fetchTodos().then(setTodos);
  }, []);

  const handleToggle = async (id) => {
    await updateTodo(id);
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
    );
  };

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id} onClick={() => handleToggle(todo.id)}>
          {todo.title} - {todo.done ? "완료" : "미완료"}
        </li>
      ))}
    </ul>
  );
}tsx

API 호출, 상태 관리, UI 렌더링이 전부 한 곳에 있습니다. 이 컴포넌트가 커질수록 어떤 부분이 부수 효과이고 어떤 부분이 순수한 로직인지 파악하기 어려워집니다.

FCIS 패턴을 적용해서 분리해보겠습니다.

// Functional Core: 순수 컴포넌트
function TodoItem({ title, done, onToggle }) {
  return (
    <li onClick={onToggle}>
      {title} - {done ? "완료" : "미완료"}
    </li>
  );
}tsx
// Imperative Shell: 부수 효과를 가진 컴포넌트
function TodoPage() {
  const [todos, setTodos] = useState([]);

  useEffect(() => {
    fetchTodos().then(setTodos);
  }, []);

  const handleToggle = async (id) => {
    await updateTodo(id);
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
    );
  };

  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem
          key={todo.id}
          title={todo.title}
          done={todo.done}
          onToggle={() => handleToggle(todo.id)}
        />
      ))}
    </ul>
  );
}tsx

TodoItem은 순수 컴포넌트입니다. props만 보면 어떤 UI가 나올지 예측할 수 있고, 테스트하기도 쉽습니다. 부수 효과는 TodoPage에 모여 있어서 “어디서 API를 호출하고, 어디서 상태를 바꾸는지”가 명확합니다.

물론 모든 부수 효과를 최상단 컴포넌트 하나에 몰아넣는 것이 답은 아닙니다. 그러면 해당 컴포넌트의 복잡도가 폭발적으로 증가합니다. 반대로 모든 컴포넌트에 부수 효과를 분산시키면 추적이 불가능해집니다. 적절한 수준에서 경계를 나누는 것이 핵심입니다.


마무리

글 처음에 언급했던 “useEffect를 남용하지 말라”는 조언을 다시 떠올려보겠습니다. 이제 그 말의 의미가 조금 더 선명해졌을 것입니다.

useEffect는 부수 효과를 다루는 도구입니다. 부수 효과 자체가 나쁜 것은 아니지만, 관리하지 않으면 코드 전체로 퍼져나갑니다. 중요한 것은 useEffect를 쓰느냐 마느냐가 아니라, 부수 효과가 어디에 있는지 명확히 파악할 수 있는 구조를 만드는 것입니다.

순수한 코드와 부수 효과를 가진 코드를 의식적으로 분리하는 습관. 그것이 관리 가능한 코드를 만드는 첫걸음입니다.

관련 포스트