생각해보기
만약 특정 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'을 정확히 찾아서 업데이트할 수 있는 거죠!
처음에는 커링 형태의 구조가 낯설 수 있지만, 복잡한 상태 관리를 하는 라이브러리를 다루다 보면 이보다 편리한 방식이 없을거에요:)