팀장님: 루크 씨, 저희 제품 config Dialog를 좀 수정해야 할 것 같네요. 이틀 드릴게요.
루크: ..네?
문제의 발단
회사에서 업무를 하던 도중 위와 같은 요구사항이 들어왔습니다. 상세한 내용은 회사 보안상 서술하기 좀 어렵지만, 간단하게 각색해서 그려보면 아래와 같아요.
기존에는 캐릭터의 이름만 변경하도록 되어있던 아주 단순한 구조의 Dialog 컴포넌트가 이제는 Favorite도 추가/수정하도록 변경된 겁니다. 심지어 Favorite은 몇 개든지 들어가야 하는 상황이고요. 캐릭터는 약 500개가 넘고, API는 한 번에 모든 캐릭터의 이름을 불러오도록 되어있습니다. 그리고 저기 들어가는 Favorite 컴포넌트는 다른 곳에서도 동일하게 사용되어야 했기 때문에, 저는 캐릭터 이름을 변경하는 Input에 토글 버튼을 넣고 Favorite 컴포넌트를 넣어주도록 했습니다.
잘 되어갑니다
정말 어려울 것 하나 없이 순조롭게 진행이 되고 있었습니다. 그러다가 한가지 고민이 생겼습니다. 여기서부터는 코드를 보면서 설명하는 것이 편리할 것 같아서 Replit에 간단하게 코드를 재현해 보았습니다. 귀찮으신 분들을 위해서 코드도 남겨드릴게요.
const [person, setPerson] = useState<IPerson>(initialPerson);
const handleEditFavorite = (idx: number, value: string) => {
const updatedFavorites = [...person.favorites];
updatedFavorites.splice(idx, 1, value);
setPerson({ ...person, favorites: updatedFavorites });
};
return (
<div>
{person.favorites.map((favorite, idx) => (
<Favorite
key={`${favorite}_${idx}`}
idx={idx}
favorites={person.favorites}
favorite={favorite}
handleEditFavorite={handleEditFavorite}
/>
))}
</div>
);
일단 저 person에는 모든 캐릭터들이 들어있습니다. 당연히 선택된 캐릭터만을 골라서 State에 세팅해두고 수정하는 게 해답이지만, 그렇게 하기 위해서는 꽤 대대적인 리팩토링이 필요한 구조로 되어있었습니다. 그리고 주어진 시간은 단 이틀. 일단은 급하게 기능만 달아두고 추후에 리팩토링을 하는 시간을 따로 빼주기로 팀장님과 딜을 했습니다.
이래놓고 못 한 리팩토링이 한 바가지..
사실 구현 자체는 어렵지 않아요. person에 들어있는 favorites 배열을 모두 토글 안으로 출력해주면 되는 간단한 문제였습니다. Input에 입력값이 바뀔 때마다 handleEditFavorite
이 호출되어 상태를 변경합니다. 하지만 역시 모든 캐릭터가 들어있는 State
를 매번 수정하는 건 무리가 있는 일이었죠. 무슨 문제가 생겼을까요?
What will happen will happen.
네, 모든 키보드 입력을 받을 때마다 500개의 리스트를 일일이 새로 그려주는 문제가 생기고 말았습니다. 키보드 입력을 받고 나면 약 500ms 뒤에 Input 창에 표시되게 된 것이죠. 당연한 말이지만 이걸 써먹을 수 있을 리가 없습니다. 그래서 저는 자연스럽게 debounce를 떠올립니다. lodash에서 제공하는 debounce 함수는 심플합니다. 자세하게 설명하면 이것만으로도 한 편의 포스팅이 될 것 같으니 간단하게 짚고만 넘어가자면, input 이벤트가 모두 끝난 뒤에 단 한 번만 함수를 실행합니다. 그렇게 하면 자연스럽게 사용자가 다음 Input 창으로 이동하는 동안 List가 다시 렌더될테고, 최소한의 수정으로 최대한의 효과를 낼 수 있는 방법이었죠. 아래는 Favorite 컴포넌트의 내부입니다.
export default function Favorite({
idx,
favorites,
favorite,
handleEditFavorite,
}: IFavorite) {
const [tempFavorite, setTempFavorite] = useState<string>(favorite);
const handleChange = (e: any) => {
const value = e.target.value;
setTempFavorite(value);
handleUpdateFavorite(value);
};
const handleUpdateFavorite = useCallback(
_.debounce((value) => {
handleEditFavorite(idx, value);
}, 500),
[favorite]
);
return (
<input
defaultValue={favorite}
value={tempFavorite}
onChange={handleChange}
/>
);
}
코드가 워낙 심플하긴 하지만 그래도 한번 설명하자면, input 창에서 onChange 이벤트가 발생할 때, 컴포넌트 내부의 상태(tempFavorite)을 업데이트해서 input 창에 표시되는 display state를 변경해주고, debounce 함수를 걸어서 입력이 모두 끝난 뒤에 props로 전달받은 handleEditFavorite
가 실행되도록 했습니다.
debounce 함수에 useCallback을 걸어준 이유는, useCallback을 걸지 않으면 favorite 값이 변경될 때마다 debounce 함수가 새로 생성되면서 전혀 debounce의 역할을 해주지 못하기 때문입니다. 그리고 이러한 사용은 저에게 커다란 의문을 안겨주게 됩니다. 눈치 빠른 분들은 아시겠지만 현재 useCallback에 걸려있는 deps는 틀렸습니다.
의문에 대해서
처음에는 favorite이 변경될 때 useCallback이 다시 수행되어서 handleUpdateFavorite
도 최신으로 업데이트될 것으로 기대했습니다. 그리고 그런 것처럼 보였죠. 문제는 첫 번째 favorite을 변경하고 두 번째 favorite을 입력할 때 나타납니다. 첫 번째 입력된 favorite이 다시 원래 상태로 돌아와 버리는 것이었죠. 저는 이게 대체 어째서 일어나는 일인지 이해하지 못했습니다. 물론 useCallback의 deps에 handleEditFavorite
을 넣어주거나, favorites 배열 전체를 props로 전달받아서 넘겨주는 방법을 사용하면 아무런 문제가 발생하지 않습니다. 그렇지만 제 생각에는 favorite만 넘겨줘도 충분할 것처럼 생각되었거든요.
일단은 handleEditFavorite
을 deps에 넣어서 문제는 해결됐지만 저의 머릿속에 남은 근본적인 의문은 전혀 해결이 되지 않았습니다. 왜냐하면 그때 당시에 계속해서 나타나던 현상은 첫 번째 favorite만이 원복 되는 것으로 보였거든요. 사실은 더 먼저 변경한 favorite이 원복 되는 것인데 말입니다. 여러 가지 방식으로 테스트를 해보면서 차근히 문제를 파악했으면 그다지 어렵게 생각할 것도 아닌데, 괜히 애먼 데에 꽂혀서 말 그대로 삽질에 삽질을 반복했습니다.
그러다가 집단지성(a.k.a 오픈톡방)의 힘을 빌려 문제를 해결해보고자 노력을 했습니다. 그러자 상당히 많은 분들이 답변을 달아주셨어요. 이 자리를 빌려 다시 한 번 감사의 말씀을 전하고 싶습니다.
질문을 올릴 시점에 저는 deps가 잘못되었다는 부분은 인지하고 있었습니다. 그래서 그 부분은 차치하고, 해당 코드에는 두 가지 심각한 문제가 있습니다.
- useCallback과 debounce의 궁합 문제
- setPerson 함수의 사용 문제
일단 첫 번째 문제부터 살펴보겠습니다.
useCallback과 debounce의 궁합 문제
이 부분에 대해서는 전혀 인지하지 못했던 부분인데, 어떤 분이 아티클을 하나 찾아서 보여주셨습니다. 링크를 첨부할게요.
내용을 요약하자면 아래와 같습니다.
- debounce는 debounced된 eventHandler를 반환한다.
- useCallback은, 렌더 될 때마다 그 eventHandler의 함수 인스턴스를 반환한다.
a. 만일 deps가 변한다면 새로운 함수 인스턴스를 생성해서 반환하게 될 것이다. - 그러니 useMemo를 사용하는 것이 더 적절하다.
제 상황과 100% fit 되는 내용은 아닙니다. 위 아티클은 성능 개선의 목적을 가지고 있고, 저는 정확성의 문제에 대해서 이야기를 하고 있으니까요. 하지만 저 아티클을 읽으면서 나름대로 힌트를 얻을 수 있었습니다. useCallback은 deps가 변할 때 새로운 함수 인스턴스를 반환하게 됩니다. 그러니까 favoriteA와 favoriteB가 존재하는 경우, deps에 favorite을 넣어주었을 때, favoriteA가 참조하는 handleEditFavorite
과 favoriteB가 참조하는 handleEditFavorite
은 다른 인스턴스가 되는 거죠. 같은 동작을 하는 것처럼 보이지만 사실 다른 인스턴스를 참조하게 되어 문제가 발생한 거였습니다. 실제로 useMemo를 사용했을 때는 제가 겪었던 문제가 재현되지 않았습니다.
setPerson 함수의 사용 문제
이 부분에 대해서 이야기를 들었을 때 저는 정말 무릎을 치게 되었습니다. 왜냐면 아주아주 기본 중의 기본이었기 때문이에요.
다시 첫 번째 올려둔 코드로 돌아가 보겠습니다.
const [person, setPerson] = useState<IPerson>(initialPerson);
const handleEditFavorite = (idx: number, value: string) => {
const updatedFavorites = [...person.favorites];
updatedFavorites.splice(idx, 1, value);
setPerson({ ...person, favorites: updatedFavorites }); // <= 이 부분...!!
};
// ...
);
setPerson은 기존 값들은 그대로 유지하고 favorite만 새로운 배열을 생성해서 update 해주는 단순한 동작을 수행하고 있습니다. 이렇게 단순하니까 저는 조금도 의심을 하지 않았습니다만, 사실은 아래와 같이 사용되었어야 합니다.
const handleEditFavorite = (idx: number, value: string) => {
setPerson((prevPerson) => {
const updatedFavorites = [...prevPerson.favorites];
updatedFavorites.splice(idx, 1, value);
return { ...prevPerson, favorites: updatedFavorites }
})
};
변경되지 않은 Favorite 컴포넌트 내부의 useCallback 함수 내부 스코프가, 이전 person 값을 참조하게 되는 것이 가장 근본적인 문제였던 거예요. setPerson 함수의 인자로 전달되는 이전 상태 값을 참조하도록 하면 모두가 행복해지는 문제였죠.
느낀점(?)
언제나 기본기가 중요하다. Back to the basic 같은 말을 귀에 못이 박히도록 듣고, 혀에 가시가 돋도록 했는데도 제가 그걸 제대로 지키지 못한 점이 조금 분했습니다. 저는 한 번 뭔가에 꽂히면 다른 쪽으로는 생각이 잘 흘러가지 않는데, 조금 더 유연하게 기초부터 살펴보면서 디버깅을 해야겠다는 것을 다시 한 번 느낄 수 있었던 사건이었습니다. 새해에는 조금 더 침착하고 조금 더 유연하게 사고하는 연습을 해야겠어요.