고양이hyebin
함수를 리턴하는 함수? React 커스텀 훅의 '커링' 패턴
함수를 리턴하는 함수? React 커스텀 훅의 '커링' 패턴
February 17, 2026

생각해보기

만약 특정 TODO의 text를 "리액트 공부하기"로 바꾸고 싶다고 가정해봅시다.

여러분이라면 어떻게 짜실껀가요?

일단 업데이트 훅은 다음과 같을 겁니다. itemId값을 받아서 기존 값과 비교 후 같은게 있으면 업데이트 하는 방식이에요.

💡
아래 모든 예시 코드는 커링 패턴 설명에 집중하기 위해 단순화했습니다.
실제 프로젝트에서는 useState 대신 Zustand, Context 같은
전역 상태로 items를 관리해야 합니다 :)
export const useUpdateTodo = () => {
  const [items, setItems] = useState<ItemData[]>([]);

  const updateTodo = (itemId: string, updater: (item: ItemData) => ItemData) => {
    setItems(prevItems => 
      prevItems.map(item => 
        item.id === itemId ? updater(item) : item
      )
    );
  };

  return { updateTodo };
};

이제 만들어 놓은 훅으로 업데이트를 시켜봅시다!

function TodoItem({ todoId }) {
  const { updateTodo } = useUpdateTodo();

  return (
    <input 
      onChange={(e) => {
      updateTodo(todoId, (item) => ({ ...item, text: e.target.value }));
      }} 
    />
  );
}

아주 잘 동작하는 것을 볼 수 있을거에요.

그렇다면 이제 할일 업데이트뿐 만 아니라 보관이나 완료 되었다는 정보도 업데이트해야 한다면 어떻게 될까요?

function TodoItem({ todoId }) {
  const { updateTodo } = useUpdateTodo();

  return (
    <div>
      <input 
        onChange={(e) => updateTodo(todoId, (item) => ({ ...item, text: e.target.value }))} 
      />
      <input
        type="checkbox"
        onChange={(e) => updateTodo(todoId, (item) => ({ ...item, completed: e.target.checked }))}
      />
      <button onClick={() => updateTodo(todoId, (item) => ({ ...item, archived: true }))}>
        보관
      </button>
    </div>
  );
}

문제점이 보이시나요?

이벤트 핸들러마다 todoId를 반복해서 넘겨야 해요. 매번 (item) => ({ ...item, ... }) 형태의 updater 함수를 작성해야 하는데, 만약 자식 컴포넌트를 만들었다고 하면 자식 컴포넌트에 함수를 전달할 때도 ID를 함께 바인딩해야 합니다. 이렇게 되면 반복되는 코드가 많아지고 실수가 생길 수 있습니다

그러면 이 코드를 좀 더 우아하게 짤 수는 없는걸까요? 🤔

커링 패턴으로 해결하기

오늘 소개할 코드가 바로 그 문제를 해결하는 커링 패턴입니다.

일반적으로 함수를 만들 때 모든 인자를 한꺼번에 넘깁니다. 하지만 복잡한 UI를 개발하다 보면, 업데이트 할 대상은 이미 정해져 있는데, 바뀔 값만 나중에 넣고 싶을 때가 있습니다.

이제 커링 패턴을 적용해서 위 로직을 짜보겠습니다.

일단 업데이트할 훅을 만들어 주었어요.

export const useUpdateTodo = (itemId: string) => {
  const [items, setItems] = useState<ItemData[]>([]);

  const updateTodo = (updater: (item: ItemData) => ItemData) => {
    setItems(prevItems => 
      prevItems.map(item => 
        item.id === itemId ? updater(item) : item
      )
    );
  };

  return (updates: Partial<ItemData>) => {
    updateTodo((item) => ({
      ...item,
      ...updates
    }));
  };
};
function TodoItem({ todoId }) {
  const updateTodo = useUpdateTodo(todoId);

  return (
    <div>
      <input 
        onChange={(e) => updateTodo({ text: e.target.value })} 
      />
      <input
        type="checkbox"
        onChange={(e) => updateTodo({ completed: e.target.checked })}
      />
      <button onClick={() => updateTodo({ archived: true })}>
        보관
      </button>
    </div>
  );
}

달라진게 보이시나요 ? 기존에는 todoId를 매번 전달했었는데 더이상 전달하지 않아도 됩니다. 반복되는 코드가 줄어들어 가독성도 좋아지고, 무엇보다 명확해졌습니다.

커링 원리 이해하기

이러한 패턴을 프로그래밍 용어로 커링이라고 부릅니다. 쉽게 말하면 인자를 한 번에 다 받지 않고, 나눠서 받는 것이에요.

// 일반 방식: 모든 인자를 한 번에
f(a, b, c)

// 커링 방식: 인자를 나눠서
f(a, b)(c)

왜 나눠서 받을까요?

todoId는 컴포넌트가 렌더링될 때 이미 정해져 있습니다. 반면 text 값은 사용자가 입력하기 전까지는 알 수 없어요.

function TodoItem({ todoId }) {
  // todoId는 여기서 이미 확정 → 'todo-1'
  const updateTodo = useUpdateTodo(todoId);

  return (
    <input 
      // text는 사용자가 입력할 때 확정
      onChange={(e) => updateTodo({ text: e.target.value })}
    />
  );
}

이처럼 "언제 값이 정해지는가" 가 다른 인자들을 한 번에 받으려고 하면, 아직 모르는 값을 위해 매번 이미 아는 값을 반복해서 전달해야 합니다. 커링은 이 두 시점을 분리해서 이미 아는 값은 미리 고정하고, 나중에 알게 되는 값만 그때 전달하는 거예요.

그러면 커링을 적용한 코드를 단계별로 뜯어보면서 어떻게 동작하는지 살펴봅시다. 일단 훅을 호출해볼게요.

const updateTodo = useUpdateTodo('todo-1');

훅 안에서 무슨 일이 일어날까요?

export const useUpdateTodo = (itemId: string) => {
  const [items, setItems] = useState<ItemData[]>([]);

  const updateTodo = (updater: (item: ItemData) => ItemData) => {
    setItems(prevItems =>
      prevItems.map(item => 
        item.id === itemId ? updater(item) : item 
      )
    );
  };

  return (updates: Partial<ItemData>) => {
    updateTodo((item) => ({
      ...item,
      ...updates
    }));
  };
};

useUpdateTodo('todo-1')이 호출되는 순간, 내부에 있는 함수들은 itemId가 'todo-1'이라는 것을 기억한 채로 만들어집니다. 이렇게 함수가 자신이 만들어질 때의 변수를 기억하는 것을 클로저라고 합니다.

updateTodo 함수는 updater라는 함수를 받아서, 전체 리스트를 순회하다가 itemId와 맞는 항목만 updater로 변환해요. 근데 이 함수는 외부에 노출되지 않아요. 내부에서만 쓰는 함수입니다.

이제 updateTodo를 호출해볼까요?

updateTodo({ text: '리액트 공부하기' });

실제로 이렇게 작동할거에요.

setItems(prevItems =>
  prevItems.map(item =>
    item.id === 'todo-1' ? { ...item, text: '리액트 공부하기' } : item
  )
);

itemId를 따로 전달하지 않았는데도, 클로저 덕분에 'todo-1'을 정확히 찾아서 업데이트할 수 있는 거죠!

처음에는 커링 형태의 구조가 낯설 수 있지만, 복잡한 상태 관리를 하는 라이브러리를 다루다 보면 이보다 편리한 방식이 없을거에요:)